diff --git a/mv-and-ip/.gitignore b/mv-and-ip/.gitignore new file mode 100644 index 0000000..26ca391 --- /dev/null +++ b/mv-and-ip/.gitignore @@ -0,0 +1,19 @@ +## ======== Personal ======== +# VSCode settings +.vscode/ + +# All image files +*.jpg +*.png + +## ======== Python ======== +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/mv-and-ip/car_plate.py b/mv-and-ip/car_plate.py new file mode 100644 index 0000000..bd16250 --- /dev/null +++ b/mv-and-ip/car_plate.py @@ -0,0 +1,197 @@ +import cv2 as cv +import numpy as np +import matplotlib.pyplot as plt +import argparse +import typing +import logging +from pathlib import Path +from dataclasses import dataclass + + +def extract_car_plate(img: cv.typing.MatLike) -> typing.Optional[cv.typing.MatLike]: + """Extract the car plate part from given image. + + :param img: The image containing car plate in BGR format. + :return: The image of binary car plate in U8 format if succeed, otherwise None. + """ + # Step 1: Convert to grayscale + gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY) + + # Step 2: Apply Gaussian blur to reduce noise + blurred = cv.GaussianBlur(gray, (5, 5), 0) + + # Step 3: Edge detection using Canny + edges = cv.Canny(blurred, 50, 150) + + # Step 4: Morphological operations to connect edges + kernel = cv.getStructuringElement(cv.MORPH_RECT, (5, 5)) + dilated = cv.dilate(edges, kernel, iterations=2) + closed = cv.morphologyEx(dilated, cv.MORPH_CLOSE, kernel) + + # Step 5: Find contours + contours, _ = cv.findContours(closed, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE) + + if not contours: + logging.error("No contours found") + return None + + # Step 6: Find the most likely license plate contour + # License plates are typically rectangular with specific aspect ratios + max_area = 0 + best_contour = None + + for contour in contours: + area = cv.contourArea(contour) + if area < 500: # Filter out small contours + continue + + # Approximate the contour + peri = cv.arcLength(contour, True) + approx = cv.approxPolyDP(contour, 0.02 * peri, True) + + # Look for quadrilateral shapes (4 corners) + if len(approx) == 4: + x, y, w, h = cv.boundingRect(contour) + aspect_ratio = float(w) / h if h > 0 else 0 + + # Typical license plate aspect ratio is between 2 and 5 + if 2 <= aspect_ratio <= 5 and area > max_area: + max_area = area + best_contour = approx + + if best_contour is None: + # If no perfect quadrilateral found, try with largest rectangular contour + for contour in contours: + area = cv.contourArea(contour) + if area < 500: + continue + + x, y, w, h = cv.boundingRect(contour) + aspect_ratio = float(w) / h if h > 0 else 0 + + if 1.5 <= aspect_ratio <= 6 and area > max_area: + max_area = area + rect = cv.minAreaRect(contour) + box = cv.boxPoints(rect) + best_contour = np.int0(box) + + if best_contour is None: + logging.error("No valid contour found") + return None + + # Step 7: Perspective transformation to get front view + # Order points: top-left, top-right, bottom-right, bottom-left + pts = best_contour.reshape(4, 2) + rect = np.zeros((4, 2), dtype="float32") + + # Sum and difference of coordinates to find corners + s = pts.sum(axis=1) + rect[0] = pts[np.argmin(s)] # Top-left has smallest sum + rect[2] = pts[np.argmax(s)] # Bottom-right has largest sum + + diff = np.diff(pts, axis=1) + rect[1] = pts[np.argmin(diff)] # Top-right has smallest difference + rect[3] = pts[np.argmax(diff)] # Bottom-left has largest difference + + # Calculate width and height of new image + width_a = np.linalg.norm(rect[0] - rect[1]) + width_b = np.linalg.norm(rect[2] - rect[3]) + max_width = max(int(width_a), int(width_b)) + + height_a = np.linalg.norm(rect[0] - rect[3]) + height_b = np.linalg.norm(rect[1] - rect[2]) + max_height = max(int(height_a), int(height_b)) + + # Destination points for perspective transform + dst_pts = np.array([ + [0, 0], + [max_width - 1, 0], + [max_width - 1, max_height - 1], + [0, max_height - 1] + ], dtype="float32") + + # Get perspective transform matrix and apply it + M = cv.getPerspectiveTransform(rect, dst_pts) + warped = cv.warpPerspective(img, M, (max_width, max_height)) + + # Step 8: Convert warped image to grayscale + warped_gray = cv.cvtColor(warped, cv.COLOR_BGR2GRAY) + + # Step 9: Apply adaptive thresholding for better binarization + # First, enhance contrast + clahe = cv.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + enhanced = clahe.apply(warped_gray) + + # Apply Otsu's thresholding to get binary image + _, binary = cv.threshold(enhanced, 0, 255, cv.THRESH_BINARY_INV + cv.THRESH_OTSU) + + # Step 10: Clean up the binary image with morphological operations + kernel_clean = cv.getStructuringElement(cv.MORPH_RECT, (2, 2)) + cleaned = cv.morphologyEx(binary, cv.MORPH_CLOSE, kernel_clean) + + # Ensure the output is in the correct format (U8) + result = cleaned.astype(np.uint8) + + return result + + +@dataclass +class Cli: + input_file: Path + """The path to input file""" + output_file: Path + """The path to output file""" + + @staticmethod + def from_cmdline() -> "Cli": + # Build parser + parser = argparse.ArgumentParser( + prog="Car Plate Extractor", + description="Extract the car plate part from given image.", + ) + parser.add_argument( + "-i", + "--in", + required=True, + type=str, + action="store", + dest="input_file", + metavar="in.jpg", + help="""The path to input image containing car plate.""", + ) + parser.add_argument( + "-o", + "--out", + required=True, + type=str, + action="store", + dest="output_file", + metavar="out.png", + help="""The path to output image for extracted car plate.""", + ) + + # Parse argument from cmdline and return + args = parser.parse_args() + return Cli(Path(args.input_file), Path(args.output_file)) + + +def main(): + # Setup logging format + logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO) + + # Get user request + cli = Cli.from_cmdline() + + # Load file + in_img = cv.imread(str(cli.input_file), cv.IMREAD_COLOR) + if in_img is None: + logging.error(f"Fail to load image {cli.input_file}") + return + # Save extracted file if possible + out_img = extract_car_plate(in_img) + if out_img is not None: + cv.imwrite(str(cli.output_file), out_img) + + +if __name__ == "__main__": + main() diff --git a/mv-and-ip/main.py b/mv-and-ip/main.py deleted file mode 100644 index e708265..0000000 --- a/mv-and-ip/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from mv-and-ip!") - - -if __name__ == "__main__": - main()