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

Template matching trong OpenCV: định vị chi tiết trên dây chuyền

Dùng matchTemplate đúng cách: chọn phương pháp chịu thay đổi ánh sáng, tìm nhiều vật với NMS, tăng tốc bằng kim tự tháp và dời ROI theo vị trí vật.

Template matching trong OpenCV: định vị chi tiết trên dây chuyền

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

  • Dùng matchTemplate và chọn phương pháp
  • Tìm nhiều đối tượng, loại trùng
  • Hiểu giới hạn khi vật xoay, đổi tỉ lệ

Nên học trước: Đo cạnh subpixel, fitting đường thẳng và đường tròn

Nội dung bài viết
  1. 1. Ảnh mẫu: bo mạch có 5 con chip
  2. 2. matchTemplate và minMaxLoc
  3. 3. Chọn phương pháp so khớp
  4. 4. Tìm nhiều vật và loại trùng (NMS)
  5. 5. Vị trí subpixel của đỉnh
  6. 6. Tăng tốc bằng kim tự tháp ảnh (coarse-to-fine)
  7. 7. Giới hạn: vật xoay và đổi tỉ lệ
  8. 8. Tham số mask: bỏ qua phần nền trong template
  9. 9. Dời ROI theo vị trí vật (position tracking)
  10. 10. Lỗi thường gặp
  11. Bài tập
  12. Tóm tắt

Bài đo cạnh subpixel giả định bạn biết cạnh nằm ở đâu để đặt đường quét. Trên dây chuyền thật, vật không bao giờ nằm đúng một chỗ: đồ gá có khe hở, băng tải xê dịch vài milimét, khay linh kiện lệch. Vì vậy hầu hết chương trình kiểm tra bắt đầu bằng bước định vị (locate): tìm vật trong ảnh, rồi dời mọi công cụ đo, kiểm tra theo vị trí đó. Công cụ định vị đơn giản và nhanh nhất là template matching: lấy một ảnh mẫu nhỏ (template) và trượt nó trên toàn ảnh để tìm chỗ giống nhất. Bài này dạy bạn dùng nó đúng cách, và quan trọng không kém, biết khi nào nó không dùng được.

Mục tiêu cuối bàiViết được hàm tìm tất cả chi tiết giống mẫu trong ảnh, trả về vị trí và điểm số, loại kết quả trùng; chọn đúng phương pháp so khớp khi ánh sáng thay đổi; dời vùng kiểm tra (ROI) theo vị trí tìm được; và đo được giới hạn khi vật xoay hoặc đổi tỉ lệ.

1. Ảnh mẫu: bo mạch có 5 con chip

Ta tạo một bo mạch giả: nền xám có đường mạch (trace), độ sáng thay đổi chậm, một nhãn trắng sáng, và 5 con chip giống hệt nhau ở các vị trí biết trước. Template là ảnh của một con chip chụp trên "mẫu chuẩn" (golden sample), có viền nền xung quanh.

import cv2
import numpy as np


def ve_chip():
    """Ảnh mẫu 56×80: thân chip tối, 2 hàng chân sáng, chấm đánh dấu chân 1, chữ IC7."""
    c = np.full((56, 80), 120, np.uint8)                       # nền bo mạch quanh chip
    cv2.rectangle(c, (12, 10), (67, 45), 45, -1)                # thân chip
    for i in range(6):
        x = 16 + i * 9
        cv2.rectangle(c, (x, 3), (x + 4, 9), 205, -1)           # chân trên
        cv2.rectangle(c, (x, 46), (x + 4, 52), 205, -1)         # chân dưới
    cv2.circle(c, (20, 18), 3, 150, -1)                         # chấm chân 1
    cv2.putText(c, "IC7", (30, 34), cv2.FONT_HERSHEY_SIMPLEX, 0.45, 150, 1, cv2.LINE_AA)
    return c


def ve_nen(rng, h=480, w=640):
    y, x = np.mgrid[0:h, 0:w]
    nen = (120 + 10 * np.sin(x / 90.0) + 8 * np.cos(y / 70.0)).astype(np.float32)
    for _ in range(25):                                         # đường mạch ngang / dọc
        x1, y1 = int(rng.integers(0, w)), int(rng.integers(0, h))
        if rng.random() < 0.5:
            x2, y2 = x1 + int(rng.integers(-200, 200)), y1
        else:
            x2, y2 = x1, y1 + int(rng.integers(-200, 200))
        cv2.line(nen, (x1, y1), (x2, y2), 150, 3)
    return nen


rng = np.random.default_rng(7)
nen = ve_nen(rng)
cv2.rectangle(nen, (470, 330), (610, 450), 235, -1)             # nhãn trắng
chip = ve_chip()
VI_TRI = [(40, 40), (300, 60), (150, 250), (420, 200), (60, 380)]  # góc trên-trái từng chip
for x, y in VI_TRI:
    o = nen[y:y + 56, x:x + 80]
    nen[y:y + 56, x:x + 80] = np.where(chip != 120, chip, o)     # chỉ dán phần chip, giữ nền
anh = np.clip(nen + rng.normal(0, 4, nen.shape), 0, 255).astype(np.uint8)
cv2.imwrite("bo_mach.png", anh)
cv2.imwrite("mau_chip.png", chip)
print("Đã lưu bo_mach.png", anh.shape, "và mau_chip.png", chip.shape)
Đã lưu bo_mach.png (480, 640) và mau_chip.png (56, 80)

2. matchTemplate và minMaxLoc

cv2.matchTemplate(anh, mau, phuong_phap) đặt template lên mọi vị trí có thể và tính một điểm số. Kết quả là ảnh điểm số kích thước (H − h + 1) × (W − w + 1); giá trị tại (x, y) ứng với template đặt có góc trên-trái tại (x, y), không phải tâm. cv2.minMaxLoc trả về giá trị nhỏ nhất, lớn nhất và vị trí của chúng.

import cv2

anh = cv2.imread("bo_mach.png", cv2.IMREAD_GRAYSCALE)
mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
h, w = mau.shape

kq = cv2.matchTemplate(anh, mau, cv2.TM_CCOEFF_NORMED)
print("ảnh", anh.shape, "| mẫu", mau.shape, "| bản đồ điểm", kq.shape)
min_v, max_v, min_loc, max_loc = cv2.minMaxLoc(kq)
x, y = max_loc
print(f"tốt nhất tại góc trên-trái ({x}, {y}), tâm ({x + w / 2:.1f}, {y + h / 2:.1f}), điểm {max_v:.4f}")

ve = cv2.cvtColor(anh, cv2.COLOR_GRAY2BGR)
cv2.rectangle(ve, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.imwrite("ket_qua_1.png", ve)
ảnh (480, 640) | mẫu (56, 80) | bản đồ điểm (425, 561)
tốt nhất tại góc trên-trái (150, 250), tâm (190.0, 278.0), điểm 0.9962

Kết quả trúng một trong 5 chip (vị trí (150, 250) có trong danh sách). Với 5 chip giống nhau, chip nào có điểm cao nhất chỉ phụ thuộc nhiễu và nền xung quanh, nên minMaxLoc chỉ hợp khi trong ảnh có đúng một vật cần tìm. Mục 4 xử lý trường hợp nhiều vật.

3. Chọn phương pháp so khớp

OpenCV có 6 phương pháp. Gọi T là template, I là vùng ảnh dưới template, tổng lấy trên mọi pixel của template:

Phương phápCông thức (ý tưởng)Tốt nhất làBất biến với
TM_SQDIFFΣ (T − I)²nhỏ nhất (0 = giống hệt)không
TM_SQDIFF_NORMEDΣ (T − I)² / √(ΣT²·ΣI²)nhỏ nhấtgần như không
TM_CCORRΣ T·Ilớn nhấtkhông, ưu tiên vùng sáng
TM_CCORR_NORMEDΣ T·I / √(ΣT²·ΣI²)lớn nhất (≤ 1)nhân độ sáng (gain)
TM_CCOEFFΣ (T − T̄)(I − Ī)lớn nhấtcộng độ sáng (offset)
TM_CCOEFF_NORMEDtương quan chuẩn hóa (NCC), trong [−1, 1]lớn nhấtcả gain và offset

TM_CCOEFF_NORMED chính là hệ số tương quan giữa template và vùng ảnh: trừ trung bình (bỏ offset) rồi chia độ lệch chuẩn (bỏ gain). Vì vậy khi đèn yếu đi hoặc mạnh lên, điểm số gần như không đổi và bạn dùng được một ngưỡng cố định, ví dụ 0,8. Hãy kiểm chứng: chạy 6 phương pháp trên ảnh gốc và ảnh tối đi (độ sáng × 0,5 + 15), đo điểm tại một chip và điểm "tốt nhất" ở vùng nền (không có chip).

import cv2
import numpy as np

anh = cv2.imread("bo_mach.png", cv2.IMREAD_GRAYSCALE)
mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
VI_TRI = [(40, 40), (300, 60), (150, 250), (420, 200), (60, 380)]
toi = np.clip(anh.astype(np.float32) * 0.5 + 15, 0, 255).astype(np.uint8)   # đèn yếu đi

nen = np.ones((anh.shape[0] - 55, anh.shape[1] - 79), bool)                 # vùng không có chip
for x, y in VI_TRI:
    nen[max(0, y - 30):y + 30, max(0, x - 40):x + 40] = False

print(f"{'phương pháp':17s} {'ảnh':5s} {'đúng?':6s} {'điểm tại chip':>13s} {'tốt nhất ở nền':>14s}")
for ten in ["TM_SQDIFF", "TM_SQDIFF_NORMED", "TM_CCORR", "TM_CCORR_NORMED", "TM_CCOEFF", "TM_CCOEFF_NORMED"]:
    pp = getattr(cv2, ten)
    for nhan, I in (("gốc", anh), ("tối", toi)):
        kq = cv2.matchTemplate(I, mau, pp)
        mn, mx, lmn, lmx = cv2.minMaxLoc(kq)
        nho_la_tot = pp in (cv2.TM_SQDIFF, cv2.TM_SQDIFF_NORMED)
        bx, by = lmn if nho_la_tot else lmx
        dung = any(abs(bx - x) <= 3 and abs(by - y) <= 3 for x, y in VI_TRI)
        nen_tot = kq[nen].min() if nho_la_tot else kq[nen].max()
        print(f"{ten:17s} {nhan:5s} {'có' if dung else 'SAI':6s} {kq[40, 40]:13.4g} {nen_tot:14.4g}")
phương pháp       ảnh   đúng?  điểm tại chip tốt nhất ở nền
TM_SQDIFF         gốc   có         3.406e+05       1.03e+07
TM_SQDIFF         tối   có         6.901e+06      9.879e+06
TM_SQDIFF_NORMED  gốc   có          0.006147         0.1923
TM_SQDIFF_NORMED  tối   có            0.2017         0.2328
TM_CCORR          gốc   SAI        5.531e+07      1.011e+08
TM_CCORR          tối   SAI        3.401e+07      5.689e+07
TM_CCORR_NORMED   gốc   có            0.9982         0.9041
TM_CCORR_NORMED   tối   có            0.9941         0.9073
TM_CCOEFF         gốc   có         1.186e+07      4.464e+06
TM_CCOEFF         tối   có         5.929e+06      2.232e+06
TM_CCOEFF_NORMED  gốc   có            0.9916         0.4265
TM_CCOEFF_NORMED  tối   có            0.9915         0.4267

Đọc bảng như một kỹ sư:

  • TM_CCORR sai cả hai lần: tích T·I lớn nhất ở nhãn trắng, không phải ở chip. Đừng dùng nó để định vị.
  • TM_SQDIFF và TM_CCOEFF vẫn tìm đúng, nhưng giá trị điểm đổi theo độ sáng (SQDIFF tại chip tăng khoảng 20 lần, CCOEFF giảm một nửa), nên không thể đặt một ngưỡng cố định "có chip / không có chip".
  • TM_SQDIFF_NORMED vẫn đúng nhưng khi ảnh tối, điểm tại chip (0,20) gần sát điểm tốt nhất ở nền (0,23): biên an toàn gần như mất hết, tối thêm chút nữa là bắt nhầm.
  • TM_CCORR_NORMED bất biến với gain, nhưng nền cũng đạt điểm rất cao (khoảng 0,9) vì mọi vùng ảnh đều dương: độ phân biệt kém.
  • TM_CCOEFF_NORMED: điểm tại chip không đổi giữa hai ảnh, nền chỉ khoảng 0,4. Đây là lựa chọn mặc định cho định vị công nghiệp.
Template phẳng làm hỏng NCCNếu template gần như đồng màu (độ lệch chuẩn bằng 0), mẫu số của TM_CCOEFF_NORMED bằng 0 và điểm số vô nghĩa. Chọn template có chi tiết rõ: góc, chữ, lỗ, cạnh theo cả hai hướng. Một template chỉ có cạnh thẳng đứng sẽ "trượt" dọc theo cạnh mà điểm không đổi, vị trí theo trục dọc không xác định.

4. Tìm nhiều vật và loại trùng (NMS)

Cách ngây thơ là lấy mọi vị trí có điểm ≥ ngưỡng. Nhưng quanh mỗi vật, điểm số là một "ngọn đồi": các vị trí lệch 1–2 pixel cũng vượt ngưỡng, nên một chip cho ra hàng chục kết quả. Ta cần non-maximum suppression (NMS, loại bỏ không cực đại): sắp xếp ứng viên theo điểm giảm dần, giữ ứng viên tốt nhất, xóa mọi ứng viên chồng lấn nhiều với nó, lặp lại.

import cv2
import numpy as np


def iou(a, b):
    """Tỉ lệ giao / hợp của hai khung (x, y, w, h)."""
    x1, y1 = max(a[0], b[0]), max(a[1], b[1])
    x2, y2 = min(a[0] + a[2], b[0] + b[2]), min(a[1] + a[3], b[1] + b[3])
    giao = max(0, x2 - x1) * max(0, y2 - y1)
    return giao / (a[2] * a[3] + b[2] * b[3] - giao)


def tim_tat_ca(anh, mau, nguong=0.8, iou_max=0.3):
    h, w = mau.shape[:2]
    kq = cv2.matchTemplate(anh, mau, cv2.TM_CCOEFF_NORMED)
    ys, xs = np.where(kq >= nguong)
    ung_vien = sorted(zip(kq[ys, xs], xs, ys), reverse=True)     # điểm giảm dần
    giu = []
    for s, x, y in ung_vien:
        hop = (int(x), int(y), w, h)
        if all(iou(hop, g[1]) <= iou_max for g in giu):
            giu.append((float(s), hop))
    return giu, len(ung_vien)


anh = cv2.imread("bo_mach.png", cv2.IMREAD_GRAYSCALE)
mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
ket_qua, so_ung_vien = tim_tat_ca(anh, mau)
print("số vị trí vượt ngưỡng 0.8:", so_ung_vien, "→ sau NMS:", len(ket_qua))
for s, (x, y, w, h) in sorted(ket_qua, key=lambda k: (k[1][1] // 100, k[1][0])):   # theo hàng rồi cột
    print(f"  chip tại ({x:3d}, {y:3d})  điểm {s:.3f}")
số vị trí vượt ngưỡng 0.8: 45 → sau NMS: 5
  chip tại ( 40,  40)  điểm 0.992
  chip tại (300,  60)  điểm 0.987
  chip tại (150, 250)  điểm 0.996
  chip tại (420, 200)  điểm 0.984
  chip tại ( 60, 380)  điểm 0.989

NMS ở trên có độ phức tạp O(N·K) (N ứng viên, K vật giữ lại), đủ nhanh vì N chỉ vài trăm. Nếu ngưỡng thấp làm N lên hàng chục nghìn, hãy lọc trước bằng cực đại cục bộ: chỉ giữ pixel bằng giá trị của cv2.dilate(kq, kernel) tại đó. OpenCV cũng có sẵn cv2.dnn.NMSBoxes(boxes, scores, nguong_diem, nguong_iou) làm đúng việc này (thường dùng với mô hình phát hiện vật ở giai đoạn Deep Learning).

Chọn ngưỡng điểm như thế nào?Chạy trên vài chục ảnh OK thật, ghi lại điểm thấp nhất của vật đúng (ví dụ 0,93) và điểm cao nhất ở chỗ không có vật (ví dụ 0,45). Đặt ngưỡng ở khoảng giữa, nghiêng về phía vật đúng, ví dụ 0,75. Nếu hai khoảng này sát nhau, đừng chỉnh ngưỡng mà hãy sửa template hoặc chiếu sáng.

5. Vị trí subpixel của đỉnh

Đỉnh điểm số nằm ở pixel nguyên. Giống như với cạnh ở bài trước, ta nội suy parabol theo x và theo y quanh đỉnh để có vị trí dưới pixel. Thử với chip dời đi một lượng lẻ biết trước (dùng cv2.warpAffine để dịch ảnh 0,4 px theo x và 0,7 px theo y):

import cv2
import numpy as np


def dinh_subpixel(kq, x, y):
    """Nội suy parabol 1D theo x và theo y quanh đỉnh (x, y) của bản đồ điểm."""
    dx = dy = 0.0
    if 0 < x < kq.shape[1] - 1:
        a, b, c = kq[y, x - 1], kq[y, x], kq[y, x + 1]
        dx = 0.5 * (a - c) / (a - 2 * b + c)
    if 0 < y < kq.shape[0] - 1:
        a, b, c = kq[y - 1, x], kq[y, x], kq[y + 1, x]
        dy = 0.5 * (a - c) / (a - 2 * b + c)
    return x + dx, y + dy


anh = cv2.imread("bo_mach.png", cv2.IMREAD_GRAYSCALE)
mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
for tx, ty in ((0.4, 0.7), (-0.3, 0.2), (0.5, -0.5)):
    M = np.float32([[1, 0, tx], [0, 1, ty]])
    dich = cv2.warpAffine(anh, M, (anh.shape[1], anh.shape[0]), flags=cv2.INTER_CUBIC,
                          borderMode=cv2.BORDER_REFLECT)
    kq = cv2.matchTemplate(dich, mau, cv2.TM_CCOEFF_NORMED)
    vung = kq[230:270, 130:170]                                 # quanh chip thứ 3 (150, 250)
    _, _, _, (x, y) = cv2.minMaxLoc(vung)
    x, y = x + 130, y + 230
    sx, sy = dinh_subpixel(kq, x, y)
    print(f"thật ({150 + tx:.2f}, {250 + ty:.2f}) | nguyên ({x}, {y}) | subpixel ({sx:.2f}, {sy:.2f})")
thật (150.40, 250.70) | nguyên (150, 251) | subpixel (150.33, 250.78)
thật (149.70, 250.20) | nguyên (150, 250) | subpixel (149.78, 250.14)
thật (150.50, 249.50) | nguyên (150, 250) | subpixel (150.49, 249.51)

Sai số giảm từ cỡ 0,5 px xuống cỡ 0,1 px. Đủ tốt để đặt ROI và caliper; nhưng đừng dùng vị trí template matching làm kết quả đo kích thước. Phép đo chính xác luôn làm bằng caliper và fitting trên cạnh thật như bài trước, template chỉ cho biết "đặt thước ở đâu".

6. Tăng tốc bằng kim tự tháp ảnh (coarse-to-fine)

Chi phí matchTemplate tỉ lệ với (số vị trí) × (số pixel template). Giảm ảnh và template đi 2 lần mỗi chiều thì cả hai giảm 4 lần, tổng chi phí giảm khoảng 16 lần. Ý tưởng coarse-to-fine: tìm thô trên ảnh nhỏ (cv2.pyrDown hai lần, tức 1/4 kích thước), rồi tinh chỉnh trên ảnh gốc chỉ trong một cửa sổ nhỏ quanh vị trí thô.

import time

import cv2
import numpy as np

mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
h, w = mau.shape
# Ảnh lớn 1200×1600 (gần 2 MP) có đúng một chip, góc trên-trái tại (1013, 677)
rng = np.random.default_rng(3)
y, x = np.mgrid[0:1200, 0:1600]
anh = (120 + 10 * np.sin(x / 90.0) + 8 * np.cos(y / 70.0)).astype(np.float32)
o = anh[677:733, 1013:1093]
anh[677:733, 1013:1093] = np.where(mau != 120, mau, o)
anh = np.clip(anh + rng.normal(0, 4, anh.shape), 0, 255).astype(np.uint8)


def tim_day_du(anh, mau):
    kq = cv2.matchTemplate(anh, mau, cv2.TM_CCOEFF_NORMED)
    _, s, _, (x, y) = cv2.minMaxLoc(kq)
    return x, y, s


def tim_kim_tu_thap(anh, mau, cap=2, le=6):
    a, m = anh, mau
    for _ in range(cap):
        a, m = cv2.pyrDown(a), cv2.pyrDown(m)
    _, _, _, (x, y) = cv2.minMaxLoc(cv2.matchTemplate(a, m, cv2.TM_CCOEFF_NORMED))
    k = 2 ** cap
    x0, y0 = max(0, x * k - le), max(0, y * k - le)            # cửa sổ tinh chỉnh trên ảnh gốc
    cua_so = anh[y0:y0 + h + 2 * le, x0:x0 + w + 2 * le]
    _, s, _, (dx, dy) = cv2.minMaxLoc(cv2.matchTemplate(cua_so, mau, cv2.TM_CCOEFF_NORMED))
    return x0 + dx, y0 + dy, s


for ten, ham in (("đầy đủ", tim_day_du), ("kim tự tháp", tim_kim_tu_thap)):
    t0 = time.perf_counter()
    for _ in range(10):
        x, y, s = ham(anh, mau)
    ms = (time.perf_counter() - t0) / 10 * 1000
    print(f"{ten:12s}: ({x}, {y}) điểm {s:.4f}, {ms:.2f} ms/lần")
đầy đủ      : (1013, 677) điểm 0.9877, 68.18 ms/lần
kim tự tháp : (1013, 677) điểm 0.9877, 5.22 ms/lần

Thời gian phụ thuộc máy (con số trên chỉ để tham khảo, máy bạn sẽ khác), nhưng việc kim tự tháp nhanh hơn nhiều lần thì ổn định. Vị trí và điểm cuối cùng giống hệt cách đầy đủ vì bước tinh chỉnh chạy trên ảnh gốc. Hai lưu ý:

  • Template ở cấp thô phải còn đủ chi tiết. Template 80×56 xuống 20×14 vẫn nhận ra chip; template 30×20 xuống 8×5 thì gần như mất hết, khi đó chỉ nên dùng 1 cấp.
  • Khi có nhiều vật, ở cấp thô hãy giữ mọi ứng viên vượt một ngưỡng thấp hơn (ví dụ 0,6), rồi tinh chỉnh từng ứng viên ở cấp gốc với ngưỡng thật.

Với ảnh 5 MP và template lớn, khác biệt còn lớn hơn và thường quyết định trạm có đạt cycle time hay không. OpenCV tự dùng biến đổi Fourier (DFT) cho template lớn, nhưng kim tự tháp vẫn là bước tối ưu quan trọng nhất.

7. Giới hạn: vật xoay và đổi tỉ lệ

Template matching so sánh từng pixel ở đúng hướng và đúng kích thước của template. Đo xem điểm số giảm thế nào khi chip xoay hoặc to nhỏ khác đi (ví dụ đổi khoảng cách camera, hoặc lô linh kiện kích thước khác):

import cv2
import numpy as np

mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
rng = np.random.default_rng(1)


def canh(goc=0.0, ti_le=1.0):
    """Ảnh 240×320 nền 120 có một chip ở tâm, xoay `goc` độ, phóng `ti_le` lần."""
    nen = np.full((240, 320), 120, np.uint8)
    nen[92:148, 120:200] = mau
    M = cv2.getRotationMatrix2D((160, 120), goc, ti_le)
    anh = cv2.warpAffine(nen, M, (320, 240), flags=cv2.INTER_LINEAR, borderValue=120)
    return np.clip(anh + rng.normal(0, 4, anh.shape), 0, 255).astype(np.uint8)


print(" biến đổi        điểm cao nhất   tâm tìm được    lệch tâm")
for goc, tl in ((0, 1), (5, 1), (10, 1), (20, 1), (45, 1), (0, 0.9), (0, 1.1), (0, 0.8)):
    kq = cv2.matchTemplate(canh(goc, tl), mau, cv2.TM_CCOEFF_NORMED)
    _, s, _, (x, y) = cv2.minMaxLoc(kq)
    cx, cy = x + 40, y + 28
    print(f" xoay {goc:2d}°, ×{tl:.1f}    {s:6.3f}        ({cx:3d}, {cy:3d})       {np.hypot(cx - 160, cy - 120):5.1f} px")
biến đổi        điểm cao nhất   tâm tìm được    lệch tâm
 xoay  0°, ×1.0     0.997        (160, 120)         0.0 px
 xoay  5°, ×1.0     0.790        (160, 120)         0.0 px
 xoay 10°, ×1.0     0.653        (156, 121)         4.1 px
 xoay 20°, ×1.0     0.502        (160, 120)         0.0 px
 xoay 45°, ×1.0     0.338        (160, 119)         1.0 px
 xoay  0°, ×0.9     0.699        (160, 120)         0.0 px
 xoay  0°, ×1.1     0.721        (160, 120)         0.0 px
 xoay  0°, ×0.8     0.491        (155, 124)         6.4 px

Điểm số tụt rất nhanh: xoay 5° còn 0,79, xoay 10° còn 0,65, xoay 20° còn 0,50, gần bằng điểm cao nhất của vùng nền trên bo mạch ở mục 3 (0,43). Đổi tỉ lệ ±10% cũng chỉ còn khoảng 0,7. Vị trí ở đây vẫn gần đúng vì ảnh thử chỉ có một chip trên nền phẳng; trên bo mạch thật, với ngưỡng 0,8 chương trình sẽ báo "không tìm thấy" ngay từ khi chip xoay 5°, còn nếu hạ ngưỡng xuống 0,5 thì sẽ bắt nhầm nền. Với chip này, quy tắc thực tế: template matching thường chỉ tin cậy khi vật xoay trong khoảng vài độ và tỉ lệ thay đổi dưới khoảng 5%. Con số chính xác phụ thuộc kích thước và chi tiết của template: template càng lớn, chi tiết càng mảnh thì càng nhạy với xoay (điểm ở xa tâm dịch nhiều hơn khi xoay).

Ba cách xử lý khi vật có thể xoay:

CáchƯuNhược
Đồ gá cơ khí giữ góc cố địnhRẻ nhất, ổn định nhấtKhông phải lúc nào cũng làm được
Thư viện template xoay sẵn (mỗi 2–5°) và scaleDùng lại được matchTemplateChậm tỉ lệ với số góc × số tỉ lệ
Feature matching (ORB) hoặc shape-based matchingBất biến xoay, có góc chính xácPhức tạp hơn; xem bài tiếp theo

8. Tham số mask: bỏ qua phần nền trong template

Template luôn là hình chữ nhật, nhưng vật thì không. Với vật tròn (nút nhấn, nắp chai, đầu nối), các góc của template chứa nền, và nếu nền ở dây chuyền khác nền lúc chụp template (băng tải bẩn, khay khác màu), điểm số bị kéo xuống. Tham số mask của matchTemplate chỉ tính các pixel mà mask khác 0.

import cv2
import numpy as np

# Template: nút tròn có ký hiệu, chụp trên nền phẳng 120
mau = np.full((61, 61), 120, np.uint8)
cv2.circle(mau, (30, 30), 26, 60, -1)
cv2.circle(mau, (30, 30), 26, 200, 3)
cv2.line(mau, (30, 14), (30, 30), 220, 4)
cv2.ellipse(mau, (30, 33), (12, 12), 0, -60, 240, 220, 3)      # ký hiệu nút nguồn
mask = np.zeros_like(mau)
cv2.circle(mask, (30, 30), 28, 255, -1)                         # chỉ lấy phần nút

# Ảnh sản xuất: nút nằm trên khay có sọc đậm
rng = np.random.default_rng(5)
anh = np.full((200, 300), 120, np.uint8)
anh[:, ::8] = 30
anh[:, 1::8] = 30                                               # sọc tối
nut = anh[70:131, 120:181]
nut[mask > 0] = mau[mask > 0]                                   # dán đúng phần nút
anh = np.clip(anh + rng.normal(0, 4, anh.shape), 0, 255).astype(np.uint8)

for ten, m in (("không mask", None), ("có mask", mask)):
    kq = cv2.matchTemplate(anh, mau, cv2.TM_CCOEFF_NORMED, mask=m)
    kq = np.nan_to_num(kq, nan=-1.0, posinf=-1.0, neginf=-1.0)   # mask có thể sinh NaN ở vùng phẳng
    _, s, _, (x, y) = cv2.minMaxLoc(kq)
    print(f"{ten:11s}: tìm thấy tại ({x}, {y}), điểm {s:.3f}   (thật: (120, 70))")
không mask : tìm thấy tại (120, 70), điểm 0.889   (thật: (120, 70))
có mask    : tìm thấy tại (120, 70), điểm 0.999   (thật: (120, 70))
  • Mask giữ điểm số cao vì chỉ so phần thuộc vật. Không có mask, phần sọc ở góc template làm điểm giảm, và với nền phức tạp hơn có thể làm định vị sai.
  • Tài liệu OpenCV cũ ghi mask chỉ hỗ trợ TM_SQDIFF và TM_CCORR_NORMED; các bản 4.x về sau hỗ trợ mask cho mọi phương pháp. Nếu phải dùng bản cũ, hãy kiểm tra lại trên phiên bản của bạn.
  • Với mask, các vùng ảnh phẳng có thể cho NaN hoặc vô cực (chia cho 0). Luôn lọc bằng np.nan_to_num trước minMaxLoc.

9. Dời ROI theo vị trí vật (position tracking)

Ứng dụng quan trọng nhất của template matching trong trạm kiểm tra: tạo một hệ tọa độ gắn với vật (trong phần mềm thương mại gọi là fixture, position tracking hay alignment). Lúc cài đặt, bạn định nghĩa mọi ROI kiểm tra tương đối so với vị trí template trên ảnh mẫu chuẩn. Lúc chạy, tìm template, tính độ lệch (dx, dy), rồi dời mọi ROI theo đó. Ví dụ: kiểm tra đủ 12 chân của chip, trong khi chip nằm lệch 17 px sang phải và 9 px lên trên so với lúc cài đặt, và chân thứ 4 hàng dưới bị thiếu.

import cv2
import numpy as np

mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
rng = np.random.default_rng(11)


def chup(dx, dy, thieu_chan=None):
    """Ảnh 200×260 nền 120, chip ở (90+dx, 70+dy); có thể làm mất một chân dưới."""
    anh = np.full((200, 260), 120, np.uint8)
    chip = mau.copy()
    if thieu_chan is not None:
        x = 16 + thieu_chan * 9
        chip[46:53, x:x + 5] = 120
    anh[70 + dy:126 + dy, 90 + dx:170 + dx] = chip
    return np.clip(anh + rng.normal(0, 4, anh.shape), 0, 255).astype(np.uint8)


# --- Cài đặt trên ảnh mẫu chuẩn ---
chuan = chup(0, 0)
_, _, _, goc_chuan = cv2.minMaxLoc(cv2.matchTemplate(chuan, mau, cv2.TM_CCOEFF_NORMED))
ROI_CHAN = [(16 + i * 9, yy, 5, 7) for yy in (3, 46) for i in range(6)]   # tương đối so với template
print("vị trí template trên ảnh chuẩn:", goc_chuan)


def kiem_tra(anh, theo_doi=True):
    if theo_doi:
        kq = cv2.matchTemplate(anh, mau, cv2.TM_CCOEFF_NORMED)
        _, s, _, (x, y) = cv2.minMaxLoc(kq)
        if s < 0.7:
            return "NG: không tìm thấy chip", []
    else:
        x, y = goc_chuan                                         # ROI cố định như lúc cài đặt
    thieu = []
    for i, (rx, ry, rw, rh) in enumerate(ROI_CHAN):
        sang = anh[y + ry:y + ry + rh, x + rx:x + rx + rw].mean()
        if sang < 165:                                           # chân đủ phải sáng khoảng 205
            thieu.append(i)
    return ("OK" if not thieu else "NG"), thieu


san_xuat = chup(17, -9, thieu_chan=3)
for theo_doi in (False, True):
    kq, thieu = kiem_tra(san_xuat, theo_doi)
    print(f"{'có' if theo_doi else 'không'} dời ROI: {kq}, ROI báo thiếu chân: {thieu}")
vị trí template trên ảnh chuẩn: (90, 70)
không dời ROI: NG, ROI báo thiếu chân: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]
có dời ROI: NG, ROI báo thiếu chân: [9]

Không dời ROI, gần như mọi ROI rơi ra ngoài chân và chương trình báo NG sai hàng loạt (overkill), dù lỗi thật chỉ là một chân (số 9 = chân thứ 4 hàng dưới). Có dời ROI, chỉ đúng chân thiếu bị báo. Đây là cấu trúc của hầu hết chương trình kiểm tra: định vị → dời công cụ → kiểm tra/đo → quyết định. Dự án cuối chặng (đọc số seri) cũng định vị vùng chữ theo cách này.

Định vị phải tự kiểm tra chính nóNếu điểm định vị thấp hơn ngưỡng, đừng tiếp tục kiểm tra với vị trí sai: báo NG "không định vị được" (hoặc mã lỗi riêng cho PLC) và lưu ảnh. Khi vật có thể xoay, cần thêm góc vào phép dời (ma trận xoay + tịnh tiến); bài sau sẽ lấy được góc đó.

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

LỗiTriệu chứngCách sửa
Nhầm góc trên-trái với tâmROI và khung vẽ lệch nửa templateTâm = (x + w/2, y + h/2)
Dùng max_loc với TM_SQDIFFLuôn tìm saiSQDIFF: tốt nhất là min_loc
Template cắt quá sát vật hoặc chứa vùng hay thay đổi (bóng đổ, chữ in lô)Điểm dao động giữa các sản phẩmChọn vùng ổn định, có chi tiết theo cả hai hướng; dùng mask
Template ảnh màu, ảnh xám hoặc ngược lạiLỗi kiểu dữ liệu hoặc số kênhCùng kiểu, cùng số kênh; thường dùng ảnh xám
Một ngưỡng cố định cho mọi templateTemplate A ổn, template B báo saiThống kê điểm OK/nền riêng cho từng template
Vật xoay vài độ mà vẫn dùng template thẳngĐiểm tụt, thỉnh thoảng không tìm thấyĐồ gá, template xoay, hoặc phương pháp bất biến xoay

Bài tập

Bài 1. Trạm kiểm tra nhãn chai: đèn vòng LED đã dùng 2 năm, độ sáng giảm dần; nắp chai có thể xoay tự do nhưng nhãn luôn dán thẳng. Bạn cần định vị logo trên nhãn. Chọn phương pháp so khớp nào và vì sao? Có cần mask không?

Xem đáp án

Dùng TM_CCOEFF_NORMED: bất biến với gain và offset nên chịu được đèn yếu dần, ngưỡng điểm cố định vẫn dùng được. Logo trên nhãn dán thẳng nên không cần xử lý xoay (việc nắp xoay không ảnh hưởng nếu ROI tìm kiếm chỉ chứa nhãn). Cần mask nếu logo không phải hình chữ nhật và nền nhãn quanh logo có thể thay đổi (ví dụ in mã lô, ngày sản xuất sát logo); nếu xung quanh logo luôn là nền trắng cố định thì không cần. Ngoài ra vẫn nên giám sát độ sáng (bài kiểm soát ánh sáng môi trường) để thay đèn trước khi tương phản quá thấp.

Bài 2. Viết hàm tim_lap(anh, mau, nguong, toi_da) tìm nhiều vật theo cách khác NMS: lặp minMaxLoc, mỗi lần tìm được thì xóa (gán −1) vùng kích thước template quanh đỉnh trên bản đồ điểm, dừng khi điểm < ngưỡng hoặc đủ toi_da vật. So kết quả với mục 4.

Xem đáp án
import cv2


def tim_lap(anh, mau, nguong=0.8, toi_da=20):
    h, w = mau.shape[:2]
    kq = cv2.matchTemplate(anh, mau, cv2.TM_CCOEFF_NORMED)
    ds = []
    while len(ds) < toi_da:
        _, s, _, (x, y) = cv2.minMaxLoc(kq)
        if s < nguong:
            break
        ds.append((x, y, round(s, 3)))
        kq[max(0, y - h // 2):y + h // 2 + 1, max(0, x - w // 2):x + w // 2 + 1] = -1
    return ds


anh = cv2.imread("bo_mach.png", cv2.IMREAD_GRAYSCALE)
mau = cv2.imread("mau_chip.png", cv2.IMREAD_GRAYSCALE)
print(sorted(tim_lap(anh, mau)))
[(40, 40, 0.992), (60, 380, 0.989), (150, 250, 0.996), (300, 60, 0.987), (420, 200, 0.984)]

Cùng 5 vị trí như NMS. Cách này đơn giản, tự nhiên trả về theo thứ tự điểm giảm dần và có giới hạn số vật; nhược điểm là xóa một vùng cố định nên hai vật nằm sát nhau hơn nửa template có thể bị mất một.

Bài 3. Trên ảnh 2448×2048, template 200×150. Bạn muốn hỗ trợ vật xoay ±30° bằng thư viện template xoay. Từ bảng ở mục 7, bước góc nên là bao nhiêu và cần bao nhiêu template? Ước lượng chi phí so với một lần matchTemplate, và đề xuất cách giảm.

Xem đáp án

Ở mục 7, xoay 5° đã làm điểm giảm rõ rệt; để vật nằm giữa hai góc mẫu vẫn có điểm cao, sai lệch tối đa nên khoảng 1–2°, tức bước góc khoảng 2–4°. Lấy bước 2°: từ −30° đến +30° cần 31 template, chi phí khoảng 31 lần một lần matchTemplate. Template 200×150 còn lớn hơn chip trong bài nên càng nhạy với xoay, có thể phải dùng bước nhỏ hơn. Giảm chi phí: (1) kim tự tháp, ở cấp thô template nhỏ hơn nên chịu xoay tốt hơn, dùng bước góc lớn (ví dụ 8°) rồi chỉ tinh chỉnh góc quanh ứng viên tốt ở cấp gốc; (2) giới hạn vùng tìm kiếm bằng ROI; (3) cân nhắc phương pháp bất biến xoay ở bài sau.

Tóm tắt

  • matchTemplate trả về bản đồ điểm kích thước (H − h + 1) × (W − w + 1), tọa độ là góc trên-trái của template.
  • Mặc định dùng TM_CCOEFF_NORMED: bất biến với gain và offset, điểm trong [−1, 1], đặt ngưỡng cố định được. Tránh TM_CCORR.
  • Nhiều vật: ngưỡng + NMS (hoặc lặp minMaxLoc và xóa vùng). Ngưỡng chọn từ thống kê điểm OK và điểm nền.
  • Nội suy parabol quanh đỉnh cho vị trí subpixel; kim tự tháp ảnh tăng tốc nhiều lần.
  • Template matching nhạy với xoay và đổi tỉ lệ: chỉ tin cậy trong vài độ và vài phần trăm tỉ lệ.
  • mask bỏ qua nền trong template; nhớ lọc NaN.
  • Định vị → dời ROI → kiểm tra là khung của hầu hết chương trình kiểm tra.

Bài tiếp theo: Feature matching và shape matching khi vật xoay, giải quyết đúng giới hạn bạn vừa đo ở mục 7.

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í.