⭐ Vision Engineer · Chặng 3Bài 3/8Bài học~75 phút

Phân ngưỡng và contour: đếm, đo vật thể bằng OpenCV

Ngưỡng cố định, Otsu, adaptive; tìm và lọc contour, đếm đồng xu và đo kích thước theo pixel, kiểm chứng bằng ảnh có đáp án.

Phân ngưỡng và contour: đếm, đo vật thể bằng OpenCV

Học xong bài này, bạn sẽ

  • Phân ngưỡng cố định, Otsu, adaptive
  • Tìm contour và lọc theo diện tích
  • Đếm vật và đo kích thước theo pixel

Nên học trước: Lọc nhiễu và phát hiện cạnh

Nội dung bài viết
  1. 1. Ảnh mẫu: khay đồng xu có đáp án
  2. 2. Phân ngưỡng cố định
  3. 3. Otsu: tự tìm ngưỡng
  4. 4. Ngưỡng thích nghi (adaptive threshold)
  5. 5. Văn bản chụp dưới đèn không đều
  6. 6. Tìm contour và lọc theo diện tích
  7. 7. Đếm và đo kích thước theo pixel
  8. 8. Gom thành hàm và lưu ảnh minh chứng
  9. 9. Lỗi thường gặp
  10. 10. Bài tập
  11. Tóm tắt

Bài "Lọc nhiễu và phát hiện cạnh" tìm đường viền của vật. Rất nhiều bài toán trong nhà máy lại cần vùng vật: đếm sản phẩm trên khay, kiểm tra có/không có linh kiện, đo diện tích, phân loại theo kích thước. Chuỗi xử lý cổ điển cho các bài toán đó là làm mịn → phân ngưỡng → tìm contour → lọc → đo. Không cần AI, chạy vài mili giây và dễ giải thích với khách hàng. Bài này dạy ba cách phân ngưỡng (cố định, Otsu, thích nghi), cách tìm và lọc contour, rồi đếm và đo vật theo pixel. Hai bước tinh chỉnh quan trọng được dành cho hai bài ngay sau: làm sạch mặt nạ ở bài "Morphology: erode, dilate, opening, closing", và các đặc trưng hình học như độ tròn, góc xoay ở bài "Đặc trưng contour và blob analysis".

Chuẩn bịDùng opencv-python và numpy. Ảnh mẫu là một khay đồng xu vẽ bằng OpenCV, kèm file "đáp án" ghi vị trí và bán kính thật của từng đồng xu, để bạn kiểm chứng kết quả đếm và đo bằng số.

1. Ảnh mẫu: khay đồng xu có đáp án

Khay chính có 7 đồng xu lớn (bán kính 28 px) và 5 đồng xu nhỏ (bán kính 20 px) màu sáng trên nền tối, không chạm nhau, cộng 6 hạt bụi nhỏ. Đèn chiếu lệch làm bên trái tối hơn bên phải, và có nhiễu cảm biến. Cùng lúc ta tạo thêm hai khay dùng cho mục 4 và bài tập 1.

import json

import cv2
import numpy as np


def tao_khay(ten, seed=0, n_lon=7, n_nho=5, den_trai=0.6):
    """Vẽ khay đồng xu, lưu ảnh và file đáp án JSON. den_trai: độ sáng mép trái so với mép phải."""
    rng = np.random.default_rng(seed)
    H, W = 360, 480
    xu = []
    while len(xu) < n_lon + n_nho:                       # đặt ngẫu nhiên, không cho chạm nhau
        r = 28 if len(xu) < n_lon else 20
        x, y = int(rng.integers(r + 10, W - r - 10)), int(rng.integers(r + 10, H - r - 10))
        if all((x - a) ** 2 + (y - b) ** 2 > (r + rr + 12) ** 2 for a, b, rr in xu):
            xu.append((x, y, r))
    anh = np.full((H, W), 50, np.uint8)
    for x, y, r in xu:
        cv2.circle(anh, (x, y), r, int(rng.integers(175, 205)), -1)
    for _ in range(6):                                   # hạt bụi bán kính 2 px
        cv2.circle(anh, (int(rng.integers(5, W - 5)), int(rng.integers(5, H - 5))), 2, 200, -1)
    den = np.tile(np.linspace(den_trai, 1.0, W), (H, 1))  # đèn lệch: trái tối, phải sáng
    anh = np.clip(anh * den + rng.normal(0, 6, (H, W)), 0, 255).astype(np.uint8)
    cv2.imwrite(ten, anh)
    with open(ten.replace(".png", ".json"), "w", encoding="utf-8") as f:
        json.dump(xu, f)
    return xu


xu = tao_khay("khay_dong_xu.png")
tao_khay("khay_toi.png", seed=0, den_trai=0.25)          # cùng vị trí, đèn lệch mạnh (mục 4)
tao_khay("khay_5.png", seed=5, n_lon=6, n_nho=8)         # khay cho bài tập 1
print("đáp án:", len(xu), "đồng xu |", sum(r == 28 for _, _, r in xu), "lớn,", sum(r == 20 for _, _, r in xu), "nhỏ")
đáp án: 12 đồng xu | 7 lớn, 5 nhỏ

2. Phân ngưỡng cố định

Phân ngưỡng biến ảnh xám thành ảnh nhị phân (mặt nạ): điểm sáng hơn ngưỡng T thành 255 (trắng, là vật), còn lại thành 0 (đen, là nền). Quy ước vật trắng trên nền đen là bắt buộc cho các hàm contour và morphology của OpenCV.

import cv2

g = cv2.imread("khay_dong_xu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(g, (5, 5), 0)
for T in (45, 55, 90, 120, 150):
    _, mat_na = cv2.threshold(mo, T, 255, cv2.THRESH_BINARY)
    cs, _ = cv2.findContours(mat_na, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    lon = [c for c in cs if cv2.contourArea(c) > 300]
    print(f"T={T:3d}: {len(cs):4d} contour, {len(lon):2d} contour có diện tích > 300 px²")
T= 45:  315 contour, 10 contour có diện tích > 300 px²
T= 55:   18 contour, 12 contour có diện tích > 300 px²
T= 90:   17 contour, 12 contour có diện tích > 300 px²
T=120:   26 contour, 12 contour có diện tích > 300 px²
T=150:   56 contour,  3 contour có diện tích > 300 px²
  • T quá thấp (45): nền bên phải (mức khoảng 50) cộng nhiễu vượt ngưỡng, sinh ra hàng trăm contour giả, và các mảng nền dính vào nhau làm sai cả số contour lớn.
  • T quá cao (150): các đồng xu bên trái (bị tối đi) không còn vượt ngưỡng hoặc chỉ còn mảnh vụn, đếm thiếu.
  • Từ 55 đến 120 có một dải ngưỡng dùng được (luôn ra 12 contour lớn; số contour nhỏ là bụi, sẽ lọc ở mục 6). Dải này càng rộng thì hệ thống càng chịu được thay đổi ánh sáng. Khi cài đặt trạm, hãy ghi lại cả dải chứ không chỉ một giá trị, và chọn ngưỡng ở giữa dải.
KiểuKết quảDùng khi
THRESH_BINARY> T → 255, còn lại 0Vật sáng trên nền tối
THRESH_BINARY_INV> T → 0, còn lại 255Vật tối trên nền sáng (đèn nền/backlight, chữ in đen trên giấy)
THRESH_TRUNC, THRESH_TOZEROCắt hoặc xóa một phía, giữ mức xám phía còn lạiÍt dùng để tạo mặt nạ; dùng để hạn chế vùng quá sáng

Hàm cv2.threshold trả về hai giá trị: ngưỡng đã dùng và ảnh kết quả. Với ngưỡng cố định, giá trị đầu chỉ là T bạn truyền vào; với Otsu, đó là ngưỡng mà thuật toán tự tìm.

3. Otsu: tự tìm ngưỡng

Khi histogram có hai đỉnh (nền và vật), Otsu thử mọi ngưỡng 0–255 và chọn ngưỡng làm phương sai giữa hai nhóm (between-class variance) lớn nhất, tức là hai nhóm "nền" và "vật" tách nhau rõ nhất. Tự cài đặt để thấy nó không có gì bí ẩn:

import cv2
import numpy as np

g = cv2.imread("khay_dong_xu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(g, (5, 5), 0)

p = np.bincount(mo.ravel(), minlength=256) / mo.size     # xác suất từng mức xám
muc = np.arange(256)
tot_nhat, T_tot = -1.0, 0
for T in range(1, 256):
    w0, w1 = p[:T].sum(), p[T:].sum()                     # tỉ lệ nhóm nền (< T) và nhóm vật (>= T)
    if w0 == 0 or w1 == 0:
        continue
    m0 = (muc[:T] * p[:T]).sum() / w0                     # độ sáng trung bình từng nhóm
    m1 = (muc[T:] * p[T:]).sum() / w1
    pv = w0 * w1 * (m0 - m1) ** 2                         # phương sai giữa hai nhóm
    if pv > tot_nhat:
        tot_nhat, T_tot = pv, T

t_cv, mat_na = cv2.threshold(mo, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print("Otsu tự viết: vật là mức >=", T_tot, "| OpenCV: vật là mức >", int(t_cv))
cv2.imwrite("mat_na_otsu.png", mat_na)
print("tỉ lệ điểm trắng:", f"{(mat_na > 0).mean():.1%}")
Otsu tự viết: vật là mức >= 91 | OpenCV: vật là mức > 90
tỉ lệ điểm trắng: 13.6%

Hai cách cho cùng một điểm cắt (OpenCV báo T với quy ước "lớn hơn T là vật", bản tự viết dùng "lớn hơn hoặc bằng T + 1"). Khi dùng Otsu, tham số ngưỡng truyền vào (ở đây 0) bị bỏ qua. Ghi lại ngưỡng Otsu trả về trong log là thói quen tốt: nếu nó trôi dần theo ngày, đèn hoặc camera đang có vấn đề.

Khi nào Otsu thất bại?Otsu giả định histogram có hai nhóm rõ ràng. Nó cho kết quả kém khi: vật chiếm diện tích rất nhỏ (ví dụ dưới vài phần trăm ảnh, đỉnh của vật gần như không thấy trên histogram); ánh sáng không đều đến mức nền chỗ sáng sáng hơn vật chỗ tối; hoặc ảnh không có vật nào (Otsu vẫn "tìm" ra một ngưỡng và cắt nền làm đôi). Trường hợp cuối rất hay gặp khi khay trống: luôn kiểm tra thêm, ví dụ độ chênh giữa hai nhóm phải đủ lớn.

4. Ngưỡng thích nghi (adaptive threshold)

Ngưỡng thích nghi tính một ngưỡng riêng cho từng điểm ảnh: trung bình (hoặc trung bình có trọng số Gaussian) của vùng blockSize × blockSize quanh điểm đó, trừ đi hằng số C. Nhờ vậy nó chịu được ánh sáng thay đổi chậm trên ảnh. Thử trên khay khay_toi.png tạo ở mục 1: cùng vị trí đồng xu nhưng đèn lệch mạnh, mép trái chỉ còn 25% độ sáng.

import cv2

mo = cv2.GaussianBlur(cv2.imread("khay_toi.png", cv2.IMREAD_GRAYSCALE), (5, 5), 0)


def thong_ke(mat_na):
    n, _, stats, _ = cv2.connectedComponentsWithStats(mat_na, connectivity=8)
    dt = sorted(int(a) for a in stats[1:, cv2.CC_STAT_AREA] if a > 300)
    return f"{len(dt):2d} đồng xu, diện tích {dt[0]}..{dt[-1]}" if dt else "0 đồng xu"


t, m = cv2.threshold(mo, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
print(f"Otsu (T={t:.0f})       :", thong_ke(m))
for bs in (31, 61, 151):
    m = cv2.adaptiveThreshold(mo, 255, cv2.ADAPTIVE_THRESH_MEAN_C, cv2.THRESH_BINARY, bs, -10)
    print(f"adaptive block={bs:3d} :", thong_ke(m))
print("diện tích lý thuyết: nhỏ π·20² ≈ 1257, lớn π·28² ≈ 2463")
Otsu (T=75)       : 10 đồng xu, diện tích 1235..2543
adaptive block= 31 : 12 đồng xu, diện tích 935..1793
adaptive block= 61 : 12 đồng xu, diện tích 1243..2450
adaptive block=151 : 12 đồng xu, diện tích 1304..2582
diện tích lý thuyết: nhỏ π·20² ≈ 1257, lớn π·28² ≈ 2463

Đọc kết quả (diện tích ở đây là số pixel trắng của từng vùng):

  • Otsu toàn cục mất 2 đồng xu ở mép trái: ở đó đồng xu đã tối gần bằng nền bên phải.
  • block = 31 (nhỏ hơn đường kính đồng xu lớn, 57 px): ở giữa đồng xu, cả cửa sổ nằm trong đồng xu nên trung bình bằng chính độ sáng đồng xu, điểm ảnh không vượt được "trung bình + 10" và bị đánh dấu là nền. Đồng xu bị rỗng ruột, diện tích nhỏ hẳn, không phân loại lớn/nhỏ được nữa.
  • block = 61 (lớn hơn đường kính vật một chút): đủ 12 đồng xu, diện tích sát lý thuyết.
  • block = 151: cửa sổ chứa nhiều nền tối, ngưỡng hạ xuống, vật "phình" ra.

Quy tắc chọn tham số:

Tham sốQuy tắc
blockSize (lẻ, ≥ 3)Lớn hơn kích thước vật cần tách (để cửa sổ luôn chứa cả vật lẫn nền), nhỏ hơn quy mô thay đổi của ánh sáng. Với chữ: vài lần độ dày nét chữ.
CLớn hơn biên độ nhiễu, để vùng phẳng không bị lốm đốm. Với THRESH_BINARY và vật sáng, C âm nghĩa là "sáng hơn trung bình |C| mức mới là vật".
MEAN_C hay GAUSSIAN_CGaussian ưu tiên điểm gần tâm, đường biên mượt hơn một chút; khác biệt thường nhỏ.

5. Văn bản chụp dưới đèn không đều

Ứng dụng kinh điển của ngưỡng thích nghi là tách chữ trên phiếu, nhãn chụp dưới đèn không đều, trước bước OCR (chặng 7). Ta vẽ phiếu kiểm tra với chữ đen trên giấy trắng, góc trái trên bị tối, và giữ lại mặt nạ chữ thật để chấm điểm bằng IoU (Intersection over Union: phần giao chia phần hợp giữa chữ tìm được và chữ thật; 1 là hoàn hảo):

import cv2
import numpy as np

H, W = 300, 480
chu = np.zeros((H, W), np.uint8)                          # mặt nạ chữ thật (đáp án)
dong = ["PHIEU KIEM TRA CHAT LUONG", "Ma lo: 25A-0917   Ca: 2", "So luong OK: 1198  NG: 12",
        "Nguoi kiem: Tran Van B", "Ghi chu: den trai yeu, can thay"]
for i, s in enumerate(dong):
    cv2.putText(chu, s, (20, 50 + i * 50), cv2.FONT_HERSHEY_SIMPLEX, 0.75, 255, 2)
giay = 215.0 - (chu > 0) * 150.0                          # chữ 65 trên giấy 215
yy, xx = np.mgrid[0:H, 0:W]
den = 0.25 + 0.75 * np.clip((xx + 0.6 * yy) / (W * 0.9), 0, 1)   # góc trái trên tối
rng = np.random.default_rng(2)
anh = np.clip(giay * den + rng.normal(0, 4, (H, W)), 0, 255).astype(np.uint8)
cv2.imwrite("van_ban.png", anh)
cv2.imwrite("van_ban_dap_an.png", chu)


def iou(mat_na):
    a, b = mat_na > 0, chu > 0
    return (a & b).sum() / (a | b).sum()


t, otsu = cv2.threshold(anh, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
thich_nghi = cv2.adaptiveThreshold(anh, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
                                   cv2.THRESH_BINARY_INV, 31, 20)
print(f"Otsu (T={t:.0f}): IoU = {iou(otsu):.3f}, tỉ lệ ảnh bị coi là chữ {(otsu > 0).mean():.0%}")
print(f"Adaptive 31/20: IoU = {iou(thich_nghi):.3f}, tỉ lệ ảnh bị coi là chữ {(thich_nghi > 0).mean():.0%}")
print(f"Thực tế chữ chiếm {(chu > 0).mean():.0%} ảnh")
Otsu (T=147): IoU = 0.215, tỉ lệ ảnh bị coi là chữ 38%
Adaptive 31/20: IoU = 0.993, tỉ lệ ảnh bị coi là chữ 8%
Thực tế chữ chiếm 8% ảnh

Otsu toàn cục coi cả góc tối là "chữ" (IoU rất thấp); ngưỡng thích nghi khôi phục gần như hoàn hảo. Ở đây dùng THRESH_BINARY_INV vì chữ tối trên nền sáng, và C = 20 dương nghĩa là "tối hơn trung bình vùng 20 mức mới là chữ". Bài tập 2 sẽ khảo sát ảnh hưởng của blockSize và C.

6. Tìm contour và lọc theo diện tích

Contour là đường bao của một vùng trắng liên thông trong mặt nạ, lưu dưới dạng mảng điểm. Từ OpenCV 4 (và cả OpenCV 5), cv2.findContours trả về hai giá trị (contours, hierarchy); OpenCV 3 trả về ba giá trị. Đây là lý do nhiều code cũ trên mạng báo lỗi "too many values to unpack".

import cv2

mat_na = cv2.imread("mat_na_otsu.png", cv2.IMREAD_GRAYSCALE)
cs, h = cv2.findContours(mat_na, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cs_full, _ = cv2.findContours(mat_na, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
print("số contour:", len(cs), "| shape contour đầu:", cs[0].shape, "| hierarchy:", h.shape)
print("số điểm lưu (SIMPLE / NONE):", sum(len(c) for c in cs), "/", sum(len(c) for c in cs_full))

dien_tich = sorted(cv2.contourArea(c) for c in cs)
print("6 diện tích nhỏ nhất:", [round(a, 1) for a in dien_tich[:6]])
vat = [c for c in cs if cv2.contourArea(c) >= 300]
print("sau khi lọc diện tích >= 300:", len(vat), "vật")
số contour: 17 | shape contour đầu: (5, 1, 2) | hierarchy: (1, 17, 4)
số điểm lưu (SIMPLE / NONE): 884 / 1703
6 diện tích nhỏ nhất: [4.0, 4.0, 5.0, 8.0, 11.5, 1152.0]
sau khi lọc diện tích >= 300: 12 vật
  • RETR_EXTERNAL chỉ lấy đường bao ngoài cùng, đúng thứ cần để đếm vật. Các chế độ khác (RETR_CCOMP, RETR_TREE) và mảng hierarchy dùng để tìm lỗ bên trong vật, được dạy kỹ ở bài "Đặc trưng contour và blob analysis".
  • CHAIN_APPROX_SIMPLE chỉ lưu các điểm đầu mút của đoạn thẳng ngang, dọc, chéo, tiết kiệm bộ nhớ mà diện tích, khung bao không đổi. Dùng CHAIN_APPROX_NONE khi cần mọi điểm biên (ví dụ fitting đường tròn).
  • Lọc theo diện tích loại bụi và nhiễu. Ngưỡng diện tích chọn ở khoảng giữa "nhiễu lớn nhất" và "vật nhỏ nhất hợp lệ". Ở đây bụi chỉ dưới 12 px², đồng xu nhỏ nhất khoảng 1150 px², nên 300 cách xa cả hai.
  • contourArea của contour 1 điểm hoặc 1 đường thẳng bằng 0. Đừng chia cho diện tích mà không kiểm tra.
Khi lọc diện tích chưa đủLọc diện tích chỉ loại được nhiễu tách rời. Khi nhiễu dính vào vật, vật có lỗ thủng, hay đường biên lởm chởm, bạn cần làm sạch mặt nạ trước khi tìm contour: đó là nội dung bài "Morphology: erode, dilate, opening, closing" ngay sau bài này.

7. Đếm và đo kích thước theo pixel

Với mỗi contour hợp lệ, ta đo các đại lượng cơ bản và so với đáp án:

Đại lượngHàmGhi chú
Diện tíchcv2.contourArea(c)Diện tích đa giác qua tâm các pixel biên, hơi nhỏ hơn số pixel
TâmM = cv2.moments(c); M["m10"]/M["m00"]Trọng tâm, có phần thập phân
Khung baocv2.boundingRect(c)x, y, w, h theo trục ảnh
Đường tròn bao nhỏ nhấtcv2.minEnclosingCircle(c)Tâm và bán kính, hợp với vật tròn
Đường kính tương đươngsqrt(4·A/π)Đường kính của hình tròn có cùng diện tích, dùng được cho vật méo
import json
import math

import cv2
import numpy as np

MM_PER_PX = 0.4                                          # tỉ lệ giả định, từ hiệu chuẩn (chặng 8)
g = cv2.imread("khay_dong_xu.png", cv2.IMREAD_GRAYSCALE)
_, mat_na = cv2.threshold(cv2.GaussianBlur(g, (5, 5), 0), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
cs, _ = cv2.findContours(mat_na, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cs = [c for c in cs if cv2.contourArea(c) >= 300]
cs.sort(key=lambda c: cv2.boundingRect(c)[0])            # trái sang phải
dap_an = json.load(open("khay_dong_xu.json", encoding="utf-8"))

print(" #   tâm (x, y)     A(px²)  D_tđ(px)  r_bao  D(mm)  loại | r thật")
for i, c in enumerate(cs, 1):
    A = cv2.contourArea(c)
    M = cv2.moments(c)
    cx, cy = M["m10"] / M["m00"], M["m01"] / M["m00"]
    d_td = math.sqrt(4 * A / math.pi)
    (_, _), r_bao = cv2.minEnclosingCircle(c)
    loai = "lớn" if A > 1800 else "nhỏ"                   # ngưỡng giữa ~1250 và ~2450
    r_that = min(dap_an, key=lambda t: (t[0] - cx) ** 2 + (t[1] - cy) ** 2)[2]
    print(f"{i:2d}  ({cx:5.1f},{cy:6.1f})  {A:6.0f}  {d_td:8.1f}  {r_bao:5.1f}  {d_td * MM_PER_PX:5.1f}  {loai:4s} | {r_that}")
so_lon = sum(cv2.contourArea(c) > 1800 for c in cs)
print(f"Tổng: {len(cs)} đồng xu ({so_lon} lớn, {len(cs) - so_lon} nhỏ)")
#   tâm (x, y)     A(px²)  D_tđ(px)  r_bao  D(mm)  loại | r thật
 1  ( 39.1, 192.0)    1152      38.3   19.7   15.3  nhỏ  | 20
 2  ( 68.1,  42.0)    2315      54.3   27.7   21.7  lớn  | 28
 3  (108.1, 268.0)    2335      54.5   27.8   21.8  lớn  | 28
 4  (162.0,  49.0)    2367      54.9   27.9   22.0  lớn  | 28
 5  (195.0, 287.0)    1196      39.0   20.0   15.6  nhỏ  | 20
 6  (241.0, 210.0)    2370      54.9   27.9   22.0  lớn  | 28
 7  (244.0, 114.0)    2370      54.9   27.9   22.0  lớn  | 28
 8  (300.1, 297.0)    2376      55.0   28.1   22.0  lớn  | 28
 9  (311.0,  30.0)    1201      39.1   20.1   15.6  nhỏ  | 20
10  (381.0, 218.0)    2414      55.4   28.3   22.2  lớn  | 28
11  (385.1,  82.0)    1244      39.8   20.6   15.9  nhỏ  | 20
12  (437.1, 247.8)    1255      40.0   22.0   16.0  nhỏ  | 20
Tổng: 12 đồng xu (7 lớn, 5 nhỏ)

Nhận xét quan trọng khi đo theo pixel:

  • Đếm đúng 12, phân loại đúng 7 lớn và 5 nhỏ, khớp đáp án. Ngưỡng phân loại 1800 px² nằm giữa hai nhóm, cách mỗi nhóm hàng trăm px², nên có biên an toàn lớn.
  • Đồng xu vẽ với bán kính 28 có đường kính thật trên lưới pixel là 57 px (2r + 1). Đường kính tương đương và bán kính đường tròn bao hơi khác nhau và hơi khác đáp án: biên bị làm mịn, đèn lệch làm đồng xu bên trái "co" lại nhẹ khi phân ngưỡng. Sai số cỡ 1 px là bình thường cho phương pháp phân ngưỡng; độ chính xác dưới pixel cần các kỹ thuật đo cạnh ở chặng 7.
  • Đồng số 12 có r_bao = 22,0 trong khi đường kính tương đương chỉ 40,0 px: một hạt bụi dính sát mép đồng xu tạo một chỗ lồi nhỏ, đủ kéo đường tròn bao ra 2 px nhưng gần như không đổi diện tích. Đại lượng dựa trên diện tích bền với nhiễu hơn đại lượng dựa trên điểm cực biên (khung bao, đường tròn bao).
  • Đổi sang mm chỉ là nhân với tỉ lệ MM_PER_PX, nhưng tỉ lệ đó phải đến từ hiệu chuẩn (chặng 8, bài quy đổi pixel sang mm), không phải đoán. Diện tích đổi bằng MM_PER_PX².
  • Thứ tự contour OpenCV trả về không theo trái sang phải; ở đây tự sắp xếp theo x của khung bao. Cách sắp xếp theo hàng rồi cột cho khay nhiều hàng có ở bài "Đặc trưng contour và blob analysis".

8. Gom thành hàm và lưu ảnh minh chứng

Trong dự án, gom các bước thành một hàm có tham số rõ ràng, trả về danh sách kết quả có cấu trúc (như bài tổ chức pipeline ở chặng 2), và luôn lưu một ảnh minh chứng để người vận hành kiểm tra:

import cv2
import numpy as np


def dem_vat(gray, dien_tich_min=300, nguong=None, vat_toi=False, k_blur=5):
    """Trả về (danh sách vật, mặt nạ). nguong=None: dùng Otsu. vat_toi=True: vật tối trên nền sáng."""
    mo = cv2.GaussianBlur(gray, (k_blur, k_blur), 0) if k_blur > 1 else gray
    kieu = cv2.THRESH_BINARY_INV if vat_toi else cv2.THRESH_BINARY
    if nguong is None:
        t, mat_na = cv2.threshold(mo, 0, 255, kieu + cv2.THRESH_OTSU)
    else:
        t, mat_na = cv2.threshold(mo, nguong, 255, kieu)
    cs, _ = cv2.findContours(mat_na, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    ds = []
    for c in cs:
        A = cv2.contourArea(c)
        if A < dien_tich_min:
            continue
        M = cv2.moments(c)
        ds.append({"tam": (round(M["m10"] / M["m00"], 1), round(M["m01"] / M["m00"], 1)),
                   "dien_tich": round(A), "khung": cv2.boundingRect(c), "contour": c})
    ds.sort(key=lambda v: v["khung"][0])
    return ds, mat_na, t


def ve_minh_chung(gray, ds, nguong_lon=1800):
    ve = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR)
    for i, v in enumerate(ds, 1):
        mau = (0, 200, 0) if v["dien_tich"] > nguong_lon else (255, 128, 0)
        cv2.drawContours(ve, [v["contour"]], -1, mau, 2)
        x, y = int(v["tam"][0]), int(v["tam"][1])
        cv2.putText(ve, str(i), (x - 6, y + 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 2)
    cv2.putText(ve, f"Tong: {len(ds)}", (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 255), 2)
    return ve


g = cv2.imread("khay_dong_xu.png", cv2.IMREAD_GRAYSCALE)
ds, mat_na, t = dem_vat(g)
cv2.imwrite("minh_chung_dem_xu.png", ve_minh_chung(g, ds))
print(f"ngưỡng Otsu {t:.0f}, đếm được {len(ds)} vật, đã lưu minh_chung_dem_xu.png")
ngưỡng Otsu 90, đếm được 12 vật, đã lưu minh_chung_dem_xu.png

Ảnh minh chứng tô xanh lá đồng xu lớn, xanh dương đồng xu nhỏ, đánh số theo thứ tự trái sang phải giống bảng in ra. Khi khách hàng hỏi "tại sao khay này báo thiếu", bạn mở ảnh này thay vì giải thích bằng lời. Hàm trả về cả ngưỡng đã dùng để ghi log.

9. Lỗi thường gặp

Hiện tượngNguyên nhânCách sửa
Đếm ra 1 vật bằng cả khung hìnhVật tối trên nền sáng mà dùng THRESH_BINARY: nền thành vậtTHRESH_BINARY_INV
Hàng trăm vật giảNhiễu, bụi, ngưỡng quá sát nềnLọc nhiễu trước, lọc diện tích, morphology (bài sau)
Thiếu vật ở một phía ảnhÁnh sáng không đều với ngưỡng toàn cụcSửa đèn; hoặc adaptive threshold với blockSize lớn hơn vật
Vật rỗng ruột, diện tích nhỏ bất thườngblockSize của adaptive nhỏ hơn vậtTăng blockSize
Hai vật đếm thành mộtVật chạm nhauTách bằng watershed (bài "Watershed: tách các vật dính nhau") hoặc tách cơ khí
Khay trống vẫn đếm ra vậtOtsu luôn chia ảnh làm hai nhómKiểm tra độ chênh hai nhóm, hoặc dùng ngưỡng cố định khi đèn ổn định
"too many values to unpack"Code viết cho OpenCV 3cs, h = cv2.findContours(...)
Ánh sáng trước, thuật toán sauMột tấm đèn nền (backlight) đặt dưới vật biến bài toán đếm thành ảnh đen trắng gần như hoàn hảo: ngưỡng cố định chạy ổn định cả năm. Chặng 6 (chiếu sáng) bàn kỹ điều này. Adaptive threshold là công cụ cứu vãn khi không thể sửa đèn, không phải lựa chọn đầu tiên.

10. Bài tập

Bài 1. Đếm số đồng xu trong khay khay_5.png (tạo ở mục 1 với seed=5, 6 đồng lớn, 8 đồng nhỏ) và in diện tích từng đồng. So số đếm với file đáp án JSON.

Xem đáp án
import json

import cv2

g = cv2.imread("khay_5.png", cv2.IMREAD_GRAYSCALE)
_, m = cv2.threshold(cv2.GaussianBlur(g, (5, 5), 0), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
cs, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
cs = sorted([c for c in cs if cv2.contourArea(c) >= 300], key=lambda c: cv2.boundingRect(c)[0])
for i, c in enumerate(cs, 1):
    print(f"đồng {i:2d}: diện tích {cv2.contourArea(c):6.0f} px²")
dap_an = json.load(open("khay_5.json", encoding="utf-8"))
print("đếm được:", len(cs), "| đáp án:", len(dap_an), "|", "ĐÚNG" if len(cs) == len(dap_an) else "SAI")
đồng  1: diện tích   1154 px²
đồng  2: diện tích   2318 px²
đồng  3: diện tích   1156 px²
đồng  4: diện tích   1139 px²
đồng  5: diện tích   1156 px²
đồng  6: diện tích   1151 px²
đồng  7: diện tích   2364 px²
đồng  8: diện tích   2369 px²
đồng  9: diện tích   2371 px²
đồng 10: diện tích   2386 px²
đồng 11: diện tích   1230 px²
đồng 12: diện tích   1256 px²
đồng 13: diện tích   2441 px²
đồng 14: diện tích   1264 px²
đếm được: 14 | đáp án: 14 | ĐÚNG

Diện tích tách thành hai nhóm rõ rệt (quanh 1250 và quanh 2450). Với ảnh của bạn, chỉ cần đổi tên file trong cv2.imread. Nếu bạn chụp đồng xu thật, đặt chúng trên nền tối, không chạm nhau, đèn chiếu từ trên xuống đều.

Bài 2. Dùng adaptive threshold cho ảnh văn bản chụp dưới đèn không đều (van_ban.png, mục 5). Thử blockSize = 7, 15, 31, 61 và C = 5, 10, 20, in IoU. Giải thích vì sao C = 5 luôn kém.

Xem đáp án
import cv2

anh = cv2.imread("van_ban.png", cv2.IMREAD_GRAYSCALE)
chu = cv2.imread("van_ban_dap_an.png", cv2.IMREAD_GRAYSCALE) > 0
print("block |  C=5   C=10  C=20")
for bs in (7, 15, 31, 61):
    hang = []
    for C in (5, 10, 20):
        m = cv2.adaptiveThreshold(anh, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, bs, C) > 0
        hang.append((m & chu).sum() / (m | chu).sum())
    print(f"{bs:5d} | " + "  ".join(f"{v:.3f}" for v in hang))
block |  C=5   C=10  C=20
    7 | 0.432  0.794  0.551
   15 | 0.450  0.917  0.889
   31 | 0.480  0.933  0.993
   61 | 0.557  0.951  1.000
  • C = 5 nhỏ hơn biên độ nhiễu (độ lệch chuẩn 4, dao động ± 8–12 mức là thường): trên giấy trắng phẳng, rất nhiều điểm tối hơn trung bình 5 mức chỉ vì nhiễu, nên giấy bị lốm đốm "chữ". Quy tắc: C lớn hơn khoảng 2–3 lần độ lệch chuẩn nhiễu.
  • blockSize = 7 chỉ hơn độ dày nét chữ (khoảng 2–3 px) một chút: ở giữa các nét đậm, cửa sổ gần như toàn chữ, chữ bị rỗng. Từ 31 trở lên, kết quả gần hoàn hảo với C đủ lớn.
  • C quá lớn sẽ làm mất chữ ở vùng tối, nơi chữ chỉ tối hơn giấy khoảng 37 mức. Hãy luôn kiểm tra ở vùng tối nhất của ảnh.

Bài 3. Trạm đếm đai ốc dùng đèn nền: đai ốc hiện lên thành hình lục giác đen trên nền trắng. Tạo ảnh có 9 lục giác đen (bán kính 22 px) trên nền 230 và đếm chúng. Thử cả hai kiểu THRESH_BINARY và THRESH_BINARY_INV.

Xem đáp án
import cv2
import numpy as np

anh = np.full((300, 400), 230, np.uint8)
goc = np.deg2rad(np.arange(0, 360, 60))
for i in range(9):
    cx, cy = 70 + (i % 3) * 130, 60 + (i // 3) * 90
    pts = np.stack([cx + 22 * np.cos(goc), cy + 22 * np.sin(goc)], axis=1).astype(np.int32)
    cv2.fillPoly(anh, [pts], 25)
anh = np.clip(anh + np.random.default_rng(0).normal(0, 5, anh.shape), 0, 255).astype(np.uint8)

mo = cv2.GaussianBlur(anh, (5, 5), 0)
for ten, kieu in [("THRESH_BINARY    ", cv2.THRESH_BINARY), ("THRESH_BINARY_INV", cv2.THRESH_BINARY_INV)]:
    _, m = cv2.threshold(mo, 0, 255, kieu + cv2.THRESH_OTSU)
    cs, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    dt = [round(cv2.contourArea(c)) for c in cs if cv2.contourArea(c) >= 300]
    print(f"{ten}: {len(dt)} vật, diện tích {sorted(set(dt))}")
THRESH_BINARY    : 1 vật, diện tích [119301]
THRESH_BINARY_INV: 9 vật, diện tích [1285, 1300]

Không đảo, cả nền trắng thành một "vật" khổng lồ (các lục giác là lỗ bên trong nó, RETR_EXTERNAL bỏ qua). Với THRESH_BINARY_INV (tham số vat_toi=True của hàm dem_vat ở mục 8), đếm đúng 9. Diện tích lý thuyết của lục giác đều bán kính 22 là (3√3/2)·22² ≈ 1257 px²; số đo 1285–1300 px² lớn hơn khoảng 2–3% vì fillPoly tô cả các pixel nằm trên đường biên. Chênh lệch kiểu này là lý do dung sai luôn được lấy từ mẫu thật đo bằng chính chương trình, không lấy từ công thức.

Tóm tắt

  • Phân ngưỡng tạo mặt nạ vật trắng nền đen; vật tối dùng THRESH_BINARY_INV.
  • Ngưỡng cố định khi đèn ổn định (chọn giữa dải dùng được); Otsu tự tìm ngưỡng khi histogram có hai đỉnh; adaptive khi ánh sáng không đều, với blockSize lớn hơn vật và C lớn hơn nhiễu.
  • findContours (OpenCV 4/5) trả về 2 giá trị; RETR_EXTERNAL để đếm; lọc contour theo diện tích để bỏ bụi, nhiễu.
  • Đo theo pixel: diện tích, tâm, khung bao, đường tròn bao, đường kính tương đương; đổi sang mm bằng tỉ lệ từ hiệu chuẩn.
  • Luôn kiểm chứng với đáp án (ảnh mẫu, mẫu chuẩn) và lưu ảnh minh chứng.

Bài tiếp theo: Morphology: erode, dilate, opening, closing, bộ công cụ làm sạch mặt nạ khi nhiễu dính vào vật và vật có lỗ thủng.

Bài tập thực hành

  1. Đếm số đồng xu trong ảnh và in diện tích từng đồng.
  2. Dùng adaptive threshold cho ảnh văn bản chụp dưới đèn không đều.

Làm xong bài tập mới chuyển sang bài tiếp theo: tự viết code là cách duy nhất để nhớ lâu.

Bài tiếp theo
Ban biên tập HọcFree

Chúng tôi viết những bài hướng dẫn dễ hiểu, thực tế để ai cũng có thể tự học lập trình và ngoại ngữ, hoàn toàn miễn phí.