Watershed và distance transform: tách và đếm vật dính nhau
Tìm tâm vật bằng distance transform, tách viên thuốc chạm nhau bằng watershed và kiểm tra kết quả đếm trước khi gửi PLC.
Học xong bài này, bạn sẽ
- Dùng distance transform tìm tâm vật
- Tách vật chạm nhau bằng watershed
- Kiểm tra kết quả đếm
Nên học trước: Đặc trưng contour và blob analysis
Nội dung bài viết
Bài "Phân ngưỡng và contour: đếm, đo vật thể" đếm đúng khi các vật nằm tách rời. Bài "Đặc trưng contour và blob analysis" cho bạn bộ số đo của từng vật. Nhưng trên khay thuốc, băng chuyền hạt, đĩa linh kiện, vật thường chạm nhau: phân ngưỡng cho một khối liền, và contour đếm cả cụm là một vật. Bài này tách các vật dính nhau bằng distance transform (tìm tâm từng vật) và watershed (vẽ đường ranh giới), rồi quan trọng không kém: kiểm tra kết quả đếm có đáng tin hay không trước khi gửi cho PLC.
opencv-python và numpy. Ảnh mẫu là các viên thuốc tròn vẽ bằng OpenCV, xếp thành cụm chạm nhau, kèm file đáp án (vị trí, bán kính từng viên). Bài dùng opening của bài Morphology để làm sạch mặt nạ.1. Vấn đề: contour đếm cụm thành một vật
Tạo khay 19 viên thuốc: phần lớn nằm trong các cụm 2–5 viên chạm nhau (khoảng cách tâm bằng 90% tổng hai bán kính, tức hai viên "lấn" vào nhau một chút như khi chụp từ trên xuống), vài viên đứng riêng. Mỗi viên có mặt vồng nên sáng ở tâm (khoảng 210) và tối dần ra mép (khoảng 130), giống viên thuốc thật dưới đèn chiếu từ trên. Hàm tạo ảnh cũng tạo luôn khay thứ hai cho bài tập 1.
import json
import cv2
import numpy as np
def tao_khay_thuoc(ten, seed, cum, H=360, W=480):
"""cum: danh sách ((x, y), số viên). Các viên trong một cụm chạm nhau."""
rng = np.random.default_rng(seed)
vien = []
for (cx, cy), n in cum:
cac = [(cx, cy, int(rng.integers(17, 22)))]
while len(cac) < n:
bx, by, br = cac[int(rng.integers(len(cac)))]
r = int(rng.integers(17, 22))
goc = rng.uniform(0, 2 * np.pi)
d = (br + r) * 0.9 # lấn vào nhau 10%
x, y = bx + d * np.cos(goc), by + d * np.sin(goc)
if all(np.hypot(x - px, y - py) > (r + pr) * 0.88 for px, py, pr in cac) \
and r < x < W - r and r < y < H - r:
cac.append((int(x), int(y), r))
vien += cac
yy, xx = np.mgrid[0:H, 0:W]
anh = np.full((H, W), 40.0)
for x, y, r in vien: # viên thuốc vồng lên: sáng ở tâm, tối dần ra mép
d2 = ((xx - x) ** 2 + (yy - y) ** 2) / r ** 2
anh = np.maximum(anh, np.where(d2 <= 1, 130 + 80 * np.sqrt(np.clip(1 - d2, 0, 1)), 0))
anh = np.clip(anh + rng.normal(0, 6, anh.shape), 0, 255).astype(np.uint8)
cv2.imwrite(ten, anh)
with open(ten.replace(".png", ".json"), "w", encoding="utf-8") as f:
json.dump(vien, f)
return vien
CUM = [((80, 80), 3), ((250, 70), 2), ((400, 90), 4), ((90, 250), 2),
((240, 230), 5), ((400, 280), 1), ((150, 330), 1), ((330, 330), 1)]
vien = tao_khay_thuoc("vien_thuoc.png", 6, CUM)
tao_khay_thuoc("vien_thuoc_2.png", 5, CUM) # khay cho bài tập 1
anh = cv2.imread("vien_thuoc.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(anh, (5, 5), 0)
_, mat_na = cv2.threshold(mo, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
mat_na = cv2.morphologyEx(mat_na, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
cv2.imwrite("vien_thuoc_mask.png", mat_na)
cs, _ = cv2.findContours(mat_na, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
print("đáp án:", len(vien), "viên | contour thường đếm được:", len(cs))
print("diện tích các contour:", sorted(int(cv2.contourArea(c)) for c in cs))
đáp án: 19 viên | contour thường đếm được: 8
diện tích các contour: [821, 926, 1273, 2305, 2314, 2980, 4119, 4850]
Contour thường chỉ thấy 8 "vật": đúng bằng số cụm. Diện tích cũng cho thấy vấn đề: một viên đứng riêng khoảng 800–1300 px², còn các contour lớn gấp 2–5 lần. Bạn có thể ước lượng số viên bằng cách chia diện tích cho diện tích một viên, nhưng khi kích thước viên dao động ±20% và các viên lấn nhau, phép chia này dễ lệch một viên. Ta cần tách thật sự.
2. Distance transform: tâm vật là "đỉnh núi"
cv2.distanceTransform gán cho mỗi điểm trắng của mặt nạ khoảng cách (pixel) tới điểm đen gần nhất. Với vật tròn, giá trị lớn nhất nằm ở tâm và gần bằng bán kính; tại "eo" nơi hai viên chạm nhau, khoảng cách tới nền nhỏ hơn hẳn. Hai viên dính nhau trên mặt nạ trở thành hai đỉnh núi nối bởi một yên ngựa thấp.
import json
import cv2
import numpy as np
mat_na = cv2.imread("vien_thuoc_mask.png", cv2.IMREAD_GRAYSCALE)
vien = json.load(open("vien_thuoc.json", encoding="utf-8"))
dist = cv2.distanceTransform(mat_na, cv2.DIST_L2, 5) # float32, đơn vị pixel
print("kiểu:", dist.dtype, "| lớn nhất:", round(float(dist.max()), 1))
(x1, y1, r1), (x2, y2, r2) = vien[0], vien[1] # hai viên chạm nhau trong cụm đầu
xm, ym = (x1 + x2) // 2, (y1 + y2) // 2 # điểm giữa hai tâm, gần "eo"
print(f"tâm viên 1 ({x1},{y1}), r={r1}: dist = {dist[y1, x1]:.1f}")
print(f"tâm viên 2 ({x2},{y2}), r={r2}: dist = {dist[y2, x2]:.1f}")
print(f"giữa hai tâm ({xm},{ym}) : dist = {dist[ym, xm]:.1f}")
cv2.imwrite("dist.png", cv2.normalize(dist, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8))
kiểu: float32 | lớn nhất: 20.4
tâm viên 1 (80,80), r=19: dist = 18.6
tâm viên 2 (61,108), r=19: dist = 18.2
giữa hai tâm (70,94) : dist = 9.2
Tại tâm, giá trị khoảng cách xấp xỉ bán kính; tại điểm giữa hai tâm thấp hơn rõ rệt. Hai tham số: DIST_L2 là khoảng cách Euclid (đúng hình học, nên dùng); kích thước mặt nạ 5 cho xấp xỉ chính xác hơn 3 (hoặc dùng DIST_MASK_PRECISE nếu cần chính xác tuyệt đối). Mở dist.png sẽ thấy mỗi viên là một đốm sáng ở giữa, tối dần ra mép.
3. Tìm tâm (marker): hai cách
Watershed cần một marker (hạt giống) cho mỗi vật. Có hai cách lấy marker từ distance transform:
- Ngưỡng toàn cục: giữ vùng có
dist > f · dist.max(). Đơn giản, là cách trong hầu hết tài liệu hướng dẫn, nhưng phụ thuộc mạnh vào hệ sốf. - Cực đại cục bộ: điểm có
distbằng giá trị lớn nhất trong lân cận (so sánh với ảnh đãdilate), kèm điều kiệndistđủ lớn để loại đỉnh nhỏ do nhiễu.
import cv2
import numpy as np
mat_na = cv2.imread("vien_thuoc_mask.png", cv2.IMREAD_GRAYSCALE)
dist = cv2.distanceTransform(mat_na, cv2.DIST_L2, 5)
for f in (0.4, 0.5, 0.6, 0.7):
tam = (dist > f * dist.max()).astype(np.uint8)
n, _ = cv2.connectedComponents(tam)
print(f"ngưỡng {f:.1f} × max: {n - 1:2d} marker")
ke = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21)) # khoảng bán kính viên nhỏ nhất
dinh = ((dist == cv2.dilate(dist, ke)) & (dist > 10)).astype(np.uint8)
n, _ = cv2.connectedComponents(dinh)
print(f"cực đại cục bộ : {n - 1:2d} marker")
ngưỡng 0.4 × max: 8 marker
ngưỡng 0.5 × max: 19 marker
ngưỡng 0.6 × max: 19 marker
ngưỡng 0.7 × max: 19 marker
cực đại cục bộ : 19 marker
- Ngưỡng 0,4: vùng giữ lại vẫn nối qua các eo, mỗi cụm vẫn là một marker (8, như contour). Từ 0,5 đến 0,7: đủ 19. Ở ảnh này dải dùng được khá rộng vì các viên gần cùng cỡ; ảnh thật thường hẹp hơn, nên hãy quét hệ số như trên khi cài đặt và chọn giữa dải.
- Nếu viên có kích thước khác nhau nhiều, ngưỡng toàn cục cao (0,7) sẽ bỏ sót viên nhỏ, vì đỉnh của viên nhỏ thấp hơn 0,7 lần đỉnh viên lớn nhất (bài tập 2).
- Cực đại cục bộ không phụ thuộc vật lớn nhất, nhưng cần hai tham số: đường kính kernel (nhỏ hơn khoảng cách hai tâm gần nhất, ở đây khoảng 31 px; lớn hơn các gợn nhiễu) và ngưỡng tối thiểu (khoảng một nửa bán kính viên nhỏ nhất).
4. Chạy watershed
Hình dung ảnh như địa hình: đổ nước từ mỗi marker, nước dâng lên; nơi nước từ hai marker gặp nhau thì dựng "đập", đó là ranh giới giữa hai vật. OpenCV cần bảng nhãn int32 theo quy ước:
Giá trị trong markers | Ý nghĩa đầu vào | Sau cv2.watershed |
|---|---|---|
| 1 | Chắc chắn là nền | Vẫn là nền |
| 2, 3, 4… | Chắc chắn thuộc vật số 2, 3, 4… | Vùng của từng vật đã mở rộng |
| 0 | Chưa biết, để watershed quyết định | Được gán cho nền hoặc một vật |
| −1 | (không dùng) | Đường ranh giới |
import cv2
import numpy as np
anh = cv2.imread("vien_thuoc.png", cv2.IMREAD_GRAYSCALE)
mat_na = cv2.imread("vien_thuoc_mask.png", cv2.IMREAD_GRAYSCALE)
dist = cv2.distanceTransform(mat_na, cv2.DIST_L2, 5)
# 1. Vùng chắc chắn là nền: ngoài mặt nạ đã giãn rộng thêm
nen_chac = cv2.dilate(mat_na, np.ones((3, 3), np.uint8), iterations=3)
# 2. Vùng chắc chắn là tâm vật: cực đại cục bộ, nở ra một chút cho ổn định
ke = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
dinh = ((dist == cv2.dilate(dist, ke)) & (dist > 10)).astype(np.uint8) * 255
tam_chac = cv2.dilate(dinh, np.ones((5, 5), np.uint8))
# 3. Vùng chưa biết = trong mặt nạ giãn nhưng không phải tâm
chua_biet = cv2.subtract(nen_chac, tam_chac)
# 4. Đánh số marker: tâm vật 2, 3, ...; nền 1; chưa biết 0
n, markers = cv2.connectedComponents(tam_chac)
markers = markers + 1
markers[chua_biet == 255] = 0
# 5. Watershed cần ảnh màu 8 bit 3 kênh
markers = cv2.watershed(cv2.cvtColor(anh, cv2.COLOR_GRAY2BGR), markers)
nhan = [l for l in np.unique(markers) if l > 1]
print("số vật sau watershed:", len(nhan))
print("số điểm ranh giới (-1):", int((markers == -1).sum()))
np.save("markers.npy", markers)
ve = cv2.cvtColor(anh, cv2.COLOR_GRAY2BGR)
ve[markers == -1] = (0, 0, 255) # ranh giới màu đỏ
for l in nhan:
ys, xs = np.nonzero(markers == l)
cv2.putText(ve, str(l - 1), (int(xs.mean()) - 6, int(ys.mean()) + 5),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 0), 1)
cv2.imwrite("watershed_ket_qua.png", ve)
số vật sau watershed: 19
số điểm ranh giới (-1): 3561
Ba điểm hay làm sai:
- Nền phải là 1, không phải 0 (
markers + 1). Nếu để nền là 0, watershed coi cả nền là "chưa biết" và sẽ "nuốt" nền vào các vật. cv2.watershedyêu cầu ảnh BGR 8 bit; truyền ảnh xám sẽ báo lỗi. Watershed của OpenCV mở rộng vùng theo độ chênh màu giữa các điểm lân cận, nên ranh giới rơi vào nơi độ sáng thay đổi: ở đây là phần mép tối của các viên thuốc vồng. Nếu vật phẳng đồng màu tuyệt đối, ranh giới gần như tùy ý (số đếm vẫn đúng nhưng diện tích từng vật lệch nhiều), đó là lý do phải kiểm tra diện tích ở mục 5.markersbị sửa tại chỗ và cũng được trả về; đường ranh giới (−1) gồm cả viền ngoài sát nền, nên tổng diện tích các vật nhỏ hơn mặt nạ một chút.
5. Kiểm tra kết quả đếm
Một con số "19" không chứng minh được gì. Trước khi tin watershed, kiểm tra theo ba lớp, từ mạnh đến yếu.
5.1. So với đáp án (khi có ảnh mẫu đã biết số lượng)
Mỗi tâm thật phải rơi vào đúng một nhãn, và mỗi nhãn chứa đúng một tâm thật. Nhãn chứa hai tâm là dính chưa tách (đếm thiếu); tâm thật không có nhãn riêng hoặc nhãn không chứa tâm nào là tách thừa/sót.
import json
from collections import Counter
import numpy as np
markers = np.load("markers.npy")
vien = json.load(open("vien_thuoc.json", encoding="utf-8"))
nhan_cua_tam = [int(markers[y, x]) for x, y, _ in vien] # nhãn tại tâm mỗi viên thật
dem = Counter(nhan_cua_tam)
nhan = [l for l in np.unique(markers) if l > 1]
dinh_chua_tach = sum(1 for l, k in dem.items() if l > 1 and k > 1)
khong_tam = sum(1 for l in nhan if l not in dem)
mat = sum(1 for l in nhan_cua_tam if l <= 1)
dung = sum(1 for l, k in dem.items() if l > 1 and k == 1)
print(f"viên thật: {len(vien)} | nhãn: {len(nhan)} | khớp 1-1: {dung}")
print(f"nhãn chứa >1 tâm: {dinh_chua_tach} | nhãn không chứa tâm: {khong_tam} | tâm rơi vào nền/biên: {mat}")
viên thật: 19 | nhãn: 19 | khớp 1-1: 19
nhãn chứa >1 tâm: 0 | nhãn không chứa tâm: 0 | tâm rơi vào nền/biên: 0
5.2. Kiểm tra diện tích (luôn làm được, kể cả khi chạy thật)
Khi sản xuất không có đáp án, ta dựa vào việc các vật cùng loại có diện tích tương tự. Lấy trung vị diện tích (bền với vài giá trị sai) làm chuẩn, đánh dấu vật quá to (có thể là 2 viên chưa tách) hoặc quá nhỏ (mảnh vỡ, hoặc tách thừa), và ước lượng số viên độc lập từ tổng diện tích mặt nạ:
import cv2
import numpy as np
markers = np.load("markers.npy")
mat_na = cv2.imread("vien_thuoc_mask.png", cv2.IMREAD_GRAYSCALE)
nhan = [l for l in np.unique(markers) if l > 1]
dien_tich = {l: int((markers == l).sum()) for l in nhan}
tv = float(np.median(list(dien_tich.values())))
qua_to = [l - 1 for l, a in dien_tich.items() if a > 1.6 * tv]
qua_nho = [l - 1 for l, a in dien_tich.items() if a < 0.5 * tv]
uoc_luong = (mat_na > 0).sum() / tv
print(f"diện tích: nhỏ nhất {min(dien_tich.values())}, trung vị {tv:.0f}, lớn nhất {max(dien_tich.values())}")
print("vật quá to:", qua_to, "| vật quá nhỏ:", qua_nho)
print(f"ước lượng theo diện tích: {uoc_luong:.1f} viên | watershed: {len(nhan)} viên")
ket_luan = "OK" if not qua_to and not qua_nho and abs(uoc_luong - len(nhan)) < 2 else "CẦN KIỂM TRA"
print("kết luận:", ket_luan)
diện tích: nhỏ nhất 729, trung vị 1022, lớn nhất 1283
vật quá to: [] | vật quá nhỏ: []
ước lượng theo diện tích: 20.0 viên | watershed: 19 viên
kết luận: OK
Ước lượng theo diện tích lớn hơn số đếm một chút vì mặt nạ còn gồm các đường ranh giới (−1) và vì các viên lớn hơn trung vị. Hai con số chênh nhau dưới 2 viên là hợp lý; chênh nhiều hơn là dấu hiệu tách sai. Trong nhà máy, trạng thái "CẦN KIỂM TRA" nên đẩy khay sang kiểm tra lại (hoặc báo NG có lý do) thay vì gửi một con số đáng ngờ cho PLC.
5.3. Kiểm tra bằng mắt
Ảnh watershed_ket_qua.png vẽ ranh giới đỏ và số thứ tự. Khi cài đặt, xem ít nhất vài chục ảnh như vậy, chú ý các cụm lớn nhất. Đây cũng là ảnh minh chứng lưu kèm mỗi khay báo NG.
6. Gom thành hàm dùng lại
import cv2
import numpy as np
def dem_vat_dinh_nhau(gray, k_dinh=21, dist_min=10, k_mo=5):
"""Đếm vật sáng trên nền tối, kể cả khi chạm nhau. Trả về (số vật, markers, diện tích từng vật)."""
mo = cv2.GaussianBlur(gray, (5, 5), 0)
_, m = cv2.threshold(mo, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k_mo, k_mo)))
dist = cv2.distanceTransform(m, cv2.DIST_L2, 5)
ke = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k_dinh, k_dinh))
dinh = ((dist == cv2.dilate(dist, ke)) & (dist > dist_min)).astype(np.uint8) * 255
tam = cv2.dilate(dinh, np.ones((5, 5), np.uint8))
nen = cv2.dilate(m, np.ones((3, 3), np.uint8), iterations=3)
_, markers = cv2.connectedComponents(tam)
markers = markers + 1
markers[cv2.subtract(nen, tam) == 255] = 0
markers = cv2.watershed(cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR), markers)
nhan = [l for l in np.unique(markers) if l > 1]
return len(nhan), markers, [int((markers == l).sum()) for l in nhan]
anh = cv2.imread("vien_thuoc.png", cv2.IMREAD_GRAYSCALE)
so, _, dt = dem_vat_dinh_nhau(anh)
print("số viên:", so, "| diện tích trung vị:", int(np.median(dt)))
số viên: 19 | diện tích trung vị: 1022
7. Giới hạn và khi nào chuyển sang Deep Learning
- Vật không lồi (thuôn dài, hình chữ L, viên nang): distance transform có "sống núi" dài thay vì một đỉnh, dễ sinh nhiều marker cho một vật, tức tách thừa (bài tập 3).
- Vật chồng lên nhau nhiều tầng: phần bị che không có trên ảnh, không thuật toán nào đếm chắc chắn. Giải pháp đúng là cơ khí: rung, dàn đều, tách hàng, hoặc dùng khay có ô định vị.
- Phân ngưỡng ban đầu kém (vật có lỗ, bóng, phản xạ): lỗ trong mặt nạ tạo đỉnh giả trên distance transform. Làm sạch mặt nạ trước (closing ở bài Morphology).
- Nền phức tạp, vật có kết cấu không đồng nhất, hình dạng đa dạng: chuyển sang instance segmentation (chặng 12, bài phân đoạn ảnh và YOLO26-seg). Kể cả khi đó, các bước kiểm tra ở mục 5 vẫn nguyên giá trị.
8. Lỗi thường gặp
| Hiện tượng | Nguyên nhân | Cách sửa |
|---|---|---|
| Watershed "ăn" cả nền, chỉ ra 1–2 vật khổng lồ | Quên markers + 1, nền mang nhãn 0 | Nền = 1, chưa biết = 0 |
| Lỗi kiểu ảnh khi gọi watershed | Truyền ảnh xám hoặc float | cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR); markers kiểu int32 |
| Vẫn đếm thiếu | Hệ số ngưỡng dist thấp, kernel cực đại cục bộ quá lớn | Tăng f hoặc giảm kernel; kiểm tra bằng nhãn quá to |
| Đếm thừa, một viên bị chia đôi | Kernel cực đại quá nhỏ, mặt nạ có lỗ, vật thuôn dài | Tăng kernel và dist_min, closing lấp lỗ, hoặc ngưỡng dist |
| Bỏ sót vật nhỏ | Ngưỡng f · dist.max() theo vật lớn nhất | Cực đại cục bộ với dist_min theo vật nhỏ nhất |
9. Bài tập
Bài 1. Đếm viên thuốc dính nhau trong khay mẫu thứ hai vien_thuoc_2.png (tạo ở mục 1), so sánh kết quả với đếm contour thường và với đáp án.
Xem đáp án
import json
import cv2
import numpy as np
anh = cv2.imread("vien_thuoc_2.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(anh, (5, 5), 0)
_, m = cv2.threshold(mo, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
cs, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
dist = cv2.distanceTransform(m, cv2.DIST_L2, 5)
dinh = ((dist == cv2.dilate(dist, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))))
& (dist > 10)).astype(np.uint8) * 255
tam = cv2.dilate(dinh, np.ones((5, 5), np.uint8))
nen = cv2.dilate(m, np.ones((3, 3), np.uint8), iterations=3)
_, markers = cv2.connectedComponents(tam)
markers = markers + 1
markers[cv2.subtract(nen, tam) == 255] = 0
markers = cv2.watershed(cv2.cvtColor(anh, cv2.COLOR_GRAY2BGR), markers)
vien = json.load(open("vien_thuoc_2.json", encoding="utf-8"))
print("đáp án:", len(vien), "| contour thường:", len(cs),
"| watershed:", len([l for l in np.unique(markers) if l > 1]))
đáp án: 19 | contour thường: 8 | watershed: 19
Contour thường chỉ đếm số cụm; watershed đếm đúng số viên. Trong chương trình thật, bạn gọi hàm dem_vat_dinh_nhau ở mục 6 thay vì chép lại các bước.
Bài 2. Khay có cả viên lớn (bán kính 22) và viên nhỏ (bán kính 11) chạm nhau. So sánh marker bằng ngưỡng 0,6 × dist.max() và bằng cực đại cục bộ. Cách nào bỏ sót, và vì sao?
Xem đáp án
import cv2
import numpy as np
anh = np.full((200, 400), 40, np.uint8)
viens = [(60, 100, 22), (98, 100, 22), (124, 118, 11), (220, 100, 22), (252, 82, 11),
(330, 60, 11), (330, 140, 22)] # 7 viên: 4 lớn, 3 nhỏ
for x, y, r in viens:
cv2.circle(anh, (x, y), r, 200, -1)
_, m = cv2.threshold(anh, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
dist = cv2.distanceTransform(m, cv2.DIST_L2, 5)
n1, _ = cv2.connectedComponents((dist > 0.6 * dist.max()).astype(np.uint8))
ke = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))
n2, _ = cv2.connectedComponents(((dist == cv2.dilate(dist, ke)) & (dist > 5)).astype(np.uint8))
print("đáp án 7 | ngưỡng 0.6*max:", n1 - 1, "| cực đại cục bộ:", n2 - 1)
print("đỉnh viên lớn ≈", round(float(dist[100, 220]), 1), "| đỉnh viên nhỏ ≈", round(float(dist[60, 330]), 1),
"| ngưỡng 0.6*max =", round(0.6 * float(dist.max()), 1))
đáp án 7 | ngưỡng 0.6*max: 4 | cực đại cục bộ: 7
đỉnh viên lớn ≈ 22.0 | đỉnh viên nhỏ ≈ 11.0 | ngưỡng 0.6*max = 13.2
Đỉnh của viên nhỏ (11) thấp hơn ngưỡng 0,6 × 22 = 13,2, nên ngưỡng toàn cục xóa mất cả 3 viên nhỏ. Cực đại cục bộ chỉ hỏi "điểm này có cao nhất trong vùng lân cận không", nên giữ được viên nhỏ. Lưu ý kernel phải giảm xuống 11 (cỡ bán kính viên nhỏ nhất) và dist_min xuống 5.
Bài 3. Thay viên tròn bằng 5 viên nang thuôn dài (dài 50 px, bán kính 15 px), hai viên chạm nhau. Đếm marker bằng cực đại cục bộ và bằng ngưỡng dist với hệ số 0,5; 0,7; 0,8; 0,9. Kết luận gì?
Xem đáp án
import cv2
import numpy as np
rng = np.random.default_rng(1)
anh = np.full((240, 400), 40, np.uint8)
for (cx, cy), goc in [((80, 70), 20), ((125, 95), -30), ((260, 80), 90), ((300, 170), 0), ((110, 180), 60)]:
d = np.array([np.cos(np.deg2rad(goc)), np.sin(np.deg2rad(goc))]) * 25
p1 = tuple(int(v) for v in np.array([cx, cy]) - d)
p2 = tuple(int(v) for v in np.array([cx, cy]) + d)
cv2.line(anh, p1, p2, 200, 30) # thân viên nang
cv2.circle(anh, p1, 15, 200, -1) # hai đầu tròn
cv2.circle(anh, p2, 15, 200, -1)
anh = np.clip(anh + rng.normal(0, 6, anh.shape), 0, 255).astype(np.uint8)
_, m = cv2.threshold(cv2.GaussianBlur(anh, (5, 5), 0), 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
cs, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
dist = cv2.distanceTransform(m, cv2.DIST_L2, 5)
ke = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (21, 21))
n, _ = cv2.connectedComponents(((dist == cv2.dilate(dist, ke)) & (dist > 8)).astype(np.uint8))
print("đáp án 5 | contour:", len(cs), "| cực đại cục bộ:", n - 1)
for f in (0.5, 0.7, 0.8, 0.9):
n, _ = cv2.connectedComponents((dist > f * dist.max()).astype(np.uint8))
print(f"ngưỡng {f}: {n - 1}")
đáp án 5 | contour: 4 | cực đại cục bộ: 27
ngưỡng 0.5: 4
ngưỡng 0.7: 4
ngưỡng 0.8: 5
ngưỡng 0.9: 1
Với viên nang, distance transform có một sống núi phẳng dọc thân viên; nhiễu ở mép mặt nạ tạo ra nhiều gợn nhỏ dọc sống núi, mỗi gợn thành một "cực đại cục bộ": tách thừa nghiêm trọng. Ngưỡng toàn cục lại hoạt động tốt ở một dải hẹp (0,8), vì mọi viên nang cùng bề rộng nên cùng chiều cao sống núi. Kết luận: không có cách tạo marker đúng cho mọi hình dạng. Chọn theo hình dạng sản phẩm, và luôn giữ lớp kiểm tra diện tích ở mục 5 để bắt các trường hợp tách sai.
Tóm tắt
- Vật chạm nhau làm contour đếm thiếu; distance transform biến mỗi vật thành một đỉnh, eo tiếp xúc thành yên ngựa.
- Marker: ngưỡng
f · dist.max()(đơn giản, hợp vật cùng cỡ và thuôn dài) hoặc cực đại cục bộ (hợp vật tròn khác cỡ). - Watershed: nền = 1, chưa biết = 0, vật = 2, 3…; ảnh BGR 8 bit; ranh giới = −1.
- Kiểm tra kết quả: so đáp án trên ảnh mẫu; khi chạy thật, so diện tích với trung vị và ước lượng số lượng theo diện tích; trạng thái nghi ngờ thì không gửi số đếm.
- Vật chồng nhiều tầng: giải bằng cơ khí; nền phức tạp: instance segmentation.
Bài tiếp theo: Biến đổi hình học: affine và phối cảnh, căn thẳng vật bị xoay và nắn ảnh chụp nghiêng trước khi đo.
Bài tập thực hành
- Đếm viên thuốc dính nhau trong ảnh mẫu, so sánh kết quả với đếm contour thường.
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.


