⭐ Vision Engineer · Chặng 12Bài 5/8Bài học~90 phút

Phân đoạn vết lỗi với YOLO26-seg và đo kích thước lỗi theo mm

Nhãn đa giác YOLO-seg, chuyển mặt nạ sang đa giác, huấn luyện instance segmentation, đo diện tích, chiều dài theo khung xương và sai số đo.

Phân đoạn vết lỗi với YOLO26-seg và đo kích thước lỗi theo mm

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

  • Gán nhãn đa giác
  • Huấn luyện instance segmentation
  • Đo kích thước vết lỗi theo mm

Nên học trước: Phân đoạn ảnh: semantic, instance, panoptic

Nội dung bài viết
  1. 1. Nhãn YOLO-seg: đa giác chuẩn hóa
  2. 2. Gán nhãn đa giác cho vết lỗi
  3. 3. Dataset mẫu: từ mặt nạ sang nhãn đa giác
  4. 4. Kiểm tra nhãn và sai số của đa giác
  5. 5. Huấn luyện và kiểm tra YOLO26-seg
  6. 6. Lấy mặt nạ từ kết quả dự đoán
  7. 7. Đo vết lỗi theo mm
  8. 8. Bài thực hành: YOLO26n-seg và diện tích từng vết theo mm²
  9. 9. Sai lầm thường gặp
  10. 10. Bài tập
  11. Tóm tắt

Bài "Phân đoạn ảnh: semantic, instance, panoptic" đã chỉ ra rằng khi tiêu chuẩn chất lượng viết theo từng vết ("vết xước dài quá 3 mm thì loại", "vết móp lớn hơn 0,8 mm² thì loại") và các vết có thể nằm sát nhau, bạn cần instance segmentation. Bài này làm trọn quy trình đó với YOLO26-seg của Ultralytics: định dạng nhãn đa giác, cách gán nhãn đa giác cho vết mảnh, chuyển mặt nạ hoặc file của công cụ gán nhãn sang nhãn YOLO-seg và kiểm tra sai số của đa giác, huấn luyện và đọc mAP mặt nạ, rồi phần quan trọng nhất với nhà máy: đo diện tích, chiều dài, bề rộng từng vết theo mm từ mặt nạ dự đoán, kèm sai số đo, và áp tiêu chí OK/NG. Quy trình huấn luyện giống hệt bài "Object Detection với YOLO26", nên bài này tập trung vào những gì khác. Bài sau, "U-Net cho phân đoạn ảnh", tự xây một mô hình semantic segmentation từ đầu bằng PyTorch.

Mục tiêu cuối bàiViết và đọc nhãn YOLO-seg; gán nhãn đa giác đúng cách cho vết mảnh và vết tròn; chuyển mặt nạ và JSON của công cụ gán nhãn sang nhãn YOLO-seg; kiểm tra nhãn và đo sai số xấp xỉ đa giác; huấn luyện, kiểm tra YOLO26n-seg (metrics.seg.map50); lấy mặt nạ từ kết quả dự đoán; đo diện tích (mm²), chiều dài theo khung xương và bề rộng (mm) từng vết; ước lượng sai số đo và áp tiêu chí OK/NG có vùng an toàn.

1. Nhãn YOLO-seg: đa giác chuẩn hóa

Mỗi ảnh vẫn có một file .txt cùng tên, mỗi dòng một đối tượng, nhưng thay vì 4 số của hộp, sau mã lớp là danh sách đỉnh đa giác x1 y1 x2 y2 … xn yn, chuẩn hóa 0–1 theo chiều rộng, chiều cao ảnh:

<class_id> <x1> <y1> <x2> <y2> ... <xn> <yn>      (n ≥ 3 đỉnh)

Hộp bao không cần ghi: thư viện tự suy ra từ đa giác. Ảnh không có lỗi vẫn có file rỗng. Hai hạn chế của định dạng cần biết: mỗi dòng là một đa giác (vết bị đứt thành hai mảnh phải nối lại hoặc tách thành hai đối tượng), và đa giác không biểu diễn được lỗ (vết dạng vòng khuyên).

Công cụ gán nhãn (CVAT, Label Studio, Labelme…) thường cho đa giác theo pixel trong file JSON. Chuyển sang YOLO-seg:

import json

LOP = {"vet_xuoc": 0, "vet_mop": 1}

# Kiểu JSON mà nhiều công cụ xuất ra (ở đây theo kiểu Labelme): đỉnh đa giác theo pixel
nhan_cong_cu = json.loads("""{
  "imageWidth": 1280, "imageHeight": 1024,
  "shapes": [
    {"label": "vet_xuoc", "shape_type": "polygon",
     "points": [[400, 300], [520, 330], [522, 336], [401, 307]]},
    {"label": "vet_mop", "shape_type": "polygon",
     "points": [[900, 600], [930, 590], [955, 610], [945, 640], [910, 645], [895, 625]]}
  ]
}""")


def json_sang_yolo_seg(d):
    W, H = d["imageWidth"], d["imageHeight"]
    dong = []
    for s in d["shapes"]:
        if s["shape_type"] != "polygon" or len(s["points"]) < 3:
            raise ValueError(f"bỏ qua hình không phải đa giác: {s['label']}")
        toa_do = " ".join(f"{x / W:.6f} {y / H:.6f}" for x, y in s["points"])
        dong.append(f"{LOP[s['label']]} {toa_do}")
    return dong


for d in json_sang_yolo_seg(nhan_cong_cu):
    print(d)
0 0.312500 0.292969 0.406250 0.322266 0.407813 0.328125 0.313281 0.299805
1 0.703125 0.585938 0.726562 0.576172 0.746094 0.595703 0.738281 0.625000 0.710938 0.629883 0.699219 0.610352

Ánh xạ tên lớp → mã lớp (LOP) phải trùng với names trong data.yaml. Một lỗi hay gặp là mỗi đợt gán nhãn công cụ tự đánh số lớp theo thứ tự xuất hiện, nên đợt sau "vet_mop" thành lớp 0. Luôn chuyển theo tên lớp, không theo số của công cụ.

2. Gán nhãn đa giác cho vết lỗi

  • Đỉnh nằm trên biên vết, không chừa viền. Viết quy ước bằng văn bản kèm ảnh ví dụ, đặc biệt cho biên mờ (vết móp chuyển dần): "biên là nơi độ sáng thay đổi rõ".
  • Vết xước mảnh (2–4 px): đa giác phải bao cả hai mép của vết, đi dọc một mép rồi quay về theo mép kia. Một đường gấp khúc chỉ có một phía (diện tích bằng 0) là nhãn hỏng.
  • Mật độ đỉnh: đủ để sai lệch giữa đa giác và biên thật nhỏ hơn 1 px. Vết cong cần nhiều đỉnh hơn vết thẳng. Mục 4 đo sai lệch này bằng số.
  • Dùng công cụ có hỗ trợ SAM (bấm một điểm hoặc kéo hộp, mô hình đề xuất mặt nạ) để gán nhanh vết tròn, vết loang; nhưng luôn kiểm tra lại biên, nhất là với vết mảnh mà SAM hay làm phình to.
  • Ảnh OK có file nhãn rỗng, chiếm khoảng 10–30% dữ liệu, gồm cả ảnh có vân, bụi, phản xạ được phép để mô hình học "đây không phải lỗi".

3. Dataset mẫu: từ mặt nạ sang nhãn đa giác

Ta sinh 60 ảnh 320×320 bề mặt kim loại chải xước, mỗi ảnh 0–3 lỗi thuộc hai lớp: vet_xuoc (vết cong, rộng 2–3 px, sáng) và vet_mop (vùng elip tối, biên mềm). Hệ số quy đổi 0,04 mm/px (trường nhìn 12,8 mm). Mỗi lỗi được vẽ thành một mặt nạ riêng; chiều dài thật của đường tâm vết xước và diện tích thật của vết móp được lưu lại để lát nữa kiểm tra phép đo. Mặt nạ được chuyển sang đa giác bằng cv2.findContours rồi đơn giản hóa bằng cv2.approxPolyDP, đúng như cách các công cụ chuyển đổi làm khi bạn có mặt nạ thay vì đa giác.

import json
from pathlib import Path

import cv2
import numpy as np

rng = np.random.default_rng(1)
S, MM_PX = 320, 0.04
GOC = Path("dataset_loi")


def vet_xuoc(rng):
    p0, p2 = rng.uniform(30, 290, 2), rng.uniform(30, 290, 2)
    while np.hypot(*(p2 - p0)) < 40 or np.hypot(*(p2 - p0)) > 160:
        p2 = rng.uniform(30, 290, 2)
    p1 = (p0 + p2) / 2 + rng.normal(0, 18, 2)                     # điểm điều khiển: vết cong nhẹ
    t = np.linspace(0, 1, 40)[:, None]
    pts = (1 - t) ** 2 * p0 + 2 * (1 - t) * t * p1 + t ** 2 * p2  # đường cong Bezier bậc 2
    m = np.zeros((S, S), np.uint8)
    cv2.polylines(m, [pts.round().astype(np.int32)], False, 1, int(rng.integers(2, 4)))
    return m.astype(bool), float(np.sum(np.hypot(*np.diff(pts, axis=0).T)))


def vet_mop(rng):
    c, a = rng.uniform(40, 280, 2).astype(int), rng.uniform(6, 18, 2).astype(int)
    m = np.zeros((S, S), np.uint8)
    cv2.ellipse(m, tuple(int(v) for v in c), tuple(int(v) for v in a), float(rng.uniform(0, 180)), 0, 360, 1, -1)
    return m.astype(bool), float(np.pi * a[0] * a[1])


def mat_na_sang_da_giac(m, eps=0.5):
    cnts = cv2.findContours(m.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)[0]
    c = max(cnts, key=cv2.contourArea)                             # giữ mảnh lớn nhất
    return cv2.approxPolyDP(c, eps, True)[:, 0]                    # (n, 2) pixel


that = {}                                                          # số đo thật, để kiểm tra phép đo sau
for i in range(60):
    tap = "train" if i < 48 else "val"
    for d in ("images", "labels"):
        (GOC / d / tap).mkdir(parents=True, exist_ok=True)
    van = rng.normal(0, 7, (S, 1)) + rng.normal(0, 3, (S, S))     # vân chải ngang + nhiễu
    img = 128 + van
    dong, ds_that = [], []
    for k in range(0 if i % 8 == 0 else int(rng.integers(1, 4))):
        lop = int(rng.random() < 0.4)                              # 60% xước, 40% móp
        m, so_do = vet_xuoc(rng) if lop == 0 else vet_mop(rng)
        if lop == 0:
            img[m] = 205
        else:
            mem = cv2.GaussianBlur(m.astype(np.float32), (7, 7), 0)
            img = img - 45 * mem                                    # vết móp tối, biên mềm
        da_giac = mat_na_sang_da_giac(m)
        dong.append(f"{lop} " + " ".join(f"{x / S:.6f} {y / S:.6f}" for x, y in da_giac))
        ds_that.append({"lop": lop, "so_do_px": so_do, "dien_tich_px": int(m.sum())})
    cv2.imwrite(str(GOC / "images" / tap / f"b_{i:03d}.png"), np.clip(img, 0, 255).astype(np.uint8))
    (GOC / "labels" / tap / f"b_{i:03d}.txt").write_text("\n".join(dong) + ("\n" if dong else ""))
    that[f"b_{i:03d}"] = ds_that
Path("so_do_that.json").write_text(json.dumps(that))

n_doi_tuong = sum(len(v) for v in that.values())
dinh = [len(d.split()) // 2 for f in GOC.glob("labels/*/*.txt") for d in f.read_text().splitlines()]
print(f"{len(that)} ảnh, {n_doi_tuong} vết lỗi, {sum(not v for v in that.values())} ảnh OK")
print(f"số đỉnh đa giác: ít nhất {min(dinh)}, trung vị {int(np.median(dinh))}, nhiều nhất {max(dinh)}")
print("ví dụ:", (GOC / "labels/train/b_001.txt").read_text().splitlines()[0][:70], "...")
60 ảnh, 117 vết lỗi, 8 ảnh OK
số đỉnh đa giác: ít nhất 10, trung vị 42, nhiều nhất 105
ví dụ: 0 0.850000 0.093750 0.843750 0.100000 0.831250 0.103125 0.831250 0.106 ...

4. Kiểm tra nhãn và sai số của đa giác

Ngoài các kiểm tra như bài phát hiện (lớp hợp lệ, tọa độ trong 0–1), nhãn đa giác có thêm lỗi riêng: số tọa độ lẻ, ít hơn 3 đỉnh, đa giác diện tích gần bằng 0 (vết xước vẽ thành đường gấp khúc). Ta cũng đo đa giác bám mặt nạ tốt đến đâu: vẽ lại đa giác thành mặt nạ và tính IoU với mặt nạ gốc, với nhiều mức đơn giản hóa eps (sai lệch tối đa cho phép của approxPolyDP, tính bằng pixel).

from pathlib import Path

import cv2
import numpy as np


def kiem_tra_dong_seg(dong, n_lop, W, H, min_dt_px=4):
    so = dong.split()
    if not so[0].isdigit() or int(so[0]) >= n_lop:
        return f"lớp '{so[0]}' không hợp lệ"
    if len(so[1:]) % 2 or len(so[1:]) < 6:
        return f"cần số chẵn tọa độ và ≥ 3 đỉnh, có {len(so) - 1} số"
    xy = np.array(so[1:], float).reshape(-1, 2)
    if xy.min() < 0 or xy.max() > 1:
        return "tọa độ ngoài 0–1 (chưa chuẩn hóa?)"
    dt = cv2.contourArea((xy * [W, H]).astype(np.float32))
    if dt < min_dt_px:
        return f"đa giác diện tích {dt:.1f} px², có thể chỉ vẽ một phía của vết"
    return None


loi = [(f.name, k, e) for f in Path("dataset_loi/labels").glob("*/*.txt")
       for k, d in enumerate(f.read_text().splitlines(), 1) if (e := kiem_tra_dong_seg(d, 2, 320, 320))]
print("dataset_loi: lỗi nhãn =", len(loi))
for d in ("0 0.1 0.1 0.2 0.2", "0 0.1 0.1 0.2 0.2 0.3 0.3", "1 0.1 0.1 0.5 0.1 0.5", "0 32 40 60 40 60 80"):
    print(f"{d:28s} → {kiem_tra_dong_seg(d, 2, 320, 320)}")

# Đa giác bám mặt nạ tốt đến đâu, theo mức đơn giản hóa eps
rng = np.random.default_rng(7)
mau = {"vet_xuoc": [vet_xuoc(rng)[0] for _ in range(30)], "vet_mop": [vet_mop(rng)[0] for _ in range(30)]}
print("eps (px) | " + " | ".join(f"{t}: số đỉnh, IoU" for t in mau))
for eps in (0.5, 1.0, 2.0, 4.0):
    hang = []
    for ten, ds in mau.items():
        dinh, iou = [], []
        for m in ds:
            dg = mat_na_sang_da_giac(m, eps)
            ve = cv2.fillPoly(np.zeros_like(m, np.uint8), [dg], 1).astype(bool)
            dinh.append(len(dg)); iou.append((m & ve).sum() / (m | ve).sum())
        hang.append(f"{np.mean(dinh):5.1f}, {np.mean(iou):.3f}")
    print(f"{eps:8.1f} | " + " | ".join(f"{h:>18s}" for h in hang))
dataset_loi: lỗi nhãn = 0
0 0.1 0.1 0.2 0.2            → cần số chẵn tọa độ và ≥ 3 đỉnh, có 4 số
0 0.1 0.1 0.2 0.2 0.3 0.3    → đa giác diện tích 0.0 px², có thể chỉ vẽ một phía của vết
1 0.1 0.1 0.5 0.1 0.5        → cần số chẵn tọa độ và ≥ 3 đỉnh, có 5 số
0 32 40 60 40 60 80          → tọa độ ngoài 0–1 (chưa chuẩn hóa?)
eps (px) | vet_xuoc: số đỉnh, IoU | vet_mop: số đỉnh, IoU
     0.5 |        49.8, 0.973 |        18.5, 0.989
     1.0 |        15.6, 0.876 |         9.7, 0.964
     2.0 |         6.9, 0.767 |         7.3, 0.920
     4.0 |         3.8, 0.453 |         4.6, 0.738

Hai bài học. Thứ nhất, dòng thứ ba (1 0.1 0.1 0.5 0.1 0.5) có 5 số tọa độ: lỗi thường do chép tay hoặc script cắt chuỗi sai. Thứ hai, đơn giản hóa càng mạnh thì IoU càng giảm, và vết xước mảnh mất nhanh hơn nhiều so với vết móp: ở eps = 1 px vết xước đã mất khoảng 12% IoU, ở 4 px còn chưa tới một nửa, vì đa giác thô "ăn" mất phần lớn vết rộng 2–3 px. Đây là lý do khi gán nhãn tay vết mảnh phải đặt đỉnh dày, và khi chuyển từ mặt nạ thì dùng eps nhỏ (0,5 px như dataset mẫu, đổi lại số đỉnh nhiều hơn). Ngay cả eps = 0,5 cũng không đạt IoU 1 vì đa giác đi qua tâm các pixel biên, nên luôn hụt khoảng nửa pixel quanh biên; với vết rộng 2–3 px, sai số này là đáng kể (mục 7 tính nó ra mm²).

Như mọi dataset, hãy vẽ nhãn lên ảnh để xem bằng mắt:

from pathlib import Path

import cv2
import numpy as np

o = []
for f in sorted(Path("dataset_loi/images/train").glob("*.png"))[1:5]:
    img = cv2.cvtColor(cv2.imread(str(f), cv2.IMREAD_GRAYSCALE), cv2.COLOR_GRAY2BGR)
    lop_phu = img.copy()
    for d in (f.parent.parent.parent / "labels/train" / f"{f.stem}.txt").read_text().splitlines():
        lop, *xy = d.split()
        dg = (np.array(xy, float).reshape(-1, 2) * 320).round().astype(np.int32)
        mau = (0, 0, 255) if lop == "0" else (255, 160, 0)
        cv2.fillPoly(lop_phu, [dg], mau)
        cv2.polylines(img, [dg], True, mau, 1)
    o.append(cv2.addWeighted(img, 0.7, lop_phu, 0.3, 0))
cv2.imwrite("kiem_tra_nhan_seg.png", cv2.hconcat(o))
print("đã lưu kiem_tra_nhan_seg.png:", cv2.imread("kiem_tra_nhan_seg.png").shape)
đã lưu kiem_tra_nhan_seg.png: (320, 1280, 3)

5. Huấn luyện và kiểm tra YOLO26-seg

data.yaml có dạng giống hệt bài phát hiện; loại tác vụ được xác định bởi mô hình (yolo26n-seg.pt) chứ không phải file dữ liệu:

from pathlib import Path

goc = Path("dataset_loi").resolve()
noi_dung = (f"path: {goc.as_posix()}\ntrain: images/train\nval: images/val\n"
            "names:\n  0: vet_xuoc\n  1: vet_mop\n")
Path("data_seg.yaml").write_text(noi_dung, encoding="utf-8")
print("".join(noi_dung.splitlines(keepends=True)[1:]), end="")
train: images/train
val: images/val
names:
  0: vet_xuoc
  1: vet_mop
# (minh họa) cần ultralytics và trọng số yolo26n-seg.pt; nên có GPU
from ultralytics import YOLO

model = YOLO("yolo26n-seg.pt")             # trọng số phân đoạn đã huấn luyện trên COCO
model.train(data="data_seg.yaml", epochs=150, imgsz=640, batch=16, patience=40,
            fliplr=0.5, flipud=0.5,          # bề mặt không có hướng: lật dọc cũng hợp lệ
            project="runs_seg", name="yolo26n_seg")

metrics = model.val(data="data_seg.yaml", split="val")
print(f"mặt nạ: mAP50 = {metrics.seg.map50:.3f}, mAP50-95 = {metrics.seg.map:.3f}")
print(f"hộp   : mAP50 = {metrics.box.map50:.3f}, mAP50-95 = {metrics.box.map:.3f}")

Kết quả val có hai bộ chỉ số: metrics.box (IoU tính trên hộp) và metrics.seg (IoU tính trên mặt nạ, đây là con số chính của bài toán phân đoạn). Như bài "Phân đoạn ảnh: semantic, instance, panoptic" đã tính, IoU mặt nạ của vết mảnh rất nhạy với sai lệch 1 px ở biên, nên mAP50-95 mặt nạ của vết xước thường thấp hơn nhiều so với vết móp mà mô hình vẫn dùng tốt; đừng kết luận chỉ từ con số đó, hãy đo sai số kích thước theo mm (mục 7).

Lưu ý kích thước: vết xước rộng 3 px trên ảnh 2448 px, co về imgsz=640, chỉ còn chưa tới 1 px, mô hình không thể học. Tính trước bề rộng vết nhỏ nhất ở độ phân giải mạng; nếu dưới khoảng 3 px, tăng imgsz (1024, 1280), cắt ROI quanh vùng cần kiểm tra, hoặc chia ảnh thành ô chồng lấn khi huấn luyện và dự đoán.

6. Lấy mặt nạ từ kết quả dự đoán

# (minh họa) cần ultralytics và best.pt đã huấn luyện
from ultralytics import YOLO

model = YOLO("runs_seg/yolo26n_seg/weights/best.pt")
r = model.predict("be_mat.png", conf=0.3, imgsz=640, retina_masks=True)[0]
if r.masks is not None:
    da_giac = r.masks.xy                  # list, mỗi phần tử (n, 2): đa giác theo pixel ẢNH GỐC
    mat_na = r.masks.data.cpu().numpy()   # (N, h, w): mặt nạ 0/1; với retina_masks=True là độ phân giải ảnh gốc
    lop = r.boxes.cls.cpu().numpy().astype(int)
    diem = r.boxes.conf.cpu().numpy()

Hai cách lấy hình dạng vết:

  • r.masks.xy: đa giác theo pixel ảnh gốc, gọn, dùng thẳng với các hàm OpenCV. Vẽ lại thành mặt nạ bằng cv2.fillPoly để đếm pixel.
  • r.masks.data: mặt nạ nhị phân. Mặc định ở độ phân giải đầu vào mạng (có thể kèm phần đệm letterbox), nên đo trực tiếp trên đó sẽ sai tỉ lệ; với retina_masks=True, mặt nạ được trả ở độ phân giải ảnh gốc, chính xác hơn cho đo lường nhưng chậm hơn một chút.

Để chạy được ở đây (không có mô hình), mục sau dùng đa giác nhãn của tập val, cộng thêm nhiễu biên ±1 px, làm "đa giác dự đoán": cùng dạng dữ liệu với r.masks.xy.

7. Đo vết lỗi theo mm

Từ một đa giác (pixel ảnh gốc) và hệ số quy đổi MM_PX đã hiệu chuẩn:

  • Diện tích = số pixel của mặt nạ × MM_PX². (Không dùng cv2.contourArea của đa giác cho vết mảnh: nó tính diện tích hình học bên trong đường đi qua tâm pixel biên, hụt nửa pixel mỗi phía như mục 4.)
  • Chiều dài vết xước cong = chiều dài khung xương (skeleton: đường tâm rộng 1 px) của mặt nạ. Cạnh dài của hình chữ nhật xoay (cv2.minAreaRect) chỉ đúng với vết thẳng; với vết cong nó đo khoảng cách hai đầu.
  • Bề rộng trung bình ≈ diện tích / chiều dài.
  • Sai số: biên mặt nạ sai ±1 px (rất thường gặp) làm diện tích sai khoảng ± chu vi × 1 px. Với vết nhỏ, sai số tương đối lớn.

Khung xương được tính bằng thuật toán làm mảnh Zhang–Suen, cài bằng NumPy (OpenCV bản contrib có sẵn cv2.ximgproc.thinning làm việc tương tự). Chiều dài là đường đi dài nhất trên khung xương, bước ngang/dọc tính 1 px, bước chéo √2 px, tìm bằng thuật toán đường đi ngắn nhất Dijkstra của SciPy (pip install scipy, thường đã có sẵn vì scikit-learn cần nó). Đi tìm đường dài nhất thay vì đếm pixel giúp bỏ qua các nhánh vụn của khung xương.

import numpy as np
from scipy.sparse import coo_matrix
from scipy.sparse.csgraph import dijkstra


def khung_xuong(m):
    """Làm mảnh Zhang–Suen: mặt nạ bool → khung xương rộng 1 px."""
    a = np.pad(m.astype(np.uint8), 1)
    huong = ((-1, 0), (-1, 1), (0, 1), (1, 1), (1, 0), (1, -1), (0, -1), (-1, -1))   # P2..P9
    while True:
        doi = False
        for buoc in (0, 1):
            P = [np.roll(a, (-dy, -dx), (0, 1)) for dy, dx in huong]
            B = sum(P)                                              # số láng giềng bằng 1
            A = sum((P[k] == 0) & (P[(k + 1) % 8] == 1) for k in range(8))   # số chuyển 0→1
            if buoc == 0:
                c = (P[0] * P[2] * P[4] == 0) & (P[2] * P[4] * P[6] == 0)
            else:
                c = (P[0] * P[2] * P[6] == 0) & (P[0] * P[4] * P[6] == 0)
            xoa = (a == 1) & (B >= 2) & (B <= 6) & (A == 1) & c
            if xoa.any():
                a[xoa], doi = 0, True
        if not doi:
            return a[1:-1, 1:-1].astype(bool)


def chieu_dai_khung(sk):
    """Chiều dài đường đi dài nhất trên khung xương: bước ngang/dọc = 1, bước chéo = √2."""
    ys, xs = np.nonzero(sk)
    chi_so = -np.ones(sk.shape, int)
    chi_so[ys, xs] = np.arange(len(ys))
    a = np.pad(chi_so, 1, constant_values=-1)
    hang, cot, w = [], [], []
    for dy, dx in ((0, 1), (1, 0), (1, 1), (1, -1)):              # nối mỗi pixel với láng giềng
        lg = a[1 + dy:a.shape[0] - 1 + dy, 1 + dx:a.shape[1] - 1 + dx][ys, xs]
        co = lg >= 0
        hang += list(np.flatnonzero(co)); cot += list(lg[co]); w += [np.hypot(dy, dx)] * int(co.sum())
    G = coo_matrix((w, (hang, cot)), shape=(len(ys), len(ys)))
    d = dijkstra(G, directed=False, indices=0)
    dau = int(np.argmax(np.where(np.isinf(d), -1, d)))            # một đầu mút của vết
    d = dijkstra(G, directed=False, indices=dau)
    return float(d[~np.isinf(d)].max())                           # tới đầu mút kia


m = np.zeros((40, 120), bool)
m[18:21, 10:110] = True                                            # vết thẳng 100 × 3 px
print("vết thẳng 100×3 px: dài theo khung xương =", round(chieu_dai_khung(khung_xuong(m)), 1), "px")
vết thẳng 100×3 px: dài theo khung xương = 96.0 px

Vết thẳng dài 100 px cho khung xương 96 px: thuật toán làm mảnh "gặm" vào mỗi đầu khoảng nửa bề rộng vết. Ngược lại, với vết xiên hoặc cong, đường đi 8 hướng trên lưới pixel dài hơn đường trơn một chút (nhiều nhất khoảng 8%, ở hướng xiên 22,5°). Hai sai số ngược chiều và đều cỡ vài pixel; ta dùng thẳng chiều dài khung xương và đo sai số bằng thực nghiệm. Giờ đo mọi vết trong tập val từ "đa giác dự đoán" và so với số đo thật đã lưu lúc sinh dữ liệu (chiều dài đường tâm cho vết xước, diện tích mặt nạ thật cho vết móp):

import json
from pathlib import Path

import cv2
import numpy as np

MM_PX = 0.04
TIEU_CHI = {0: ("dài", 3.0, "mm"), 1: ("diện tích", 0.8, "mm²")}      # vượt ngưỡng → NG
TEN = {0: "vet_xuoc", 1: "vet_mop"}
that = json.loads(Path("so_do_that.json").read_text())
rng = np.random.default_rng(3)


def do_vet(da_giac_px, lop, shape):
    m = cv2.fillPoly(np.zeros(shape, np.uint8), [np.round(da_giac_px).astype(np.int32)], 1).astype(bool)
    dt_px = int(m.sum())
    chu_vi = cv2.arcLength(da_giac_px.astype(np.float32), True)
    kq = {"dien_tich": dt_px * MM_PX ** 2, "sai_so_dt": chu_vi * MM_PX ** 2}   # ± 1 px biên
    if lop == 0:
        dai_khung = chieu_dai_khung(khung_xuong(m))
        rong = dt_px / max(dai_khung, 1)
        kq["dai"] = dai_khung * MM_PX
        kq["rong"] = rong * MM_PX
    return kq


print("ảnh  | lớp      | đo được                           | thật           | kết luận")
sai_dai, sai_dt = [], []
for f in sorted(Path("dataset_loi/labels/val").glob("*.txt")):
    for d, t in zip(f.read_text().splitlines(), that[f.stem]):
        lop, *xy = d.split()
        lop = int(lop)
        dg = np.array(xy, float).reshape(-1, 2) * 320 + rng.uniform(-1, 1, (len(xy) // 2, 2))   # "dự đoán"
        kq = do_vet(dg, lop, (320, 320))
        if lop == 0:
            gia_tri, mo_ta = kq["dai"], f"dài {kq['dai']:.2f} mm, rộng {kq['rong']:.3f} mm"
            that_mm = t["so_do_px"] * MM_PX
            sai_dai.append(kq["dai"] - that_mm)
        else:
            gia_tri, mo_ta = kq["dien_tich"], f"{kq['dien_tich']:.3f} ± {kq['sai_so_dt']:.3f} mm²"
            that_mm = t["dien_tich_px"] * MM_PX ** 2                  # diện tích mặt nạ thật
            sai_dt.append(kq["dien_tich"] / that_mm - 1)
        ten_tc, nguong, dv = TIEU_CHI[lop]
        print(f"{f.stem} | {TEN[lop]:8s} | {mo_ta:33s} | {that_mm:5.2f} {dv:3s}     | "
              f"{'NG' if gia_tri > nguong else 'OK'} ({ten_tc} {'>' if gia_tri > nguong else '≤'} {nguong})")
print(f"sai số chiều dài xước: trung bình {np.mean(sai_dai):+.3f} mm, lớn nhất {np.abs(sai_dai).max():.3f} mm")
print(f"sai số diện tích móp : trung bình {np.mean(sai_dt):+.1%}, lớn nhất {np.abs(sai_dt).max():.1%}")
ảnh  | lớp      | đo được                           | thật           | kết luận
b_049 | vet_xuoc | dài 3.83 mm, rộng 0.104 mm        |  3.54 mm      | NG (dài > 3.0)
b_049 | vet_mop  | 0.976 ± 0.145 mm²                 |  0.96 mm²     | NG (diện tích > 0.8)
b_049 | vet_mop  | 0.805 ± 0.130 mm²                 |  0.81 mm²     | NG (diện tích > 0.8)
b_050 | vet_xuoc | dài 3.94 mm, rộng 0.185 mm        |  3.97 mm      | NG (dài > 3.0)
b_050 | vet_xuoc | dài 6.55 mm, rộng 0.122 mm        |  6.25 mm      | NG (dài > 3.0)
b_051 | vet_xuoc | dài 4.06 mm, rộng 0.112 mm        |  3.74 mm      | NG (dài > 3.0)
b_052 | vet_xuoc | dài 1.90 mm, rộng 0.127 mm        |  1.97 mm      | OK (dài ≤ 3.0)
b_052 | vet_xuoc | dài 2.84 mm, rộng 0.117 mm        |  2.65 mm      | OK (dài ≤ 3.0)
b_053 | vet_xuoc | dài 5.00 mm, rộng 0.125 mm        |  4.68 mm      | NG (dài > 3.0)
b_054 | vet_xuoc | dài 3.17 mm, rộng 0.119 mm        |  2.95 mm      | NG (dài > 3.0)
b_054 | vet_xuoc | dài 2.11 mm, rộng 0.118 mm        |  1.99 mm      | OK (dài ≤ 3.0)
b_054 | vet_xuoc | dài 3.37 mm, rộng 0.133 mm        |  3.25 mm      | NG (dài > 3.0)
b_055 | vet_xuoc | dài 2.29 mm, rộng 0.184 mm        |  2.17 mm      | OK (dài ≤ 3.0)
b_055 | vet_xuoc | dài 5.78 mm, rộng 0.120 mm        |  5.40 mm      | NG (dài > 3.0)
b_055 | vet_xuoc | dài 4.10 mm, rộng 0.185 mm        |  3.93 mm      | NG (dài > 3.0)
b_057 | vet_mop  | 1.202 ± 0.163 mm²                 |  1.19 mm²     | NG (diện tích > 0.8)
b_057 | vet_xuoc | dài 5.07 mm, rộng 0.201 mm        |  4.71 mm      | NG (dài > 3.0)
b_057 | vet_xuoc | dài 3.02 mm, rộng 0.166 mm        |  2.84 mm      | NG (dài > 3.0)
b_058 | vet_xuoc | dài 6.34 mm, rộng 0.113 mm        |  6.06 mm      | NG (dài > 3.0)
b_058 | vet_xuoc | dài 5.93 mm, rộng 0.180 mm        |  5.61 mm      | NG (dài > 3.0)
b_058 | vet_xuoc | dài 3.23 mm, rộng 0.134 mm        |  3.19 mm      | NG (dài > 3.0)
b_059 | vet_mop  | 0.448 ± 0.106 mm²                 |  0.43 mm²     | OK (diện tích ≤ 0.8)
sai số chiều dài xước: trung bình +0.200 mm, lớn nhất 0.378 mm
sai số diện tích móp : trung bình +1.6%, lớn nhất 4.9%

Đọc kết quả như một kỹ sư đo lường:

  • Chiều dài theo khung xương lệch trung bình khoảng +0,2 mm so với đường tâm thật, lớn nhất gần 0,4 mm (khoảng 9 px), kể cả với vết cong. Phần lớn sai số đến từ biên nhiễu ±1 px của "dự đoán" làm khung xương ngoằn ngoèo, và từ hai đầu tròn của nét vẽ. Hộp bao hay minAreaRect sai nhiều hơn hẳn với vết cong (bài tập 3).
  • Diện tích vết móp lệch trung bình khoảng 1,6%, lớn nhất khoảng 5% so với diện tích mặt nạ thật. Cột "±" là sai số khi biên lệch 1 px: với vết móp nhỏ, nó lên tới khoảng 20–25% diện tích.
  • Vì vậy giá trị sát ngưỡng không đáng tin. Ảnh b_054: vết xước thật dài 2,95 mm (OK theo tiêu chí 3 mm) nhưng đo được 3,17 mm, bị loại nhầm; b_057 cũng vậy (thật 2,84 mm, đo 3,02 mm). Vết móp thứ hai của b_049 đo 0,805 ± 0,130 mm², nằm ngay trên ngưỡng 0,8. Nhà máy xử lý bằng vùng an toàn (guard band): ví dụ trên 0,8 mm² là NG, 0,65–0,8 mm² là "cần xem lại" (chuyển trạm kiểm tra tay), dưới 0,65 mm² là OK. Độ rộng vùng này lấy từ sai số đo thực nghiệm trên ảnh có nhãn, như cách đánh giá hệ thống đo ở chặng 8.
Hệ số mm/px chỉ đúng trên mặt phẳng đã hiệu chuẩnPhép đo theo mm cần camera đặt vuông góc (hoặc đã nắn bằng homography), ống kính đã hiệu chỉnh méo, và bề mặt nằm đúng khoảng cách làm việc. Nếu chi tiết cao thấp khác nhau, cân nhắc ống kính telecentric (chặng 6). Mô hình phân đoạn tốt đến đâu cũng không sửa được hiệu chuẩn sai.

8. Bài thực hành: YOLO26n-seg và diện tích từng vết theo mm²

  1. Chọn một bề mặt có lỗi nhìn thấy được: tấm nhựa, mặt kim loại, gỗ ép; tạo lỗi thật (vạch xước, chấm sơn) nếu cần. Chụp ít nhất 100 ảnh ở khoảng cách cố định, ánh sáng cố định, thêm 20% ảnh không lỗi.
  2. Hiệu chuẩn hệ số mm/px bằng một vật mẫu đã biết kích thước (thước kẻ, bàn cờ) đặt trên mặt phẳng sản phẩm, như chặng 8.
  3. Gán nhãn đa giác (CVAT hoặc Label Studio, có SAM thì dùng), xuất YOLO hoặc JSON rồi chuyển bằng hàm ở mục 1. Chạy kiểm tra nhãn ở mục 4, vẽ nhãn ra ảnh.
  4. Huấn luyện yolo26n-seg.pt như mục 5, báo cáo metrics.seg.map50 và metrics.seg.map trên val.
  5. Dự đoán trên ảnh val, đo diện tích từng vết (mm²) bằng do_vet với đa giác r.masks.xy, và so với diện tích đo từ nhãn tay cùng ảnh: báo cáo sai số trung bình và lớn nhất.
  6. Đặt tiêu chí giả định (ví dụ diện tích > 1 mm² là NG) và vùng an toàn theo sai số vừa đo.

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

  • Vết xước gán nhãn bằng đường gấp khúc một phía: diện tích 0, mô hình không học được.
  • Đơn giản hóa đa giác quá mạnh khi chuyển từ mặt nạ: mất phần lớn vết mảnh (eps 4 px còn chưa tới nửa IoU).
  • Đo trên r.masks.data mặc định (độ phân giải mạng, có đệm) rồi nhân với mm/px của ảnh gốc.
  • Đo chiều dài vết cong bằng hộp bao hoặc minAreaRect.
  • Đánh giá chỉ bằng mAP mặt nạ, không đo sai số kích thước theo mm so với nhãn tay.
  • Đặt ngưỡng NG sát giá trị đo mà không có vùng an toàn: kết quả dao động OK/NG giữa các lần chụp cùng một sản phẩm.
  • Co ảnh lớn về 640 px khi vết chỉ rộng vài pixel: vết biến mất trước khi vào mạng.

10. Bài tập

Bài 1. Ảnh 2000×1500. Một vết móp được gán nhãn bằng đa giác 4 đỉnh (800, 600), (860, 600), (860, 650), (800, 650), lớp 1. Viết dòng nhãn YOLO-seg. Với hệ số 0,02 mm/px, diện tích hình học của đa giác là bao nhiêu mm²?

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

W, H, MMPX = 2000, 1500, 0.02
dg = np.array([(800, 600), (860, 600), (860, 650), (800, 650)], np.float32)
print("1 " + " ".join(f"{x / W:.6f} {y / H:.6f}" for x, y in dg))
print(f"diện tích = {cv2.contourArea(dg):.0f} px² × {MMPX}² = {cv2.contourArea(dg) * MMPX ** 2:.2f} mm²")
1 0.400000 0.400000 0.430000 0.400000 0.430000 0.433333 0.400000 0.433333
diện tích = 3000 px² × 0.02² = 1.20 mm²

Hình chữ nhật 60×50 px = 3000 px², tức 1,2 mm². Nếu vẽ lại thành mặt nạ và đếm pixel sẽ được 61×51 = 3111 px (fillPoly tô cả các pixel nằm trên biên), chênh 3,7%: với vết nhỏ, quy ước đo (diện tích đa giác hay số pixel) phải thống nhất giữa lúc đánh giá và lúc chạy.

Bài 2. Camera cho ảnh 2448×2048, hệ số 0,01 mm/px. Vết xước nhỏ nhất cần phát hiện rộng 0,04 mm. Huấn luyện với imgsz=640 có ổn không? Đề xuất cách làm.

Xem đáp án

Vết rộng 0,04 / 0,01 = 4 px trên ảnh gốc. Co 2448 → 640 là hệ số 0,26, vết còn khoảng 1 px: gần như không còn thông tin, mô hình sẽ bỏ sót. Cách làm: (1) cắt ROI nếu vùng cần kiểm tra nhỏ hơn cả ảnh; (2) chia ảnh thành ô 640×640 chồng lấn khoảng 10–20% và huấn luyện, dự đoán trên ô ở độ phân giải gốc (vết giữ 4 px), sau đó ghép kết quả và gộp các vết bị cắt ở biên ô; (3) hoặc tăng imgsz lên 1280–1600 nếu GPU và cycle time cho phép. Kiểm tra lại: bề rộng vết nhỏ nhất ở độ phân giải mạng nên từ khoảng 3 px trở lên.

Bài 3. Thực hành mục 7: tạo một vết xước hình chữ S (hai cung nối nhau) rộng 3 px, đo chiều dài bằng (a) cạnh dài minAreaRect và (b) khung xương. So với chiều dài đường tâm thật.

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

t = np.linspace(0, 2 * np.pi, 200)
pts = np.stack([20 + t * 25, 60 + 25 * np.sin(t)], 1)                # chữ S: dài theo x, uốn theo y
that = np.sum(np.hypot(*np.diff(pts, axis=0).T))
m = cv2.polylines(np.zeros((120, 200), np.uint8), [pts.round().astype(np.int32)], False, 1, 3).astype(bool)
cnt = cv2.findContours(m.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)[0][0]
a = max(cv2.minAreaRect(cnt)[1])
k = chieu_dai_khung(khung_xuong(m))
print(f"thật {that:.1f} px | minAreaRect {a:.1f} px | khung xương {k:.1f} px")
thật 191.0 px | minAreaRect 157.2 px | khung xương 195.2 px

minAreaRect chỉ cho khoảng cách giữa hai đầu (theo hướng chính), hụt nhiều với vết cong; khung xương bám theo đường cong nên chỉ lệch khoảng 4 px (2%). Với tiêu chí chiều dài, hãy dùng khung xương.

Tóm tắt

  • Nhãn YOLO-seg: class x1 y1 … xn yn chuẩn hóa 0–1, n ≥ 3, một đa giác mỗi dòng, không có lỗ; chuyển từ JSON công cụ theo tên lớp.
  • Gán nhãn vết mảnh: đa giác bao cả hai mép, đỉnh dày; chuyển từ mặt nạ dùng approxPolyDP với eps khoảng 0,5 px và kiểm tra IoU đa giác–mặt nạ.
  • Huấn luyện như phát hiện, với yolo26n-seg.pt; đánh giá bằng metrics.seg.map50, metrics.seg.map; giữ bề rộng vết nhỏ nhất ≥ khoảng 3 px ở độ phân giải mạng.
  • Lấy hình dạng từ r.masks.xy (pixel ảnh gốc) hoặc r.masks.data với retina_masks=True.
  • Diện tích = số pixel × (mm/px)²; chiều dài vết cong = đường đi dài nhất trên khung xương; bề rộng = diện tích / chiều dài; sai số ±1 px biên ≈ chu vi × (mm/px)².
  • Đánh giá bằng sai số mm so với nhãn tay, đặt tiêu chí OK/NG có vùng an toàn.

Bài tiếp theo: U-Net cho phân đoạn ảnh, tự cài một mạng encoder–decoder bằng PyTorch và dùng Dice loss cho vùng lỗi nhỏ.

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

  1. Huấn luyện YOLO26n-seg, đo diện tích từng vết lỗi theo mm² bằng hệ số quy đổi đã hiệu chuẩ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í.