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

Object Detection với YOLO26: chuẩn bị dữ liệu, huấn luyện và xuất mô hình

Nhãn YOLO, script kiểm tra nhãn, data.yaml, huấn luyện và kiểm tra YOLO26 bằng Ultralytics, từ hộp bao đến quyết định OK/NG, letterbox và xuất ONNX.

Object Detection với YOLO26: chuẩn bị dữ liệu, huấn luyện và xuất mô hình

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

  • Chuẩn bị dữ liệu định dạng YOLO
  • Huấn luyện, kiểm tra, dự đoán
  • Xuất mô hình

Nên học trước: Bài tập chặng 11: thiết kế chiến lược AI cho một bài toán lỗi

Nội dung bài viết
  1. 1. Object Detection và họ YOLO
  2. 2. Định dạng nhãn YOLO
  3. 3. Tạo dataset mẫu và cấu trúc thư mục
  4. 4. Kiểm tra nhãn trước khi huấn luyện
  5. 5. Huấn luyện
  6. 6. Kiểm tra và dự đoán
  7. 7. Từ hộp bao đến quyết định OK/NG
  8. 8. Xuất mô hình để triển khai
  9. 9. Bài thực hành: YOLO26n trên dữ liệu của bạn
  10. 10. Sai lầm thường gặp
  11. 11. Bài tập
  12. Tóm tắt

Ở chặng 10 bạn đã huấn luyện mô hình phân loại ảnh OK/NG: cả ảnh nhận một nhãn. Nhiều trạm kiểm tra cần nhiều hơn thế: khay phải có đủ 4 con vít và 2 đai ốc, mỗi vết lỗi phải được khoanh vị trí để người vận hành nhìn thấy, robot cần tọa độ từng vật để gắp. Đó là bài toán Object Detection (phát hiện đối tượng): mô hình trả về danh sách hộp bao, mỗi hộp có lớp và điểm tin cậy. Bài này là bài mở đầu chặng 12, nối tiếp "Bài tập chặng 11: thiết kế chiến lược AI cho một bài toán lỗi": bạn sẽ chuẩn bị dữ liệu đúng định dạng YOLO (phần mà phần lớn lỗi dự án nằm ở đó), viết script kiểm tra nhãn, sinh file cấu hình dataset, huấn luyện, kiểm tra, dự đoán và xuất mô hình YOLO26 bằng thư viện Ultralytics, rồi biến kết quả dự đoán thành quyết định OK/NG gửi PLC. Bài sau, "IoU, mAP: đánh giá Object Detection", giải thích kỹ các con số mà bước huấn luyện in ra.

Mục tiêu cuối bàiĐổi qua lại giữa tọa độ pixel và nhãn YOLO class cx cy w h; tạo cấu trúc thư mục dataset và file data.yaml; viết script phát hiện nhãn sai trước khi huấn luyện; biết các tham số huấn luyện quan trọng của Ultralytics và đọc kết quả trong runs/detect/; chạy dự đoán, kiểm tra (val) và xuất ONNX; viết phần hậu xử lý đếm vật, ra quyết định OK/NG; tự làm được bài thực hành: huấn luyện YOLO26n trên ít nhất 100 ảnh của bạn và báo cáo mAP50.

1. Object Detection và họ YOLO

Một mô hình phát hiện đối tượng nhận một ảnh và trả về danh sách (x1, y1, x2, y2, điểm tin cậy, lớp). So với các bài toán bạn đã gặp:

Bài toánĐầu raVí dụ trong nhà máyCông gán nhãn
Phân loại (chặng 10)Một nhãn cho cả ảnhẢnh nắp chai OK hay NGThấp: 1 nhãn/ảnh
Phát hiện đối tượngHộp bao + lớp + điểm tin cậyĐếm vít trên khay, khoanh vị trí vết móp, định vị vật cho robotTrung bình: kéo hộp quanh từng vật
Phân đoạn (bài 3–5 của chặng)Mặt nạ từng pixelĐo diện tích vết loang, chiều dài vết xướcCao: vẽ đa giác

YOLO (You Only Look Once) là họ mô hình phát hiện một giai đoạn: một lần chạy mạng cho ra tất cả hộp, đủ nhanh để chạy thời gian thực. Thư viện Ultralytics (Python) gói gọn toàn bộ quy trình huấn luyện, kiểm tra, dự đoán và xuất mô hình cho nhiều thế hệ YOLO; YOLO26 là thế hệ mới của Ultralytics, được Ultralytics giới thiệu từ cuối năm 2025 (xem trang tài liệu chính thức docs.ultralytics.com để biết phiên bản mới nhất). Theo các tài liệu mô tả kiến trúc, các điểm đáng chú ý với kỹ sư triển khai:

  • End-to-end, không cần NMS: các đời trước trả về rất nhiều hộp chồng lên nhau và cần bước hậu xử lý NMS (Non-Maximum Suppression, giữ hộp điểm cao nhất, bỏ các hộp trùng) mà bạn sẽ tự cài ở bài sau. YOLO26 trả thẳng danh sách hộp cuối cùng, nên code triển khai (C#, C++, thiết bị biên) đơn giản hơn.
  • Bỏ DFL (Distribution Focal Loss) ở đầu ra hộp, giúp xuất sang ONNX, TensorRT, OpenVINO gọn và tương thích hơn.
  • Công thức huấn luyện mới (bộ tối ưu MuSGD, các hàm mất mát ProgLoss và STAL nhắm tới vật nhỏ) và tối ưu cho suy luận trên CPU.
  • Năm cỡ n, s, m, l, x (nano đến extra-large), và các biến thể cho phân đoạn (-seg, dùng ở bài "Phân đoạn vết lỗi với YOLO26-seg") cùng các tác vụ khác.
Giấy phépThư viện và trọng số Ultralytics phát hành theo giấy phép AGPL-3.0. Dùng trong phần mềm thương mại đóng mã nguồn (ví dụ phần mềm trạm kiểm tra bán cho khách hàng) cần mua giấy phép Enterprise của Ultralytics hoặc chọn mô hình có giấy phép khác. Hãy hỏi bộ phận pháp lý trước khi đưa vào sản phẩm.

Cài đặt trên máy của bạn (khuyến nghị môi trường ảo riêng; có GPU NVIDIA thì cài PyTorch bản CUDA trước theo hướng dẫn của pytorch.org):

pip install ultralytics

Chạy thử mô hình đã huấn luyện sẵn trên bộ COCO (80 lớp đồ vật thông dụng). Lần đầu chạy, thư viện tự tải file trọng số yolo26n.pt:

# (minh họa) cần ultralytics và tải trọng số từ Internet
from ultralytics import YOLO

model = YOLO("yolo26n.pt")                     # trọng số COCO
results = model.predict("anh_test.jpg", conf=0.25, imgsz=640)
r = results[0]                                 # một ảnh → một đối tượng Results
for (x1, y1, x2, y2), c, k in zip(r.boxes.xyxy.tolist(), r.boxes.conf.tolist(), r.boxes.cls.tolist()):
    print(f"{model.names[int(k)]:10s} {c:.2f}  ({x1:.0f}, {y1:.0f}) - ({x2:.0f}, {y2:.0f})")
r.save("ket_qua.jpg")                          # ảnh có vẽ hộp

Mô hình COCO biết "người", "chai", "cốc" nhưng không biết "vít M3" hay "vết móp". Phần còn lại của bài là dạy nó lớp của bạn bằng transfer learning: bắt đầu từ trọng số COCO, huấn luyện tiếp trên dữ liệu riêng, giống ý tưởng bạn đã làm với mô hình phân loại ở chặng 10.

2. Định dạng nhãn YOLO

Mỗi ảnh có một file .txt cùng tên. Mỗi dòng là một vật:

<class_id> <cx> <cy> <w> <h>
  • class_id: số nguyên bắt đầu từ 0, khớp với danh sách names trong data.yaml.
  • cx, cy: tâm hộp; w, h: rộng, cao của hộp. Cả bốn số được chuẩn hóa về 0–1: chia tọa độ x, rộng cho chiều rộng ảnh, tọa độ y, cao cho chiều cao ảnh.
  • Ảnh không có vật nào (ảnh nền, ảnh OK): file .txt rỗng. Ảnh nền rất quan trọng để mô hình học "không có gì thì không báo".

Nhờ chuẩn hóa, nhãn không phụ thuộc độ phân giải: thư viện co ảnh về imgsz khi huấn luyện mà không phải sửa nhãn. Hai hàm đổi qua lại:

def xyxy_sang_yolo(x1, y1, x2, y2, W, H):
    """Hộp pixel (góc trên trái, góc dưới phải) → (cx, cy, w, h) chuẩn hóa 0–1."""
    return ((x1 + x2) / 2 / W, (y1 + y2) / 2 / H, (x2 - x1) / W, (y2 - y1) / H)


def yolo_sang_xyxy(cx, cy, w, h, W, H):
    """Ngược lại: nhãn YOLO → hộp pixel."""
    return ((cx - w / 2) * W, (cy - h / 2) * H, (cx + w / 2) * W, (cy + h / 2) * H)


W, H = 1280, 1024                       # ảnh camera 1,3 MP
hop = (400, 300, 560, 420)              # vít: x1, y1, x2, y2 theo pixel
nhan = xyxy_sang_yolo(*hop, W, H)
dong = "0 " + " ".join(f"{v:.6f}" for v in nhan)
print("dòng nhãn :", dong)
print("đổi ngược :", [round(v, 2) for v in yolo_sang_xyxy(*map(float, dong.split()[1:]), W, H)])
dòng nhãn : 0 0.375000 0.351562 0.125000 0.117188
đổi ngược : [400.0, 300.0, 560.0, 420.0]

Ghi 6 chữ số thập phân là đủ: sai số làm tròn nhỏ hơn 0,001 pixel. Các công cụ gán nhãn như CVAT, Label Studio (bài "Công cụ gán nhãn miễn phí" ở chặng 11) xuất thẳng định dạng này; tuy vậy bạn vẫn nên hiểu và tự kiểm tra, vì lỗi nhãn là nguyên nhân số một khiến mô hình kém.

Ba định dạng hộp hay bị nhầmYOLO: cx, cy, w, h chuẩn hóa. Pascal VOC: x1, y1, x2, y2 pixel. COCO JSON: x, y, w, h pixel, với x, y là góc trên trái. Chuyển định dạng sai thường không báo lỗi gì, chỉ làm mô hình học "lệch" một cách bí ẩn. Luôn vẽ lại nhãn lên ảnh để kiểm tra bằng mắt (mục 4).

3. Tạo dataset mẫu và cấu trúc thư mục

Để chạy được mọi bước mà không cần camera, ta sinh 120 ảnh khay linh kiện 320×256: nền kim loại có nhiễu, mỗi ảnh 0–5 vật thuộc hai lớp vit (đầu vít tròn có rãnh) và dai_oc (lục giác có lỗ), khoảng 10% ảnh là ảnh nền không có vật. Hộp bao lấy đúng từ pixel đã vẽ (cv2.boundingRect), giống như người gán nhãn kéo hộp sát mép vật. Cấu trúc thư mục là cấu trúc chuẩn mà Ultralytics mong đợi:

dataset_vit/
  images/train/  img_000.png ...      labels/train/  img_000.txt ...
  images/val/    img_007.png ...      labels/val/    img_007.txt ...

Thư viện tìm file nhãn bằng cách thay /images/ trong đường dẫn ảnh bằng /labels/ và đổi đuôi thành .txt, nên tên thư mục images và labels phải đúng chính tả.

from pathlib import Path

import cv2
import numpy as np

rng = np.random.default_rng(0)
GOC = Path("dataset_vit")
TEN_LOP = ["vit", "dai_oc"]
W, H = 320, 256


def ve_vat(img, lop, cx, cy, r):
    """Vẽ một vật lên img, trả về hộp (x1, y1, x2, y2) lấy từ pixel đã vẽ."""
    mask = np.zeros(img.shape[:2], np.uint8)
    if lop == 0:                                                 # vít: đầu tròn + rãnh
        cv2.circle(mask, (cx, cy), r, 255, -1)
        img[mask > 0] = 200
        goc = rng.uniform(0, np.pi)
        dx, dy = int(r * 0.7 * np.cos(goc)), int(r * 0.7 * np.sin(goc))
        cv2.line(img, (cx - dx, cy - dy), (cx + dx, cy + dy), 60, 3)
    else:                                                        # đai ốc: lục giác + lỗ
        a = np.deg2rad(np.arange(0, 360, 60) + rng.uniform(0, 60))
        pts = np.stack([cx + r * np.cos(a), cy + r * np.sin(a)], 1).round().astype(np.int32)
        cv2.fillPoly(mask, [pts], 255)
        img[mask > 0] = 165
        cv2.circle(img, (cx, cy), r // 2, 70, -1)
    x, y, w, h = cv2.boundingRect(mask)
    return x, y, x + w, y + h


def tao_anh(k):
    img = (110 + rng.normal(0, 6, (H, W)) + np.linspace(-15, 15, W)[None]).clip(0, 255).astype(np.uint8)
    dong, tam = [], []
    n_vat = 0 if k % 10 == 0 else int(rng.integers(1, 6))      # ~10% ảnh nền
    while len(dong) < n_vat:
        lop, r = int(rng.integers(0, 2)), int(rng.integers(12, 22))
        cx, cy = int(rng.integers(r + 2, W - r - 2)), int(rng.integers(r + 2, H - r - 2))
        if any(np.hypot(cx - a, cy - b) < r + rb + 6 for a, b, rb in tam):
            continue                                             # tránh vật chồng nhau
        tam.append((cx, cy, r))
        x1, y1, x2, y2 = ve_vat(img, lop, cx, cy, r)
        dong.append(f"{lop} {(x1 + x2) / 2 / W:.6f} {(y1 + y2) / 2 / H:.6f} {(x2 - x1) / W:.6f} {(y2 - y1) / H:.6f}")
    return cv2.cvtColor(cv2.GaussianBlur(img, (3, 3), 0), cv2.COLOR_GRAY2BGR), dong


thu_tu = rng.permutation(120)
for k, i in enumerate(thu_tu):
    tap = "train" if k < 96 else "val"                          # 80% train, 20% val
    (GOC / "images" / tap).mkdir(parents=True, exist_ok=True)
    (GOC / "labels" / tap).mkdir(parents=True, exist_ok=True)
    img, dong = tao_anh(k)
    cv2.imwrite(str(GOC / "images" / tap / f"img_{i:03d}.png"), img)
    (GOC / "labels" / tap / f"img_{i:03d}.txt").write_text("\n".join(dong) + ("\n" if dong else ""))

for tap in ("train", "val"):
    print(tap, len(list((GOC / "images" / tap).glob("*.png"))), "ảnh")
print("ví dụ", sorted((GOC / "labels/train").glob("*.txt"))[1].name, ":")
print(sorted((GOC / "labels/train").glob("*.txt"))[1].read_text().strip())
train 96 ảnh
val 24 ảnh
ví dụ img_001.txt :
1 0.401562 0.341797 0.078125 0.105469

Ở đây chia ngẫu nhiên vì mỗi ảnh tổng hợp độc lập nhau. Với ảnh thật, đừng chia ngẫu nhiên từng ảnh khi nhiều ảnh gần như trùng nhau (chụp liên tiếp cùng một sản phẩm, cắt từ cùng một video): ảnh val sẽ là "bản sao" của ảnh train và mAP đẹp giả tạo. Chia theo lô, theo ngày hoặc theo sản phẩm, như bài "Overfitting và chia train/val/test" ở chặng 10.

File data.yaml khai báo dataset. Nên ghi path là đường dẫn tuyệt đối: nếu là đường dẫn tương đối không tồn tại tính từ thư mục đang chạy, Ultralytics sẽ tìm trong thư mục datasets mặc định của nó và báo không thấy dữ liệu, một lỗi rất hay gặp. Sinh file bằng code thay vì gõ tay để không sai thứ tự lớp:

from pathlib import Path

TEN_LOP = ["vit", "dai_oc"]
goc = Path("dataset_vit").resolve()
yaml = f"path: {goc.as_posix()}\ntrain: images/train\nval: images/val\nnames:\n"
yaml += "".join(f"  {i}: {ten}\n" for i, ten in enumerate(TEN_LOP))
Path("data.yaml").write_text(yaml, encoding="utf-8")

print("path tuyệt đối:", goc.is_absolute())
print("".join(Path("data.yaml").read_text(encoding="utf-8").splitlines(keepends=True)[1:]), end="")
path tuyệt đối: True
train: images/train
val: images/val
names:
  0: vit
  1: dai_oc

Thứ tự trong names là hợp đồng giữa nhãn, mô hình và phần mềm trạm: lớp 0 phải luôn là vit. Đổi thứ tự sau khi đã gán nhãn là cách nhanh nhất để có một mô hình "đọc ngược" lớp. Nếu có tập test riêng, thêm dòng test: images/test.

4. Kiểm tra nhãn trước khi huấn luyện

Huấn luyện tốn hàng giờ GPU; kiểm tra nhãn tốn vài giây. Script dưới đây bắt các lỗi hay gặp khi gộp dữ liệu từ nhiều người, nhiều công cụ: ảnh thiếu file nhãn, nhãn không có ảnh, lớp ngoài danh sách, tọa độ chưa chuẩn hóa (vẫn là pixel), hộp tràn ra ngoài ảnh, hộp quá nhỏ, dòng trùng lặp. Đồng thời in thống kê số vật mỗi lớp, số ảnh nền.

from collections import Counter
from pathlib import Path

import cv2


def kiem_tra_dataset(goc, n_lop, tap=("train", "val"), min_px=4):
    loi, canh_bao = [], []
    for t in tap:
        anh = {p.stem: p for p in (Path(goc) / "images" / t).glob("*.*")}
        nhan = {p.stem: p for p in (Path(goc) / "labels" / t).glob("*.txt")}
        dem, so_nen = Counter(), 0
        for ten in sorted(set(nhan) - set(anh)):
            loi.append(f"[{t}] {ten}.txt: có nhãn nhưng không có ảnh")
        for ten, p_anh in sorted(anh.items()):
            if ten not in nhan:
                canh_bao.append(f"[{t}] {p_anh.name}: thiếu file nhãn (sẽ bị coi là ảnh nền!)")
                continue
            Hh, Ww = cv2.imread(str(p_anh), cv2.IMREAD_GRAYSCALE).shape
            dong = [d.split() for d in nhan[ten].read_text().splitlines() if d.strip()]
            so_nen += not dong
            if len(dong) != len({tuple(d) for d in dong}):
                loi.append(f"[{t}] {ten}.txt: có dòng trùng lặp")
            for k, d in enumerate(dong, 1):
                vt = f"[{t}] {ten}.txt dòng {k}"
                if len(d) != 5:
                    loi.append(f"{vt}: cần 5 số, có {len(d)}"); continue
                lop, (cx, cy, w, h) = d[0], map(float, d[1:])
                if not lop.isdigit() or int(lop) >= n_lop:
                    loi.append(f"{vt}: lớp '{lop}' ngoài danh sách 0..{n_lop - 1}"); continue
                if max(cx, cy, w, h) > 1:
                    loi.append(f"{vt}: tọa độ > 1, có lẽ chưa chuẩn hóa (pixel?)"); continue
                if cx - w / 2 < -1e-3 or cy - h / 2 < -1e-3 or cx + w / 2 > 1 + 1e-3 or cy + h / 2 > 1 + 1e-3:
                    loi.append(f"{vt}: hộp tràn ra ngoài ảnh")
                if w * Ww < min_px or h * Hh < min_px:
                    canh_bao.append(f"{vt}: hộp chỉ {w * Ww:.1f}×{h * Hh:.1f} px, quá nhỏ?")
                dem[int(lop)] += 1
        print(f"{t:5s}: {len(anh)} ảnh, {so_nen} ảnh nền, số vật theo lớp {dict(sorted(dem.items()))}")
    return loi, canh_bao


loi, canh_bao = kiem_tra_dataset("dataset_vit", n_lop=2)
print("lỗi:", len(loi), "| cảnh báo:", len(canh_bao))
train: 96 ảnh, 10 ảnh nền, số vật theo lớp {0: 126, 1: 116}
val  : 24 ảnh, 2 ảnh nền, số vật theo lớp {0: 40, 1: 32}
lỗi: 0 | cảnh báo: 0

Dataset vừa sinh sạch. Để thấy script bắt lỗi thế nào, tạo một dataset nhỏ "bẩn" mô phỏng tình huống gộp nhãn từ nhiều nguồn:

import shutil
from pathlib import Path

import cv2
import numpy as np

bad = Path("dataset_loi")
shutil.rmtree(bad, ignore_errors=True)
(bad / "images/train").mkdir(parents=True)
(bad / "labels/train").mkdir(parents=True)
nen = np.full((256, 320, 3), 110, np.uint8)
noi_dung = {
    "a": "0 0.500000 0.500000 0.100000 0.120000\n1 0.500000 0.500000 0.005000 0.010000\n",   # đúng + hộp tí hon
    "b": "1 160 128 30 30\n",                                       # quên chuẩn hóa: vẫn là pixel
    "c": "2 0.400000 0.300000 0.100000 0.120000\n",                 # lớp 2 không tồn tại
    "d": None,                                                      # ảnh chưa có file nhãn
    "e": "0 0.950000 0.500000 0.200000 0.100000\n0 0.950000 0.500000 0.200000 0.100000\n",  # tràn + trùng
}
for ten, txt in noi_dung.items():
    cv2.imwrite(str(bad / "images/train" / f"{ten}.png"), nen)
    if txt is not None:
        (bad / "labels/train" / f"{ten}.txt").write_text(txt)
(bad / "labels/train/z.txt").write_text("0 0.5 0.5 0.1 0.1\n")    # nhãn mồ côi

loi, canh_bao = kiem_tra_dataset("dataset_loi", n_lop=2, tap=("train",))
print("\n".join(["LỖI     " + x for x in loi] + ["CẢNH BÁO " + x for x in canh_bao]))
train: 5 ảnh, 0 ảnh nền, số vật theo lớp {0: 3, 1: 1}
LỖI     [train] z.txt: có nhãn nhưng không có ảnh
LỖI     [train] b.txt dòng 1: tọa độ > 1, có lẽ chưa chuẩn hóa (pixel?)
LỖI     [train] c.txt dòng 1: lớp '2' ngoài danh sách 0..1
LỖI     [train] e.txt: có dòng trùng lặp
LỖI     [train] e.txt dòng 1: hộp tràn ra ngoài ảnh
LỖI     [train] e.txt dòng 2: hộp tràn ra ngoài ảnh
CẢNH BÁO [train] a.txt dòng 2: hộp chỉ 1.6×2.6 px, quá nhỏ?
CẢNH BÁO [train] d.png: thiếu file nhãn (sẽ bị coi là ảnh nền!)

Cảnh báo "thiếu file nhãn" đáng sợ hơn vẻ ngoài: Ultralytics coi ảnh không có file nhãn là ảnh nền, tức là dạy mô hình rằng vật trong ảnh đó không phải vật cần tìm. Ảnh chưa gán nhãn phải được loại khỏi thư mục, không được để lẫn. Ngoài kiểm tra bằng code, hãy vẽ nhãn lên ảnh và nhìn: đây là cách duy nhất phát hiện hộp lệch, lớp gán nhầm (vít gán thành đai ốc), vật bị bỏ sót không gán.

from pathlib import Path

import cv2

MAU = {0: (0, 200, 0), 1: (0, 140, 255)}
TEN = {0: "vit", 1: "dai_oc"}
o = []
for p in sorted(Path("dataset_vit/images/train").glob("*.png"))[:8]:
    img = cv2.imread(str(p))
    Hh, Ww = img.shape[:2]
    for d in (p.parent.parent.parent / "labels/train" / f"{p.stem}.txt").read_text().split("\n"):
        if not d:
            continue
        k, cx, cy, w, h = int(d.split()[0]), *map(float, d.split()[1:])
        x1, y1 = round((cx - w / 2) * Ww), round((cy - h / 2) * Hh)
        x2, y2 = round((cx + w / 2) * Ww), round((cy + h / 2) * Hh)
        cv2.rectangle(img, (x1, y1), (x2, y2), MAU[k], 1)
        cv2.putText(img, TEN[k], (x1, max(10, y1 - 3)), cv2.FONT_HERSHEY_SIMPLEX, 0.35, MAU[k], 1)
    cv2.putText(img, p.name, (4, 250), cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 255, 0), 1)
    o.append(img)
cv2.imwrite("kiem_tra_nhan.png", cv2.vconcat([cv2.hconcat(o[:4]), cv2.hconcat(o[4:])]))
print("đã lưu kiem_tra_nhan.png:", cv2.imread("kiem_tra_nhan.png").shape)
đã lưu kiem_tra_nhan.png: (512, 1280, 3)

Mở ảnh: mỗi vít có hộp xanh lá, mỗi đai ốc hộp cam, hộp ôm sát mép vật. Với dữ liệu thật, hãy xem ngẫu nhiên vài chục ảnh mỗi lần có người gán nhãn mới tham gia. Quy ước gán nhãn (hộp ôm sát hay chừa viền, vật bị che quá nửa có gán không, vật cắt ngang mép ảnh xử lý thế nào) phải viết thành văn bản, như bài "Tự xây dataset ảnh công nghiệp" ở chặng 11 đã nhấn mạnh.

5. Huấn luyện

Khi dữ liệu sạch, huấn luyện chỉ là vài dòng. Đoạn sau chạy trên máy của bạn (có GPU NVIDIA sẽ nhanh hơn CPU hàng chục lần; không có GPU thì dùng device="cpu" với ít epoch, hoặc một dịch vụ notebook có GPU):

# (minh họa) cần ultralytics và trọng số yolo26n.pt; nên có GPU
from ultralytics import YOLO

model = YOLO("yolo26n.pt")              # bắt đầu từ trọng số COCO (transfer learning)
model.train(
    data="data.yaml",
    epochs=100,                          # số lần đi qua toàn bộ tập train
    imgsz=640,                           # cạnh ảnh đưa vào mạng; ảnh được co giữ tỉ lệ + đệm viền
    batch=16,                            # giảm nếu GPU hết bộ nhớ; batch=-1 để thư viện tự chọn
    patience=30,                         # dừng sớm nếu 30 epoch liền không cải thiện trên val
    device=0,                            # GPU số 0; "cpu" nếu không có GPU
    seed=0,
    project="runs_vit", name="yolo26n_640",
)
Tham sốÝ nghĩa và cách chọn
epochs50–150 là mức hợp lý cho vài trăm ảnh với trọng số pretrained. Kết hợp patience để không phí thời gian khi mô hình đã bão hòa.
imgszMặc định 640. Vật nhỏ: tính kích thước vật sau khi co ảnh. Ảnh 2448 px co về 640 px thì vật 24 px chỉ còn khoảng 6 px, quá nhỏ. Khi đó tăng imgsz (1024, 1280) hoặc cắt ảnh thành ô (tiling) hay cắt ROI trước.
batchLớn hơn thì ổn định hơn nhưng tốn bộ nhớ GPU. imgsz tăng gấp đôi thì bộ nhớ tăng khoảng 4 lần.
Cỡ mô hìnhBắt đầu với yolo26n hoặc yolo26s. Chỉ tăng cỡ khi đã chắc vấn đề không nằm ở dữ liệu, và vẫn đáp ứng cycle time.
AugmentationThư viện bật sẵn nhiều phép tăng cường (ghép mosaic, đổi màu HSV, co giãn, lật ngang với fliplr=0.5). Nếu hướng trái/phải của vật có ý nghĩa (chữ, chi tiết bất đối xứng, "lắp ngược" là lỗi), đặt fliplr=0.

Kết quả nằm trong runs_vit/yolo26n_640/:

FileDùng để làm gì
weights/best.ptTrọng số tốt nhất trên tập val (theo chỉ số tổng hợp thiên về mAP50-95). Đây là file bạn kiểm tra, xuất và triển khai.
weights/last.ptTrọng số epoch cuối, dùng để huấn luyện tiếp khi bị ngắt: YOLO("…/last.pt").train(resume=True).
results.csv, results.pngLoss và chỉ số theo từng epoch. Loss train giảm mà chỉ số val đứng yên hoặc giảm: overfitting.
train_batch*.jpgẢnh train sau augmentation, có vẽ nhãn. Xem để chắc nhãn đọc đúng và augmentation hợp lý.
val_batch*_labels.jpg / val_batch*_pred.jpgNhãn thật và dự đoán trên ảnh val, đặt cạnh nhau để so.
Ma trận nhầm lẫn, đường cong PR/F1Lớp nào bị nhầm với lớp nào hoặc với nền; giải thích ở bài sau.

6. Kiểm tra và dự đoán

val chạy mô hình trên một tập có nhãn và tính chỉ số. metrics.box.map50 là mAP50 (độ chính xác trung bình với ngưỡng trùng khớp IoU 0,5), metrics.box.map là mAP50-95. Hai con số này được định nghĩa và tự tính tay ở bài sau; ở đây chỉ cần biết: càng gần 1 càng tốt, và mAP50 là con số bài thực hành yêu cầu báo cáo.

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

model = YOLO("runs_vit/yolo26n_640/weights/best.pt")
metrics = model.val(data="data.yaml", split="val", imgsz=640)
print(f"mAP50 = {metrics.box.map50:.3f} | mAP50-95 = {metrics.box.map:.3f}")
print("mAP50-95 từng lớp:", {model.names[i]: round(float(v), 3) for i, v in enumerate(metrics.box.maps)})

# Dự đoán cả thư mục; stream=True để không giữ mọi kết quả trong RAM
for r in model.predict("anh_moi/", conf=0.25, imgsz=640, stream=True):
    xyxy = r.boxes.xyxy.cpu().numpy()     # (N, 4) pixel ảnh gốc
    conf = r.boxes.conf.cpu().numpy()     # (N,)
    cls = r.boxes.cls.cpu().numpy().astype(int)
    print(r.path, len(xyxy), "vật")

Ba điều cần nhớ khi dự đoán:

  • conf là ngưỡng điểm tin cậy: hộp có điểm thấp hơn bị bỏ. Mặc định 0,25 là mức để xem thử, không phải ngưỡng sản xuất; ngưỡng sản xuất được chọn theo escape và overkill trên tập kiểm định (bài "Chọn ngưỡng và đánh giá theo escape, overkill, cycle time").
  • imgsz khi dự đoán nên bằng imgsz khi huấn luyện, nếu không kích thước vật mà mạng "thấy" sẽ khác lúc học.
  • Tọa độ trong r.boxes.xyxy đã được đổi về pixel của ảnh gốc, dùng thẳng được với phép quy đổi pixel sang mm của chặng 8.

7. Từ hộp bao đến quyết định OK/NG

PLC không cần danh sách hộp; nó cần một kết quả OK/NG (và mã lỗi). Phần hậu xử lý này là code của bạn, không phải của Ultralytics, và là nơi đặt các quy tắc của khách hàng. Ví dụ khay phải có đúng 4 vít ở 4 lỗ cố định và 2 đai ốc. Dưới đây dùng mảng NumPy có cùng dạng với r.boxes.xyxy, conf, cls ở trên (giá trị mô phỏng), để chạy được không cần mô hình:

import numpy as np

LO_VIT = np.array([(60, 60), (260, 60), (60, 196), (260, 196)])   # tâm 4 lỗ vít trên khay, pixel
DUNG_SAI_PX = 15


def quyet_dinh(xyxy, conf, cls, nguong=0.5):
    giu = conf >= nguong
    xyxy, cls = xyxy[giu], cls[giu]
    tam = (xyxy[:, :2] + xyxy[:, 2:]) / 2
    ly_do = []
    tam_vit = tam[cls == 0]
    for k, lo in enumerate(LO_VIT):                                   # mỗi lỗ phải có 1 vít gần đó
        d = np.hypot(*(tam_vit - lo).T) if len(tam_vit) else np.array([np.inf])
        if d.min() > DUNG_SAI_PX:
            ly_do.append(f"thiếu vít ở lỗ {k + 1}")
    if (cls == 0).sum() > len(LO_VIT):
        ly_do.append(f"thừa vít ({(cls == 0).sum()})")
    if (cls == 1).sum() != 2:
        ly_do.append(f"cần 2 đai ốc, thấy {(cls == 1).sum()}")
    return ("NG", ly_do) if ly_do else ("OK", [])


du = np.array([[48, 48, 72, 72], [249, 47, 273, 71], [47, 185, 71, 209], [248, 184, 272, 208],
               [140, 100, 170, 128], [150, 150, 180, 178]], float)
lop = np.array([0, 0, 0, 0, 1, 1])
ca = {
    "khay đủ": (du, np.array([.93, .91, .95, .90, .88, .92]), lop),
    "thiếu vít lỗ 3": (du[[0, 1, 3, 4, 5]], np.array([.93, .91, .90, .88, .92]), lop[[0, 1, 3, 4, 5]]),
    "vít lỗ 4 điểm 0,42": (du, np.array([.93, .91, .95, .42, .88, .92]), lop),
}
for ten, (b, c, k) in ca.items():
    for nguong in (0.5, 0.3):
        kq, ly_do = quyet_dinh(b, c, k, nguong)
        print(f"{ten:20s} ngưỡng {nguong}: {kq} {ly_do}")
khay đủ              ngưỡng 0.5: OK []
khay đủ              ngưỡng 0.3: OK []
thiếu vít lỗ 3       ngưỡng 0.5: NG ['thiếu vít ở lỗ 3']
thiếu vít lỗ 3       ngưỡng 0.3: NG ['thiếu vít ở lỗ 3']
vít lỗ 4 điểm 0,42   ngưỡng 0.5: NG ['thiếu vít ở lỗ 4']
vít lỗ 4 điểm 0,42   ngưỡng 0.3: OK []

Trường hợp thứ ba là bài học quan trọng: vít có thật nhưng mô hình chỉ cho điểm 0,42 (ví dụ do phản xạ). Ở ngưỡng 0,5 khay bị loại nhầm (overkill); ở ngưỡng 0,3 thì đúng, nhưng ngưỡng thấp lại có nguy cơ nhận vệt sáng thành vít, tức là lọt lỗi thiếu vít (escape). Không có ngưỡng nào "đúng" khi chỉ nhìn một ảnh: phải đo trên hàng trăm ảnh, như bài sau và bài chọn ngưỡng ở chặng 11 hướng dẫn. Lưu ý thêm: kiểm tra theo vị trí (mỗi lỗ một vít) chặt hơn chỉ đếm số lượng, vì đếm đủ 4 vít vẫn có thể có 2 vít nằm sai chỗ.

8. Xuất mô hình để triển khai

File .pt cần Python và PyTorch. Máy tính trạm thường chạy phần mềm C# hoặc C++, nên ta xuất sang định dạng trung gian:

# (minh họa) cần ultralytics và best.pt
from ultralytics import YOLO

model = YOLO("runs_vit/yolo26n_640/weights/best.pt")
duong_dan = model.export(format="onnx", imgsz=640)    # tạo best.onnx cạnh best.pt, trả về đường dẫn
# model.export(format="openvino")                     # CPU Intel
# model.export(format="engine", half=True)            # TensorRT, phải xuất trên đúng máy có GPU đích

kiem_tra = YOLO(duong_dan)                            # Ultralytics nạp được cả .onnx để so sánh nhanh
print(len(kiem_tra.predict("anh_test.png", conf=0.25)[0].boxes), "hộp")

Khi tự chạy file ONNX (ONNX Runtime trong C# hoặc Python), bạn phải tự làm đúng phần tiền xử lý mà Ultralytics làm ngầm: letterbox (co ảnh giữ tỉ lệ về imgsz, đệm viền màu xám 114), đổi BGR sang RGB, chia 255, đổi trục HWC sang CHW; và phần ngược lại: đổi tọa độ hộp từ ảnh letterbox về ảnh gốc. Sai bước này là lỗi số một khi triển khai: mô hình "chạy" nhưng hộp lệch hoặc điểm tin cậy thấp bất thường.

import cv2
import numpy as np


def letterbox(img, size=640, mau=114):
    h, w = img.shape[:2]
    s = min(size / h, size / w)                                  # hệ số co, giữ tỉ lệ
    nh, nw = round(h * s), round(w * s)
    top, left = (size - nh) // 2, (size - nw) // 2               # đệm đều hai phía
    out = np.full((size, size, 3), mau, np.uint8)
    out[top:top + nh, left:left + nw] = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
    return out, s, left, top


def ve_anh_goc(hop, s, left, top):
    x1, y1, x2, y2 = hop
    return ((x1 - left) / s, (y1 - top) / s, (x2 - left) / s, (y2 - top) / s)


anh = np.zeros((1024, 1280, 3), np.uint8)                        # ảnh camera 1280×1024
vao, s, left, top = letterbox(anh)
blob = vao[:, :, ::-1].transpose(2, 0, 1)[None].astype(np.float32) / 255   # BGR→RGB, HWC→CHW, /255
print("ảnh vào mạng:", blob.shape, "| hệ số co", s, "| đệm trái", left, "trên", top)
print("hộp trên ảnh 640 → ảnh gốc:", ve_anh_goc((200, 150, 280, 210), s, left, top))
ảnh vào mạng: (1, 3, 640, 640) | hệ số co 0.5 | đệm trái 0 trên 64
hộp trên ảnh 640 → ảnh gốc: (400.0, 172.0, 560.0, 292.0)

Chi tiết đầy đủ (kiểm tra đầu vào/đầu ra của file ONNX, so sánh số với PyTorch, thẻ mô hình) nằm ở bài "Xuất ONNX và kiểm tra kết quả khớp PyTorch" của chặng 13. Một điểm riêng của YOLO26: vì là end-to-end, đầu ra ONNX đã là danh sách hộp cuối cùng, không cần viết NMS; nhưng luôn in tên và shape đầu ra của file trước khi viết code giải mã, đừng chép code giải mã của YOLOv8/YOLO11.

9. Bài thực hành: YOLO26n trên dữ liệu của bạn

Yêu cầu: huấn luyện YOLO26n trên bộ dữ liệu nhỏ của bạn (ít nhất 100 ảnh) và báo cáo mAP50. Quy trình đề xuất:

  1. Chọn đề bài có 1–3 lớp vật thấy rõ: linh kiện trên bàn (vít, đai ốc, tụ điện), nắp chai, viên thuốc. Chụp ít nhất 100 ảnh bằng điện thoại hoặc webcam, thay đổi vị trí, số lượng, nền, ánh sáng; thêm khoảng 10% ảnh không có vật.
  2. Gán nhãn bằng CVAT hoặc Label Studio, xuất định dạng YOLO. Viết quy ước gán nhãn trước khi bắt đầu.
  3. Chia train/val (ví dụ 80/20) theo buổi chụp, không trộn ngẫu nhiên các ảnh chụp liên tiếp. Nếu được, giữ thêm 20–30 ảnh chụp hôm khác làm test.
  4. Chạy script kiểm tra nhãn ở mục 4 và vẽ nhãn ra ảnh; sửa đến khi 0 lỗi.
  5. Huấn luyện 50–100 epoch, imgsz=640, rồi val trên tập val và tập test.
  6. Báo cáo theo bảng dưới, kèm 3 ảnh mô hình sai và giải thích vì sao.
MụcVí dụ ghi
Dữ liệu120 ảnh (96 train / 24 val), 2 lớp, 242 vật train, 12 ảnh nền
Cấu hìnhyolo26n, imgsz 640, 100 epoch (dừng ở epoch …), batch 16, GPU/CPU gì
Kết quả valmAP50 = …, mAP50-95 = …, mAP50 từng lớp …
Kết quả test (hôm khác)mAP50 = … (thường thấp hơn val; chênh nhiều là dấu hiệu dữ liệu chưa đa dạng)
Lỗi điển hình3 ảnh sai + nguyên nhân + hướng sửa (thêm dữ liệu loại nào)

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

  • Huấn luyện ngay khi chưa nhìn nhãn: hộp lệch vì nhầm định dạng, lớp đảo thứ tự. Luôn chạy script kiểm tra và vẽ nhãn.
  • Ảnh chưa gán nhãn để lẫn trong thư mục images: thành ảnh nền, dạy mô hình bỏ qua vật.
  • Val giống hệt train (ảnh liên tiếp chia ngẫu nhiên): mAP50 0,99 trên val, thất bại ở lô sau.
  • Tăng epoch hoặc cỡ mô hình thay vì sửa dữ liệu. Xem các ảnh val bị sai, bổ sung đúng loại ảnh đó (góc chụp, phản xạ, vật dính nhau).
  • Vật quá nhỏ sau khi co về imgsz: tính trước kích thước vật theo pixel ở độ phân giải mạng; dưới khoảng 8–10 px là rủi ro.
  • Dùng ngưỡng conf mặc định 0,25 trong sản xuất mà không đo escape/overkill.
  • Tiền xử lý khi triển khai khác lúc huấn luyện (resize kéo giãn thay vì letterbox, quên BGR→RGB).

11. Bài tập

Bài 1. Camera 5 MP cho ảnh 2448×2048. Một đai ốc (lớp 1) có góc trên trái (1000, 800), rộng 120 px, cao 100 px. Viết dòng nhãn YOLO. Ngược lại, nhãn 0 0.5 0.25 0.1 0.2 trên ảnh này là hộp pixel nào? Nếu huấn luyện với imgsz=640, đai ốc còn khoảng bao nhiêu pixel?

Xem đáp án
W, H = 2448, 2048
x1, y1, w, h = 1000, 800, 120, 100
print(f"1 {(x1 + w / 2) / W:.6f} {(y1 + h / 2) / H:.6f} {w / W:.6f} {h / H:.6f}")
cx, cy, ww, hh = 0.5, 0.25, 0.1, 0.2
print([round(v, 1) for v in ((cx - ww / 2) * W, (cy - hh / 2) * H, (cx + ww / 2) * W, (cy + hh / 2) * H)])
s = 640 / max(W, H)
print(f"hệ số co {s:.4f} → đai ốc còn {w * s:.1f} × {h * s:.1f} px")
1 0.433007 0.415039 0.049020 0.048828
[1101.6, 307.2, 1346.4, 716.8]
hệ số co 0.2614 → đai ốc còn 31.4 × 26.1 px

Đai ốc còn khoảng 31×26 px ở độ phân giải mạng, đủ lớn để phát hiện. Nếu vật chỉ 30 px trên ảnh gốc thì còn khoảng 8 px: nên tăng imgsz hoặc cắt ROI.

Bài 2. Ảnh 640×480, data.yaml có 2 lớp. File nhãn có bốn dòng: 0 0.31 0.42 0.08 0.10, 1 0.97 0.50 0.10 0.10, 1 320 240 40 40, 0 0.55 0.60 0.002 0.003. Dòng nào sai hoặc đáng ngờ, vì sao? Kiểm tra bằng hàm kiem_tra_dataset.

Xem đáp án
import shutil
from pathlib import Path

import cv2
import numpy as np

g = Path("dataset_bai2")
shutil.rmtree(g, ignore_errors=True)
(g / "images/train").mkdir(parents=True)
(g / "labels/train").mkdir(parents=True)
cv2.imwrite(str(g / "images/train/x.png"), np.zeros((480, 640), np.uint8))
(g / "labels/train/x.txt").write_text("0 0.31 0.42 0.08 0.10\n1 0.97 0.50 0.10 0.10\n"
                                      "1 320 240 40 40\n0 0.55 0.60 0.002 0.003\n")
loi, canh_bao = kiem_tra_dataset(g, n_lop=2, tap=("train",))
print("\n".join(loi + canh_bao))
train: 1 ảnh, 0 ảnh nền, số vật theo lớp {0: 2, 1: 1}
[train] x.txt dòng 2: hộp tràn ra ngoài ảnh
[train] x.txt dòng 3: tọa độ > 1, có lẽ chưa chuẩn hóa (pixel?)
[train] x.txt dòng 4: hộp chỉ 1.3×1.4 px, quá nhỏ?

Dòng 1 đúng. Dòng 2: tâm x 0,97, rộng 0,10 nên mép phải ở 1,02, tràn ra ngoài ảnh (thường do công cụ xuất sai hoặc vật cắt mép ảnh; cần kẹp về 1). Dòng 3: tọa độ pixel chưa chuẩn hóa. Dòng 4: hộp 1,3×1,4 px, gần như chắc chắn là cú nhấp chuột nhầm khi gán nhãn.

Bài 3. Bạn làm bài thực hành và được mAP50 = 0,97 trên val, nhưng chạy thử trên 30 ảnh chụp hôm sau thì mô hình bỏ sót nhiều vật. Liệt kê ít nhất ba nguyên nhân có thể và cách kiểm tra từng nguyên nhân.

Xem đáp án
  • Val rò rỉ từ train: ảnh val chụp cùng buổi, cùng bố cục với train. Kiểm tra: chia lại theo buổi chụp, val trên ảnh hôm sau (gán nhãn 30 ảnh đó, chạy model.val với chúng làm tập test). mAP50 test thấp hơn hẳn val xác nhận nguyên nhân.
  • Điều kiện chụp thay đổi: ánh sáng, nền, khoảng cách camera. Kiểm tra: so độ sáng trung bình, kích thước vật (pixel) giữa hai bộ ảnh. Sửa: thêm ảnh đa dạng điều kiện, cố định chiếu sáng (chặng 6).
  • Ngưỡng conf quá cao cho dữ liệu mới: xem điểm tin cậy của các vật bị sót, nếu nằm quanh 0,2–0,4 thì mô hình "thấy" nhưng chưa chắc. Sửa bằng dữ liệu, không chỉ hạ ngưỡng.
  • Khác imgsz giữa huấn luyện và dự đoán, hoặc ảnh mới có độ phân giải khác: vật nhỏ đi so với lúc học.

Báo cáo trung thực cả mAP50 val và mAP50 test; con số trên ảnh hôm khác mới gần với hiệu năng thật trên dây chuyền.

Tóm tắt

  • Object Detection trả về hộp bao + lớp + điểm tin cậy; YOLO26 là thế hệ mới của Ultralytics, end-to-end không cần NMS, nhiều cỡ từ n đến x; lưu ý giấy phép AGPL-3.0.
  • Nhãn YOLO: mỗi ảnh một file .txt, mỗi dòng class cx cy w h chuẩn hóa 0–1; ảnh nền có file rỗng; thư mục images/ và labels/ song song; data.yaml với path tuyệt đối và names đúng thứ tự.
  • Kiểm tra nhãn bằng script (lớp, chuẩn hóa, tràn ảnh, trùng, quá nhỏ, thiếu file) và vẽ nhãn ra ảnh trước khi huấn luyện.
  • model.train(data, epochs, imgsz, batch, patience); kết quả trong runs/…/weights/best.pt; model.val() cho metrics.box.map50 và metrics.box.map; model.predict(conf, imgsz) cho hộp theo pixel ảnh gốc.
  • Quyết định OK/NG là code hậu xử lý của bạn (đếm, kiểm tra vị trí), ngưỡng conf chọn theo escape/overkill.
  • model.export(format="onnx"); khi tự chạy ONNX phải làm đúng letterbox và đổi tọa độ về ảnh gốc.

Bài tiếp theo: IoU, mAP: đánh giá Object Detection, tự tính IoU, NMS, precision, recall và mAP bằng NumPy để hiểu thật sự các con số của model.val().

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

  1. Huấn luyện YOLO26n trên bộ dữ liệu nhỏ của bạn (ít nhất 100 ảnh) và báo cáo mAP50.

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