⭐ Vision Engineer · Chặng 11Bài 1/7Bài học~45 phút

Rule-based hay Deep Learning: chọn cách nào cho bài toán kiểm tra lỗi?

Bảng tiêu chí và hàm chấm điểm, thí nghiệm luật phân ngưỡng so với CNN nhỏ, thiết kế hybrid có cổng kiểm tra và cách ước lượng chi phí dữ liệu.

Rule-based hay Deep Learning: chọn cách nào cho bài toán kiểm tra lỗi?

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

  • Biết khi nào dùng thuật toán cổ điển, khi nào dùng AI
  • Kết hợp hai cách trong một hệ thống
  • Ước lượng chi phí dữ liệu

Nên học trước: Dự án chặng 10: phân loại ảnh OK/NG bằng transfer learning

Nội dung bài viết
  1. 1. Hai cách tiếp cận
  2. 2. Bảng tiêu chí quyết định
  3. 3. Thí nghiệm: luật cổ điển thắng ở đâu, thua ở đâu
  4. 4. Thiết kế hybrid: rule-based làm cổng, AI phán lỗi bề mặt
  5. 5. Ước lượng chi phí dữ liệu
  6. 6. Sai lầm thường gặp
  7. 7. Bài tập
  8. Tóm tắt

"Bài toán này có nên dùng AI không?" là câu hỏi đầu tiên của mọi dự án kiểm tra lỗi, và câu trả lời đúng hiếm khi là "luôn luôn AI" hay "không bao giờ AI". Ở chặng 10, dự án "Phân loại ảnh OK/NG bằng transfer learning" cho bạn thấy một mô hình học sâu học được lỗi từ ảnh; các chặng trước đó cho bạn bộ công cụ cổ điển: phân ngưỡng, blob, đo cạnh subpixel, so khớp mẫu. Bài mở đầu chặng 11 này dạy cách chọn giữa hai trường phái bằng tiêu chí cụ thể, chứng minh bằng một thí nghiệm chạy được vì sao luật cổ điển thắng tuyệt đối ở bài toán này nhưng thua ở bài toán khác, thiết kế hệ thống hybrid (rule-based làm cổng kiểm tra và đo, AI phán lỗi bề mặt), và ước lượng chi phí dữ liệu trước khi hứa với khách hàng. Bài sau, "Tự xây dataset ảnh công nghiệp", biến ước lượng đó thành kế hoạch thu thập thật.

Mục tiêu cuối bàiDùng bảng tiêu chí và một hàm chấm điểm để chọn rule-based, AI hay hybrid cho từng loại lỗi; chạy thí nghiệm so sánh luật phân ngưỡng với một CNN nhỏ trên hai loại bề mặt; viết pipeline hybrid có cổng kiểm tra chất lượng ảnh, định vị, đo kích thước rồi mới gọi AI; tính số ngày thu thập ảnh lỗi, giờ công gán nhãn và chi phí dữ liệu cho một dự án. Code dùng NumPy, OpenCV và PyTorch 2.14 (CPU là đủ).

1. Hai cách tiếp cận

Rule-based (xử lý ảnh cổ điển): kỹ sư tự thiết kế chuỗi xử lý (phân ngưỡng, lọc, contour, đo cạnh, so khớp mẫu) và viết luật rõ ràng, ví dụ "vết tối có diện tích ≥ 0,30 mm² trong vùng A là NG". Mọi tham số đều đọc được, chỉnh được, và kết quả lặp lại y hệt với cùng một ảnh.

Deep Learning: mô hình tự học đặc trưng từ ảnh. Có bốn dạng hay gặp trong nhà máy:

DạngĐầu raDữ liệu cầnKhi nào dùng
Phân loại (classification)OK/NG hoặc loại lỗi cho cả ảnh/ROIẢnh có nhãn cấp ảnhChỉ cần biết có lỗi hay không, ROI nhỏ đã cắt sẵn
Phát hiện (object detection)Khung bao + loại lỗiKhung bao từng vếtCần vị trí, đếm số vết, lỗi nhỏ trên ảnh lớn
Phân đoạn (segmentation)Mặt nạ từng pixelĐa giác/mặt nạ từng vếtCần diện tích, chiều dài vết để áp tiêu chí mm, mm²
Phát hiện bất thường (anomaly detection)Điểm bất thường + bản đồ nhiệtChủ yếu ảnh OKẢnh lỗi hiếm, loại lỗi chưa biết trước

Điểm mấu chốt: AI không thay thế được phép đo. Một CNN phân loại không trả lời "đường kính bao nhiêu mm"; một mô hình phân đoạn cho mặt nạ vết lỗi nhưng việc quy đổi pixel sang mm, áp tiêu chí theo vùng vẫn là rule-based.

2. Bảng tiêu chí quyết định

Tiêu chíNghiêng về rule-based khi…Nghiêng về AI khi…
Bản chất yêu cầuĐo kích thước, vị trí, góc; đếm; có/không có chi tiết; đọc mãPhán đoán ngoại hình: "vết bẩn", "lỗi dệt", "nấm mốc"
Mô tả lỗi bằng con sốViết được thành luật: diện tích, độ tương phản, số lượngQC chỉ nói "nhìn là biết", định nghĩa phải kèm ảnh mẫu
Nền, kết cấuNền đồng nhất, chiếu sáng tạo tương phản cao (đèn nền, đèn góc thấp)Kết cấu tự nhiên biến thiên: gỗ, vải, da, đá, kim loại đúc, phun cát
Độ đa dạng của lỗiÍt loại, hình dạng ổn địnhNhiều hình dạng, kích thước, hướng; lỗi mới xuất hiện theo lô
Dữ liệuVài ảnh mẫu để chỉnh tham sốHàng trăm ảnh lỗi gán nhãn (có giám sát) hoặc hàng trăm ảnh OK (anomaly)
Giải thích, truy xuấtBắt buộc giải thích từng quyết định bằng con số (ô tô, y tế, hàng không)Chấp nhận điểm số + bản đồ nhiệt + nghiệm thu thống kê
Phần cứng, cycle timeCPU, vài ms; không muốn thêm GPUCó ngân sách GPU/bộ tăng tốc hoặc mô hình đủ nhỏ cho CPU
Thay đổi sản phẩmĐổi mã thường xuyên nhưng chỉ đổi kích thước: sửa công thức (recipe)Đổi mã ít, mỗi lần đổi chấp nhận thu thêm ảnh và huấn luyện lại
Câu hỏi đầu tiên luôn là chiếu sángRất nhiều bài toán "cần AI" thật ra là bài toán chiếu sáng kém. Vết xước gần như vô hình dưới đèn vòm khuếch tán có thể nổi bật như một đường trắng dưới đèn góc thấp (chặng 6), và khi đó một luật phân ngưỡng là đủ. Huấn luyện AI trên ảnh chụp kém chỉ tạo ra một mô hình kém ổn định.

Hàm chấm điểm giúp ra quyết định

Biến bảng trên thành một hàm nhỏ để cả nhóm trả lời cùng một bộ câu hỏi cho từng loại lỗi. Mỗi tiêu chí trả lời 0 (không), 1 (một phần), 2 (rõ ràng); trọng số âm kéo về rule-based, dương kéo về AI. Đây là công cụ gợi ý để thảo luận có cấu trúc, không phải công thức chính xác: hãy chỉnh trọng số theo kinh nghiệm của nhóm bạn.

TRONG_SO = {
    "can_do_kich_thuoc": -2,     # yêu cầu đo mm/µm, so dung sai bản vẽ
    "mo_ta_bang_luat": -2,       # lỗi viết được thành luật: diện tích, độ tương phản, số lượng
    "can_giai_thich": -1,        # khách hàng đòi giải thích từng quyết định bằng con số
    "nen_bien_thien": +2,        # nền/kết cấu tự nhiên thay đổi: gỗ, vải, da, đúc
    "loi_da_dang": +2,           # hình dạng lỗi đa dạng, khó liệt kê
    "phan_doan_chu_quan": +1,    # QC phán "nhìn là biết", định nghĩa dựa vào ảnh mẫu
}


def goi_y(tra_loi, so_anh_loi_thu_duoc):
    """tra_loi: dict tiêu chí -> 0/1/2. so_anh_loi_thu_duoc: số ảnh lỗi có thể có trong thời hạn dự án."""
    diem = sum(TRONG_SO[k] * v for k, v in tra_loi.items())
    if diem <= -2:
        ket_luan = "RULE-BASED"
    elif diem >= 3:
        ket_luan = "AI có giám sát" if so_anh_loi_thu_duoc >= 200 else "AI: bắt đầu bằng anomaly detection"
    else:
        ket_luan = "HYBRID / thử cả hai"
    if diem > -2 and tra_loi.get("can_do_kich_thuoc", 0) >= 1:
        ket_luan += " + đo kích thước bằng rule-based"
    return diem, ket_luan


BAI_TOAN = {
    "Đếm đủ 12 chân IC": ({"can_do_kich_thuoc": 1, "mo_ta_bang_luat": 2, "can_giai_thich": 2}, 50),
    "Lỗi dệt trên vải mộc": ({"mo_ta_bang_luat": 0, "nen_bien_thien": 2, "loi_da_dang": 2,
                              "phan_doan_chu_quan": 1}, 800),
    "Nhãn chai: lệch và nhăn": ({"can_do_kich_thuoc": 2, "mo_ta_bang_luat": 1, "loi_da_dang": 1,
                                 "phan_doan_chu_quan": 2, "nen_bien_thien": 1}, 300),
    "Vết rỗ trên vỏ nhôm đúc": ({"can_do_kich_thuoc": 1, "nen_bien_thien": 2, "loi_da_dang": 2,
                                 "phan_doan_chu_quan": 1}, 60),
}
for ten, (tl, n_loi) in BAI_TOAN.items():
    diem, kl = goi_y(tl, n_loi)
    print(f"{ten:26s} điểm {diem:+3d} → {kl}")
Đếm đủ 12 chân IC          điểm  -8 → RULE-BASED
Lỗi dệt trên vải mộc       điểm  +9 → AI có giám sát
Nhãn chai: lệch và nhăn    điểm  +0 → HYBRID / thử cả hai + đo kích thước bằng rule-based
Vết rỗ trên vỏ nhôm đúc    điểm  +7 → AI: bắt đầu bằng anomaly detection + đo kích thước bằng rule-based

Đọc kết quả: đếm chân IC là bài toán đo và đếm thuần túy, rule-based thắng tuyệt đối. Lỗi dệt trên vải có nền biến thiên và lỗi đa dạng, nhiều ảnh lỗi, nên dùng AI có giám sát. Nhãn chai vừa có phần đo (lệch bao nhiêu mm) vừa có phần ngoại hình (nhăn, rách) nên hybrid. Vết rỗ trên nhôm đúc nghiêng hẳn về AI nhưng chỉ thu được 60 ảnh lỗi trong thời hạn, nên bắt đầu bằng anomaly detection rồi chuyển dần sang có giám sát (chặng 12 dạy cả hai).

3. Thí nghiệm: luật cổ điển thắng ở đâu, thua ở đâu

Hai bài toán, cùng kích thước ảnh 64×64, cùng một luật đơn giản "đếm pixel tối hơn ngưỡng, nếu diện tích vượt mức thì NG":

  • Bài toán A: mặt kim loại phẳng, chiếu sáng ổn định, lỗi là đốm rỗ tối. Lỗi có kiểm soát: tối hơn nền rõ rệt và nền không có gì tối.
  • Bài toán B: mặt gỗ có vân (thớ sáng tối xen kẽ), mỗi tấm sáng tối khác nhau, có mắt gỗ tối nhưng không phải lỗi; lỗi là vết nứt mảnh cắt ngang thớ.

Để công bằng, ngưỡng xám và diện tích của luật được chọn tự động: thử mọi tổ hợp trên tập train, giữ tổ hợp có độ chính xác cao nhất, rồi đánh giá trên tập test riêng.

import cv2
import numpy as np

rng = np.random.default_rng(0)
S = 64


def be_mat_kim_loai(loi):
    """Bài toán A: mặt kim loại phẳng, đèn ổn định; lỗi = đốm rỗ tối."""
    img = rng.normal(150, 6, (S, S))
    if loi:
        c = rng.integers(10, S - 10, 2)
        cv2.circle(img, (int(c[0]), int(c[1])), int(rng.integers(3, 6)), 70, -1)
    return np.clip(img, 0, 255).astype(np.uint8)


def van_go(loi):
    """Bài toán B: vân gỗ, mắt gỗ tối (không phải lỗi); lỗi = vết nứt mảnh cắt ngang thớ."""
    yy, xx = np.mgrid[0:S, 0:S].astype(np.float32)
    chu_ky = rng.uniform(7, 12)
    song = 3 * np.sin(xx / rng.uniform(8, 14) + rng.uniform(0, 6))
    img = 150 + 30 * np.sin(2 * np.pi * (yy + song) / chu_ky) + rng.normal(0, 8, (S, S))
    img += rng.uniform(-25, 25)                              # mỗi tấm gỗ sáng tối khác nhau
    for _ in range(int(rng.integers(0, 3))):                 # 0-2 mắt gỗ
        c = rng.integers(8, S - 8, 2)
        truc = (int(rng.integers(3, 7)), int(rng.integers(3, 6)))
        cv2.ellipse(img, (int(c[0]), int(c[1])), truc, 0, 0, 360, float(rng.uniform(60, 90)), -1)
    if loi:
        x0, y0 = rng.integers(12, S - 12, 2)
        goc = np.deg2rad(90 + rng.uniform(-25, 25))          # gần vuông góc với thớ
        L = rng.uniform(14, 26)
        dx, dy = L / 2 * np.cos(goc), L / 2 * np.sin(goc)
        vet = np.zeros((S, S), np.float32)
        cv2.line(vet, (int(x0 - dx), int(y0 - dy)), (int(x0 + dx), int(y0 + dy)), 1.0, 1, cv2.LINE_AA)
        img -= 55 * vet
    return np.clip(img, 0, 255).astype(np.uint8)


def tao_bo(ham, n):
    y = np.array([i % 2 for i in range(n)])                  # xen kẽ OK/NG, 50/50
    return np.stack([ham(bool(v)) for v in y]), y


def luat_dom_toi(X, nguong_xam, dien_tich_min):
    return ((X < nguong_xam).sum(axis=(1, 2)) >= dien_tich_min).astype(int)


def chinh_luat(X, y):
    tot, tham_so = 0, None
    for t in range(50, 131, 5):
        for a in (3, 5, 10, 20, 40, 80, 150, 300):
            acc = np.mean(luat_dom_toi(X, t, a) == y)
            if acc > tot:
                tot, tham_so = acc, (t, a)
    return tham_so


def bao_cao(ten, y, du_doan):
    print(f"{ten:24s} escape {np.mean(du_doan[y == 1] == 0):6.1%} | overkill {np.mean(du_doan[y == 0] == 1):6.1%}")


for ten_bt, ham in (("A. đốm rỗ trên kim loại", be_mat_kim_loai), ("B. vết nứt trên vân gỗ", van_go)):
    Xtr, ytr = tao_bo(ham, 600)
    Xte, yte = tao_bo(ham, 400)
    t, a = chinh_luat(Xtr, ytr)
    print(f"{ten_bt}: luật tốt nhất trên train = pixel < {t}, diện tích >= {a} px")
    bao_cao("  rule-based trên test", yte, luat_dom_toi(Xte, t, a))
    if ham is van_go:
        np.savez_compressed("go.npz", Xtr=Xtr, ytr=ytr, Xte=Xte, yte=yte)
        cv2.imwrite("vi_du_go.png", cv2.resize(cv2.hconcat(list(Xte[:6])), None, fx=3, fy=3,
                                               interpolation=cv2.INTER_NEAREST))
A. đốm rỗ trên kim loại: luật tốt nhất trên train = pixel < 75, diện tích >= 3 px
  rule-based trên test   escape   0.0% | overkill   0.0%
B. vết nứt trên vân gỗ: luật tốt nhất trên train = pixel < 65, diện tích >= 3 px
  rule-based trên test   escape  40.5% | overkill  17.0%

Ở bài toán A, luật hai tham số đạt 0% lọt, 0% loại nhầm, không cần một ảnh lỗi gán nhãn nào ngoài vài ảnh để chỉnh ngưỡng. Ở bài toán B, luật tốt nhất có thể vẫn lọt khoảng 40% vết nứt và loại nhầm khoảng 17% tấm tốt. Mở vi_du_go.png (ảnh chẵn là OK, lẻ là NG) để thấy lý do: mắt gỗ và rãnh thớ tối không kém vết nứt, và tấm gỗ tối có rãnh thớ tối hơn cả vết nứt trên tấm gỗ sáng. Không ngưỡng xám nào tách được, vì thứ phân biệt lỗi là hình dạng và hướng (một đường mảnh cắt ngang thớ), không phải độ tối.

Một kỹ sư giỏi vẫn có thể viết luật tốt hơn cho B: lọc theo hướng vuông góc với thớ, chuẩn hóa độ sáng từng tấm, loại blob tròn. Nhưng mỗi biến thể mới (gỗ loại khác, vết nứt chéo, mắt gỗ dài) lại cần thêm luật, và chuỗi luật ngày càng mong manh. Đó là tín hiệu nên để mô hình học.

Một CNN nhỏ học bài toán B

Mạng ba lớp tích chập, kết thúc bằng AdaptiveMaxPool2d: lấy giá trị lớn nhất trên toàn ảnh nên vết lỗi ở đâu cũng được "nhìn thấy". Ta huấn luyện với số ảnh train tăng dần để thấy ngay chi phí dữ liệu, mỗi lần cùng 300 bước cập nhật. Augmentation duy nhất là lật ngang, an toàn vì thớ gỗ không có chiều trái/phải (bài "Dataset, DataLoader và augmentation" ở chặng 10 giải thích cách chọn).

import time

import numpy as np
import torch
from torch import nn

torch.manual_seed(0)
torch.set_num_threads(4)
d = np.load("go.npz")


def sang_tensor(X):
    return (torch.from_numpy(X).float().unsqueeze(1) / 255.0 - 0.5) / 0.25


Xtr_all, ytr_all = sang_tensor(d["Xtr"]), torch.from_numpy(d["ytr"]).float()
Xte, yte = sang_tensor(d["Xte"]), d["yte"]


def tao_mo_hinh():
    return nn.Sequential(
        nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),       # 32×32
        nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),      # 16×16
        nn.Conv2d(16, 16, 3, padding=1), nn.ReLU(),
        nn.AdaptiveMaxPool2d(1), nn.Flatten(), nn.Linear(16, 1))


def huan_luyen(n_anh, so_buoc=300):
    torch.manual_seed(0)
    X, y = Xtr_all[:n_anh], ytr_all[:n_anh]
    mo_hinh = tao_mo_hinh()
    toi_uu = torch.optim.Adam(mo_hinh.parameters(), lr=3e-3)
    ham_mat = nn.BCEWithLogitsLoss()
    mo_hinh.train()
    for _ in range(so_buoc):
        idx = torch.randint(0, n_anh, (32,))
        xb = X[idx]
        lat = torch.rand(32) < 0.5                                       # lật ngang ngẫu nhiên
        xb = torch.where(lat[:, None, None, None], xb.flip(3), xb)
        loss = ham_mat(mo_hinh(xb).squeeze(1), y[idx])
        toi_uu.zero_grad()
        loss.backward()
        toi_uu.step()
    mo_hinh.eval()
    with torch.no_grad():
        p = torch.sigmoid(mo_hinh(Xte).squeeze(1)).numpy()
    return mo_hinh, (p >= 0.5).astype(int)


t0 = time.time()
print("ảnh train (NG) | escape | overkill")
for n in (40, 100, 200, 600):
    mo_hinh, du_doan = huan_luyen(n)
    print(f"{n:5d} ({n // 2:3d} NG)  | {np.mean(du_doan[yte == 1] == 0):6.1%} | {np.mean(du_doan[yte == 0] == 1):6.1%}")
torch.save(mo_hinh.state_dict(), "cnn_go.pt")                           # mô hình 600 ảnh, dùng ở mục 4
print(f"thời gian huấn luyện: {time.time() - t0:.0f} s (thay đổi theo máy)")
ảnh train (NG) | escape | overkill
   40 ( 20 NG)  |  17.5% |  17.5%
  100 ( 50 NG)  |   0.0% |   0.0%
  200 (100 NG)  |   0.0% |   0.0%
  600 (300 NG)  |   0.0% |   0.5%
thời gian huấn luyện: 19 s (thay đổi theo máy)

Từ khoảng 50 ảnh NG trở lên, CNN tí hon gần như không lọt và không loại nhầm trên tập test, điều luật phân ngưỡng không làm được. Nhưng với 20 ảnh NG, nó lọt và loại nhầm khoảng 17%: chất lượng phụ thuộc trực tiếp vào số ảnh lỗi. Con số cụ thể có thể lệch đôi chút giữa các máy và phiên bản thư viện; xu hướng thì không đổi. Đây cũng là bài toán rất dễ (một loại lỗi, ảnh mô phỏng sạch, một kiểu gỗ); bài toán thật có nhiều loại lỗi, nhiều lô vật liệu và thường cần nhiều ảnh hơn hẳn, mục 5 sẽ tính chi phí đó.

Bài học từ thí nghiệmRule-based không "kém" hơn AI: ở bài toán A nó hoàn hảo, rẻ, giải thích được và không cần dữ liệu gán nhãn. AI thắng khi thứ phân biệt OK/NG là hình dạng trong một nền biến thiên. Câu hỏi đúng không phải "cái nào mạnh hơn" mà là "lỗi được định nghĩa bằng gì".

4. Thiết kế hybrid: rule-based làm cổng, AI phán lỗi bề mặt

Hệ thống hiệu quả nhất trong nhà máy hầu như luôn là hybrid. Mỗi phần làm đúng việc nó giỏi:

  1. Cổng chất lượng ảnh (rule-based): ảnh quá tối/quá sáng (đèn hỏng, trigger lệch), ảnh mờ (rung, lệch nét). Ảnh hỏng không được đưa vào AI, vì mô hình sẽ trả về một điểm số vô nghĩa nhưng trông rất tự tin.
  2. Định vị và kiểm tra có chi tiết (rule-based): tìm chi tiết, cắt ROI chuẩn hóa. Mô hình chỉ phải học một việc trên ảnh đã căn chỉnh, nên cần ít dữ liệu hơn.
  3. Đo kích thước (rule-based): áp dung sai bản vẽ bằng mm.
  4. AI phán lỗi bề mặt trong ROI.
  5. Luật tổng hợp: ghép kết quả, phân biệt "NG sản phẩm" với "lỗi hệ thống", gửi mã cho PLC và ghi log.

Phân biệt NG sản phẩm và lỗi hệ thống rất quan trọng: nếu đèn hỏng mà trạm báo NG hàng loạt, sản phẩm tốt bị loại và không ai biết đèn hỏng. Mã riêng cho lỗi hệ thống cho phép PLC dừng băng tải và gọi kỹ thuật. Đoạn code dưới ghép năm bước trên khung hình 128×128 (tấm gỗ 64×64 trên nền băng tải tối), dùng CNN vừa lưu:

import cv2
import numpy as np
import torch
from torch import nn

torch.set_num_threads(4)
mo_hinh = nn.Sequential(
    nn.Conv2d(1, 8, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(8, 16, 3, padding=1), nn.ReLU(), nn.MaxPool2d(2),
    nn.Conv2d(16, 16, 3, padding=1), nn.ReLU(),
    nn.AdaptiveMaxPool2d(1), nn.Flatten(), nn.Linear(16, 1))
mo_hinh.load_state_dict(torch.load("cnn_go.pt", weights_only=True))
mo_hinh.eval()

MA_PLC = {"OK": 1, "NG_BE_MAT": 2, "NG_KICH_THUOC": 3, "LOI_HE_THONG": 9}
MM_PX = 0.5                        # mm/pixel sau hiệu chuẩn (giả định)
DAI_DANH_NGHIA, DUNG_SAI = 32.0, 1.5   # mm: chiều dài tấm 32 ± 1,5 mm


def kiem_tra(khung):
    # 1. cổng chất lượng ảnh
    sang = float(khung.mean())
    if not 35 <= sang <= 120:
        return "LOI_HE_THONG", f"độ sáng trung bình {sang:.0f} ngoài [35, 120]"
    # 2. định vị: tìm tấm gỗ sáng trên nền tối
    _, nhi_phan = cv2.threshold(khung, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    cs, _ = cv2.findContours(nhi_phan, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    cs = [c for c in cs if 2000 < cv2.contourArea(c) < 8000]      # tấm 64×64 ≈ 4096 px
    if not cs:
        return "LOI_HE_THONG", "không thấy chi tiết trong FOV"
    x, y, w, h = cv2.boundingRect(max(cs, key=cv2.contourArea))
    roi = khung[y:y + h, x:x + w]
    do_net = cv2.Laplacian(roi, cv2.CV_64F).var()
    if do_net < 300:
        return "LOI_HE_THONG", f"ảnh mờ (phương sai Laplacian {do_net:.0f} < 300)"
    # 3. đo kích thước bằng rule-based
    dai_mm = w * MM_PX
    if abs(dai_mm - DAI_DANH_NGHIA) > DUNG_SAI:
        return "NG_KICH_THUOC", f"dài {dai_mm:.1f} mm, ngoài {DAI_DANH_NGHIA} ± {DUNG_SAI}"
    # 4. AI trên ROI đã chuẩn hóa kích thước
    roi = cv2.resize(roi, (64, 64), interpolation=cv2.INTER_AREA)
    x_t = (torch.from_numpy(roi).float()[None, None] / 255.0 - 0.5) / 0.25
    with torch.no_grad():
        diem = torch.sigmoid(mo_hinh(x_t)).item()
    # 5. luật tổng hợp
    if diem >= 0.5:
        return "NG_BE_MAT", f"điểm AI {diem:.2f}"
    return "OK", f"dài {dai_mm:.1f} mm, điểm AI {diem:.2f}"


d = np.load("go.npz")
Xte, yte = d["Xte"], d["yte"]


def dat_len_bang_tai(tam, x, y):
    khung = np.full((128, 128), 30, np.uint8)
    khung[y:y + tam.shape[0], x:x + tam.shape[1]] = tam
    return khung


ca_thu = {
    "tấm OK, lệch vị trí": dat_len_bang_tai(Xte[0], 50, 20),
    "tấm có vết nứt": dat_len_bang_tai(Xte[1], 30, 40),
    "tấm bị cắt ngắn": dat_len_bang_tai(Xte[2][:, :54], 40, 30),
    "đèn hỏng (ảnh tối)": (dat_len_bang_tai(Xte[4], 32, 32) * 0.2).astype(np.uint8),
    "băng tải trống": np.full((128, 128), 30, np.uint8) + 40,
    "rung máy (ảnh mờ)": cv2.GaussianBlur(dat_len_bang_tai(Xte[6], 32, 32), (0, 0), 2.5),
}
for ten, khung in ca_thu.items():
    ket_qua, ly_do = kiem_tra(khung)
    print(f"{ten:20s} → {ket_qua:13s} (PLC {MA_PLC[ket_qua]}) | {ly_do}")
tấm OK, lệch vị trí  → OK            (PLC 1) | dài 32.0 mm, điểm AI 0.01
tấm có vết nứt       → NG_BE_MAT     (PLC 2) | điểm AI 1.00
tấm bị cắt ngắn      → NG_KICH_THUOC (PLC 3) | dài 27.0 mm, ngoài 32.0 ± 1.5
đèn hỏng (ảnh tối)   → LOI_HE_THONG  (PLC 9) | độ sáng trung bình 13 ngoài [35, 120]
băng tải trống       → LOI_HE_THONG  (PLC 9) | không thấy chi tiết trong FOV
rung máy (ảnh mờ)    → LOI_HE_THONG  (PLC 9) | ảnh mờ (phương sai Laplacian 123 < 300)

Bốn trong sáu trường hợp được quyết định trước khi tới AI, và ba trong số đó (ảnh tối, băng tải trống, ảnh mờ) nếu đưa vào AI sẽ nhận một điểm số vô nghĩa mà không ai biết. Cách chia này còn có lợi về dữ liệu: vì ROI đã được định vị và chuẩn hóa, mô hình không phải học "tấm gỗ có thể nằm ở đâu", và tập dữ liệu không cần bao phủ mọi vị trí đặt.

Mẫu hybrid hay gặpRule-based làmAI làm
Định vị + phân loại ROISo khớp mẫu, căn chỉnh, cắt ROIPhân loại lỗi trong ROI
AI phân đoạn + đoQuy đổi mặt nạ sang mm, mm²; áp tiêu chí theo vùngPhân đoạn vết lỗi
Rule-based sàng lọc + AI xác nhậnBắt mọi vết khả nghi (ngưỡng thấp, ít lọt)Loại các vết khả nghi thực ra là OK (giảm overkill)
AI phát hiện + OCR/đọc mã cổ điểnGiải mã DataMatrix, so khớp ký tựTìm vùng mã trên nền phức tạp

5. Ước lượng chi phí dữ liệu

Trước khi hứa "dùng AI", hãy tính ba con số: bao lâu mới có đủ ảnh lỗi, bao nhiêu giờ công gán nhãn, và chi phí đó so với phương án rule-based. Lỗi hiếm nhất quyết định lịch: nếu dây chuyền tốt chỉ sinh 0,03% hở lớp phủ, 10.000 sản phẩm/ngày cho bạn 3 chi tiết/ngày.

Loại nhãnThời gian gán điển hình (tham khảo, đo lại trên 50 ảnh đầu)
Nhãn cấp ảnh (OK/NG, loại lỗi)2–5 giây/ảnh khi xem lưới ảnh nhỏ
Khung bao10–30 giây/vết
Đa giác, mặt nạ30–120 giây/vết (nhanh hơn nhiều nếu có công cụ hỗ trợ kiểu SAM)

Số ảnh cần cũng chỉ có khoảng kinh nghiệm: phân loại ROI đã căn chỉnh thường bắt đầu dùng được từ vài trăm ảnh mỗi lớp; phát hiện/phân đoạn cần vài trăm vết mỗi loại lỗi, nhiều hơn khi lỗi đa dạng; anomaly detection cần vài trăm ảnh OK đại diện cộng một ít ảnh NG thật để đánh giá. Công cụ dưới đây tính lịch và giờ công từ các giả định của bạn:

import math


def chi_phi_du_lieu(san_luong_ngay, ti_le_loi, giu_lai, muc_tieu, n_ok, giay_moi_vet,
                    vet_moi_anh=1.2, giay_ok=3, ti_le_kiem_cheo=0.2, gia_gio=60_000):
    """ti_le_loi, muc_tieu: dict loại lỗi -> tỉ lệ / số ảnh NG cần. gia_gio: đồng/giờ công (giả định)."""
    print(f"{'loại lỗi':14s} {'NG/ngày':>8s} {'thu được/ngày':>14s} {'số ngày':>8s}")
    so_ngay_max = 0
    for loai, p in ti_le_loi.items():
        thu = san_luong_ngay * p * giu_lai
        ngay = math.ceil(muc_tieu[loai] / thu)
        so_ngay_max = max(so_ngay_max, ngay)
        print(f"{loai:14s} {san_luong_ngay * p:8.1f} {thu:14.1f} {ngay:8d}")
    n_ng = sum(muc_tieu.values())
    giay = n_ng * vet_moi_anh * giay_moi_vet + n_ok * giay_ok
    giay *= 1 + ti_le_kiem_cheo                       # người thứ hai kiểm lại một phần
    gio = giay / 3600
    print(f"→ đủ dữ liệu sau {so_ngay_max} ngày sản xuất (lỗi hiếm nhất quyết định)")
    print(f"→ gán nhãn {n_ng} ảnh NG + {n_ok} ảnh OK: {gio:.0f} giờ công ≈ {gio * gia_gio / 1e6:.1f} triệu đồng")
    return so_ngay_max, gio


TI_LE = {"xuoc": 0.0015, "rỗ_khí": 0.0006, "hở_lớp_phủ": 0.0003}
MUC_TIEU = {"xuoc": 300, "rỗ_khí": 200, "hở_lớp_phủ": 150}
for ten, giay_vet in (("khung bao", 20), ("đa giác", 60)):
    print(f"=== Phương án gán nhãn {ten} ({giay_vet} s/vết)")
    chi_phi_du_lieu(10_000, TI_LE, giu_lai=0.7, muc_tieu=MUC_TIEU, n_ok=3000, giay_moi_vet=giay_vet)
=== Phương án gán nhãn khung bao (20 s/vết)
loại lỗi        NG/ngày  thu được/ngày  số ngày
xuoc               15.0           10.5       29
rỗ_khí              6.0            4.2       48
hở_lớp_phủ          3.0            2.1       72
→ đủ dữ liệu sau 72 ngày sản xuất (lỗi hiếm nhất quyết định)
→ gán nhãn 650 ảnh NG + 3000 ảnh OK: 8 giờ công ≈ 0.5 triệu đồng
=== Phương án gán nhãn đa giác (60 s/vết)
loại lỗi        NG/ngày  thu được/ngày  số ngày
xuoc               15.0           10.5       29
rỗ_khí              6.0            4.2       48
hở_lớp_phủ          3.0            2.1       72
→ đủ dữ liệu sau 72 ngày sản xuất (lỗi hiếm nhất quyết định)
→ gán nhãn 650 ảnh NG + 3000 ảnh OK: 19 giờ công ≈ 1.1 triệu đồng

Hai bài học từ con số. Thứ nhất, giờ công gán nhãn thường không phải nút thắt; thời gian chờ lỗi hiếm mới là: 72 ngày sản xuất để có 150 mẫu hở lớp phủ, trong khi gán nhãn chỉ khoảng 8–19 giờ công (chưa kể viết hướng dẫn, họp thống nhất và sửa nhãn, thường cộng thêm vài chục phần trăm). Muốn rút ngắn, hãy thu cả hàng NG tồn kho, nhờ QC giữ lại mọi chi tiết NG, dùng anomaly detection giai đoạn đầu, hoặc bổ sung dữ liệu tổng hợp (bài "Khi thiếu ảnh lỗi: augmentation và dữ liệu tổng hợp"). Thứ hai, giờ công trên là một lần; mỗi lần đổi mã sản phẩm, đổi camera hay đèn, bạn cần một phần dữ liệu mới. Phương án rule-based ngược lại tốn giờ kỹ sư để thiết kế và chỉnh luật nhưng gần như không tốn chi phí dữ liệu.

Khoản chi phíRule-basedAI có giám sát
Dữ liệuVài chục ảnh để chỉnh và kiểm traHàng trăm đến hàng nghìn ảnh, gán nhãn, kiểm chéo
Thời gian chờ lỗiThấp (cần mẫu giới hạn để nghiệm thu)Cao với lỗi hiếm
Kỹ sưCao với bài toán khó (luật mong manh)Trung bình: pipeline dữ liệu, huấn luyện, đánh giá
Phần cứngCPUCó thể cần GPU/bộ tăng tốc (chặng 14)
Bảo trì khi đổi mãChỉnh recipeThu thêm ảnh, huấn luyện lại, nghiệm thu lại trên golden set

6. Sai lầm thường gặp

  • Dùng AI để bù chiếu sáng kém. Sửa ánh sáng trước; nhiều bài toán "cần AI" trở thành bài toán phân ngưỡng.
  • Dùng AI để đo. Hồi quy kích thước bằng CNN cho sai số lớn hơn và khó truy xuất hơn đo cạnh subpixel. Để AI tìm, để rule-based đo.
  • Không có cổng chất lượng ảnh. Ảnh tối, mờ, không có chi tiết vẫn được đưa vào mô hình và sinh NG hàng loạt (hoặc tệ hơn, OK hàng loạt).
  • Hứa thời hạn mà không tính lịch thu thập lỗi hiếm. Lỗi 0,03% không cho bạn 150 ảnh trong 2 tuần.
  • Đánh giá bằng accuracy chung chung. Nhà máy nói bằng escape và overkill tại một ngưỡng cụ thể (bài "Chọn ngưỡng và đánh giá theo escape, overkill, cycle time").
  • Tập test cùng đợt chụp với tập train. Kết quả đẹp trên giấy, thất bại khi đổi ca hoặc đổi lô vật liệu. Bài sau dạy cách chia không rò rỉ.

7. Bài tập

Bài 1. Với 4 bài toán: (a) đo đường kính trục 12,00 ± 0,02 mm; (b) phát hiện vết xước trên mặt gỗ ván sàn; (c) đọc mã DataMatrix khắc laser trên chi tiết kim loại; (d) phân loại bánh mì có nấm mốc hay không. Chọn rule-based, AI hay hybrid và giải thích. Dùng hàm chấm điểm ở mục 2 để kiểm tra lập luận.

Xem đáp án
TRONG_SO = {"can_do_kich_thuoc": -2, "mo_ta_bang_luat": -2, "can_giai_thich": -1,
            "nen_bien_thien": +2, "loi_da_dang": +2, "phan_doan_chu_quan": +1}


def goi_y(tra_loi, so_anh_loi):
    diem = sum(TRONG_SO[k] * v for k, v in tra_loi.items())
    if diem <= -2:
        kl = "RULE-BASED"
    elif diem >= 3:
        kl = "AI có giám sát" if so_anh_loi >= 200 else "AI: bắt đầu bằng anomaly detection"
    else:
        kl = "HYBRID / thử cả hai"
    if diem > -2 and tra_loi.get("can_do_kich_thuoc", 0) >= 1:
        kl += " + đo kích thước bằng rule-based"
    return diem, kl


BON_BAI = {
    "(a) đường kính trục": ({"can_do_kich_thuoc": 2, "mo_ta_bang_luat": 2, "can_giai_thich": 2}, 0),
    "(b) xước trên gỗ": ({"nen_bien_thien": 2, "loi_da_dang": 2, "phan_doan_chu_quan": 1,
                          "can_do_kich_thuoc": 1}, 400),
    "(c) DataMatrix khắc laser": ({"mo_ta_bang_luat": 2, "can_giai_thich": 1, "nen_bien_thien": 1}, 0),
    "(d) nấm mốc trên bánh": ({"nen_bien_thien": 2, "loi_da_dang": 2, "phan_doan_chu_quan": 2}, 500),
}
for ten, (tl, n) in BON_BAI.items():
    diem, kl = goi_y(tl, n)
    print(f"{ten:26s} điểm {diem:+3d} → {kl}")
(a) đường kính trục        điểm -10 → RULE-BASED
(b) xước trên gỗ           điểm  +7 → AI có giám sát + đo kích thước bằng rule-based
(c) DataMatrix khắc laser  điểm  -3 → RULE-BASED
(d) nấm mốc trên bánh      điểm +10 → AI có giám sát
  • (a) Rule-based: đèn nền (backlight) + ống kính telecentric, đo cạnh subpixel, quy đổi mm bằng hiệu chuẩn (chặng 7–8). Dung sai ±0,02 mm đòi độ lặp lại mà AI không cho được, và cần truy xuất giá trị đo.
  • (b) AI + rule-based đo: vân gỗ biến thiên, vết xước đủ hình dạng; dùng phát hiện hoặc phân đoạn, rồi rule-based quy đổi chiều dài vết sang mm để áp tiêu chí "xước ≥ x mm". Nếu chưa đủ ảnh xước, bắt đầu bằng anomaly detection.
  • (c) Rule-based (bộ giải mã chuyên dụng: thư viện giải mã hoặc đầu đọc mã công nghiệp) cùng chiếu sáng phù hợp với mã khắc (đèn góc thấp, đèn vòm). Giải mã là bài toán có đáp án đúng/sai tuyệt đối và có tiêu chuẩn chấm chất lượng mã. AI chỉ đáng cân nhắc để tìm vùng mã trên nền rất phức tạp trước khi đưa cho bộ giải mã.
  • (d) AI (phân loại hoặc phân đoạn): nấm mốc có màu, hình dạng, kích thước đa dạng trên bề mặt bánh vốn đã không đồng nhất; luật màu sắc sẽ nhầm với vết nướng cháy. Cần camera màu, ánh sáng ổn định và dữ liệu từ nhiều mẻ bánh.

Bài 2. Dây chuyền 4.000 sản phẩm/ngày, hai loại lỗi: móp 0,2% và nứt 0,05%. QC giữ lại được 80% số NG. Bạn cần 250 ảnh mỗi loại, 2.000 ảnh OK, gán nhãn khung bao 15 giây/vết, 1,5 vết/ảnh NG. Bao nhiêu ngày để đủ dữ liệu và bao nhiêu giờ công gán nhãn (kiểm chéo 20%, ảnh OK 3 giây)? Nếu khách hàng muốn chạy thử sau 4 tuần (20 ngày sản xuất), bạn đề xuất gì?

Xem đáp án
import math

san_luong, giu_lai = 4000, 0.8
for loai, p in (("móp", 0.002), ("nứt", 0.0005)):
    thu = san_luong * p * giu_lai
    print(f"{loai}: {thu:.1f} ảnh/ngày → {math.ceil(250 / thu)} ngày")
giay = (500 * 1.5 * 15 + 2000 * 3) * 1.2
print(f"giờ công gán nhãn: {giay / 3600:.1f} giờ")
móp: 6.4 ảnh/ngày → 40 ngày
nứt: 1.6 ảnh/ngày → 157 ngày
giờ công gán nhãn: 5.8 giờ

Vết nứt cần 157 ngày sản xuất, gấp gần 8 lần thời hạn chạy thử, trong khi gán nhãn chỉ khoảng 6 giờ. Đề xuất: (1) chạy thử bằng anomaly detection huấn luyện trên ảnh OK (có ngay) kết hợp luật cho phần đo được; (2) song song thu mọi chi tiết NG, kể cả hàng NG tồn kho; (3) chuyển sang mô hình có giám sát cho lỗi móp sau khoảng 40 ngày, lỗi nứt sau khoảng 157 ngày, có thể bổ sung dữ liệu tổng hợp cho huấn luyện; (4) ghi rõ trong hợp đồng rằng tiêu chí nghiệm thu lỗi nứt phụ thuộc số mẫu thật thu được.

Bài 3. Trạm kiểm tra nắp chai: phải xác nhận có nắp, nắp vặn đủ chặt (khe hở nắp–cổ chai ≤ 0,5 mm), và không có vết móp, trầy trên mặt nắp in logo nhiều màu. Thiết kế pipeline hybrid: liệt kê từng bước, bước nào rule-based, bước nào AI, và mã gửi PLC.

Xem đáp án
  1. Cổng chất lượng ảnh (rule-based): độ sáng trung bình trong khoảng cho phép, độ nét (phương sai Laplacian) trên ngưỡng; sai thì mã "lỗi hệ thống".
  2. Có chai, có nắp (rule-based): blob/contour trong ROI cổ chai; không có nắp là NG ngay, không cần AI.
  3. Khe hở nắp–cổ chai (rule-based): camera nhìn ngang với đèn nền, đo khoảng cách cạnh subpixel, quy đổi mm; > 0,5 mm là "NG kích thước".
  4. Định vị mặt nắp (rule-based): tìm vòng tròn nắp, cắt ROI và xoay về hướng chuẩn theo logo (so khớp mẫu).
  5. Móp, trầy trên logo nhiều màu (AI): mặt nắp in nhiều màu làm luật phân ngưỡng nhầm với nét in; dùng anomaly detection trên ROI đã căn chỉnh (ảnh OK nhiều) hoặc mô hình có giám sát khi đã đủ ảnh lỗi.
  6. Luật tổng hợp: mã PLC 1 = OK, 2 = NG bề mặt, 3 = NG lắp ráp/khe hở, 9 = lỗi hệ thống; ghi log điểm AI và giá trị đo cho mỗi chai.

Tóm tắt

  • Rule-based thắng khi yêu cầu là đo, đếm, đọc mã, có/không, và khi chiếu sáng tạo được tương phản rõ; AI thắng khi lỗi được định nghĩa bằng hình dạng trên nền biến thiên.
  • Dùng bảng tiêu chí và một hàm chấm điểm để cả nhóm trả lời cùng một bộ câu hỏi cho từng loại lỗi; kết quả là gợi ý để thảo luận, không phải công thức.
  • Thí nghiệm: luật phân ngưỡng hoàn hảo trên đốm rỗ kim loại nhưng lọt khoảng 40% vết nứt trên vân gỗ; CNN nhỏ học được vết nứt nhưng chất lượng phụ thuộc số ảnh lỗi.
  • Hệ thống hybrid: cổng chất lượng ảnh, định vị, đo bằng rule-based; AI phán lỗi bề mặt trên ROI đã chuẩn hóa; tách mã "lỗi hệ thống" khỏi "NG".
  • Ước lượng chi phí dữ liệu: lỗi hiếm nhất quyết định lịch thu thập; giờ công gán nhãn tính theo loại nhãn; nhớ chi phí lặp lại khi đổi mã sản phẩm.

Bài tiếp theo: Tự xây dataset ảnh công nghiệp, biến ước lượng hôm nay thành kế hoạch thu thập, cấu trúc thư mục, phiên bản và cách chia tập không rò rỉ.

Bài tập thực hành

  1. Với 4 bài toán (đo đường kính, phát hiện vết xước gỗ, đọc mã DataMatrix, phân loại nấm mốc), chọn rule-based hay AI và giải thích.

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