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

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.

Lọc nhiễu và phát hiện cạnh: Gaussian, median, Sobel, Canny

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. 1. Kernel và phép tích chập
  2. 2. Ảnh thử và các bộ lọc làm mịn
  3. 3. So sánh Gaussian và median bằng số
  4. 4. Gradient và Sobel
  5. 5. Canny: bộ phát hiện cạnh tiêu chuẩn
  6. 6. Chọn ngưỡng Canny có căn cứ
  7. 7. Thanh trượt (trackbar) chỉnh ngưỡng trực tiếp
  8. 8. Ứng dụng nhanh: tìm lỗ tròn bằng Hough
  9. 9. Lỗi thường gặp
  10. 10. Bài tập
  11. 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.

Chuẩn bịDùng 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, filter2D tí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ọcHàm OpenCVCách làmƯu / nhược
Trung bình (box)cv2.blur(img, (5, 5))Trung bình cộng lân cậnNhanh nhất; làm nhòe cạnh nhiều
Gaussiancv2.GaussianBlur(img, (5, 5), 0)Trung bình có trọng số hình chuông, gần tâm nặng hơnMịn tự nhiên, hợp với nhiễu cảm biến; vẫn nhòe cạnh
Mediancv2.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
Bilateralcv2.bilateralFilter(img, 9, 75, 75)Gaussian nhưng bỏ qua điểm có độ sáng khác xaLà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ảm sigmaColor sẽ 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.
Quy tắc chọn bộ lọcNhiễu điểm đơn lẻ (muối tiêu, pixel hỏng, bụi lóe sáng) → median, kernel nhỏ nhất xóa được nhiễu (3 hoặc 5). Nhiễu hạt mịn đều khắp ảnh → Gaussian trước khi tìm cạnh. Cần giữ cạnh tuyệt đối và có thời gian xử lý dư → bilateral. Kernel càng lớn càng mịn nhưng càng nhòe và làm mất chi tiết nhỏ, giống quy tắc chọn kernel ở bài Morphology sau này.

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ùng CV_32F hoặc CV_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:

  1. Tính gradient bằng Sobel 3×3 (tham số apertureSize). Mặc định độ lớn là |gx| + |gy| (chuẩn L1); đặt L2gradient=True để dùng căn bậc hai chính xác hơn.
  2. 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.
  3. 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".
  4. 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.
Canny không tự làm mịnNhiều tài liệu liệt kê "làm mịn Gaussian" là bước đầu của thuật toán Canny, nhưng hàm 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: None là bắt buộc trong Python dù không dùng; ta đọc giá trị bằng getTrackbarPos trong 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ỗiHậu quảCách đúng
Sobel với CV_8UMất cạnh âm, gradient bị cắt ở 255CV_32F/CV_64F, rồi lấy trị tuyệt đối
Gọi Canny trên ảnh chưa lọcHà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ạnhMedian 3 hoặc 5
Kernel lọc quá lớnMất chi tiết nhỏ (vết xước), cạnh nhòe làm đo saiKernel 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 ảnhChạ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ìnhCạnh của đồ gá, băng tải gây nhiễu kết quả, tốn thời gianGiớ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.Canny khô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

  1. Thêm nhiễu muối tiêu vào ảnh rồi so sánh lọc Gaussian và median.
  2. 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.

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