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.
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. Ảnh mẫu: bo mạch có 5 con chip
- 2. matchTemplate và minMaxLoc
- 3. Chọn phương pháp so khớp
- 4. Tìm nhiều vật và loại trùng (NMS)
- 5. Vị trí subpixel của đỉnh
- 6. Tăng tốc bằng kim tự tháp ảnh (coarse-to-fine)
- 7. Giới hạn: vật xoay và đổi tỉ lệ
- 8. Tham số mask: bỏ qua phần nền trong template
- 9. Dời ROI theo vị trí vật (position tracking)
- 10. Lỗi thường gặp
- Bài tập
- 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.
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áp | Cô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ất | gần như không |
TM_CCORR | Σ T·I | lớn nhất | khô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ất | cộng độ sáng (offset) |
TM_CCOEFF_NORMED | tương quan chuẩn hóa (NCC), trong [−1, 1] | lớn nhất | cả 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_CCORRsai 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_SQDIFFvàTM_CCOEFFvẫ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_NORMEDvẫ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_NORMEDbấ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.
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).
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 | Ưu | Nhược |
|---|---|---|
| Đồ gá cơ khí giữ góc cố định | Rẻ nhất, ổn định nhất | Không phải lúc nào cũng làm được |
| Thư viện template xoay sẵn (mỗi 2–5°) và scale | Dùng lại được matchTemplate | Chậm tỉ lệ với số góc × số tỉ lệ |
| Feature matching (ORB) hoặc shape-based matching | Bất biến xoay, có góc chính xác | Phứ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_SQDIFFvà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_numtrướcminMaxLoc.
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.
10. Lỗi thường gặp
| Lỗi | Triệu chứng | Cách sửa |
|---|---|---|
| Nhầm góc trên-trái với tâm | ROI và khung vẽ lệch nửa template | Tâm = (x + w/2, y + h/2) |
Dùng max_loc với TM_SQDIFF | Luôn tìm sai | SQDIFF: 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ẩm | Chọ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ại | Lỗi kiểu dữ liệu hoặc số kênh | Cùng kiểu, cùng số kênh; thường dùng ảnh xám |
| Một ngưỡng cố định cho mọi template | Template A ổn, template B báo sai | Thố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
matchTemplatetrả 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ánhTM_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ệ.
maskbỏ 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.


