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

Khi thiếu ảnh lỗi: augmentation, cắt dán lỗi và dữ liệu tổng hợp

Cắt dán lỗi thật lên ảnh OK bằng alpha và seamlessClone, sinh nhãn YOLO tự động, lỗi thủ tục, domain gap và đánh giá trung thực trên ảnh thật.

Khi thiếu ảnh lỗi: augmentation, cắt dán lỗi và dữ liệu tổng hợp

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

  • Tạo thêm mẫu lỗi hiếm
  • Cắt dán lỗi lên ảnh tốt
  • Đánh giá trung thực trên ảnh thật

Nên học trước: Công cụ gán nhãn miễn phí

Nội dung bài viết
  1. 1. Khi thiếu ảnh lỗi: các lựa chọn theo thứ tự
  2. 2. Dữ liệu thật mô phỏng cho bài này
  3. 3. Augmentation an toàn: bước rẻ nhất
  4. 4. Cắt dán lỗi thật lên ảnh OK
  5. 5. Kiểm tra bộ sinh: đếm, vị trí nhãn, ảnh hiệu
  6. 6. Lỗi thủ tục: vẽ vết xước và đốm bằng thuật toán
  7. 7. Thí nghiệm: dữ liệu tổng hợp giúp được bao nhiêu?
  8. 8. Domain gap và cách thu hẹp
  9. 9. Sai lầm thường gặp
  10. 10. Bài tập
  11. Tóm tắt

Vấn đề kinh điển của AI Vision trong nhà máy: dây chuyền tốt hiếm khi sinh lỗi, nên bạn có hàng nghìn ảnh OK nhưng chỉ vài chục ảnh NG. Bài "Tự xây dataset ảnh công nghiệp" cho thấy lỗi hiếm nhất quyết định lịch thu thập, có khi vài tháng; bài "Công cụ gán nhãn miễn phí" cho bạn định dạng nhãn YOLO và script kiểm tra. Bài này dạy cách tạo thêm mẫu lỗi trong lúc chờ: augmentation an toàn, cắt dán lỗi thật lên ảnh OK bằng trộn alpha và Poisson blending (cv2.seamlessClone) với nhãn khung bao sinh tự động, lỗi thủ tục (procedural) vẽ bằng thuật toán, và quan trọng nhất: đánh giá trung thực chỉ trên ảnh thật. Một thí nghiệm nhỏ sẽ cho bạn thấy dữ liệu tổng hợp giúp được đến đâu, và giới hạn của nó. Bài sau, "Chọn ngưỡng và đánh giá theo escape, overkill, cycle time", dạy cách biến điểm số mô hình thành quyết định nghiệm thu.

Mục tiêu cuối bàiBiết thứ tự các lựa chọn khi thiếu ảnh lỗi; cắt mảnh lỗi từ ảnh NG thật theo mặt nạ, xoay, lật rồi dán lên ảnh OK bằng alpha blending và cv2.seamlessClone, tránh hai bẫy của hàm này; sinh 200 ảnh kèm nhãn YOLO và tự kiểm tra số lượng, vị trí nhãn; vẽ lỗi thủ tục (xước, đốm); hiểu domain gap; huấn luyện lại một CNN nhỏ và so recall trên ảnh lỗi thật. Code dùng NumPy, OpenCV 5.0 và PyTorch 2.14 (CPU).

1. Khi thiếu ảnh lỗi: các lựa chọn theo thứ tự

Lựa chọnTạo được gìChi phíRủi ro chính
Thu thêm ảnh thật (tủ NG, hàng tồn, mẫu từ phòng thí nghiệm)Lỗi thật, đúng phân bốThời gianChậm
Anomaly detection (chỉ học từ ảnh OK)Không cần ảnh lỗi để huấn luyệnThấpKhông phân loại được loại lỗi, khó đo theo tiêu chí kích thước
Augmentation ảnh NG có sẵnBiến thể vị trí, hướng, độ sáng của cùng vết lỗiRất thấpKhông tạo kiểu lỗi mới; augmentation sai làm sai nhãn
Cắt dán lỗi thật lên ảnh OKVết lỗi thật trên nền mới, vị trí mới, nhãn tự độngThấpViền dán, độ sáng không khớp; mô hình học "dấu vết dán"
Lỗi thủ tục (vẽ bằng thuật toán)Số lượng không giới hạn, kiểm soát kích thướcThấp đến trung bìnhDomain gap lớn nếu mô phỏng không giống lỗi thật
Render 3D, mô hình sinh (diffusion, inpainting)Lỗi đa dạng, ảnh chân thực hơnCao (công sức, GPU, chuyên môn)Khó kiểm soát, khó xác nhận nhãn; vẫn cần đánh giá trên ảnh thật

Nguyên tắc chung: dữ liệu tổng hợp bổ sung cho dữ liệu thật trong tập train, không thay thế, và không bao giờ xuất hiện trong val, test hay golden set.

2. Dữ liệu thật mô phỏng cho bài này

Để mọi đoạn code chạy được, ta mô phỏng một tình huống thực tế: bề mặt kim loại chải xước (brushed metal) 64×64; tập train có 400 ảnh OK nhưng chỉ 12 ảnh NG thật (kèm mặt nạ vết lỗi, như sau khi gán nhãn đa giác); tập test thật từ các lô khác có 300 OK và 100 NG. "Lỗi thật" được mô phỏng có những đặc điểm khó bắt chước: vết cong, lõi tối, viền sáng phản xạ một bên, đậm nhạt dọc theo vết, hơi nhòe. Ngoài đời, đây là ảnh camera của bạn.

import cv2
import numpy as np

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


def anh_ok(sang):
    """Kim loại chải xước: vân ngang mảnh + nhiễu."""
    van = cv2.GaussianBlur(rng.normal(0, 25, (S, S)), (0, 0), sigmaX=6, sigmaY=0.6)
    return np.clip(sang + van + rng.normal(0, 3, (S, S)), 0, 255).astype(np.uint8)


def vet_xuoc_that(img):
    """'Lỗi thật': đường cong Bézier, lõi tối, viền sáng một bên, đậm nhạt dọc vết, hơi nhòe."""
    tam = rng.uniform(18, S - 18, 2)
    p0, p1, p2 = tam + rng.uniform(-14, 14, (3, 2))
    t = np.linspace(0, 1, 60)[:, None]
    duong = (1 - t) ** 2 * p0 + 2 * (1 - t) * t * p1 + t ** 2 * p2
    loi = np.zeros((S, S), np.float32)
    sang = np.zeros((S, S), np.float32)
    w, pha = int(rng.integers(1, 3)), rng.uniform(0, 6)
    for k in range(len(duong) - 1):
        a, b = duong[k].astype(int), duong[k + 1].astype(int)
        d = 0.6 + 0.4 * np.sin(k / 9 + pha)
        cv2.line(loi, (int(a[0]), int(a[1])), (int(b[0]), int(b[1])), float(d), w)
        cv2.line(sang, (int(a[0]) + 1, int(a[1]) + 1), (int(b[0]) + 1, int(b[1]) + 1), float(d), 1)
    loi = cv2.GaussianBlur(loi, (0, 0), 0.7)
    sang = cv2.GaussianBlur(sang, (0, 0), 0.7) * (loi < 0.2)
    out = img.astype(np.float32) - rng.uniform(35, 60) * loi + rng.uniform(15, 30) * sang
    return np.clip(out, 0, 255).astype(np.uint8), (loi > 0.25).astype(np.uint8)


def tao(n_ok, n_ng, khoang_sang):
    ok = [anh_ok(rng.uniform(*khoang_sang)) for _ in range(n_ok)]
    ng, mk = zip(*[vet_xuoc_that(anh_ok(rng.uniform(*khoang_sang))) for _ in range(n_ng)])
    return np.array(ok), np.array(ng), np.array(mk)


ok_tr, ng_tr, mask_tr = tao(400, 12, (120, 160))     # lô cũ: 400 OK, chỉ 12 NG thật
ok_te, ng_te, mask_te = tao(300, 100, (110, 170))    # lô mới: tập test thật
np.savez_compressed("that.npz", ok_tr=ok_tr, ng_tr=ng_tr, mask_tr=mask_tr,
                    ok_te=ok_te, ng_te=ng_te, mask_te=mask_te)
cv2.imwrite("anh_that.png", cv2.resize(cv2.hconcat(list(ok_tr[:2]) + list(ng_tr[:6])), None, fx=3, fy=3,
                                        interpolation=cv2.INTER_NEAREST))
print(f"train: {len(ok_tr)} OK, {len(ng_tr)} NG thật | test thật: {len(ok_te)} OK, {len(ng_te)} NG")
print("diện tích vết (px) của 12 NG train:", mask_tr.sum(axis=(1, 2)).tolist())
train: 400 OK, 12 NG thật | test thật: 300 OK, 100 NG
diện tích vết (px) của 12 NG train: [17, 38, 4, 4, 36, 14, 54, 50, 13, 55, 56, 50]

3. Augmentation an toàn: bước rẻ nhất

Bài "Dataset, DataLoader và augmentation" ở chặng 10 đã có bảng augmentation an toàn và nguy hiểm cho ảnh công nghiệp; nhắc lại cho bề mặt chải xước ở đây:

  • An toàn: lật ngang, lật dọc (vân chải vẫn nằm ngang); thay đổi độ sáng ±10 mức xám (biên độ đã đo giữa các lô); nhiễu nhẹ.
  • Nguy hiểm: xoay 90° (vân chải thành dọc, không bao giờ có trên dây chuyền); crop ngẫu nhiên (cắt mất vết lỗi mà nhãn vẫn NG); phóng to (vết OK nhỏ thành cỡ NG); làm mờ mạnh (xóa vết mảnh).

Augmentation chỉ tạo biến thể của 12 vết lỗi đã có. Mô hình vẫn chỉ thấy 12 hình dạng vết, trên 12 nền. Hai kỹ thuật sau tạo ra tổ hợp mới: vết thật trên nền mới, hoặc vết mới hoàn toàn.

4. Cắt dán lỗi thật lên ảnh OK

Quy trình: (1) từ ảnh NG thật của tập train, cắt mảnh chứa vết theo khung bao của mặt nạ, chừa lề; (2) biến đổi mảnh: xoay góc bất kỳ (vết xước trên bề mặt chải có thể theo mọi hướng), lật; (3) dán lên một ảnh OK ở vị trí ngẫu nhiên; (4) nhãn YOLO lấy từ mặt nạ đã biến đổi, không cần gán tay.

Hai cách dán:

  • Alpha blending: lấy độ chênh giữa vết và nền của mảnh (vết tối hơn nền bao nhiêu), cộng vào nền mới với trọng số alpha là mặt nạ làm mềm biên. Vết giữ đúng độ sâu, nền mới giữ đúng độ sáng của nó.
  • Poisson blending (cv2.seamlessClone): giải phương trình Poisson để vùng dán có gradient của mảnh nhưng biên khớp liên tục với nền mới. Chế độ NORMAL_CLONE chép gradient của mảnh; MIXED_CLONE lấy gradient mạnh hơn giữa mảnh và nền, thường hợp với vết mảnh trên nền có kết cấu.
Hai bẫy của cv2.seamlessClone(1) Tham số p là vị trí đặt tâm khung bao của mặt nạ, không phải góc trên trái của mảnh. Nếu mặt nạ không nằm giữa mảnh, vết bị dán lệch khỏi chỗ bạn tưởng và nhãn sinh tự động sai vị trí. Tính tâm từ cv2.boundingRect(mask) như trong code. (2) Mặt nạ quá sát vết mảnh: trong thử nghiệm với OpenCV 5.0, mặt nạ chỉ nới 2 pixel quanh vết xước mảnh làm vết gần như biến mất sau khi dán; nới mặt nạ khoảng 4 pixel mỗi phía (giãn nở 9×9) thì vết hiện đúng. (3) Trong môi trường thử của bài (OpenCV 5.0.0), hàm sửa trực tiếp mảng mặt nạ truyền vào (vùng mặt nạ bị co lại), nên gọi lại với cùng mảng cho kết quả khác, có lần vết không được dán; hãy luôn truyền mask.copy() hoặc tạo mặt nạ mới mỗi lần gọi (bài tập 2 cho bạn kiểm tra trên máy mình). Bài học chung: luôn kiểm tra ảnh sau khi dán bằng ảnh hiệu (mục 5), đừng tin hàm đã làm đúng.

Đoạn code sau định nghĩa các hàm cắt, biến đổi, dán, rồi sinh 200 ảnh tổng hợp, mỗi ảnh 1–2 vết, nửa dán alpha nửa dán Poisson, kèm nhãn YOLO (lớp 0 = xước) và file ghi nguồn gốc từng ảnh. Nền chỉ lấy từ ảnh OK của tập train; mảnh lỗi chỉ lấy từ 12 ảnh NG train.

import csv
import os
import shutil

import cv2
import numpy as np

rng = np.random.default_rng(1)
d = np.load("that.npz")
ok_tr, ng_tr, mask_tr = d["ok_tr"], d["ng_tr"], d["mask_tr"]
S = 64
NOI = 4                                          # nới mặt nạ 4 px mỗi phía


def cat_manh(img, mask, le=8):
    x, y, w, h = cv2.boundingRect(mask)
    x0, y0, x1, y1 = max(x - le, 0), max(y - le, 0), min(x + w + le, S), min(y + h + le, S)
    return img[y0:y1, x0:x1].copy(), mask[y0:y1, x0:x1].copy()


def bien_doi(manh, mk, le=6):
    """Xoay góc bất kỳ trên khung đủ lớn, cắt sát lại quanh vết, lật ngẫu nhiên."""
    h, w = mk.shape
    n = int(np.ceil(np.hypot(h, w))) + 2
    M = cv2.getRotationMatrix2D((w / 2, h / 2), rng.uniform(0, 360), 1.0)
    M[:, 2] += (n - w) / 2, (n - h) / 2
    m2 = (cv2.warpAffine(mk * 255, M, (n, n)) > 127).astype(np.uint8)
    p2 = cv2.warpAffine(manh, M, (n, n), borderMode=cv2.BORDER_REFLECT)
    x, y, w2, h2 = cv2.boundingRect(m2)
    x0, y0, x1, y1 = max(x - le, 0), max(y - le, 0), min(x + w2 + le, n), min(y + h2 + le, n)
    m2, p2 = m2[y0:y1, x0:x1], p2[y0:y1, x0:x1]
    if rng.random() < 0.5:
        m2, p2 = m2[:, ::-1].copy(), p2[:, ::-1].copy()
    return p2, m2


def dan_alpha(nen, manh, mk, x, y):
    h, w = mk.shape
    alpha = cv2.GaussianBlur(cv2.dilate(mk, np.ones((3, 3), np.uint8)).astype(np.float32), (0, 0), 1.0)
    chenh = manh.astype(np.float32) - np.median(manh[mk == 0])      # vết = độ chênh so với nền của mảnh
    out = nen.copy()
    vung = out[y:y + h, x:x + w].astype(np.float32)
    out[y:y + h, x:x + w] = np.clip(vung + alpha * chenh, 0, 255).astype(np.uint8)
    return out


def dan_poisson(nen, manh, mk, x, y):
    m = cv2.dilate(mk, np.ones((2 * NOI + 1, 2 * NOI + 1), np.uint8)) * 255
    m[0, :] = m[-1, :] = m[:, 0] = m[:, -1] = 0
    bx, by, bw, bh = cv2.boundingRect(m)
    tam = (x + bx + bw // 2, y + by + bh // 2)                    # tâm khung bao MẶT NẠ
    out = cv2.seamlessClone(cv2.cvtColor(manh, cv2.COLOR_GRAY2BGR), cv2.cvtColor(nen, cv2.COLOR_GRAY2BGR),
                            m, tam, cv2.MIXED_CLONE)
    return cv2.cvtColor(out, cv2.COLOR_BGR2GRAY)


MANH = [cat_manh(a, m) for a, m in zip(ng_tr, mask_tr)]
shutil.rmtree("synth", ignore_errors=True)
os.makedirs("synth/images")
os.makedirs("synth/labels")
nguon, bi_loai = [], 0
i = 0
while len(os.listdir("synth/images")) < 200:
    i += 1
    k_nen = int(rng.integers(len(ok_tr)))
    cach = "alpha" if i % 2 == 0 else "poisson"
    img, dong = ok_tr[k_nen].copy(), []
    for _ in range(int(rng.integers(1, 3))):
        k_manh = int(rng.integers(len(MANH)))
        p, m = bien_doi(*MANH[k_manh])
        h, w = m.shape
        if max(h, w) > S - 4:                                     # mảnh quá lớn so với ảnh: bỏ qua lần này
            continue
        x, y = int(rng.integers(2, S - w - 1)), int(rng.integers(2, S - h - 1))
        moi = dan_alpha(img, p, m, x, y) if cach == "alpha" else dan_poisson(img, p, m, x, y)
        bx, by, bw, bh = cv2.boundingRect(m)                      # nhãn từ mặt nạ đã biến đổi
        bx, by = bx + x, by + y
        hieu = np.abs(moi.astype(int) - img.astype(int))[by:by + bh, bx:bx + bw]
        if hieu.max() < 12:                                       # vết quá mờ sau khi dán: không gắn nhãn NG
            bi_loai += 1
            continue
        img = moi
        dong.append(f"0 {(bx + bw / 2) / S:.6f} {(by + bh / 2) / S:.6f} {bw / S:.6f} {bh / S:.6f}")
        nguon.append({"anh": f"syn_{i:03d}", "nen_ok": k_nen, "manh_ng": k_manh, "cach": cach})
    if not dong:                                                  # không dán được vết nào: tạo lại
        continue
    cv2.imwrite(f"synth/images/syn_{i:03d}.png", img)
    with open(f"synth/labels/syn_{i:03d}.txt", "w") as f:
        f.write("\n".join(dong) + "\n")
with open("synth/nguon.csv", "w", newline="") as f:
    wr = csv.DictWriter(f, fieldnames=["anh", "nen_ok", "manh_ng", "cach"])
    wr.writeheader()
    wr.writerows(nguon)
print("đã sinh", len(os.listdir("synth/images")), "ảnh,", len(nguon), "vết; loại", bi_loai, "lần dán quá mờ")
đã sinh 200 ảnh, 296 vết; loại 21 lần dán quá mờ

File nguon.csv ghi mỗi vết lấy từ ảnh NG nào, dán lên nền OK nào, bằng cách nào. Đây là "phả hệ" của dữ liệu tổng hợp: khi phát hiện một mảnh lỗi bị gán nhãn sai hoặc thuộc tập test, bạn biết ngay những ảnh nào phải xóa.

5. Kiểm tra bộ sinh: đếm, vị trí nhãn, ảnh hiệu

Bộ sinh dữ liệu là code, và code có lỗi. Mỗi lần sinh, kiểm tra tự động: số ảnh bằng số file nhãn, số dòng nhãn bằng số vết trong nhật ký, mọi khung nằm trong ảnh, và quan trọng nhất là ảnh hiệu giữa ảnh tổng hợp và nền OK gốc: bên trong mỗi khung phải có thay đổi rõ (vết thật sự được dán), bên ngoài mọi khung (đã nới vài pixel) không được thay đổi.

import csv
import glob
import os

import cv2
import numpy as np

d = np.load("that.npz")
ok_tr = d["ok_tr"]
S = 64
with open("synth/nguon.csv") as f:
    nguon = list(csv.DictReader(f))
anh = sorted(glob.glob("synth/images/*.png"))
nhan = sorted(glob.glob("synth/labels/*.txt"))
so_dong = sum(len(open(p).read().split()) // 5 for p in nhan)
print(f"ảnh {len(anh)} | file nhãn {len(nhan)} | dòng nhãn {so_dong} | vết trong nhật ký {len(nguon)}")
assert len(anh) == len(nhan) == 200 and so_dong == len(nguon)

nen_cua = {r["anh"]: int(r["nen_ok"]) for r in nguon}
khung_yeu, thay_doi_ngoai, do_sau = 0, 0, {"alpha": [], "poisson": []}
cach_cua = {r["anh"]: r["cach"] for r in nguon}
for p in anh:
    ten = os.path.basename(p)[:-4]
    img = cv2.imread(p, cv2.IMREAD_GRAYSCALE).astype(int)
    hieu = np.abs(img - ok_tr[nen_cua[ten]].astype(int))
    ngoai = np.ones((S, S), bool)
    for dong in open(f"synth/labels/{ten}.txt").read().strip().split("\n"):
        c, cx, cy, w, h = map(float, dong.split())
        x0, y0 = int(round((cx - w / 2) * S)), int(round((cy - h / 2) * S))
        x1, y1 = int(round((cx + w / 2) * S)), int(round((cy + h / 2) * S))
        assert 0 <= x0 < x1 <= S and 0 <= y0 < y1 <= S, (ten, dong)
        dau = hieu[y0:y1, x0:x1].max()
        khung_yeu += dau < 10
        do_sau[cach_cua[ten]].append(dau)
        ngoai[max(y0 - 6, 0):y1 + 6, max(x0 - 6, 0):x1 + 6] = False
    thay_doi_ngoai += int((hieu[ngoai] > 3).sum())
print(f"khung có thay đổi < 10 mức xám (vết không hiện): {khung_yeu}")
print(f"pixel thay đổi > 3 mức xám nằm ngoài mọi khung: {thay_doi_ngoai}")
for cach, v in do_sau.items():
    print(f"{cach:7s}: thay đổi lớn nhất trong khung, trung vị {np.median(v):.0f} mức xám")
# độ sâu vết lỗi thật (để so): nền trung vị trừ điểm tối nhất trên vết
that = [np.median(a) - a[m > 0].min() for a, m in zip(d["ng_te"], d["mask_te"])]
print(f"vết thật (test): độ sâu trung vị {np.median(that):.0f} mức xám")
ảnh 200 | file nhãn 200 | dòng nhãn 296 | vết trong nhật ký 296
khung có thay đổi < 10 mức xám (vết không hiện): 0
pixel thay đổi > 3 mức xám nằm ngoài mọi khung: 0
alpha  : thay đổi lớn nhất trong khung, trung vị 35 mức xám
poisson: thay đổi lớn nhất trong khung, trung vị 33 mức xám
vết thật (test): độ sâu trung vị 37 mức xám

Bộ sinh ở mục 4 đã tự loại các lần dán làm vết quá mờ (một số mảnh lỗi thật vốn rất nhạt, chỉ vài pixel); nếu bỏ bước loại đó, kiểm tra này báo hơn chục khung "không có vết", tức bộ sinh gắn nhãn NG cho ảnh gần như sạch, loại nhãn sai tệ nhất. Thay đổi lớn nhất trong khung của cả hai cách dán cũng nằm cùng khoảng với độ sâu vết thật, dấu hiệu tốt đầu tiên về độ giống. Hãy thử đổi NOI = 1 ở đoạn code trước rồi chạy lại phần kiểm tra để thấy bẫy thứ hai của seamlessClone hiện ra bằng con số. Cuối cùng, mở vài ảnh và vẽ nhãn lên bằng script của bài "Công cụ gán nhãn miễn phí": kiểm tra bằng mắt vẫn bắt buộc.

6. Lỗi thủ tục: vẽ vết xước và đốm bằng thuật toán

Khi không có mảnh lỗi thật nào (hoặc muốn kiểm soát kích thước), vẽ lỗi bằng thuật toán: vết xước là đường gấp khúc ngẫu nhiên với độ rộng, độ tối thay đổi; đốm là hình elip mờ. Mặt nạ có sẵn từ chính lúc vẽ, nên nhãn tự động. Đoạn code dưới cố tình dùng mô hình vết đơn giản (đường thẳng gấp khúc, không viền sáng, không nhòe) như cách nhiều người làm lần đầu, để thí nghiệm ở mục 7 cho thấy khoảng cách miền:

import os
import shutil

import cv2
import numpy as np

rng = np.random.default_rng(2)
d = np.load("that.npz")
ok_tr = d["ok_tr"]
S = 64


def ve_xuoc(img):
    mk = np.zeros((S, S), np.uint8)
    diem = [rng.uniform(10, S - 10, 2)]
    goc = rng.uniform(0, np.pi)
    for _ in range(int(rng.integers(2, 5))):                    # 2-4 đoạn gấp khúc
        goc += rng.normal(0, 0.3)
        diem.append(np.clip(diem[-1] + rng.uniform(4, 9) * np.array([np.cos(goc), np.sin(goc)]), 2, S - 3))
    pts = np.array(diem, np.int32)
    cv2.polylines(mk, [pts], False, 1, int(rng.integers(1, 3)))
    out = img.astype(np.float32) - rng.uniform(30, 60) * mk
    return np.clip(out, 0, 255).astype(np.uint8), mk


def ve_dom(img):
    mk = np.zeros((S, S), np.uint8)
    c = (int(rng.integers(8, S - 8)), int(rng.integers(8, S - 8)))
    cv2.ellipse(mk, c, (int(rng.integers(2, 5)), int(rng.integers(2, 5))), float(rng.uniform(0, 180)),
                0, 360, 1, -1)
    mem = cv2.GaussianBlur(mk.astype(np.float32), (0, 0), 1.0)
    return np.clip(img - rng.uniform(25, 50) * mem, 0, 255).astype(np.uint8), mk


shutil.rmtree("synth_thu_tuc", ignore_errors=True)
os.makedirs("synth_thu_tuc/images")
os.makedirs("synth_thu_tuc/labels")
dem = {"xuoc": 0, "dom": 0}
for i in range(200):
    img, dong = ok_tr[int(rng.integers(len(ok_tr)))].copy(), []
    for _ in range(int(rng.integers(1, 3))):
        loai = "xuoc" if rng.random() < 0.8 else "dom"
        img, mk = ve_xuoc(img) if loai == "xuoc" else ve_dom(img)
        x, y, w, h = cv2.boundingRect(mk)
        dong.append(f"{0 if loai == 'xuoc' else 1} {(x + w / 2) / S:.6f} {(y + h / 2) / S:.6f} {w / S:.6f} {h / S:.6f}")
        dem[loai] += 1
    cv2.imwrite(f"synth_thu_tuc/images/tt_{i:03d}.png", img)
    with open(f"synth_thu_tuc/labels/tt_{i:03d}.txt", "w") as f:
        f.write("\n".join(dong) + "\n")
print("đã sinh", len(os.listdir("synth_thu_tuc/images")), "ảnh; số vết:", dem)
đã sinh 200 ảnh; số vết: {'xuoc': 234, 'dom': 71}

Lỗi thủ tục tốt cần được "hiệu chỉnh" theo lỗi thật: đo phân bố chiều dài, độ rộng, độ sâu, hướng của vết thật đã gán nhãn rồi lấy mẫu theo đúng phân bố đó; mô phỏng hiệu ứng quang học (viền sáng phản xạ ở đèn góc thấp, độ nhòe của ống kính); cho chuyên gia QC xem trộn ảnh thật và tổng hợp, nếu họ dễ dàng chỉ ra ảnh nào giả thì mô hình cũng sẽ phân biệt được.

7. Thí nghiệm: dữ liệu tổng hợp giúp được bao nhiêu?

Bài tập thực hành của bài này: huấn luyện lại với 200 ảnh lỗi tổng hợp và so recall trên ảnh lỗi thật. Bốn cấu hình huấn luyện, cùng một CNN nhỏ (như bài "Rule-based hay Deep Learning?"), cùng 300 bước cập nhật, mỗi batch cân bằng OK/NG, augmentation lật ngang/dọc:

  • A: 400 OK + 12 NG thật.
  • B: A + 200 ảnh cắt dán (mục 4).
  • C: A + 200 ảnh lỗi thủ tục (mục 6).
  • D: 400 OK + chỉ 200 ảnh cắt dán, không có NG thật nào trong train.

Đánh giá trên 300 OK + 100 NG thật của các lô khác. Ngưỡng được chọn theo yêu cầu thường gặp "overkill ≤ 2%" trên ảnh OK của train (chọn trên train để không nhìn vào test), rồi báo recall và overkill trên test thật. Ta cũng đánh giá mô hình C trên chính ảnh thủ tục để thấy con số "đánh giá trên ảnh tổng hợp" nói gì.

import glob
import time

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

torch.set_num_threads(4)
d = np.load("that.npz")
doc_thu_muc = lambda pat: np.array([cv2.imread(p, cv2.IMREAD_GRAYSCALE) for p in sorted(glob.glob(pat))])
cat_dan = doc_thu_muc("synth/images/*.png")
thu_tuc = doc_thu_muc("synth_thu_tuc/images/*.png")
t = lambda X: (torch.from_numpy(np.asarray(X)).float().unsqueeze(1) / 255.0 - 0.5) / 0.25


def huan_luyen(ok, ng, so_buoc=300, seed=0):
    torch.manual_seed(seed)
    g = np.random.default_rng(seed)
    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))
    toi_uu = torch.optim.Adam(mo_hinh.parameters(), lr=3e-3)
    Xok, Xng = t(ok), t(ng)
    y = torch.cat([torch.zeros(16), torch.ones(16)])                  # 16 OK + 16 NG mỗi batch
    for _ in range(so_buoc):
        xb = torch.cat([Xok[g.integers(0, len(Xok), 16)], Xng[g.integers(0, len(Xng), 16)]])  # batch cân bằng
        if g.random() < 0.5:
            xb = xb.flip(3)
        if g.random() < 0.5:
            xb = xb.flip(2)
        loss = nn.functional.binary_cross_entropy_with_logits(mo_hinh(xb).squeeze(1), y)
        toi_uu.zero_grad()
        loss.backward()
        toi_uu.step()
    mo_hinh.eval()
    return mo_hinh


def diem(mo_hinh, X):
    with torch.no_grad():
        return torch.sigmoid(mo_hinh(t(X)).squeeze(1)).numpy()


ok_tr, ng_tr, ok_te, ng_te = d["ok_tr"], d["ng_tr"], d["ok_te"], d["ng_te"]
CAU_HINH = {
    "A: 12 NG thật": ng_tr,
    "B: 12 thật + 200 cắt dán": np.concatenate([ng_tr, cat_dan]),
    "C: 12 thật + 200 thủ tục": np.concatenate([ng_tr, thu_tuc]),
    "D: chỉ 200 cắt dán": cat_dan[:200],
}
t0 = time.time()
print("cấu hình                  | ngưỡng | recall thật | overkill thật | AUROC thật")
mo_hinh_C = None
for ten, ng in CAU_HINH.items():
    m = huan_luyen(ok_tr, ng)
    T = float(np.quantile(diem(m, ok_tr), 0.98))                 # overkill 2% trên OK train
    s_ok, s_ng = diem(m, ok_te), diem(m, ng_te)
    auc = np.mean(s_ng[:, None] > s_ok[None, :]) + 0.5 * np.mean(s_ng[:, None] == s_ok[None, :])  # AUROC
    print(f"{ten:25s} | {T:6.3f} | {np.mean(s_ng >= T):11.0%} | {np.mean(s_ok >= T):13.1%} | {auc:10.3f}")
    if ten.startswith("C"):
        mo_hinh_C, T_C = m, T
# đánh giá "sai cách": mô hình C trên chính kiểu ảnh thủ tục nó đã học
print(f"mô hình C trên 200 ảnh thủ tục (đánh giá sai cách): recall {np.mean(diem(mo_hinh_C, thu_tuc) >= T_C):.0%}")
print(f"thời gian: {time.time() - t0:.0f} s (thay đổi theo máy)")
cấu hình                  | ngưỡng | recall thật | overkill thật | AUROC thật
A: 12 NG thật             |  0.127 |         86% |          1.3% |      0.928
B: 12 thật + 200 cắt dán  |  0.055 |         99% |          3.0% |      0.998
C: 12 thật + 200 thủ tục  |  0.022 |         97% |          1.3% |      0.992
D: chỉ 200 cắt dán        |  0.294 |         97% |          4.0% |      0.995
mô hình C trên 200 ảnh thủ tục (đánh giá sai cách): recall 100%
thời gian: 40 s (thay đổi theo máy)

Đọc kết quả (con số có thể lệch đôi chút giữa các máy và phiên bản thư viện): với chỉ 12 ảnh NG thật, mô hình bắt được 86% vết xước thật ở mức overkill khoảng 1%. Thêm 200 ảnh cắt dán đưa recall thật lên 99% và AUROC từ 0,93 lên gần 1: mô hình đã thấy cùng các vết thật đó ở nhiều hướng, trên nhiều nền, nên học được "vết xước" thay vì học thuộc 12 ảnh. Lỗi thủ tục cũng giúp (97%), dù hình dạng vết đơn giản hơn lỗi thật. Cấu hình D không có ảnh NG thật nào trong train vẫn đạt 97%, vì mảnh dán vốn là lỗi thật. Bây giờ là phần giới hạn, cũng quan trọng không kém:

  • Mô phỏng này dễ hơn đời thật. "Lỗi thật" ở đây sinh từ một quy trình đơn giản, nên cả cắt dán lẫn vẽ thủ tục đều khá giống nó. Với lỗi thật đa dạng (xước sâu nông, phản xạ thay đổi theo góc đèn, lỗi chưa từng có trong 12 mẫu), mức cải thiện thường nhỏ hơn và phải đo lại trên dữ liệu của bạn. Cắt dán không tạo ra kiểu lỗi mới: loại lỗi không có trong 12 ảnh gốc thì mô hình vẫn không biết.
  • Overkill trên test cao hơn mục tiêu 2% ở B và D (3–4%), dù ngưỡng được chọn để overkill 2% trên OK của train. Ảnh OK của lô mới có dải độ sáng rộng hơn; ngưỡng phải được chọn và xác nhận trên ảnh thật của lô khác (bài sau).
  • Đánh giá trên ảnh tổng hợp nói quá: mô hình C đạt 100% trên ảnh thủ tục, cao hơn 97% trên ảnh thật. Ở đây chênh lệch nhỏ vì mô phỏng dễ; với dữ liệu thật, chênh lệch có thể rất lớn và che giấu hoàn toàn domain gap.
  • Mọi kết luận dựa trên 100 NG thật của test: 99% và 97% chênh nhau 2 ảnh, nằm trong mức dao động giữa các lần huấn luyện (xem bài tập 1). Đừng chọn phương án dựa trên chênh lệch cỡ đó.
Quy tắc vàng: chỉ đánh giá trên ảnh thậtMô hình luôn đạt điểm cao trên chính kiểu dữ liệu tổng hợp nó được huấn luyện, vì nó học được cả "chữ ký" của bộ sinh (viền dán, độ sắc nét, kiểu nhiễu). Val, test, golden set: 100% ảnh thật, từ lô và thời gian khác với ảnh nguồn của mảnh lỗi. Mảnh lỗi cắt dán cũng chỉ được lấy từ ảnh NG thuộc tập train: dán một vết của tập test lên ảnh train là rò rỉ, và con số test sẽ đẹp giả tạo.

8. Domain gap và cách thu hẹp

Domain gap (khoảng cách miền) là sự khác biệt có hệ thống giữa dữ liệu tổng hợp và dữ liệu thật. Mô hình học trên miền này rồi chạy trên miền kia sẽ kém hơn con số đo trên miền tổng hợp; trong thí nghiệm trên khoảng cách còn nhỏ vì "lỗi thật" mô phỏng khá đơn giản, ngoài đời nó thường lớn hơn nhiều. Các dấu hiệu và cách kiểm tra:

Nguồn khác biệtKiểm traCách thu hẹp
Hình dạng vết (cong, đứt đoạn, độ dài)So phân bố chiều dài, độ cong, độ rộng của vết thật và tổng hợpLấy mẫu tham số theo phân bố đo từ vết thật
Quang học (viền sáng, nhòe, phản xạ)So profile cắt ngang vết thật và tổng hợpCắt dán vết thật thay vì vẽ; mô phỏng viền sáng, làm nhòe theo PSF ống kính
Biên vùng dánẢnh hiệu, gradient quanh biên mặt nạLàm mềm alpha, Poisson blending, chừa lề rộng
Thống kê toàn ảnhHistogram, độ nét, nhiễu của ảnh tổng hợp so với ảnh thậtDùng nền OK thật, thêm nhiễu cảm biến sau khi dán
"Chữ ký" bộ sinhHuấn luyện một bộ phân loại "thật hay tổng hợp": nếu nó phân biệt dễ dàng thì domain gap lớnThêm biến thể, trộn nhiều cách sinh

Mô hình sinh (diffusion, inpainting) và render 3D từ CAD là các bước tiếp theo khi cắt dán không đủ, ví dụ cần loại lỗi chưa từng có ảnh thật nào hoặc bài toán định vị nhiều tư thế. Chúng đòi hỏi công sức và chuyên môn cao hơn nhiều, nhãn sinh ra cũng phải được kiểm tra, và quy tắc vàng giữ nguyên: đánh giá trên ảnh thật.

Chiến lược thực tế cho dự án mới: triển khai sớm bằng anomaly detection (chỉ cần ảnh OK, chặng 12), thu ảnh NG thật trong lúc chạy, bổ sung cắt dán từ các NG thật đã có cho tập train, rồi chuyển dần sang mô hình có giám sát khi đủ ảnh thật; theo dõi recall trên golden set thật ở mỗi phiên bản.

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

  • Đánh giá trên ảnh tổng hợp hoặc trộn ảnh tổng hợp vào val/test: con số đẹp vô nghĩa.
  • Cắt mảnh lỗi từ ảnh NG của tập test rồi dán vào ảnh train: rò rỉ.
  • Không kiểm tra ảnh hiệu: seamlessClone dán lệch hoặc làm biến mất vết, nhãn NG gắn cho ảnh sạch.
  • Dán lên nền không thể có lỗi đó (vết xước ra ngoài chi tiết, lên vùng nền băng tải) hoặc với kích thước không thực tế.
  • Tỉ lệ tổng hợp quá cao so với thật mà không thử nhiều tỉ lệ: mô hình chuyên về "lỗi giả". Thử 0×, 1×, 5×, 20× số NG thật và chọn theo recall trên val thật.
  • Mọi vết dán có cùng một kiểu viền: mô hình học viền dán thay vì học lỗi; dấu hiệu là recall trên ảnh tổng hợp cao nhưng trên ảnh thật thấp.
  • Augmentation làm sai nhãn (crop mất lỗi, xoay 90° nền có hướng) cho cả ảnh thật và tổng hợp.

10. Bài tập

Bài 1. Lặp lại thí nghiệm mục 7 với số ảnh cắt dán thêm vào là 0, 50 và 200 (luôn giữ 12 NG thật), mỗi cấu hình huấn luyện với 2 seed, báo recall trung bình trên test thật. Kết luận gì về việc chọn tỉ lệ?

Xem đáp án
import glob

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

torch.set_num_threads(4)
d = np.load("that.npz")
cat_dan = np.array([cv2.imread(p, cv2.IMREAD_GRAYSCALE) for p in sorted(glob.glob("synth/images/*.png"))])
t = lambda X: (torch.from_numpy(np.asarray(X)).float().unsqueeze(1) / 255.0 - 0.5) / 0.25


def huan_luyen_va_do(ok, ng, seed, so_buoc=300):
    torch.manual_seed(seed)
    g = np.random.default_rng(seed)
    m = 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))
    opt = torch.optim.Adam(m.parameters(), lr=3e-3)
    Xok, Xng, y = t(ok), t(ng), torch.cat([torch.zeros(16), torch.ones(16)])
    for _ in range(so_buoc):
        xb = torch.cat([Xok[g.integers(0, len(Xok), 16)], Xng[g.integers(0, len(Xng), 16)]])
        xb = xb.flip(3) if g.random() < 0.5 else xb
        xb = xb.flip(2) if g.random() < 0.5 else xb
        loss = nn.functional.binary_cross_entropy_with_logits(m(xb).squeeze(1), y)
        opt.zero_grad()
        loss.backward()
        opt.step()
    m.eval()
    with torch.no_grad():
        s_tr = torch.sigmoid(m(t(ok)).squeeze(1)).numpy()
        s_ng = torch.sigmoid(m(t(d["ng_te"])).squeeze(1)).numpy()
    return np.mean(s_ng >= np.quantile(s_tr, 0.98))


for n_syn in (0, 50, 200):
    ng = np.concatenate([d["ng_tr"], cat_dan[:n_syn]])
    r = [huan_luyen_va_do(d["ok_tr"], ng, s) for s in (0, 1)]
    print(f"12 thật + {n_syn:3d} cắt dán: recall thật {np.mean(r):.0%} (seed: {', '.join(f'{v:.0%}' for v in r)})")
12 thật +   0 cắt dán: recall thật 85% (seed: 86%, 84%)
12 thật +  50 cắt dán: recall thật 98% (seed: 98%, 97%)
12 thật + 200 cắt dán: recall thật 98% (seed: 99%, 97%)

Từ 0 lên 50 ảnh cắt dán, recall thật tăng mạnh (khoảng 85% lên 98%); từ 50 lên 200 gần như không thêm gì, và hai seed lệch nhau 1–2 điểm, cỡ bằng chính mức "cải thiện". Kết luận: lợi ích của dữ liệu tổng hợp bão hòa nhanh; chọn tỉ lệ bằng recall và overkill trên val thật, lặp vài seed, và ưu tiên thời gian cho việc thu thêm lỗi thật đa dạng hơn là sinh thêm hàng nghìn ảnh từ cùng 12 mảnh.

Bài 2. Đồng nghiệp gọi cv2.seamlessClone(src, dst, mask, (x + w // 2, y + h // 2), cv2.NORMAL_CLONE) với (x, y) là góc trên trái nơi muốn đặt mảnh kích thước w × h, và ghi nhãn khung bao từ mặt nạ cộng (x, y). Mặt nạ của mảnh 40×40 chỉ chiếm vùng (2, 2) đến (21, 21) trong mảnh, vết tối nằm ở (8, 8) đến (16, 16). Vết thật sự nằm ở đâu so với nhãn? Sửa thế nào?

Xem đáp án
import cv2
import numpy as np

nen = np.full((120, 120, 3), 140, np.uint8)
manh = np.full((40, 40, 3), 140, np.uint8)
mask = np.zeros((40, 40), np.uint8)
mask[2:22, 2:22] = 255                                     # mặt nạ lệch về góc trên trái của mảnh
manh[8:17, 8:17] = 40                                      # vết tối nằm giữa mặt nạ
x, y, w, h = 30, 30, 40, 40
for ten, tam in (("sai: tâm của mảnh", (x + w // 2, y + h // 2)),
                 ("đúng: tâm khung bao mặt nạ", None)):
    if tam is None:
        bx, by, bw, bh = cv2.boundingRect(mask)
        tam = (x + bx + bw // 2, y + by + bh // 2)
    out = cv2.seamlessClone(manh, nen, mask.copy(), tam, cv2.NORMAL_CLONE)    # luôn truyền bản sao mask
    ys, xs = np.where(out[..., 0] < 100)
    print(f"{ten:28s}: vết thật ở x {xs.min()}-{xs.max()}, y {ys.min()}-{ys.max()} | nhãn ghi x 38-46, y 38-46")
truoc = mask.copy()
cv2.seamlessClone(manh, nen, mask, tam, cv2.NORMAL_CLONE)                   # truyền thẳng mask
print(f"OpenCV {cv2.__version__}: số pixel mask bị hàm sửa sau một lần gọi: {int((mask != truoc).sum())}")
sai: tâm của mảnh           : vết thật ở x 46-54, y 46-54 | nhãn ghi x 38-46, y 38-46
đúng: tâm khung bao mặt nạ  : vết thật ở x 38-46, y 38-46 | nhãn ghi x 38-46, y 38-46
OpenCV 5.0.0: số pixel mask bị hàm sửa sau một lần gọi: 196

Với tâm của mảnh, OpenCV đặt tâm khung bao mặt nạ (20×20) vào điểm (50, 50), nên vết bị dịch 8 pixel về phía phải dưới so với nhãn. Sửa: tính tâm từ cv2.boundingRect(mask) như dòng thứ hai, và luôn kiểm tra bằng ảnh hiệu. Dòng cuối cho thấy vì sao code truyền mask.copy(): nếu số pixel bị sửa khác 0 trên phiên bản OpenCV của bạn, dùng lại cùng một mảng mặt nạ cho nhiều lần dán sẽ cho kết quả sai.

Bài 3. Dự án có 30 ảnh NG thật của lỗi "hở lớp phủ" thu trong 2 tháng. Bạn định cắt dán để có 600 ảnh huấn luyện. Lập kế hoạch chia 30 ảnh thật và cách đánh giá để báo cáo recall trung thực cho khách hàng.

Xem đáp án
  • Chia 30 ảnh thật theo chi tiết và theo thời gian trước mọi thao tác: ví dụ 18 train, 12 test (các chi tiết thu gần đây nhất). Không dùng val riêng vì quá ít; chọn tham số bằng kiểm định chéo theo nhóm trên 18 ảnh train nếu cần.
  • Mảnh lỗi để cắt dán chỉ lấy từ 18 ảnh train; nền OK lấy từ ảnh OK của tập train. Ghi phả hệ (ảnh nào sinh từ mảnh nào).
  • Huấn luyện với 18 thật + 600 tổng hợp; thử vài tỉ lệ, so trên kiểm định chéo thật.
  • Đánh giá cuối trên 12 NG thật + vài trăm OK thật của lô mới. Báo recall kèm số mẫu và cận trên tin cậy: 12/12 bắt được vẫn chỉ chứng minh escape dưới khoảng 22% ở độ tin cậy 95% (bài "Chọn ngưỡng và đánh giá theo escape, overkill, cycle time").
  • Cam kết tiếp tục thu ảnh thật trong sản xuất, bổ sung golden set và đánh giá lại mỗi phiên bản.

Tóm tắt

  • Khi thiếu ảnh lỗi: thu thêm ảnh thật, anomaly detection, augmentation an toàn, cắt dán lỗi thật, lỗi thủ tục, rồi mới đến render 3D hay mô hình sinh.
  • Cắt dán: mảnh lỗi theo mặt nạ từ ảnh NG train, xoay/lật, dán bằng alpha (cộng độ chênh) hoặc cv2.seamlessClone; nhãn YOLO lấy từ mặt nạ đã biến đổi; ghi phả hệ từng ảnh.
  • seamlessClone: điểm đặt là tâm khung bao mặt nạ; mặt nạ quá sát vết mảnh có thể làm vết biến mất. Luôn kiểm tra bằng ảnh hiệu: thay đổi trong khung, không thay đổi ngoài khung.
  • Lỗi thủ tục cần được hiệu chỉnh theo phân bố lỗi thật; mô phỏng thô tạo domain gap lớn.
  • Chỉ đánh giá trên ảnh thật của lô khác; dữ liệu tổng hợp chỉ ở trong tập train và không bao giờ thay thế hoàn toàn dữ liệu thật.

Bài tiếp theo: GAN – Mạng đối sinh, khi muốn mạng nơ-ron tự học cách sinh ảnh. Sau đó là Chọn ngưỡng và đánh giá theo escape, overkill, cycle time, biến điểm số của mô hình thành quyết định OK/NG và tiêu chí nghiệm thu.

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

  1. Tạo 200 ảnh lỗi tổng hợp bằng cách dán vết xước lên ảnh OK, huấn luyện lại và so sánh recall trên ảnh lỗi thật.

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