Khi xử lý ảnh thực tế từ camera công nghiệp, ảnh nhị phân (Binary Mask) thường không sạch hoàn hảo:
Vật thể có thể bị rỗ lỗ bên trong do phản xạ ánh sáng.
Có các hạt nhiễu li ti (nhiễu muối tiêu) xung quanh.
Các vật thể đặt gần nhau có thể bị dính mép viền vào nhau.
Các phép toán hình thái học (sử dụng một hạt nhân ma trận nhỏ gọi là Structuring Element / Kernel) giúp ta giải quyết triệt để các vấn đề này.
Link tham khảo:
https://tapit.vn/xu-ly-anh-hinh-thai-hoc-morphological-image-processing/
Erosion (Phép bào mòn - cv2.erode): Làm teo các vùng trắng, loại bỏ những hạt nhiễu nhỏ li ti đứng riêng lẻ.
Dilation (Phép giãn nở - cv2.dilate): Làm phình to vùng trắng, nối các đứt gãy và lấp các lỗ rỗng nhỏ bên trong vật thể.
Opening (cv2.morphologyEx với MORPH_OPEN): Thực hiện Erode trước $\rightarrow$ Dilate sau. Công dụng: Xóa sạch các chấm nhiễu nền mà không làm đổi kích thước vật thể chính.
Closing (cv2.morphologyEx với MORPH_CLOSE): Thực hiện Dilate trước $\rightarrow$ Erode sau. Công dụng: Hàn kín các lỗ thủng, vết nứt bên trong vật thể.
Chúng ta sẽ tạo một bài toán mô phỏng thực tế: Một ảnh chứa nhiều sản phẩm (hình khối kích thước to/nhỏ khác nhau) bị nhiễu chấm trắng và thủng lỗ. Chúng ta sẽ lọc sạch nhiễu, bóc tách viền, đếm số lượng và phân loại vật thể theo diện tích.
Dán đoạn mã sau vào một cell mới trên Colab và chạy:
import cv2
import numpy as np
from google.colab.patches import cv2_imshow
# =====================================================================
# BƯỚC 1: TẠO ẢNH MẪU CÓ NHIỄU (MÔ PHỎNG DÂY CHUYỀN SẢN XUẤT)
# =====================================================================
width, height = 600, 400
img = np.zeros((height, width), dtype=np.uint8)
# 1. Vẽ các sản phẩm loại Lớn (Bán kính 45px)
cv2.circle(img, (120, 150), 45, 255, -1)
cv2.circle(img, (300, 150), 45, 255, -1)
# 2. Vẽ sản phẩm loại Nhỏ (Bán kính 25px)
cv2.circle(img, (480, 150), 25, 255, -1)
cv2.circle(img, (200, 300), 25, 255, -1)
cv2.circle(img, (400, 300), 25, 255, -1)
# 3. Tạo lỗi thực tế: Đục lỗ đen bên trong sản phẩm (do chói đèn)
cv2.circle(img, (120, 150), 12, 0, -1)
cv2.circle(img, (300, 150), 10, 0, -1)
# 4. Tạo nhiễu hạt bụi (nhiễu muối tiêu rải rác xung quanh)
noise = np.random.randint(0, 2, (height, width), dtype=np.uint8) * 255
noise_mask = np.random.rand(height, width) < 0.01 # 1% điểm ảnh bị nhiễu
img[noise_mask] = noise[noise_mask]
print("1. Ảnh nhị phân đầu vào (bị nhiễu hạt bụi và thủng lỗ chói sáng):")
cv2_imshow(img)
# =====================================================================
# BƯỚC 2: KHỬ NHIỄU BẰNG PHÉP TOÁN HÌNH THÁI HỌC (OPENING & CLOSING)
# =====================================================================
# Tạo hạt nhân hình elip (kernel) kích thước 5x5
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
# A. Phép Opening: Bào mòn trước -> Phình sau để quét sạch các hạt bụi li ti
cleaned = cv2.morphologyEx(img, cv2.MORPH_OPEN, kernel)
# B. Phép Closing: Dùng kernel lớn hơn (15x15) để vá kín các lỗ thủng phản quang
kernel_fill = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel_fill)
print("\n2. Ảnh sau khi xử lý hình thái học (Sạch nhiễu nền, kín lõi hoàn toàn):")
cv2_imshow(cleaned)
# =====================================================================
# BƯỚC 3: TRÍCH XUẤT ĐƯỜNG BAO (CONTOURS) & PHÂN LOẠI KÍCH THƯỚC
# =====================================================================
contours, _ = cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Tạo ảnh màu kết quả để trực quan hóa
output = cv2.cvtColor(cleaned, cv2.COLOR_GRAY2BGR)
count_large = 0
count_small = 0
print(f"\n3. Kết quả phân tích chi tiết từng vật thể:")
for i, cnt in enumerate(contours):
# Tính diện tích (Pixel Area)
area = cv2.contourArea(cnt)
# Tính tọa độ khung chữ nhật bao quanh (Bounding Box)
x, y, w, h = cv2.boundingRect(cnt)
# Phân loại dựa trên ngưỡng diện tích (ngưỡng phân cách là 3000 px^2)
if area > 3000:
label = "Loai A (Lon)"
color = (0, 255, 0) # Màu xanh lá
count_large += 1
else:
label = "Loai B (Nho)"
color = (0, 165, 255) # Màu cam
count_small += 1
# Vẽ khung chữ nhật và nhãn thông tin
cv2.rectangle(output, (x, y), (x + w, y + h), color, 2)
cv2.putText(output, f"{label}: {int(area)}px", (x - 10, y - 10),
cv2.FONT_HERSHEY_SIMPLEX, 0.5, color, 1)
print(f" - Vật thể #{i+1}: Diện tích = {int(area)} px -> {label}")
print(f"\n👉 TỔNG KẾT: Phát hiện {len(contours)} vật thể (Lớn: {count_large}, Nhỏ: {count_small})")
print("Ảnh phân loại trực quan cuối cùng:")
cv2_imshow(output)
cv2.getStructuringElement: Tạo hình dạng của "cọ quét" (hình chữ nhật, chữ thập, hoặc hình tròn/elip). Với các vật thể dạng hạt tròn, dùng MORPH_ELLIPSE sẽ giữ viền mượt nhất.
cv2.contourArea: Cho phép máy tính đo đạc kích thước thực của vùng vật thể tính bằng pixel để làm căn cứ ra quyết định phân loại mà không cần mô hình AI phức tạp.
Trong lệnh contours, _ = cv2.findContours(cleaned, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE), biến contours là một tuple (hoặc list) trong Python chứa các mảng NumPy.
Cụ thể:
contours là tập hợp chứa tất cả các đường bao viền vật thể tìm thấy trong ảnh.
Mỗi phần tử con (ví dụ cnt = contours[0]) là một mảng NumPy 3 chiều đại diện cho tọa độ các điểm biên nối liền nhau tạo thành đường bao khép kín của một vật thể đơn lẻ.
cntMỗi đường bao cnt trong contours có cấu trúc:
Kiểu dữ liệu: numpy.ndarray với dtype=int32
Shape (Kích thước mảng): (N, 1, 2)
N: Số lượng điểm biên mẫu được thuật toán giữ lại.
1: Chiều kích thước phụ (thừa kế từ cấu trúc mảng điểm vector trong mã nguồn gốc C++ của OpenCV).
2: Cặp tọa độ theo đúng chuẩn OpenCV: [x, y] (với x là hoành độ ngang / cột, y là tung độ dọc / dòng).
Ví dụ nếu in ra:
print(type(contours)) # <class 'tuple'> (hoặc list tùy phiên bản)
print(len(contours)) # Số lượng vật thể phát hiện được
cnt0 = contours[0]
print(type(cnt0)) # <class 'numpy.ndarray'>
print(cnt0.shape) # (N, 1, 2), ví dụ: (18, 1, 2)
print(cnt0.dtype) # int32
Một mảng cnt cụ thể trông như sau:
array([[[100, 150]],
[[100, 220]],
[[180, 220]],
[[180, 150]]], dtype=int32)
cv2.CHAIN_APPROX_SIMPLE với contoursSố lượng điểm $N$ trong mảng phụ thuộc trực tiếp vào cờ nén bạn truyền vào:
Nếu dùng cv2.CHAIN_APPROX_NONE: Thuật toán lưu tất cả mọi pixel nằm trên đường biên. Một đường thẳng dài 100 pixel sẽ sinh ra 100 điểm tọa độ.
Nếu dùng cv2.CHAIN_APPROX_SIMPLE (như trong code của bạn): Thuật toán nén bằng cách chỉ lưu các điểm uốn / điểm góc. Một hình chữ nhật thẳng hàng sẽ được rút gọn chỉ còn đúng 4 điểm đỉnh, giúp tiết kiệm đáng kể bộ nhớ RAM và tăng tốc tính toán.
contours để tính toánVì cnt là ma trận các điểm tọa độ, bạn có thể truyền nó vào hàng loạt các hàm giải tích hình học của OpenCV:
for cnt in contours:
# 1. Diện tích vật thể (pixels vuông)
area = cv2.contourArea(cnt)
# 2. Chu vi đường biên
perimeter = cv2.arcLength(cnt, closed=True)
# 3. Khung chữ nhật bao ngoài (Bounding box): x, y, width, height
x, y, w, h = cv2.boundingRect(cnt)
# 4. Tọa độ tâm trọng tâm (Centroid) qua Moments
M = cv2.moments(cnt)
if M["m00"] != 0:
cX = int(M["m10"] / M["m00"])
cY = int(M["m01"] / M["m00"])
# 5. Làm phẳng mảng để lấy tọa độ thuần túy [N, 2]
points = cnt.reshape(-1, 2) # [[x1, y1], [x2, y2], ...]
_ là gì?Trong cú pháp contours, _ = ..., biến _ hứng giá trị Hierarchy (Cây phả hệ phân cấp):
Đây là một mảng numpy.ndarray mô tả quan hệ cha - con giữa các đường viền (đường viền nào nằm lồng bên trong đường viền nào, ví dụ: hình vành khuyên hoặc lỗ rỗng bên trong bánh xe).
Vì trong code bạn dùng cờ cv2.RETR_EXTERNAL (chỉ lấy các đường viền ngoài cùng, bỏ qua lỗ rỗng bên trong), thông tin phân cấp này không cần dùng tới nên quy ước đặt tên biến là _ để bỏ qua.