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

Tự xây dataset ảnh công nghiệp: kế hoạch, phiên bản, chia tập chống rò rỉ

Lập kế hoạch thu thập theo lô và ca, đặt tên file, manifest SHA-256, dataset card, tìm ảnh trùng bằng pHash và chia tập theo nhóm không rò rỉ.

Tự xây dataset ảnh công nghiệp: kế hoạch, phiên bản, chia tập chống rò rỉ

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

  • Lập kế hoạch thu thập ảnh
  • Tổ chức thư mục, đặt tên, phiên bản
  • Chia tập không rò rỉ

Nên học trước: Rule-based hay Deep Learning?

Nội dung bài viết
  1. 1. Bắt đầu từ danh mục lỗi, không từ máy ảnh
  2. 2. Kế hoạch thu thập
  3. 3. Thu thập đúng cách
  4. 4. Cấu trúc thư mục và quy ước đặt tên
  5. 5. Phiên bản: manifest SHA-256 và dataset card
  6. 6. Phát hiện ảnh trùng và gần trùng
  7. 7. Chia tập không rò rỉ
  8. 8. Cải thiện liên tục sau triển khai
  9. 9. Sai lầm thường gặp
  10. 10. Bài tập
  11. Tóm tắt

Dataset công khai giúp học, nhưng mô hình chạy trên dây chuyền của bạn cần ảnh từ chính dây chuyền đó: đúng camera, đúng đèn, đúng vật liệu, đủ các lô và các ca. Ở bài "Rule-based hay Deep Learning?", bạn đã ước lượng cần bao nhiêu ảnh lỗi và mất bao lâu để có. Bài này biến ước lượng đó thành quy trình làm việc thật: lập kế hoạch thu thập có bảng phủ điều kiện, tổ chức thư mục và quy ước đặt tên kiểm tra được bằng máy, quản lý phiên bản bằng manifest SHA-256 và dataset card, phát hiện ảnh trùng bằng perceptual hash, và chia tập không rò rỉ theo chi tiết, lô, thời gian. Một thí nghiệm nhỏ sẽ cho bạn thấy chia ngẫu nhiên từng ảnh có thể biến một mô hình vô dụng thành "chính xác 99%" trên giấy. Bài sau, "Công cụ gán nhãn miễn phí", tiếp nhận thư mục ảnh này để gán nhãn.

Mục tiêu cuối bàiViết kế hoạch thu thập gồm số chi tiết mỗi lớp, ma trận điều kiện (mã sản phẩm, ca, lô), lịch thu thập và người gán nhãn; dựng cấu trúc thư mục, quy ước tên file và metadata kiểm tra tự động; tạo manifest SHA-256, dataset card và phát hiện file bị sửa; tìm ảnh trùng và gần trùng bằng pHash; chia train/val/test theo nhóm bằng GroupShuffleSplit hoặc theo thời gian, kiểm tra rò rỉ và độ phủ lớp. Code chạy được với NumPy, OpenCV và scikit-learn trên bộ ảnh mô phỏng.

1. Bắt đầu từ danh mục lỗi, không từ máy ảnh

Trước khi chụp tấm ảnh đầu tiên, thống nhất với bộ phận chất lượng (QA/QC) một danh mục lỗi (defect catalog). Mỗi lớp nhãn phải có định nghĩa đo được, mức nghiêm trọng và ảnh mẫu; nếu không, hai người gán nhãn sẽ gán khác nhau và mô hình học theo người gán ẩu nhất.

MãTên lớpĐịnh nghĩa (theo tiêu chí khách hàng)MứcGhi chú gán nhãn
OKĐạtKhông có lỗi nào vượt tiêu chí; bụi, vân tay lau được là OK-Gắn thẻ "OK khó" nếu có bụi, vết gia công cho phép
D01XướcVết dài ≥ 2,0 mm ở vùng A, ≥ 4,0 mm ở vùng BChínhXước đứt đoạn cách nhau < 0,5 mm tính là một vết
D02Đốm, rỗĐường kính tương đương ≥ 0,5 mmChínhKhông gán bóng phản xạ quanh đốm
D03Hở lớp phủMọi vùng lộ nền ≥ 0,2 mmNghiêm trọngKhông chắc thì gắn thẻ "cần QA", không tự quyết

Tiêu chí bằng mm, mm² giúp bạn quyết định luôn độ phân giải và kiểu nhãn: lỗi nhỏ nhất 0,2 mm cần ít nhất 3–5 pixel (chặng 6), và tiêu chí theo kích thước đòi nhãn đa giác hoặc mặt nạ để đo được, không chỉ nhãn OK/NG cấp ảnh.

2. Kế hoạch thu thập

Bao nhiêu ảnh mỗi lớp?

Không có con số chuẩn; các khoảng sau là điểm xuất phát theo kinh nghiệm, bạn sẽ điều chỉnh bằng đường cong học (huấn luyện với 25%, 50%, 100% dữ liệu và xem chỉ số còn tăng không):

Bài toánĐiểm xuất phát cho phiên bản đầu
Phân loại ROI đã căn chỉnh200–500 chi tiết mỗi lớp lỗi, vài nghìn chi tiết OK
Phát hiện, phân đoạn200–500 vết mỗi loại lỗi, nhiều hơn khi lỗi đa dạng về hình dạng, kích thước
Anomaly detectionVài trăm ảnh OK đại diện mọi lô và ca; vài chục đến vài trăm ảnh NG thật chỉ để đánh giá
Tập val + testMỗi loại lỗi tối thiểu vài chục chi tiết ở mỗi tập, nếu không con số escape không có ý nghĩa thống kê

Đếm theo chi tiết vật lý, không theo số ảnh. Chụp một chi tiết 10 lần cho 10 ảnh nhưng gần như chỉ một mẫu thông tin. Chụp lặp có ích (đặt lại, xoay) để mô hình chịu được sai số định vị, nhưng các ảnh đó luôn là một nhóm khi chia tập (mục 7).

Ma trận điều kiện

Dataset phải phủ mọi nguồn biến thiên sẽ gặp khi chạy thật: mã sản phẩm, lô nguyên liệu, ca (nhiệt độ, người vận hành), máy gia công, tuổi đèn, độ bẩn kính. Cách đơn giản nhất là lập bảng các ô điều kiện và đặt chỉ tiêu tối thiểu cho mỗi ô:

import math

SAN_LUONG_NGAY = 12_000                    # sản phẩm/ngày, 2 ca
MA_SP = ["TS-120", "TS-150", "TS-200"]     # tỉ trọng sản lượng 50% / 30% / 20%
TI_TRONG = [0.5, 0.3, 0.2]
CA = ["A", "B"]
GIU_LAI = 0.7                              # tỉ lệ chi tiết NG QC giữ lại được để chụp
LOP = {                                    # lớp: (tỉ lệ trên sản phẩm, số CHI TIẾT mục tiêu cho v1)
    "D01_xuoc": (0.0020, 300),
    "D02_dom": (0.0010, 200),
    "D03_ho_phu": (0.0002, 120),
}
N_OK, LAN_CHUP = 3000, 2                   # chi tiết OK; mỗi chi tiết chụp 2 lần (đặt lại)
O_TOI_THIEU = 10                           # chỉ tiêu tối thiểu mỗi ô (mã SP × ca) cho mỗi lớp lỗi

print(f"{'lớp':11s} {'chi tiết/ngày':>13s} {'ngày':>5s}  ô (mã SP × ca) ít nhất")
lich = {}
for lop, (p, muc_tieu) in LOP.items():
    moi_ngay = SAN_LUONG_NGAY * p * GIU_LAI
    ngay = math.ceil(muc_tieu / moi_ngay)
    lich[lop] = ngay
    # ô hiếm nhất: mã sản phẩm có tỉ trọng nhỏ nhất, chia đều 2 ca
    o_it_nhat = muc_tieu * min(TI_TRONG) / len(CA)
    canh_bao = "" if o_it_nhat >= O_TOI_THIEU else f"  ← thiếu, cần chủ động gom mã {MA_SP[-1]}"
    print(f"{lop:11s} {moi_ngay:13.1f} {ngay:5d}  {o_it_nhat:5.0f}{canh_bao}")
n_ng = sum(m for _, m in LOP.values())
print(f"Tổng: {n_ng} chi tiết NG + {N_OK} chi tiết OK = {(n_ng + N_OK) * LAN_CHUP} ảnh "
      f"({LAN_CHUP} lần chụp/chi tiết)")
print(f"Đủ v1 sau {max(lich.values())} ngày sản xuất; nên đóng băng v0 (thử nghiệm) sau "
      f"{min(lich.values())} ngày")
lớp         chi tiết/ngày  ngày  ô (mã SP × ca) ít nhất
D01_xuoc             16.8    18     30
D02_dom               8.4    24     20
D03_ho_phu            1.7    72     12
Tổng: 620 chi tiết NG + 3000 chi tiết OK = 7240 ảnh (2 lần chụp/chi tiết)
Đủ v1 sau 72 ngày sản xuất; nên đóng băng v0 (thử nghiệm) sau 18 ngày

Kế hoạch lộ ra hai vấn đề ngay trên giấy: lỗi hở lớp phủ cần 72 ngày, và ô "TS-200 × một ca" của lỗi hiếm chỉ có khoảng 12 chi tiết. Các cách xử lý: xin QC gom hàng NG tồn kho, nhờ phòng thí nghiệm của khách hàng cung cấp mẫu lỗi, chủ động chạy thử mã TS-200 khi có lô mới, và chấp nhận phiên bản đầu (v0) thiếu một số ô nhưng ghi rõ trong dataset card.

Mẫu kế hoạch thu thập (một trang)

MụcNội dung cần điền
Mục tiêuBài toán, đầu ra mô hình (phân loại / phát hiện / phân đoạn), tiêu chí lỗi đính kèm
Lớp và số lượngBảng lớp × số chi tiết mục tiêu cho v0, v1; tỉ lệ lỗi thực tế; nguồn mẫu NG (tủ cách ly, hàng tồn)
Ma trận điều kiệnMã sản phẩm, lô, ca, máy, mùa; chỉ tiêu tối thiểu mỗi ô
Điều kiện chụpCamera, ống kính, đèn, phơi sáng, gain, khoảng cách làm việc: giống hệt trạm thật; định dạng PNG/TIFF không nén mất dữ liệu; số lần chụp mỗi chi tiết
MetadataDanh sách cột bắt buộc (mục 3); ai ghi, ghi bằng gì (tự động từ phần mềm trạm)
Người gán nhãnAi gán, ai kiểm (QA ký), tỉ lệ kiểm chéo, hướng dẫn gán nhãn phiên bản mấy
LịchNgày bắt đầu, mốc đóng băng v0, v1; ai chịu trách nhiệm từng việc
Bảo mậtNơi lưu, ai được truy cập, có được đưa ảnh ra khỏi nhà máy không (hợp đồng)

3. Thu thập đúng cách

  • Đúng phần cứng và cấu hình chạy thật: camera, ống kính, đèn, phơi sáng, khoảng cách. Ảnh chụp "tạm" bằng điện thoại hoặc ở bàn thí nghiệm khác đèn sẽ tạo khoảng cách miền (domain gap) và mô hình kém khi triển khai.
  • Ảnh gốc không nén mất dữ liệu (PNG, BMP, TIFF). JPEG tạo khối 8×8 và làm mờ lỗi vài pixel. Camera 10/12 bit thì lưu 16 bit (PNG/TIFF 16 bit) nếu pipeline dùng dải động đó.
  • Thư mục raw/ là bất biến: không ai sửa, xóa, ghi đè ảnh gốc. Cắt ROI, đổi kích thước, cân bằng sáng là bước xử lý tạo thư mục khác, có script tái tạo được.
  • Metadata ghi tự động từ phần mềm trạm, không chép tay: thời gian, trạm, mã sản phẩm, lô, mã chi tiết, lần chụp, phơi sáng, gain, nhiệt độ nếu có.
  • Chụp chủ động mẫu lỗi: QC giữ hàng NG trong tủ cách ly có dán mã; mỗi tuần mang ra chụp ở nhiều vị trí và hướng.
  • Đừng chỉ chụp OK "đẹp": gom các chi tiết OK khó (bụi, vết gia công cho phép, sắc độ ở biên dung sai). Đó là nguồn overkill chính khi chạy thật.

4. Cấu trúc thư mục và quy ước đặt tên

dataset-tam-thep/
  raw/                       # ảnh gốc, bất biến, chia theo ngày chụp
    2026-09-15/20260915_081502_L2C1_LOT0915A_P0007_s1.png
    ...
  metadata.csv               # một dòng cho mỗi ảnh, ghi tự động
  labels/labels.csv          # nhãn tách khỏi tên file (nhãn có thể được sửa)
  splits/v1/train.txt        # danh sách đường dẫn ảnh theo tập
  splits/v1/val.txt
  splits/v1/test.txt
  manifest_v1.csv            # đường dẫn, kích thước, SHA-256 từng file
  DATASET_CARD.md            # mô tả, định nghĩa lớp, số liệu, lịch sử phiên bản

Quy ước tên: <ngày>_<giờ>_<trạm>_<lô>_<chi tiết>_s<lần chụp>.png. Tên file phải duy nhất toàn cục (có ngày giờ và trạm), sắp xếp được theo thời gian, chỉ dùng chữ không dấu, số, gạch dưới, và không chứa nhãn: nhãn sẽ được sửa nhiều lần, đổi tên file theo nhãn làm mất dấu vết và phá manifest.

Đoạn code sau dựng một dataset mô phỏng theo đúng cấu trúc: 8 lô trong 8 ngày, mỗi lô 16 chi tiết, mỗi chi tiết chụp 2 lần (lần 2 lệch 1 pixel và nhiễu khác). Mỗi chi tiết có vân bề mặt riêng; lỗi xước và đốm khá mờ. Ta cũng cố tình gây hai sự cố hay gặp ngoài đời: một ảnh bị chép trùng sang thư mục ngày khác, và một chi tiết bị chụp lại ở lô sau với mã chi tiết mới.

import csv
import os
import shutil

import cv2
import numpy as np

rng = np.random.default_rng(1)
GOC = "dataset-tam-thep"
shutil.rmtree(GOC, ignore_errors=True)
LOP = ["OK", "D01_xuoc", "D02_dom"]


def van_be_mat(seed):
    """Vân thép cán riêng của từng chi tiết: sọc ngang ngẫu nhiên + hạt."""
    r = np.random.default_rng(seed)
    soc = cv2.resize(r.normal(0, 14, (18, 1)), (72, 72), interpolation=cv2.INTER_CUBIC)
    vet_loang = cv2.GaussianBlur(r.normal(0, 70, (72, 72)), (0, 0), 3.0)
    return 140 + soc + vet_loang + cv2.GaussianBlur(r.normal(0, 10, (72, 72)), (0, 0), 1.0)


def chup(van, nhan, seed_loi, lan, sang_lo):
    img = van.copy() + sang_lo
    r = np.random.default_rng(seed_loi)                     # lỗi nằm cố định trên chi tiết
    if nhan == "D01_xuoc":
        x, y = r.integers(16, 56, 2)
        cv2.line(img, (int(x) - 7, int(y) - 3), (int(x) + 7, int(y) + 3), float(img.mean() - 22), 1)
    elif nhan == "D02_dom":
        x, y = r.integers(16, 56, 2)
        cv2.circle(img, (int(x), int(y)), 2, float(img.mean() - 22), -1)
    dx, dy = (0, 0) if lan == 1 else rng.integers(-1, 2, 2)  # lần 2: đặt lại, lệch 1 pixel
    img = img[4 + dy:68 + dy, 4 + dx:68 + dx] + rng.normal(0, 3, (64, 64))
    return np.clip(img, 0, 255).astype(np.uint8)


dong = []
for k in range(8):                                           # 8 lô, mỗi lô một ngày
    ngay = f"2026-09-{15 + k:02d}"
    lo = f"LOT{9}{15 + k:02d}A"
    sang_lo = rng.uniform(-10, 10)                           # mỗi lô thép sáng tối khác nhau
    os.makedirs(f"{GOC}/raw/{ngay}", exist_ok=True)
    for p in range(16):
        ma_ct = f"P{k * 100 + p:04d}"
        nhan = LOP[p % 3] if p < 12 else "OK"
        van = van_be_mat(1000 * k + p)
        for lan in (1, 2):
            gio = f"{8 + p // 4:02d}{(p * 7) % 60:02d}{10 * lan:02d}"
            ten = f"{ngay.replace('-', '')}_{gio}_L2C1_{lo}_{ma_ct}_s{lan}.png"
            cv2.imwrite(f"{GOC}/raw/{ngay}/{ten}", chup(van, nhan, 1000 * k + p, lan, sang_lo))
            dong.append({"file": f"raw/{ngay}/{ten}", "ngay": ngay, "ca": "A" if p < 8 else "B",
                         "tram": "L2C1", "lo": lo, "chi_tiet": ma_ct, "lan": lan, "nhan": nhan,
                         "exposure_us": 800, "gain_db": 0})

# sự cố 1: một file bị chép trùng sang thư mục ngày khác (người dùng kéo thả nhầm)
nguon = dong[5]
dich = f"raw/2026-09-22/20260922_150000_L2C1_LOT922A_P0799_s1.png"
shutil.copy(f"{GOC}/{nguon['file']}", f"{GOC}/{dich}")
dong.append({**nguon, "file": dich, "ngay": "2026-09-22", "lo": "LOT922A", "chi_tiet": "P0799", "lan": 1})
# sự cố 2: chi tiết P0103 (lô 16/9) bị đưa vào lô 21/9 và chụp lại với mã mới P0699
van = van_be_mat(1000 * 1 + 3)
ten = "20260921_163000_L2C1_LOT921A_P0699_s1.png"
cv2.imwrite(f"{GOC}/raw/2026-09-21/{ten}", chup(van, LOP[3 % 3], 1003, 2, 0.0))
dong.append({**dong[0], "file": f"raw/2026-09-21/{ten}", "ngay": "2026-09-21", "ca": "B",
             "lo": "LOT921A", "chi_tiet": "P0699", "lan": 1, "nhan": LOP[3 % 3]})

with open(f"{GOC}/metadata.csv", "w", newline="", encoding="utf-8") as f:
    w = csv.DictWriter(f, fieldnames=list(dong[0].keys()))
    w.writeheader()
    w.writerows(dong)
print(f"{len(dong)} ảnh, {len({d['lo'] for d in dong})} lô, "
      f"{len({d['chi_tiet'] for d in dong})} mã chi tiết")
for lop in LOP:
    print(f"  {lop:9s}: {sum(d['nhan'] == lop for d in dong)} ảnh")
258 ảnh, 8 lô, 130 mã chi tiết
  OK       : 129 ảnh
  D01_xuoc : 64 ảnh
  D02_dom  : 65 ảnh

Kiểm tra tên file và metadata bằng máy

Quy ước chỉ có giá trị khi được kiểm tra tự động mỗi lần thêm ảnh. Script dưới dùng biểu thức chính quy (regex) để tách các trường trong tên, so với metadata, và kiểm tra file tồn tại, đọc được, đúng kích thước:

import csv
import os
import re

import cv2

GOC = "dataset-tam-thep"
MAU_TEN = re.compile(r"^(?P<ngay>\d{8})_(?P<gio>\d{6})_(?P<tram>[A-Z0-9]+)_"
                     r"(?P<lo>LOT\d{3}[A-Z])_(?P<chi_tiet>P\d{4})_s(?P<lan>\d)\.png$")
LOP_HOP_LE = {"OK", "D01_xuoc", "D02_dom"}

with open(f"{GOC}/metadata.csv", encoding="utf-8") as f:
    dong = list(csv.DictReader(f))
loi = []
for d in dong:
    ten = os.path.basename(d["file"])
    m = MAU_TEN.match(ten)
    if not m:
        loi.append((ten, "tên sai quy ước"))
        continue
    if m["lo"] != d["lo"] or m["chi_tiet"] != d["chi_tiet"] or m["ngay"] != d["ngay"].replace("-", ""):
        loi.append((ten, "tên và metadata không khớp"))
    if d["nhan"] not in LOP_HOP_LE:
        loi.append((ten, f"nhãn lạ {d['nhan']}"))
    img = cv2.imread(f"{GOC}/{d['file']}", cv2.IMREAD_UNCHANGED)
    if img is None or img.shape != (64, 64):
        loi.append((ten, "không đọc được hoặc sai kích thước"))
trong_thu_muc = {os.path.relpath(os.path.join(r, n), GOC).replace(os.sep, "/")
                 for r, _, fs in os.walk(f"{GOC}/raw") for n in fs}
khong_co_metadata = trong_thu_muc - {d["file"] for d in dong}
print(f"kiểm tra {len(dong)} dòng metadata: {len(loi)} lỗi, {len(khong_co_metadata)} file không có metadata")
for ten, ly_do in loi:
    print("  ", ten, "→", ly_do)
kiểm tra 258 dòng metadata: 0 lỗi, 0 file không có metadata

Bộ dữ liệu sạch về mặt quy ước, nhưng hai sự cố đã gây vẫn chưa bị phát hiện: tên file và metadata đều "hợp lệ". Cần hai công cụ khác: hash nội dung SHA-256 (mục 5–6) và perceptual hash cho ảnh gần trùng (mục 6).

5. Phiên bản: manifest SHA-256 và dataset card

"Mô hình v3 được huấn luyện trên dữ liệu nào?" phải trả lời được bằng một mã duy nhất, kể cả sau một năm. Cách tối giản không cần công cụ ngoài:

  • Manifest: bảng mọi file kèm kích thước và SHA-256 (hàm băm mật mã: chỉ đổi 1 bit trong file, mã băm đổi hoàn toàn).
  • Mã phiên bản dữ liệu: SHA-256 của chính file manifest. Hai người có cùng mã này chắc chắn có cùng dữ liệu.
  • Dataset card: file mô tả cho con người: nguồn, thời gian, lớp, số lượng, cách chia, vấn đề đã biết, lịch sử thay đổi.
import csv
import datetime
import hashlib
import os

GOC = "dataset-tam-thep"


def sha256_file(duong_dan, khoi=1 << 20):
    h = hashlib.sha256()
    with open(duong_dan, "rb") as f:
        while chunk := f.read(khoi):                     # đọc từng khối, không nạp cả file lớn
            h.update(chunk)
    return h.hexdigest()


def tao_manifest(goc, ten_file):
    files = sorted(os.path.relpath(os.path.join(r, n), goc).replace(os.sep, "/")
                   for r, _, fs in os.walk(f"{goc}/raw") for n in fs)
    with open(f"{goc}/{ten_file}", "w", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        w.writerow(["file", "bytes", "sha256"])
        for p in files:
            w.writerow([p, os.path.getsize(f"{goc}/{p}"), sha256_file(f"{goc}/{p}")])
    return sha256_file(f"{goc}/{ten_file}")


def kiem_tra_manifest(goc, ten_file):
    with open(f"{goc}/{ten_file}", encoding="utf-8") as f:
        ghi = {d["file"]: d["sha256"] for d in csv.DictReader(f)}
    hien_tai = {os.path.relpath(os.path.join(r, n), goc).replace(os.sep, "/")
                for r, _, fs in os.walk(f"{goc}/raw") for n in fs}
    bi_sua = [p for p in ghi if p in hien_tai and sha256_file(f"{goc}/{p}") != ghi[p]]
    return bi_sua, sorted(set(ghi) - hien_tai), sorted(hien_tai - set(ghi))


ma_v1 = tao_manifest(GOC, "manifest_v1.csv")
print("mã phiên bản dữ liệu v1:", ma_v1[:16])

with open(f"{GOC}/metadata.csv", encoding="utf-8") as f:
    meta = list(csv.DictReader(f))
dem = {}
for d in meta:
    dem[d["nhan"]] = dem.get(d["nhan"], 0) + 1
the = f"""# Dataset tấm thép cán, trạm L2C1 — v1
- Mã phiên bản (SHA-256 của manifest_v1.csv): {ma_v1}
- Ngày đóng băng: {datetime.date(2026, 9, 23)}; người chịu trách nhiệm: kỹ sư vision trạm L2
- Nguồn: camera trạm L2C1, ảnh xám 8 bit, PNG; phơi sáng 800 µs, gain 0 dB
- Khoảng thời gian: 2026-09-15 đến 2026-09-22, {len({d['lo'] for d in meta})} lô, ca A và B
- Lớp: {", ".join(f"{k} ({v} ảnh)" for k, v in sorted(dem.items()))}
- Định nghĩa lớp: danh mục lỗi DM-07 rev.B (QA ký 2026-09-10)
- Chia tập: theo nhóm chi tiết, xem splits/v1/
- Vấn đề đã biết: chưa có mã TS-200; chưa có ảnh ca đêm
- Lịch sử: v1 = bản đầu tiên
"""
with open(f"{GOC}/DATASET_CARD.md", "w", encoding="utf-8") as f:
    f.write(the)

# Ai đó "chỉnh sáng" một ảnh gốc rồi lưu đè, và xóa một ảnh khác (cố tình phá để thử)
p_sua, p_xoa = meta[10]["file"], meta[11]["file"]
sao_luu = {p: open(f"{GOC}/{p}", "rb").read() for p in (p_sua, p_xoa)}   # "bản sao lưu"
with open(f"{GOC}/{p_sua}", "r+b") as f:
    f.seek(-20, os.SEEK_END)
    b = f.read(1)
    f.seek(-20, os.SEEK_END)
    f.write(bytes([b[0] ^ 1]))                            # đổi đúng 1 bit
os.remove(f"{GOC}/{p_xoa}")
bi_sua, bi_mat, moi_them = kiem_tra_manifest(GOC, "manifest_v1.csv")
print("bị sửa:", [os.path.basename(x) for x in bi_sua])
print("bị mất:", [os.path.basename(x) for x in bi_mat], "| mới thêm:", moi_them)

for p, noi_dung in sao_luu.items():                       # khôi phục từ sao lưu rồi kiểm tra lại
    with open(f"{GOC}/{p}", "wb") as f:
        f.write(noi_dung)
print("sau khôi phục:", kiem_tra_manifest(GOC, "manifest_v1.csv"))
mã phiên bản dữ liệu v1: 97e3e263b71e6359
bị sửa: ['20260915_093510_L2C1_LOT915A_P0005_s1.png']
bị mất: ['20260915_093520_L2C1_LOT915A_P0005_s2.png'] | mới thêm: []
sau khôi phục: ([], [], [])

Chỉ một bit thay đổi cũng bị phát hiện, kể cả khi ảnh nhìn bằng mắt không khác gì. Trong thực tế bạn không bao giờ sửa ảnh gốc; khi kiểm tra báo lỗi, khôi phục từ bản sao lưu và chạy lại kiểm tra cho đến khi cả ba danh sách rỗng, như dòng cuối.

Khi dataset lớn: DVC và kho lưu trữ đối tượngGit không hợp để chứa hàng chục GB ảnh. Các công cụ như DVC lưu file lớn ở kho riêng (ổ mạng, máy chủ lưu trữ đối tượng) và chỉ đưa vào Git một file mô tả nhỏ chứa hash, nên mỗi commit mã nguồn trỏ đúng tới một phiên bản dữ liệu (bài "Git cho dự án AI Vision"). Ý tưởng giống hệt manifest ở trên; bắt đầu bằng manifest là đủ cho dự án nhỏ.

Quy tắc phiên bản: đóng băng một phiên bản thì không sửa nữa; mọi thay đổi (thêm lô, sửa nhãn, bỏ ảnh hỏng) tạo phiên bản mới với dòng ghi chú trong lịch sử của dataset card, và mỗi mô hình ghi rõ mã phiên bản dữ liệu đã dùng.

6. Phát hiện ảnh trùng và gần trùng

Ảnh trùng gây hai tác hại: làm lệch phân bố (một ảnh được "đếm" hai lần) và nguy hiểm hơn, rò rỉ khi hai bản nằm ở train và test. Có hai loại:

  • Trùng tuyệt đối (cùng byte): so SHA-256, đã có sẵn trong manifest.
  • Gần trùng: cùng chi tiết chụp lại, lệch vài pixel, nhiễu khác, lưu lại sang định dạng khác. SHA-256 khác hoàn toàn, cần perceptual hash (pHash): thu ảnh về 32×32, lấy biến đổi cosine rời rạc (DCT), giữ khối 8×8 tần số thấp, so mỗi hệ số với trung vị được 64 bit. Ảnh giống nhau về cấu trúc cho mã gần nhau; độ khác nhau đo bằng khoảng cách Hamming (số bit khác).
import csv
import hashlib
import os

import cv2
import numpy as np

GOC = "dataset-tam-thep"
with open(f"{GOC}/metadata.csv", encoding="utf-8") as f:
    meta = list(csv.DictReader(f))


def phash(img):
    nho = cv2.resize(img, (32, 32), interpolation=cv2.INTER_AREA).astype(np.float32)
    dct = cv2.dct(nho)[:8, :8].flatten()
    return dct[1:] > np.median(dct[1:])                    # 63 bit, bỏ hệ số DC (độ sáng trung bình)


anh = [cv2.imread(f"{GOC}/{d['file']}", cv2.IMREAD_GRAYSCALE) for d in meta]
H = np.array([phash(a) for a in anh])
sha = [hashlib.sha256(open(f"{GOC}/{d['file']}", "rb").read()).hexdigest() for d in meta]

# trùng tuyệt đối
theo_sha = {}
for i, h in enumerate(sha):
    theo_sha.setdefault(h, []).append(i)
for ds in theo_sha.values():
    if len(ds) > 1:
        print("TRÙNG TUYỆT ĐỐI:", [os.path.basename(meta[i]["file"]) for i in ds])

# gần trùng: khoảng cách Hamming giữa mọi cặp
D = (H[:, None, :] != H[None, :, :]).sum(axis=2)
cung_ct = np.array([[a["chi_tiet"] == b["chi_tiet"] for b in meta] for a in meta])
tren = np.triu(np.ones_like(D, bool), 1)
print(f"Hamming, 2 lần chụp cùng chi tiết: trung vị {np.median(D[tren & cung_ct]):.0f}, "
      f"lớn nhất {D[tren & cung_ct].max()}")
print(f"Hamming, khác chi tiết: trung vị {np.median(D[tren & ~cung_ct]):.0f}, "
      f"5 giá trị nhỏ nhất {np.sort(D[tren & ~cung_ct])[:5]}")
NGUONG = 10
for i, j in zip(*np.where(tren & ~cung_ct & (D <= NGUONG))):
    print(f"GẦN TRÙNG (Hamming {D[i, j]}): {meta[i]['chi_tiet']} ({meta[i]['lo']}) "
          f"~ {meta[j]['chi_tiet']} ({meta[j]['lo']})")
TRÙNG TUYỆT ĐỐI: ['20260915_081420_L2C1_LOT915A_P0002_s2.png', '20260922_150000_L2C1_LOT922A_P0799_s1.png']
Hamming, 2 lần chụp cùng chi tiết: trung vị 6, lớn nhất 14
Hamming, khác chi tiết: trung vị 32, 5 giá trị nhỏ nhất [ 0  2 10 14 16]
GẦN TRÙNG (Hamming 0): P0002 (LOT915A) ~ P0799 (LOT922A)
GẦN TRÙNG (Hamming 2): P0103 (LOT916A) ~ P0699 (LOT921A)
GẦN TRÙNG (Hamming 10): P0103 (LOT916A) ~ P0699 (LOT921A)

Hai sự cố đều lộ ra: file chép trùng (SHA-256 giống hệt, Hamming 0) và chi tiết P0103 bị chụp lại dưới mã P0699 ở lô khác (Hamming 2 và 10 với hai lần chụp của P0103, trong khi các chi tiết khác nhau có trung vị 32 bit). Ngưỡng Hamming phải chọn từ chính dữ liệu của bạn: hai lần chụp cùng chi tiết ở đây cách nhau tới 14 bit, còn cặp chi tiết khác nhau gần nhất cũng 14 bit, nên hai phân bố chạm nhau. Chọn ngưỡng thấp hơn (10) để ít báo nhầm, và luôn mở từng cặp bị gắn cờ ra xem trước khi gộp. Với bề mặt rất đồng nhất (kính, nhựa trơn) mọi ảnh đều gần nhau và pHash kém tác dụng; khi đó so trên ROI có kết cấu, hoặc dựa vào mã chi tiết trong metadata.

Xử lý: xóa bản chép trùng khỏi metadata (giữ file gốc), và gộp các ảnh gần trùng vào cùng một nhóm trước khi chia tập. Với dataset lớn (hàng trăm nghìn ảnh), so mọi cặp tốn O(n²); khi đó chia theo tiền tố hash hoặc dùng thư viện tìm kiếm láng giềng gần, nhưng nguyên lý không đổi.

7. Chia tập không rò rỉ

Bài "Overfitting và chia train/val/test" đã nêu nguyên tắc; ở đây ta đo tác hại bằng con số. Thí nghiệm: một bộ phân loại láng giềng gần nhất (1-NN) trên ảnh thu nhỏ 16×16, một "mô hình" chỉ biết học thuộc. Nếu chia ngẫu nhiên từng ảnh, lần chụp thứ hai của cùng chi tiết thường nằm ở tập kia, và 1-NN chỉ cần tìm ra "anh em sinh đôi" của ảnh test. Nếu chia theo nhóm, nó phải thật sự nhận ra lỗi.

import csv
import os

import cv2
import numpy as np
from sklearn.model_selection import GroupShuffleSplit, train_test_split
from sklearn.neighbors import KNeighborsClassifier

GOC = "dataset-tam-thep"
with open(f"{GOC}/metadata.csv", encoding="utf-8") as f:
    meta = list(csv.DictReader(f))
# gộp nhóm theo kết quả mục 6: bỏ bản chép trùng, P0699 chính là P0103
meta = [d for d in meta if d["chi_tiet"] != "P0799"]
for d in meta:
    d["nhom"] = "P0103" if d["chi_tiet"] == "P0699" else d["chi_tiet"]

# đặc trưng đơn giản: ảnh thu nhỏ 16×16 (chịu được lệch vài pixel giữa hai lần chụp)
X = np.array([cv2.resize(cv2.imread(f"{GOC}/{d['file']}", cv2.IMREAD_GRAYSCALE), (16, 16),
                         interpolation=cv2.INTER_AREA).ravel() for d in meta], np.float32)
X -= X.mean(axis=1, keepdims=True)                        # bỏ khác biệt độ sáng giữa các lô
y = np.array([d["nhan"] != "OK" for d in meta], int)       # 1 = NG
nhom = np.array([d["nhom"] for d in meta])
lo = np.array([d["lo"] for d in meta])


def chay(cach_chia, so_lan=10):
    acc, acc_co_anh_em, acc_khong, ro_ri = [], [], [], []
    for seed in range(so_lan):
        tr, te = cach_chia(seed)
        dung = KNeighborsClassifier(n_neighbors=1).fit(X[tr], y[tr]).predict(X[te]) == y[te]
        co_anh_em = np.isin(nhom[te], nhom[tr])           # ảnh test có "anh em" cùng chi tiết ở train
        acc.append(dung.mean())
        ro_ri.append(co_anh_em.mean())
        if co_anh_em.any():
            acc_co_anh_em.append(dung[co_anh_em].mean())
        if (~co_anh_em).any():
            acc_khong.append(dung[~co_anh_em].mean())
    tach = lambda v: f"{np.mean(v):6.1%}" if v else "     -"
    return np.mean(acc), np.mean(ro_ri), tach(acc_co_anh_em), tach(acc_khong)


chi_so = np.arange(len(meta))
CACH = {
    "ngẫu nhiên từng ảnh": lambda s: train_test_split(chi_so, test_size=0.25, random_state=s, stratify=y),
    "theo chi tiết (nhóm)": lambda s: next(GroupShuffleSplit(1, test_size=0.25, random_state=s).split(X, y, nhom)),
    "theo lô": lambda s: next(GroupShuffleSplit(1, test_size=0.25, random_state=s).split(X, y, lo)),
}
print("cách chia (10 lần)    | accuracy | ảnh test có anh em ở train | acc. nhóm đó | acc. còn lại")
for ten, cach in CACH.items():
    acc, ro_ri, a1, a2 = chay(cach)
    print(f"{ten:21s} | {acc:8.1%} | {ro_ri:26.1%} | {a1:>12s} | {a2:>12s}")
print(f"tỉ lệ NG trong dữ liệu: {y.mean():.1%} (đoán 'luôn OK' đạt {1 - y.mean():.1%})")
cách chia (10 lần)    | accuracy | ảnh test có anh em ở train | acc. nhóm đó | acc. còn lại
ngẫu nhiên từng ảnh   |    85.8% |                      72.3% |       100.0% |        48.5%
theo chi tiết (nhóm)  |    50.4% |                       0.0% |            - |        50.4%
theo lô               |    45.4% |                       0.8% |       100.0% |        44.9%
tỉ lệ NG trong dữ liệu: 49.8% (đoán 'luôn OK' đạt 50.2%)

Đọc bảng: với chia ngẫu nhiên, khoảng 3/4 ảnh test có "anh em" (lần chụp kia của cùng chi tiết) trong train, và trên nhóm này 1-NN đúng gần như tuyệt đối chỉ nhờ nhận ra chi tiết, không phải nhận ra lỗi. Trên các ảnh không có anh em, nó chỉ ngang đoán mò (khoảng 50%, vì dữ liệu có một nửa là NG). Chia theo chi tiết hoặc theo lô cho thấy sự thật: bộ phân loại này vô dụng. Chú ý cột "theo lô" vẫn còn một ít ảnh có anh em: đó là chi tiết P0103 được chụp lại ở lô khác, lý do phải gộp nhóm bằng phát hiện trùng trước khi chia. Mạng sâu học thuộc mạnh hơn 1-NN rất nhiều, nên cái bẫy này áp dụng nguyên vẹn cho CNN: nhiều báo cáo "99% trên tập test" sụp đổ đúng vì lý do này

Chia ba tập theo nhóm và theo thời gian

Thứ tự ưu tiên cho khóa nhóm: chi tiết (bắt buộc), rồi lô nếu lô có đặc trưng riêng (sắc độ, kiểu vân), rồi thời gian nếu muốn mô phỏng đúng việc mô hình gặp dữ liệu tương lai. Cách làm phổ biến: tập test là các lô mới nhất, phần còn lại chia train/val theo nhóm. Với scikit-learn, GroupShuffleSplit chia một lần; StratifiedGroupKFold cố giữ tỉ lệ lớp khi chia theo nhóm (hữu ích khi lỗi hiếm).

import csv
import os

import numpy as np
from sklearn.model_selection import GroupShuffleSplit

GOC = "dataset-tam-thep"
with open(f"{GOC}/metadata.csv", encoding="utf-8") as f:
    meta = list(csv.DictReader(f))
meta = [d for d in meta if d["chi_tiet"] != "P0799"]       # bỏ bản chép trùng
for d in meta:
    d["nhom"] = "P0103" if d["chi_tiet"] == "P0699" else d["chi_tiet"]

# 1) test = 2 lô mới nhất (theo thời gian); nhóm nào có ảnh trong lô test thì cả nhóm về test
cac_lo = sorted({d["lo"] for d in meta})
lo_test = set(cac_lo[-2:])
nhom_test = {d["nhom"] for d in meta if d["lo"] in lo_test}
con_lai = [d for d in meta if d["nhom"] not in nhom_test]
test = [d for d in meta if d["nhom"] in nhom_test]
# 2) train/val từ phần còn lại, theo nhóm chi tiết
gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=0)
tr, va = next(gss.split(con_lai, groups=[d["nhom"] for d in con_lai]))
tap = {"train": [con_lai[i] for i in tr], "val": [con_lai[i] for i in va], "test": test}

os.makedirs(f"{GOC}/splits/v1", exist_ok=True)
for ten, ds in tap.items():
    with open(f"{GOC}/splits/v1/{ten}.txt", "w", encoding="utf-8") as f:
        f.write("\n".join(sorted(d["file"] for d in ds)) + "\n")

# kiểm tra bắt buộc: rò rỉ theo nhóm, theo lô, độ phủ lớp
nhom_cua = {ten: {d["nhom"] for d in ds} for ten, ds in tap.items()}
print("nhóm chung train-val:", len(nhom_cua["train"] & nhom_cua["val"]),
      "| train-test:", len(nhom_cua["train"] & nhom_cua["test"]),
      "| val-test:", len(nhom_cua["val"] & nhom_cua["test"]))
print(f"{'tập':5s} {'ảnh':>4s} {'lô':>3s}  " + "  ".join(f"{l:>8s}" for l in ("OK", "D01_xuoc", "D02_dom")))
for ten, ds in tap.items():
    print(f"{ten:5s} {len(ds):4d} {len({d['lo'] for d in ds}):3d}  " +
          "  ".join(f"{sum(d['nhan'] == l for d in ds):8d}" for l in ("OK", "D01_xuoc", "D02_dom")))
print("lô của test:", sorted({d["lo"] for d in test}))
nhóm chung train-val: 0 | train-test: 0 | val-test: 0
tập    ảnh  lô        OK  D01_xuoc   D02_dom
train  152   6        74        38        40
val     38   6        20        10         8
test    67   3        35        16        16
lô của test: ['LOT916A', 'LOT921A', 'LOT922A']

Chú ý chi tiết P0103: nó thuộc lô 16/9 nhưng bản chụp lại nằm trong lô 21/9 (lô test), nên cả nhóm phải về test, kéo theo một lô thứ ba xuất hiện trong test. Đây là kiểu ngoại lệ chỉ được xử lý đúng khi bạn chia theo nhóm đã gộp trùng, không phải theo cột "lô" trong metadata. Hai kiểm tra cuối (không nhóm nào ở hai tập, mỗi lớp có mặt ở mọi tập với số lượng đủ dùng) phải chạy mỗi lần tạo phiên bản dữ liệu; với dataset thật, val và test cần nhiều mẫu lỗi hơn nhiều so với ví dụ này.

Tập test là để dànhKhông xem ảnh test để chỉnh tham số, không thêm ảnh test vào train khi "mô hình sai mấy ảnh này". Một khi bạn đã tối ưu theo tập test, nó thành tập val và bạn mất thước đo trung thực. Khi muốn dùng các ảnh đó, hãy thu tập test mới cho phiên bản sau.

8. Cải thiện liên tục sau triển khai

Dataset không kết thúc ở v1. Sau triển khai, lưu lại ảnh mô hình không chắc chắn (điểm gần ngưỡng), ảnh người vận hành đánh giá lại khác với máy, và mọi ảnh của loại lỗi mới. Gán nhãn, đưa vào phiên bản kế tiếp theo đúng quy trình: thêm vào raw/, cập nhật metadata, chạy kiểm tra tên, manifest mới, kiểm tra trùng, chia lại theo nhóm (giữ nguyên các nhóm đã ở test cũ trong test), ghi lịch sử vào dataset card. Đây là cách hiệu quả nhất để mô hình tốt dần theo thời gian.

Pháp lý và bảo mật: ảnh sản phẩm của khách hàng thường là thông tin mật. Thỏa thuận rõ quyền sử dụng dữ liệu trong hợp đồng (được dùng cho dự án nào, có được giữ sau dự án không), lưu trữ có phân quyền, sao lưu định kỳ, và không tải lên dịch vụ công cộng (kể cả công cụ gán nhãn trên mây) khi chưa được phép.

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

  • Đếm ảnh thay vì đếm chi tiết: 1.000 ảnh từ 50 chi tiết NG là 50 mẫu, không phải 1.000.
  • Chụp bằng cấu hình khác trạm thật (điện thoại, đèn khác, JPEG) rồi ngạc nhiên vì mô hình kém khi triển khai.
  • Nhãn nằm trong tên file hoặc tên thư mục rồi đổi tên khi sửa nhãn: mất dấu vết, manifest vô nghĩa.
  • Sửa ảnh gốc tại chỗ (crop, chỉnh sáng, lưu đè): không tái tạo được, không biết mô hình cũ học trên ảnh nào.
  • Chia ngẫu nhiên từng ảnh khi có nhiều ảnh mỗi chi tiết hoặc ảnh gần trùng: chỉ số đẹp giả tạo.
  • Chỉ thu dữ liệu một ca, một lô: mô hình hỏng khi đổi lô nguyên liệu hoặc ca đêm.
  • Không ghi phiên bản dữ liệu cho mô hình: ba tháng sau không ai trả lời được vì sao mô hình mới kém hơn.

10. Bài tập

Bài 1. Viết kế hoạch thu thập ảnh cho bài toán kiểm tra lỗi của bạn (hoặc bài toán sau nếu chưa có: kiểm tra mặt nắp nhôm in logo, 20.000 nắp/ngày, 2 ca, 2 mã màu; lỗi móp 0,15%, trầy sơn 0,3%, in thiếu 0,05%; khách muốn chạy thử sau 6 tuần). Nêu số chi tiết mỗi lớp, điều kiện chụp, ma trận điều kiện, người gán nhãn và lịch.

Xem đáp án
MụcKế hoạch mẫu
Lớp và số lượng v1Trầy sơn 300, móp 250, in thiếu 150 chi tiết NG; 3.000 chi tiết OK trong đó ≥ 300 "OK khó" (bụi, vân tay, sắc độ ở biên). Mỗi chi tiết chụp 2 lần (đặt lại, xoay 90°)
Lịch (giữ lại 70% NG)Trầy: 20.000 × 0,3% × 0,7 = 42/ngày → 8 ngày; móp: 21/ngày → 12 ngày; in thiếu: 7/ngày → 22 ngày. Đủ v1 sau khoảng 22 ngày sản xuất, kịp mốc 6 tuần; đóng băng v0 sau 1 tuần để thử pipeline
Ma trận điều kiện2 mã màu × 2 ca × ≥ 10 lô sơn; mỗi ô ≥ 20 chi tiết cho mỗi lớp lỗi; ghi máy dập và lô sơn trong metadata
Điều kiện chụpCamera, ống kính, đèn của trạm thật (đèn vòm khuếch tán cho mặt nắp cong); phơi sáng và gain cố định, ghi trong metadata; PNG 8 bit; kiểm tra độ sáng mẫu chuẩn mỗi đầu ca
Người gán nhãn2 kỹ thuật viên gán khung bao trong công cụ tự cài trên máy chủ nội bộ; QA khách hàng duyệt danh mục lỗi và 100 ảnh đầu; kiểm chéo 20% ảnh; hướng dẫn gán nhãn v1.0 có ảnh mẫu từng lớp
Chia tậpNhóm = mã chi tiết (gộp ảnh gần trùng); test = các lô sơn của tuần cuối; train/val chia theo nhóm 80/20; kiểm tra rò rỉ và độ phủ lớp tự động
Phiên bản, bảo mậtManifest SHA-256 + dataset card cho mỗi phiên bản; dữ liệu lưu trên máy chủ nhà máy, sao lưu hằng tuần, không đưa ra ngoài

Bài 2. Viết hàm dhash(img) (difference hash): thu ảnh về 9×8, so sánh mỗi pixel với pixel bên phải, được 64 bit. Áp dụng cho hai lần chụp cùng một chi tiết và cho hai chi tiết khác nhau của dataset mô phỏng; so khoảng cách Hamming.

Xem đáp án
import csv
import os

import cv2
import numpy as np

GOC = "dataset-tam-thep"


def dhash(img):
    nho = cv2.resize(img, (9, 8), interpolation=cv2.INTER_AREA).astype(np.int16)
    return (nho[:, 1:] > nho[:, :-1]).flatten()            # 8 × 8 = 64 bit


with open(f"{GOC}/metadata.csv", encoding="utf-8") as f:
    meta = list(csv.DictReader(f))
theo_ct = {}
for d in meta:
    theo_ct.setdefault(d["chi_tiet"], []).append(d["file"])
a, b = [cv2.imread(f"{GOC}/{p}", cv2.IMREAD_GRAYSCALE) for p in theo_ct["P0200"]]
c = cv2.imread(f"{GOC}/{theo_ct['P0305'][0]}", cv2.IMREAD_GRAYSCALE)
print("cùng chi tiết P0200:", int((dhash(a) != dhash(b)).sum()), "bit khác")
print("P0200 và P0305   :", int((dhash(a) != dhash(c)).sum()), "bit khác")
cùng chi tiết P0200: 7 bit khác
P0200 và P0305   : 36 bit khác

dHash rất nhanh và đủ dùng cho ảnh chụp lại; pHash (DCT) bền hơn với thay đổi độ sáng, nén và làm mờ. Cả hai đều không bền với xoay lớn: chi tiết xoay 90° cho hash khác hẳn, nên vẫn cần mã chi tiết trong metadata.

Bài 3. Một đồng nghiệp báo: "Mô hình phân loại đạt 99,2% trên test, nhưng chạy thử trên dây chuyền tuần này lọt rất nhiều." Dataset có 4.000 ảnh từ 800 chi tiết (mỗi chi tiết 5 ảnh, xoay 72°), chia ngẫu nhiên 80/10/10 theo ảnh, chụp trong 3 ngày của tháng trước. Liệt kê các nguyên nhân có thể và cách kiểm chứng từng nguyên nhân.

Xem đáp án
  • Rò rỉ theo chi tiết: gần như mọi chi tiết test có 4 "anh em" trong train. Kiểm chứng: đếm số mã chi tiết chung giữa train và test; chia lại theo chi tiết và đánh giá lại, chỉ số sẽ giảm.
  • Thiếu đa dạng thời gian, lô: 3 ngày tháng trước không phủ lô nguyên liệu, ca, tuổi đèn tuần này. Kiểm chứng: so độ sáng trung bình, histogram, sắc độ ảnh tuần này với dataset; đánh giá trên một tập test mới chụp tuần này.
  • Accuracy che escape: nếu NG chỉ chiếm 2%, đoán toàn OK đã đạt 98%. Kiểm chứng: tính escape và overkill riêng, theo từng loại lỗi, kèm số mẫu.
  • Loại lỗi mới chưa có trong dataset. Kiểm chứng: xem ảnh bị lọt, so với danh mục lỗi.
  • Khác cấu hình chụp giữa lúc thu thập và trạm thật (phơi sáng, đèn, vị trí). Kiểm chứng: so metadata phơi sáng/gain và ảnh mẫu chuẩn.

Tóm tắt

  • Bắt đầu từ danh mục lỗi có định nghĩa đo được và ảnh mẫu được QA ký; nó quyết định độ phân giải và kiểu nhãn.
  • Kế hoạch thu thập: số chi tiết (không phải số ảnh) mỗi lớp, ma trận điều kiện mã SP × lô × ca, lịch theo lỗi hiếm nhất, điều kiện chụp giống trạm thật, người gán và người duyệt.
  • Thư mục raw/ bất biến, tên file duy nhất theo quy ước và không chứa nhãn, metadata ghi tự động, kiểm tra quy ước bằng script.
  • Phiên bản: manifest SHA-256 từng file, mã phiên bản là hash của manifest, dataset card mô tả và lịch sử; đóng băng rồi không sửa.
  • Trùng tuyệt đối tìm bằng SHA-256, gần trùng bằng pHash và khoảng cách Hamming; gộp ảnh gần trùng vào một nhóm.
  • Chia theo nhóm chi tiết, lô, thời gian (GroupShuffleSplit, StratifiedGroupKFold, test = lô mới nhất); chia ngẫu nhiên từng ảnh tạo độ chính xác giả tạo.

Bài tiếp theo: Công cụ gán nhãn miễn phí, chọn CVAT, Label Studio hay X-AnyLabeling, xuất nhãn đúng định dạng và kiểm tra chất lượng nhãn bằng script.

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

  1. Viết kế hoạch thu thập ảnh cho bài toán kiểm tra lỗi của bạn: số ảnh mỗi lớp, điều kiện chụp, người gán nhãn.

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