Lọc nhiễu và phát hiện cạnh: Gaussian, median, Sobel, Canny
Kernel và tích chập, chọn Gaussian hay median cho nhiễu muối tiêu, gradient Sobel, Canny và cách chọn ngưỡng có căn cứ, kèm trackbar.
Học xong bài này, bạn sẽ
- Hiểu kernel và phép tích chập
- Lọc Gaussian, median trước khi tìm cạnh
- Dùng Sobel, Canny và chọn ngưỡng
Nên học trước: Histogram và cân bằng sáng
Nội dung bài viết
- 1. Kernel và phép tích chập
- 2. Ảnh thử và các bộ lọc làm mịn
- 3. So sánh Gaussian và median bằng số
- 4. Gradient và Sobel
- 5. Canny: bộ phát hiện cạnh tiêu chuẩn
- 6. Chọn ngưỡng Canny có căn cứ
- 7. Thanh trượt (trackbar) chỉnh ngưỡng trực tiếp
- 8. Ứng dụng nhanh: tìm lỗ tròn bằng Hough
- 9. Lỗi thường gặp
- 10. Bài tập
- Tóm tắt
Bài "Histogram và cân bằng sáng" cho thấy nhiễu cảm biến luôn có mặt và còn bị khuếch đại khi ta tăng tương phản. Bài này xử lý nhiễu bằng lọc ảnh, rồi dùng chính công cụ đó để tìm cạnh (edge): nơi độ sáng thay đổi đột ngột, tức mép chi tiết, đường viền nhãn, vết xước. Cạnh là nền tảng của đo kích thước, định vị và kiểm tra lỗi. Bạn sẽ hiểu kernel và phép tích chập, biết chọn Gaussian hay median, dùng Sobel và Canny, và quan trọng nhất là chọn ngưỡng Canny có căn cứ thay vì thử mò. Bài tiếp theo, "Phân ngưỡng và contour: đếm, đo vật thể", sẽ dùng ảnh đã lọc nhiễu để tách vật.
opencv-python và numpy. Mọi ví dụ tự tạo ảnh có "đáp án" biết trước (vị trí cạnh, vết xước), nên ta đo được bộ lọc nào tốt hơn bằng con số, không chỉ bằng mắt. Riêng ví dụ thanh trượt (trackbar) ở mục 7 cần bản opencv-python có giao diện, không phải bản opencv-python-headless.1. Kernel và phép tích chập
Lọc ảnh tuyến tính là trượt một ma trận nhỏ, gọi là kernel, qua từng điểm ảnh. Giá trị mới của điểm ảnh bằng tổng có trọng số của các điểm lân cận, trọng số chính là các phần tử của kernel. Kernel khác nhau cho tác dụng khác nhau: làm mịn, làm sắc, lấy đạo hàm. Tự tính một điểm bằng tay rồi so với OpenCV:
import cv2
import numpy as np
a = np.array([[10, 10, 10, 10, 10],
[10, 10, 10, 10, 10],
[10, 10, 100, 10, 10],
[10, 10, 10, 10, 10],
[10, 10, 10, 10, 10]], np.float32)
k = np.ones((3, 3), np.float32) / 9 # kernel trung bình 3×3
tay = (a[1:4, 1:4] * k).sum() # điểm (2, 2): nhân từng phần tử rồi cộng
cv = cv2.filter2D(a, -1, k)
print("tính tay:", round(float(tay), 2), "| filter2D:", round(float(cv[2, 2]), 2))
print(np.round(cv, 1))
tính tay: 20.0 | filter2D: 20.0
[[10. 10. 10. 10. 10.]
[10. 20. 20. 20. 10.]
[10. 20. 20. 20. 10.]
[10. 20. 20. 20. 10.]
[10. 10. 10. 10. 10.]]
- Điểm sáng đơn lẻ (100) bị "trải" ra cả vùng 3×3 thành 20: kernel trung bình làm mịn nhưng không xóa được điểm nhiễu, chỉ làm nó nhạt và to ra. Ghi nhớ điều này cho nhiễu muối tiêu ở mục 3.
- Tổng các phần tử kernel làm mịn bằng 1, nên độ sáng trung bình của ảnh được giữ nguyên.
- Biên ảnh: ở mép không đủ lân cận, OpenCV mặc định phản chiếu ảnh qua mép (
BORDER_REFLECT_101). Vì vậy kết quả ở sát mép ảnh luôn kém tin cậy hơn; trong trạm kiểm tra, đặt ROI cách mép ảnh vài pixel. - Nói chính xác,
filter2Dtính tương quan (correlation), không lật kernel như định nghĩa tích chập toán học. Với kernel đối xứng (trung bình, Gaussian) hai phép giống hệt nhau; với kernel không đối xứng như Sobel, điều này quyết định dấu của kết quả.
2. Ảnh thử và các bộ lọc làm mịn
Ảnh thử là một tấm kim loại (mức 130) trên nền tối (60), có lỗ tròn bán kính 30 px và một vết xước mờ chỉ tối hơn bề mặt 20 mức. Ta tạo ba phiên bản: sạch (đáp án), nhiễu Gaussian (nhiễu cảm biến thông thường, độ lệch chuẩn 8) và nhiễu muối tiêu (salt-and-pepper: điểm đen trắng ngẫu nhiên do lỗi truyền dữ liệu, pixel hỏng, bụi phản xạ).
import cv2
import numpy as np
sach = np.full((240, 320), 60, np.uint8)
cv2.rectangle(sach, (60, 50), (259, 189), 130, -1) # tấm kim loại 200×140 px
cv2.circle(sach, (160, 100), 30, 60, -1) # lỗ tròn bán kính 30
cv2.line(sach, (80, 160), (240, 150), 110, 2) # vết xước mờ: tối hơn bề mặt 20 mức
rng = np.random.default_rng(0)
nhieu_gauss = np.clip(sach + rng.normal(0, 8, sach.shape), 0, 255).astype(np.uint8)
muoi_tieu = sach.copy()
r = rng.random(sach.shape)
muoi_tieu[r < 0.025] = 0 # 2,5% điểm tiêu (đen)
muoi_tieu[r > 0.975] = 255 # 2,5% điểm muối (trắng)
cv2.imwrite("chi_tiet_sach.png", sach)
cv2.imwrite("chi_tiet_nhieu.png", nhieu_gauss)
cv2.imwrite("chi_tiet_muoi_tieu.png", muoi_tieu)
print("điểm bị muối tiêu:", int((muoi_tieu != sach).sum()), "/", sach.size)
điểm bị muối tiêu: 3832 / 76800
| Bộ lọc | Hàm OpenCV | Cách làm | Ưu / nhược |
|---|---|---|---|
| Trung bình (box) | cv2.blur(img, (5, 5)) | Trung bình cộng lân cận | Nhanh nhất; làm nhòe cạnh nhiều |
| Gaussian | cv2.GaussianBlur(img, (5, 5), 0) | Trung bình có trọng số hình chuông, gần tâm nặng hơn | Mịn tự nhiên, hợp với nhiễu cảm biến; vẫn nhòe cạnh |
| Median | cv2.medianBlur(img, 5) | Lấy trung vị của lân cận (phi tuyến) | Xóa sạch điểm nhiễu đơn lẻ, giữ cạnh thẳng sắc; làm tròn góc nhọn, chậm hơn với kernel lớn |
| Bilateral | cv2.bilateralFilter(img, 9, 75, 75) | Gaussian nhưng bỏ qua điểm có độ sáng khác xa | Làm mịn mà giữ cạnh; chậm nhất |
Với GaussianBlur, tham số thứ ba là độ lệch chuẩn σ; đặt 0 thì OpenCV tự tính từ kích thước kernel (kernel 5×5 cho σ ≈ 1,1). Có thể làm ngược lại: cv2.GaussianBlur(img, (0, 0), 2) chỉ định σ = 2 và để OpenCV tự chọn kích thước. Kích thước kernel luôn là số lẻ.
3. So sánh Gaussian và median bằng số
Ba thước đo, đều tính được vì có ảnh sạch làm đáp án:
- Nhiễu còn lại: độ lệch chuẩn của (ảnh lọc − ảnh sạch) trong một vùng phẳng của tấm kim loại.
- Xung còn lại: số điểm ảnh lệch hơn 15 mức so với ảnh sạch, chỉ đếm ở vùng cách xa cạnh (để không lẫn với độ nhòe cạnh): đó là vết muối tiêu chưa xóa được hoặc bị trải thành đốm.
- Độ nhòe cạnh: số pixel chuyển tiếp ở mép trái tấm kim loại, khi lọc ảnh sạch (cạnh lý tưởng có 0 pixel chuyển tiếp).
import cv2
import numpy as np
sach = cv2.imread("chi_tiet_sach.png", cv2.IMREAD_GRAYSCALE)
gauss = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mt = cv2.imread("chi_tiet_muoi_tieu.png", cv2.IMREAD_GRAYSCALE)
PHANG = (slice(55, 85), slice(65, 120)) # vùng phẳng trên tấm kim loại
XA_CANH = cv2.dilate(cv2.Canny(sach, 10, 30), np.ones((9, 9), np.uint8)) == 0 # cách cạnh > 4 px
def nhieu_con(a):
return float((a.astype(np.float64) - sach)[PHANG].std())
def xung_con(a):
return int(((np.abs(a.astype(int) - sach) > 15) & XA_CANH).sum())
def do_nhoe(a):
p = a[110:140, 50:71].astype(np.float64).mean(axis=0) # mặt cắt ngang qua mép trái (x = 60)
lo, hi = p[:5].mean(), p[-5:].mean()
return int(((p > lo + 0.1 * (hi - lo)) & (p < lo + 0.9 * (hi - lo))).sum())
bo_loc = {
"không lọc": lambda a: a,
"blur 5x5": lambda a: cv2.blur(a, (5, 5)),
"Gaussian 5x5": lambda a: cv2.GaussianBlur(a, (5, 5), 0),
"median 3": lambda a: cv2.medianBlur(a, 3),
"median 5": lambda a: cv2.medianBlur(a, 5),
"bilateral 9": lambda a: cv2.bilateralFilter(a, 9, 75, 75),
}
print(f"{'bộ lọc':13s} | {'muối tiêu: nhiễu':>16s} {'xung':>5s} | {'Gauss: nhiễu':>12s} | {'nhòe cạnh':>9s}")
for ten, f in bo_loc.items():
a, b = f(mt), f(gauss)
print(f"{ten:13s} | {nhieu_con(a):16.1f} {xung_con(a):5d} | {nhieu_con(b):12.1f} | {do_nhoe(f(sach)):9d}")
bộ lọc | muối tiêu: nhiễu xung | Gauss: nhiễu | nhòe cạnh
không lọc | 31.4 3319 | 7.7 | 0
blur 5x5 | 6.2 4349 | 1.4 | 4
Gaussian 5x5 | 8.6 6994 | 2.0 | 2
median 3 | 0.0 1 | 3.1 | 0
median 5 | 0.0 0 | 1.8 | 0
bilateral 9 | 6.4 1883 | 1.0 | 4
Đọc bảng theo từng loại nhiễu:
- Muối tiêu: median 3 và median 5 xóa sạch (nhiễu còn lại 0, không còn xung). Gaussian và blur không xóa được điểm 0 và 255 mà trải chúng thành đốm mờ: 3319 điểm nhiễu ban đầu biến thành 4349–6994 điểm bị lệch, tức là nhiều hơn cả lúc chưa lọc. Bilateral cũng kém vì nó coi điểm 255 là "cạnh" cần giữ.
- Nhiễu Gaussian: các bộ lọc tuyến tính và bilateral giảm nhiễu tốt; median 3 kém nhất vì lân cận 9 điểm quá ít để lấy trung vị ổn định.
- Cạnh: median giữ cạnh thẳng sắc nguyên vẹn (0 pixel chuyển tiếp); Gaussian 5×5 làm nhòe 2 pixel, blur 5×5 nhòe 4 pixel. Cạnh nhòe nghĩa là vị trí cạnh kém chính xác khi đo. Bilateral ở đây cũng nhòe 4 pixel: nó chỉ giữ cạnh khi độ chênh sáng của cạnh (70 mức) lớn hơn rõ rệt tham số
sigmaColor(75); giảmsigmaColorsẽ giữ cạnh tốt hơn nhưng lọc nhiễu yếu hơn. - Median có nhược điểm không thấy trong bảng: nó gọt tròn góc nhọn và xóa các nét mảnh hơn nửa kernel. Vì vậy chọn kernel median nhỏ nhất đủ xóa nhiễu.
4. Gradient và Sobel
Cạnh là nơi đạo hàm (gradient) của độ sáng lớn. Toán tử Sobel ước lượng đạo hàm bằng kernel 3×3: lấy hiệu hai bên theo một hướng, đồng thời làm mịn theo hướng vuông góc (trọng số 1, 2, 1) để bớt nhạy nhiễu.
import cv2
import numpy as np
kx, ky = cv2.getDerivKernels(1, 0, 3) # đạo hàm theo x, kernel 3
print("Sobel theo x:\n", (ky @ kx.T).astype(int))
hang = np.array([[50] * 4 + [150] * 4 + [50] * 4], np.uint8) # cạnh lên rồi cạnh xuống
g64 = cv2.Sobel(hang, cv2.CV_64F, 1, 0, ksize=3)
g8 = cv2.Sobel(hang, cv2.CV_8U, 1, 0, ksize=3)
print("độ sáng :", hang[0].tolist())
print("Sobel 64F :", g64[0].astype(int).tolist())
print("Sobel 8U :", g8[0].tolist())
Sobel theo x:
[[-1 0 1]
[-2 0 2]
[-1 0 1]]
độ sáng : [50, 50, 50, 50, 150, 150, 150, 150, 50, 50, 50, 50]
Sobel 64F : [0, 0, 0, 400, 400, 0, 0, -400, -400, 0, 0, 0]
Sobel 8U : [0, 0, 0, 255, 255, 0, 0, 0, 0, 0, 0, 0]
- Cạnh lên (50 → 150) cho gradient dương, cạnh xuống cho gradient âm. Độ lớn 400 = 4 × 100 vì tổng trọng số làm mịn (1 + 2 + 1) bằng 4.
- Với kiểu kết quả
CV_8U, giá trị âm bị cắt về 0 và 400 bị cắt về 255: cạnh xuống biến mất hoàn toàn. Đây là lỗi kinh điển của người mới. Luôn dùngCV_32FhoặcCV_64F, rồi lấy trị tuyệt đối hoặc độ lớn. - Dấu của gradient có ích: khi đo chiều rộng một chi tiết sáng trên nền tối, mép trái là cạnh lên, mép phải là cạnh xuống. Chặng 7 (đo cạnh subpixel) dùng chính tính chất này.
Trên ảnh 2D, tính hai thành phần gx, gy, rồi lấy độ lớn (cạnh mạnh hay yếu) và hướng (hướng gradient vuông góc với cạnh):
import cv2
import numpy as np
anh = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(anh, (5, 5), 0)
gx = cv2.Sobel(mo, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(mo, cv2.CV_32F, 0, 1, ksize=3)
do_lon = cv2.magnitude(gx, gy) # sqrt(gx² + gy²)
huong = cv2.phase(gx, gy, angleInDegrees=True) # 0..360°, hướng gradient
for ten, (y, x) in [("mép trái tấm (x=60)", (120, 60)), ("mép trên tấm (y=50)", (50, 200)),
("vết xước", (155, 160)), ("vùng phẳng", (70, 90))]:
vung = do_lon[y - 2:y + 3, x - 2:x + 3]
yy, xx = np.unravel_index(vung.argmax(), vung.shape)
print(f"{ten:20s} độ lớn {vung.max():6.1f} hướng {huong[y - 2 + yy, x - 2 + xx]:5.0f}°")
cv2.imwrite("do_lon_gradient.png", cv2.convertScaleAbs(do_lon, alpha=255.0 / do_lon.max()))
mép trái tấm (x=60) độ lớn 180.0 hướng 0°
mép trên tấm (y=50) độ lớn 183.1 hướng 92°
vết xước độ lớn 64.1 hướng 86°
vùng phẳng độ lớn 20.2 hướng 250°
Mép trái (cạnh dọc, tối sang sáng theo chiều x) cho hướng gần 0°; mép trên (cạnh ngang, tối sang sáng theo chiều y hướng xuống) cho gần 90°. Vết xước mờ có độ lớn chỉ khoảng một phần ba cạnh tấm kim loại (64 so với 180), còn vùng phẳng chỉ có gradient của nhiễu (khoảng 20). Ba con số này chính là căn cứ để chọn ngưỡng ở mục 6. Hàm convertScaleAbs ở dòng cuối đưa ảnh float về 0–255 để lưu và xem.
5. Canny: bộ phát hiện cạnh tiêu chuẩn
cv2.Canny(ảnh, ngưỡng_thấp, ngưỡng_cao) làm bốn bước:
- Tính gradient bằng Sobel 3×3 (tham số
apertureSize). Mặc định độ lớn là |gx| + |gy| (chuẩn L1); đặtL2gradient=Trueđể dùng căn bậc hai chính xác hơn. - Non-maximum suppression: chỉ giữ điểm có độ lớn cực đại theo hướng gradient, làm cạnh mảnh còn 1 pixel.
- Ngưỡng kép: điểm có độ lớn trên ngưỡng cao là cạnh chắc chắn; dưới ngưỡng thấp bị loại; ở giữa là "cạnh yếu".
- Hysteresis: cạnh yếu chỉ được giữ nếu nối liền (8 lân cận) với một cạnh chắc chắn. Nhờ vậy một đường viền liền mạch được giữ trọn, còn điểm nhiễu lẻ loi bị loại.
cv2.Canny không làm bước đó. Bạn phải tự gọi GaussianBlur (hoặc median) trước.Kiểm chứng: Canny với cùng ngưỡng (20, 60) trên ảnh chưa lọc và đã lọc. Ảnh sạch có đúng 3 đường cạnh liền (viền tấm, viền lỗ, vết xước), nên ta đếm số đoạn cạnh rời nhau bằng connectedComponents:
import cv2
def thong_ke(canh):
n, _ = cv2.connectedComponents(canh, connectivity=8)
return f"{int((canh > 0).sum()):6d} điểm cạnh, {n - 1:4d} đoạn"
sach = cv2.imread("chi_tiet_sach.png", cv2.IMREAD_GRAYSCALE)
gauss = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mt = cv2.imread("chi_tiet_muoi_tieu.png", cv2.IMREAD_GRAYSCALE)
print("ảnh sạch (đáp án) :", thong_ke(cv2.Canny(sach, 20, 60)))
print("nhiễu Gauss, không lọc :", thong_ke(cv2.Canny(gauss, 20, 60)))
print("nhiễu Gauss + Gaussian 5:", thong_ke(cv2.Canny(cv2.GaussianBlur(gauss, (5, 5), 0), 20, 60)))
print("muối tiêu + Gaussian 5 :", thong_ke(cv2.Canny(cv2.GaussianBlur(mt, (5, 5), 0), 20, 60)))
print("muối tiêu + median 5 :", thong_ke(cv2.Canny(cv2.medianBlur(mt, 5), 20, 60)))
ảnh sạch (đáp án) : 1204 điểm cạnh, 3 đoạn
nhiễu Gauss, không lọc : 26409 điểm cạnh, 1633 đoạn
nhiễu Gauss + Gaussian 5: 1235 điểm cạnh, 3 đoạn
muối tiêu + Gaussian 5 : 16438 điểm cạnh, 882 đoạn
muối tiêu + median 5 : 1190 điểm cạnh, 4 đoạn
Không lọc, nhiễu tạo ra hàng nghìn đoạn cạnh giả. Lọc đúng loại đưa kết quả về gần đáp án: Gaussian cho nhiễu Gaussian cho đúng 3 đoạn; median cho muối tiêu cho 4 đoạn vì vết xước mảnh 2 px bị median gọt đứt một chỗ thành 2 đoạn (đúng nhược điểm "xóa nét mảnh" ở mục 3), nhưng không còn cạnh giả. Lọc sai loại (Gaussian cho muối tiêu) vẫn để lại hàng trăm đoạn giả.
6. Chọn ngưỡng Canny có căn cứ
Quét nhiều cặp ngưỡng trên ảnh nhiễu Gaussian đã lọc, đếm số đoạn cạnh và số điểm cạnh trong vùng có vết xước:
import cv2
gauss = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(gauss, (5, 5), 0)
VUNG_XUOC = (slice(140, 175), slice(70, 250))
print(" thấp cao | điểm cạnh đoạn | điểm trên vết xước")
for lo, hi in [(10, 30), (20, 60), (30, 90), (60, 180), (100, 250), (150, 300)]:
e = cv2.Canny(mo, lo, hi)
n, _ = cv2.connectedComponents(e, connectivity=8)
print(f"{lo:5d} {hi:4d} | {int((e > 0).sum()):9d} {n - 1:5d} | {int((e[VUNG_XUOC] > 0).sum()):6d}")
thấp cao | điểm cạnh đoạn | điểm trên vết xước
10 30 | 17506 322 | 1561
20 60 | 1235 3 | 343
30 90 | 892 2 | 0
60 180 | 892 2 | 0
100 250 | 892 2 | 0
150 300 | 0 0 | 0
- (10, 30): nhiễu vượt ngưỡng, hàng trăm đoạn giả.
- (20, 60): đúng 3 đoạn, vết xước được tìm thấy. Đây là vùng ngưỡng đúng cho bài toán phát hiện vết xước.
- (30, 90) đến (100, 250): còn 2 đoạn, viền tấm và lỗ vẫn đủ nhưng vết xước biến mất. Đúng cho bài toán đo kích thước tấm (bớt nhạy nhiễu hơn), sai cho bài toán tìm xước.
- (150, 300): không còn cạnh nào.
Vậy "ngưỡng đúng" phụ thuộc vào cạnh yếu nhất bạn cần giữ và nhiễu mạnh nhất bạn cần loại. Cách chọn có căn cứ: đo độ lớn gradient (chuẩn L1, giống Canny) trên vùng nền phẳng và trên cạnh yếu nhất cần phát hiện, rồi đặt ngưỡng cao vào giữa:
import cv2
import numpy as np
gauss = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(gauss, (5, 5), 0)
gx = cv2.Sobel(mo, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(mo, cv2.CV_32F, 0, 1, ksize=3)
L1 = np.abs(gx) + np.abs(gy) # cùng thang đo với cv2.Canny mặc định
nhieu_max = float(L1[55:85, 65:120].max()) # vùng phẳng: chỉ có nhiễu
xuoc = float(np.percentile(L1[150:165, 90:120], 99)) # một đoạn vết xước, xa lỗ và mép tấm
cao = int((nhieu_max + xuoc) / 2)
thap = cao // 3
print(f"gradient nhiễu lớn nhất: {nhieu_max:.0f} | gradient vết xước: {xuoc:.0f}")
print(f"=> ngưỡng cao {cao}, ngưỡng thấp {thap}")
e = cv2.Canny(mo, thap, cao)
n, _ = cv2.connectedComponents(e, connectivity=8)
print("số đoạn cạnh:", n - 1)
v = float(np.median(mo)) # mẹo phổ biến theo trung vị độ sáng
print(f"mẹo trung vị: thấp {int(0.66 * v)}, cao {int(1.33 * v)} (trung vị độ sáng = {v:.0f})")
gradient nhiễu lớn nhất: 42 | gradient vết xước: 74
=> ngưỡng cao 58, ngưỡng thấp 19
số đoạn cạnh: 3
mẹo trung vị: thấp 40, cao 81 (trung vị độ sáng = 61)
Mẹo "ngưỡng = 0,66 và 1,33 lần trung vị độ sáng" lan truyền rộng trên mạng, nhưng nó dựa vào độ sáng, trong khi Canny so sánh độ lớn gradient: hai đại lượng không liên quan trực tiếp. Ảnh nền tối và ảnh nền sáng có cùng cạnh sẽ nhận ngưỡng khác nhau. Với ảnh này, cặp (40, 81) tình cờ vẫn bắt được vết xước nhờ hysteresis nhưng làm nó đứt thành 2 đoạn; với ảnh nền sáng hơn, cùng công thức sẽ cho ngưỡng cao hơn hẳn và mất vết xước. Dùng nó để thử nhanh thì được; trong dự án, hãy đo gradient như trên trên vài ảnh OK và ảnh NG có lỗi nhỏ nhất, rồi cố định ngưỡng trong file cấu hình. Tỉ lệ cao/thấp từ 2:1 đến 3:1 là khuyến nghị của chính tác giả thuật toán Canny.
7. Thanh trượt (trackbar) chỉnh ngưỡng trực tiếp
Khi cài đặt trạm, kỹ sư thường cần kéo thanh trượt và nhìn kết quả ngay. Thiết kế tốt là tách phần xử lý ra một hàm thuần (nhận ngưỡng, trả về ảnh) để kiểm thử được mà không cần cửa sổ, còn vòng lặp giao diện chỉ đọc thanh trượt, gọi hàm và hiển thị:
import cv2
anh = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(anh, (5, 5), 0)
def ve_canny(lo, hi):
"""Phần xử lý: kiểm thử được độc lập với giao diện."""
hi = max(hi, lo + 1) # đảm bảo cao > thấp
canh = cv2.Canny(mo, lo, hi)
hien = cv2.cvtColor(mo, cv2.COLOR_GRAY2BGR)
hien[canh > 0] = (0, 0, 255) # tô cạnh màu đỏ lên ảnh
n, _ = cv2.connectedComponents(canh, connectivity=8)
cv2.putText(hien, f"low={lo} high={hi} doan={n - 1}", (5, 15),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, (0, 255, 255), 1)
return hien, n - 1
print("kiểm thử hàm:", ve_canny(20, 60)[1], "đoạn") # chạy được cả khi không có màn hình
CUA_SO = "Canny"
cv2.namedWindow(CUA_SO)
cv2.createTrackbar("thap", CUA_SO, 20, 255, lambda v: None) # tên, cửa sổ, giá trị đầu, max, callback
cv2.createTrackbar("cao", CUA_SO, 60, 255, lambda v: None)
while True:
lo = cv2.getTrackbarPos("thap", CUA_SO)
hi = cv2.getTrackbarPos("cao", CUA_SO)
hien, so_doan = ve_canny(lo, hi)
cv2.imshow(CUA_SO, hien)
phim = cv2.waitKey(30) & 0xFF
if phim == ord("s"): # lưu cặp ngưỡng đang xem
cv2.imwrite(f"canny_{lo}_{hi}.png", hien)
if phim in (ord("q"), 27): # q hoặc Esc để thoát
break
cv2.destroyAllWindows()
print("ngưỡng chọn cuối cùng:", lo, max(hi, lo + 1))
Dòng in đầu tiên luôn là kiểm thử hàm: 3 đoạn (khớp bảng ở mục 6); dòng cuối in cặp ngưỡng tại vị trí thanh trượt khi bạn nhấn q, nên khác nhau mỗi lần chạy.
- Callback
lambda v: Nonelà bắt buộc trong Python dù không dùng; ta đọc giá trị bằnggetTrackbarPostrong vòng lặp, cách này đơn giản và dễ kiểm soát hơn xử lý trong callback. waitKey(30)vừa cho cửa sổ thời gian vẽ lại, vừa đọc phím. Nhấn s để lưu ảnh, q hoặc Esc để thoát.- Thanh trượt chỉ dùng ở bước cài đặt. Khi đã chọn được, ghi ngưỡng vào file cấu hình như bài tổ chức pipeline ở chặng 2; trạm sản xuất không để người vận hành kéo ngưỡng tùy ý.
8. Ứng dụng nhanh: tìm lỗ tròn bằng Hough
Bản đồ cạnh là đầu vào của nhiều thuật toán. Ví dụ HoughCircles tìm đường tròn (nó tự chạy Canny bên trong với ngưỡng cao param1, ngưỡng thấp bằng một nửa):
import cv2
import numpy as np
anh = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.medianBlur(anh, 5)
tron = cv2.HoughCircles(mo, cv2.HOUGH_GRADIENT, dp=1, minDist=50,
param1=100, param2=20, minRadius=15, maxRadius=60)
if tron is not None:
for x, y, r in np.round(tron[0]).astype(int):
print(f"lỗ tròn: tâm ({x}, {y}), bán kính {r} px") # đáp án: tâm (160, 100), r = 30
lỗ tròn: tâm (160, 100), bán kính 30 px
Hough cho kết quả theo pixel nguyên, đủ để định vị; để đo đường kính chính xác đến phần nhỏ của pixel, chặng 7 dùng phương pháp đo cạnh subpixel và fitting đường tròn.
9. Lỗi thường gặp
| Lỗi | Hậu quả | Cách đúng |
|---|---|---|
Sobel với CV_8U | Mất cạnh âm, gradient bị cắt ở 255 | CV_32F/CV_64F, rồi lấy trị tuyệt đối |
| Gọi Canny trên ảnh chưa lọc | Hàng nghìn cạnh giả | GaussianBlur hoặc medianBlur trước |
| Gaussian cho nhiễu muối tiêu | Điểm nhiễu thành vết mờ, vẫn sinh cạnh | Median 3 hoặc 5 |
| Kernel lọc quá lớn | Mất chi tiết nhỏ (vết xước), cạnh nhòe làm đo sai | Kernel nhỏ nhất đủ xóa nhiễu; kiểm tra trên ảnh NG có lỗi nhỏ nhất |
| Chọn ngưỡng Canny bằng mắt trên 1 ảnh | Chạy tốt hôm nay, sai khi đèn thay đổi | Đo gradient nền và cạnh yếu nhất trên nhiều ảnh, lưu vào cấu hình |
| Xử lý cả khung hình | Cạnh của đồ gá, băng tải gây nhiễu kết quả, tốn thời gian | Giới hạn trong ROI, cách mép ảnh vài pixel |
10. Bài tập
Bài 1. Thêm nhiễu muối tiêu vào ảnh sạch với mật độ 1%, 5% và 20%, rồi so sánh lọc Gaussian 5×5, median 3 và median 5 bằng số "xung còn lại". Median 3 còn đủ khi nào?
Xem đáp án
import cv2
import numpy as np
sach = cv2.imread("chi_tiet_sach.png", cv2.IMREAD_GRAYSCALE)
XA_CANH = cv2.dilate(cv2.Canny(sach, 10, 30), np.ones((9, 9), np.uint8)) == 0
rng = np.random.default_rng(1)
print("mật độ | Gaussian 5 | median 3 | median 5")
for mat_do in (0.01, 0.05, 0.20):
a = sach.copy()
r = rng.random(a.shape)
a[r < mat_do / 2] = 0
a[r > 1 - mat_do / 2] = 255
ket = [int(((np.abs(f(a).astype(int) - sach) > 15) & XA_CANH).sum()) for f in (
lambda x: cv2.GaussianBlur(x, (5, 5), 0),
lambda x: cv2.medianBlur(x, 3),
lambda x: cv2.medianBlur(x, 5))]
print(f"{mat_do:6.0%} | {ket[0]:10d} | {ket[1]:8d} | {ket[2]:8d}")
mật độ | Gaussian 5 | median 3 | median 5
1% | 1343 | 0 | 0
5% | 6893 | 0 | 0
20% | 25403 | 116 | 0
Ở mật độ 1% và 5%, cả median 3 lẫn median 5 đều sạch hoàn toàn, nên chọn median 3 (nhanh hơn, ít gọt góc hơn). Khi mật độ lên 20%, trong lân cận 3×3 thường có nhiều điểm nhiễu cùng phía, trung vị bị "kéo" theo nhiễu, nên median 3 để lại 116 xung; median 5 (25 điểm) vẫn sạch. Gaussian không bao giờ là lựa chọn tốt cho loại nhiễu này. Nếu ảnh thật có tới 20% điểm hỏng, vấn đề nằm ở phần cứng (cáp, camera, ánh sáng), không nên chỉ chữa bằng bộ lọc.
Bài 2. Viết công cụ thanh trượt điều chỉnh trực tiếp hai ngưỡng Canny, thêm thanh trượt thứ ba chọn kích thước Gaussian (1, 3, 5, 7, 9), và khi nhấn s thì ghi cặp ngưỡng vào file canny_config.json.
Xem đáp án
import json
import cv2
anh = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
def xu_ly(anh, k_idx, lo, hi):
k = 2 * k_idx + 1 # 0..4 -> 1, 3, 5, 7, 9
mo = cv2.GaussianBlur(anh, (k, k), 0) if k > 1 else anh
hi = max(hi, lo + 1)
canh = cv2.Canny(mo, lo, hi)
n, _ = cv2.connectedComponents(canh, connectivity=8)
return canh, {"blur": k, "low": lo, "high": hi, "so_doan": n - 1}
print(xu_ly(anh, 2, 20, 60)[1]) # kiểm thử không cần cửa sổ
CS = "Chinh Canny"
cv2.namedWindow(CS)
cv2.createTrackbar("blur", CS, 2, 4, lambda v: None)
cv2.createTrackbar("thap", CS, 20, 255, lambda v: None)
cv2.createTrackbar("cao", CS, 60, 255, lambda v: None)
while True:
canh, cau_hinh = xu_ly(anh, cv2.getTrackbarPos("blur", CS),
cv2.getTrackbarPos("thap", CS), cv2.getTrackbarPos("cao", CS))
cv2.imshow(CS, canh)
phim = cv2.waitKey(30) & 0xFF
if phim == ord("s"):
with open("canny_config.json", "w", encoding="utf-8") as f:
json.dump(cau_hinh, f, ensure_ascii=False)
if phim in (ord("q"), 27):
break
cv2.destroyAllWindows()
{'blur': 5, 'low': 20, 'high': 60, 'so_doan': 3}
Dòng in đầu tiên là phần kiểm thử hàm, luôn giống nhau. Ghi cả so_doan vào file giúp bạn biết lúc chọn ngưỡng, ảnh mẫu cho bao nhiêu đoạn cạnh, để so sánh khi chạy thật.
Bài 3. Chỉ giữ lại các điểm cạnh Canny gần thẳng đứng (gradient hướng 0° ± 10° hoặc 180° ± 10°), đếm số điểm, và giải thích tại sao xấp xỉ bằng tổng chiều cao hai mép trái, phải của tấm kim loại.
Xem đáp án
import cv2
import numpy as np
anh = cv2.imread("chi_tiet_nhieu.png", cv2.IMREAD_GRAYSCALE)
mo = cv2.GaussianBlur(anh, (5, 5), 0)
canh = cv2.Canny(mo, 30, 90) # ngưỡng cho bài toán đo tấm
gx = cv2.Sobel(mo, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(mo, cv2.CV_32F, 0, 1, ksize=3)
huong = cv2.phase(gx, gy, angleInDegrees=True)
doc = (canh > 0) & ((huong < 10) | (huong > 350) | (np.abs(huong - 180) < 10))
print("điểm cạnh tổng:", int((canh > 0).sum()), "| điểm cạnh dọc:", int(doc.sum()))
cot = np.where(doc)[1]
print("các cột có cạnh dọc nhiều nhất:", np.bincount(cot).argsort()[-2:][::-1].tolist())
điểm cạnh tổng: 892 | điểm cạnh dọc: 296
các cột có cạnh dọc nhiều nhất: [59, 260]
Hai mép dọc của tấm cao 140 px mỗi mép, nên khoảng 280 điểm, cộng thêm một phần nhỏ trên lỗ tròn, nơi cạnh tròn đi gần thẳng đứng ở hai bên trái phải. Hai cột nhiều điểm nhất là hai mép dọc của tấm (quanh x = 60 và x = 259). Lọc theo hướng gradient là kỹ thuật thường dùng khi chỉ cần đo một chiều, ví dụ chiều rộng, và muốn bỏ qua mọi cạnh khác.
Tóm tắt
- Lọc tuyến tính = trượt kernel, tính tổng có trọng số; OpenCV xử lý biên bằng phản chiếu, nên kết quả sát mép ảnh kém tin cậy.
- Nhiễu điểm (muối tiêu) → median; nhiễu hạt đều → Gaussian; giữ cạnh → bilateral. Kernel nhỏ nhất đủ dùng.
- Sobel cho gradient có dấu và hướng; luôn dùng kiểu
CV_32F/CV_64F. cv2.Cannykhông tự làm mịn; ngưỡng kép + hysteresis giữ đường viền liền và loại điểm lẻ.- Chọn ngưỡng Canny theo gradient đo được: giữa nhiễu lớn nhất và cạnh yếu nhất cần giữ, tỉ lệ cao/thấp 2:1 đến 3:1.
- Trackbar dùng để cài đặt; tách hàm xử lý khỏi giao diện để kiểm thử được, rồi lưu ngưỡng vào cấu hình.
Bài tiếp theo: Phân ngưỡng và contour: đếm, đo vật thể, chuyển từ "đường cạnh" sang "vùng vật" để đếm và đo kích thước.
Bài tập thực hành
- Thêm nhiễu muối tiêu vào ảnh rồi so sánh lọc Gaussian và median.
- Viết thanh trượt (trackbar) điều chỉnh hai ngưỡng Canny trực tiếp.
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.


