1. Lời mở đầu
Xin chào mọi người, mình vừa làm xong pbl cuối kì về đề tài nhận dạng kí tự trong biển số xe Việt Nam. Lúc mình tìm hiểu thì có thấy 1 bài hướng dẫn trên này, tuy nhiên đã outdate và mình có chạy thử thì bị sai rất nhiều. Do đó mình làm một bài hướng dẫn nhận dạng kí tự biển số xe máy mới để các bạn có thể tham khảo.
2. Dataset biển số xe Việt nam
Về phần dataset thì mình có sử dụng nguồn trên mạng gồm biển số xe ô tô và biển số xe máy . Tuy nhiên bộ dataset biển số xe máy chưa được tốt lắm, các ảnh tương đối giống nhau, các bạn nên sưu tầm thêm ảnh bằng cách tự chụp các xe máy hoặc tìm thêm các nguồn khác trên mạng ( recommend trang roboflow ) để làm giàu dữ liệu. Bộ dữ liệu này là dữ liệu thô, chưa được gán nhãn. Do đó chúng ta phải tiến hành gán nhãn dữ liệu xong mới tiến hành train model YOLO được.
3. Cách gán nhãn dữ liệu
Sau khi đã tải xong bộ dữ liệu gồm ảnh chứa biển số xe oto và xe máy xong, chúng ta bắt đầu tiến hành gán nhãn dữ liệu. Có thể gán nhãn bằng tool LabelImage hoặc có thể gán nhãn bằng trang roboflow. Mình khuyến khích dùng roboflow vì trang này hỗ trợ cả gán nhãn dữ liệu và làm giàu dữ liệu bằng cách xoay ảnh, làm nhiễu ,.. Các bạn có thể truy cập trang roboflow tại đây Về cách gán nhãn dữ liệu bằng roboflow, các bạn vui lòng xem thêm tại đây . Sau khi các bạn đã gán nhãn xong, các bạn có thể chọn các tuỳ chọn làm giàu dữ liệu bằng cách bấm vào Generate và bấm generate new version, sau đó chọn thêm các tuỳ chọn ở phần 3 Preprocessing để tăng số lượng ảnh của bạn Sau khi làm xong các bước trên, bạn có thể tải dataset về Roboflow hỗ trợ rất nhiều định dạng cho các bạn tải về. Các bạn có thể tải yolov5 giống mình. Cách train thì trong github yolov5 đã có hướng dẫn khá rõ ràng, Link tại đây, các bạn có thể vào trang chủ để đọc rõ ràng hơn
4. Xác định vùng chứa biển số bằng yolov5
best_path = "src/util/best.pt"
model_detect_frame = torch.hub.load('ultralytics/yolov5', 'custom', path=best_path, force_reload=True)
File best.pt là gì? đó là file weights sau khi các bạn train thành công model yolov5, với model yolov5 thì các bạn train khoảng 100 vòng thì loss đã giảm còn rất thấp và ổn định. Sau khi load model yolov5 đã train xong, t tiến hành cắt vùng ảnh chứa biển số
results = model_detect_frame(image) df = results.pandas().xyxy[0] for obj in df.iloc: xmin = float(obj['xmin']) xmax = float(obj['xmax']) ymin = float(obj['ymin']) ymax = float(obj['ymax']) coord = np.array([[xmin, ymin], [xmax, ymin], [xmax, ymax], [xmin, ymax]]) LpRegion = perspective.four_point_transform(image, coord)
Sau bước này, bạn sẽ thu được biến LpRegion chính là ảnh biển số bạn cắt được, kết quả sẽ như thế này: Tuy nhiên ảnh biển số bị nghiêng 1 góc, tấm này độ nghiêng k ảnh hưởng nhiều đến kết quả, tuy nhiên nếu 1 số ảnh nghiêng nhiều hơn thì sẽ làm nhận dạng bị sai, do đó ta tiến hành đến bước tiếp theo để xử lý vấn đề này
5. Xoay căn chỉnh lại biển số
Các bước như sau:
B1: Tìm hình chữ nhật bao quanh biển số bằng cách chuyển đổi hình ảnh thành hình ảnh xám, sử dụng ngưỡng nhị phân để phân đoạn ảnh và tìm các đường viền (contours) bằng phương pháp tìm đường viền. Sau đó, tìm hình chữ nhật bao quanh biển số có diện tích lớn nhất trong các đường viền.
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY+cv2.THRESH_OTSU) contours, _ = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
Xác định góc xoay của hình chữ nhật bao quanh biển số bằng cách sử dụng phương pháp minAreaRect() để tìm hình chữ nhật nhỏ nhất bao phủ đường viền lớn nhất đã tìm được ở bước 1, và sau đó lấy góc nghiêng của hình chữ nhật này.
rect = cv2.minAreaRect(max_cnt) ((cx,cy),(cw,ch),angle) = rect
Xác định ma trận xoay để xoay hình ảnh ban đầu theo góc nghiêng tìm được ở bước 2 và thực hiện việc xoay hình ảnh ban đầu. Sau đó, tìm các đường viền trên hình ảnh đã xoay.
M = cv2.getRotationMatrix2D((cx,cy), angle-90, 1) rotated = cv2.warpAffine(img, M, (img.shape[1], img.shape[0]))
Tìm hình chữ nhật có diện tích lớn nhất trong các đường viền trên hình ảnh đã xoay và cắt bớt phần thừa để lấy hình ảnh của biển số.
for cnt in contours: area = cv2.contourArea(cnt) if area > max_area: max_area = area max_cnt = cnt
x,y,w,h = cv2.boundingRect(max_cnt)
cropped = rotated[y:y+h, x:x+w]
Sau khi thực hiện bước này, chúng t sẽ thu được kết quả:
Do hình này góc nghiêng không lớn nên chúng ta không thấy thay đổi đáng kể, tuy nhiên nếu bạn lấy 1 hình có biển số bị nghiêng 1 góc lớn thì kết quả thu được sẽ rất bất ngờ đấy
6. Xác định bounding box cho từng kí tự
6.1 Tiền xử lý ảnh
Đầu tiên, ảnh được chuyển đổi từ không gian màu BGR sang không gian màu HSV (Hue, Saturation, Value). Hàm cv2.cvtColor() được sử dụng để thực hiện việc chuyển đổi này. Sau đó, hình ảnh được chia thành các kênh riêng lẻ bằng hàm cv2.split(). Kênh thứ ba (Value) được lấy ra và gán cho biến V. Áp dụng phương pháp ngưỡng cục bộ (adaptive thresholding) để tách ngưỡng ảnh V. Hàm threshold_local() của module skimage.filters được sử dụng để thực hiện việc này. Giá trị ngưỡng được tính toán dựa trên các pixel lân cận của mỗi pixel trong ảnh. Các thông số 35, offset=5, và method="gaussian" được truyền vào hàm threshold_local() làm tham số để điều chỉnh việc tách ngưỡng. 35 là kích thước cửa sổ xung quanh mỗi pixel được sử dụng để tính toán giá trị ngưỡng cục bộ. offset=5 là giá trị được thêm vào giá trị ngưỡng để tăng độ tương phản của ảnh, và method="gaussian" là phương pháp tính toán giá trị ngưỡng dựa trên phân phối Gaussian của các giá trị pixel lân cận.
V = cv2.split(cv2.cvtColor(image, cv2.COLOR_BGR2HSV))[2] # adaptive threshold T = threshold_local(V, 35, offset=5, method="gaussian") thresh = (V > T).astype("uint8") * 255
Tiếp theo chúng ta chuyển đen thành trắng và ngược lại
thresh = cv2.bitwise_not(thresh)
Kết quả thu được : Bạn có thể thấy sau bước này thì kết quả thu được có rất nhiều nhiễu. Tiếp theo chúng ta sẽ xử lý vấn đề này
_, labels = cv2.connectedComponents(thresh)
Hàm cv2.connectedComponents() tìm các thành phần liên thông trên ảnh nhị phân thresh. Kết quả trả về là một ma trận có cùng kích thước với thresh, trong đó mỗi phần tử thể hiện một nhóm (group) của các điểm ảnh liên thông với nhau (cùng giá trị). Có thể tìm hiểu thêm thuật toán này trên youtube, minh hoạ bằng video rất dễ hiểu
mask = np.zeros(thresh.shape, dtype="uint8") total_pixels = thresh.shape[0] * thresh.shape[1] lower = total_pixels // 120 upper = total_pixels // 20
Tiếp theo, ta tạo một ma trận mask với cùng kích thước như ảnh thresh ban đầu và đưa các khu vực cần thiết vào đó.
for label in np.unique(labels): if label == 0: continue labelMask = np.zeros(thresh.shape, dtype="uint8") labelMask[labels == label] = 255 numPixels = cv2.countNonZero(labelMask) if numPixels > lower and numPixels < upper: mask = cv2.add(mask, labelMask)
Sau các bước này , bạn sẽ thu được kết quả như thế này: 😁 Chúng ta đã loại bỏ được hoàn toàn các nhiễu do viền biển số và bụi bẩn gây nên Tiếp theo chúng ta bắt đầu tìm các bounding box của từng kí tự thôi nàoo
6.2 Tìm bounding box của từng kí tự
cnts, _ = cv2.findContours( mask.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boundingBoxes = [cv2.boundingRect(c) for c in cnts]
Sau bước này, chúng ta sẽ tìm được tất cả đường bao quanh từng vùng màu trắng, tuy nhiên lớp mask đôi lúc không loại bỏ được tất cả nhiễu, nên chúng ta cần phải làm thêm 1 bước để loại nhiễu nữa
mean_w = np.mean(arr[:, 2])
mean_h = np.mean(arr[:, 3]) # Tính ngưỡng dựa trên trung bình cộng của w và h
threshold_w = mean_w * 1.5
threshold_h = mean_h * 1.5
new_arr = arr[(arr[:, 2] < threshold_w) & (arr[:, 3] < threshold_h)]
Sau bước này thì chúng ta đã loại được gần như tất cả nhiễu ( những ảnh mình test thì đã loại bỏ được hoàn toàn ). Tuy nhiên biển số xe việt nam thì gồm cả 1 dòng và 2 dòng, nên cần thêm 1 bước để có thể xác định được đúng vị trí từng dòng
line1 = []
line2 = []
mean_y = np.mean(arr[:,1])
for box in new_arr: x,y,w,h =box if y > mean_y * 1.2: line2.append(box) else: line1.append(box)
🤪 Như này là xong chưa nhỉ? Chưa xong đâu, còn thêm 1 bước sắp xếp kí tự từ trái qua phải nữa
line1 = sorted(line1, key=lambda box: box[0]) line2 = sorted(line2, key=lambda box: box[0])
Vậy là chúng ta đã hoàn thành xong bước tìm bounding box cho từng kí tự và sắp xếp chúng theo đúng vị trí rồi nè
7. Nhận diện kí tự
7.1 Train model CNN Alexnet
Mình lựa chọn một model CNN đơn giản là alexnet, chi tiết cách train mình không hướng dẫn trong bài này vì đã khá dài rồi, bạn nào cần thì comment phía dưới mình sẽ viết thêm bài cách train. Sau khi train thành công thì sẽ thu được một file weight h5.
7.2 Cắt từng kí tự ra và lưu vào 1 mảng
character = mask[y:y+h, x:x+w]
character = cv2.bitwise_not(character)
Chúng ta sẽ cắt từng kí tự từ lớp mask và boundingbox đã tìm thấy lúc nãy. Tuy nhiên model CNN Alexnet cần kích thước đầu vào là 128x128, nên ta phải thêm padding vào cho ảnh đủ kích thước
paddingY = (128 - rows) // 2 if rows < 128 else int(0.15 * rows)
paddingX = ( 128 - columns) // 2 if columns < 128 else int(0.45 * columns)
character = cv2.copyMakeBorder(character, paddingY, paddingY, paddingX, paddingX, cv2.BORDER_CONSTANT, None, 255)
Và 1 điều nữa, cần phải chuyển từ ảnh xám sang lại RGB
character = cv2.cvtColor(character, cv2.COLOR_GRAY2RGB)
character = cv2.resize(character, (128, 128))
Chúng ta sẽ thu được những hình giống như thế này : Cuối cùng là chuẩn hoá về đoạn 0 1 và thêm vào mảng kí tự .Chuẩn hóa ảnh cũng giúp tăng tính ổn định của mô hình, giảm thiểu độ lệch về giá trị giữa các ảnh và giúp mô hình học được mối quan hệ giữa các đặc trưng của ảnh một cách chính xác hơn.
character = character.astype("float") / 255.0
characters.append(character)
Cuối cùng chúng ta sẽ nhận dạng các kí tự này. Kết quả của chương trình này là 1 chuỗi kí tự biển số tương ứng với ảnh đầu vào Quá trình nhận dạng khá là nhanh, tổng cả quá trình tầm 0.8s cho một tấm ảnh
8. Những hạn chế
Một số hạn chế của chương trình này:
- Biển số các xe cũ thường có các mảng đất đen ở góc làm quá trình nhận dạng bị mất số ở góc
- Đôi lúc xuất hiện tình trạng nhầm số 8 và chữ B ( rất hiếm )