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

IoU, mAP50 và mAP50-95: tự tính và chẩn đoán mô hình Object Detection

Tự viết IoU, NMS, ghép TP/FP/FN, AP và mAP bằng NumPy, đọc đúng chỉ số Ultralytics, chẩn đoán mô hình và đổi sang escape, overkill.

IoU, mAP50 và mAP50-95: tự tính và chẩn đoán mô hình Object Detection

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

  • Hiểu IoU, precision, recall theo ngưỡng
  • Đọc mAP50 và mAP50-95
  • Chẩn đoán mô hình từ chỉ số

Nên học trước: Object Detection với YOLO26

Nội dung bài viết
  1. 1. IoU: hai hộp trùng nhau bao nhiêu
  2. 2. NMS: bỏ các hộp trùng lặp
  3. 3. Ghép dự đoán với nhãn thật: TP, FP, FN
  4. 4. Precision, recall theo ngưỡng tin cậy và đường cong PR
  5. 5. AP và mAP50
  6. 6. mAP50-95: đòi hỏi hộp khít
  7. 7. Đọc kết quả của Ultralytics
  8. 8. Chẩn đoán mô hình từ chỉ số
  9. 9. Sai lầm thường gặp
  10. 10. Bài tập
  11. Tóm tắt

Ở bài "Object Detection với YOLO26", lệnh model.val() in ra precision, recall, mAP50, mAP50-95 và bạn được yêu cầu báo cáo mAP50. Nhưng mAP50 = 0,92 nghĩa là gì? Vì sao mAP50-95 chỉ 0,61? Mô hình đang bỏ sót, báo nhầm hay chỉ khoanh hộp chưa khít? Bài này trả lời bằng cách tự tính mọi con số bằng NumPy trên một tập dự đoán nhỏ mà bạn có thể kiểm tra bằng tay: IoU, NMS, ghép dự đoán với nhãn thật, precision và recall theo ngưỡng, đường cong PR, AP, mAP50 và mAP50-95. Sau đó ta mô phỏng bốn "mô hình" có bệnh khác nhau để học cách chẩn đoán từ chỉ số, và nối mAP với ngôn ngữ của nhà máy: escape và overkill ở một ngưỡng cố định. Bài sau, "Từ R-CNN đến Faster R-CNN", mở bên trong một detector để thấy IoU và NMS được dùng ở đâu; tiếp đó bài "Phân đoạn ảnh: semantic, instance, panoptic" dùng lại IoU nhưng tính trên mặt nạ thay vì hộp.

Mục tiêu cuối bàiTự viết hàm IoU và kiểm tra với các cặp hộp cho trước; hiểu vì sao IoU khắt khe với vật nhỏ; cài NMS; ghép dự đoán với nhãn thật để có TP, FP, FN ở một ngưỡng IoU; tính precision, recall theo ngưỡng tin cậy, AP (nội suy toàn điểm và 101 điểm), mAP50, mAP50-95; đọc đúng các con số của Ultralytics; chẩn đoán mô hình từ tổ hợp chỉ số; đổi sang escape/overkill cấp ảnh cho nhà máy.

1. IoU: hai hộp trùng nhau bao nhiêu

IoU (Intersection over Union) là diện tích phần giao chia cho diện tích phần hợp của hai hộp:

IoU = S(A ∩ B) / S(A ∪ B) = S(A ∩ B) / (S(A) + S(B) − S(A ∩ B))

IoU = 1 khi hai hộp trùng khít, 0 khi không chạm nhau. Hộp viết dạng (x1, y1, x2, y2) theo pixel, tọa độ liên tục (rộng = x2 − x1, không cộng 1; một số code cũ của Pascal VOC cộng 1, đừng trộn hai quy ước). Đây là bài thực hành của bài: viết hàm và kiểm tra với các cặp hộp đã tính tay.

def iou(a, b):
    """a, b: (x1, y1, x2, y2). Trả về IoU trong [0, 1]."""
    rong = max(0.0, min(a[2], b[2]) - max(a[0], b[0]))      # bề rộng phần giao (0 nếu không giao)
    cao = max(0.0, min(a[3], b[3]) - max(a[1], b[1]))
    giao = rong * cao
    hop = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - giao
    return giao / hop if hop > 0 else 0.0


cap = [
    ("lệch chéo",     (10, 10, 60, 60), (30, 30, 80, 80), 900 / 4100),
    ("trùng khít",    (0, 0, 100, 100), (0, 0, 100, 100), 1.0),
    ("tách rời",      (0, 0, 10, 10),   (20, 20, 30, 30), 0.0),
    ("nằm trong",     (0, 0, 100, 100), (25, 25, 75, 75), 2500 / 10000),
    ("chạm cạnh",     (0, 0, 10, 10),   (10, 0, 20, 10),  0.0),
]
for ten, a, b, ky_vong in cap:
    v = iou(a, b)
    assert abs(v - ky_vong) < 1e-9 and abs(v - iou(b, a)) < 1e-12     # đúng giá trị tay và đối xứng
    print(f"{ten:11s}: IoU = {v:.4f}  (tính tay {ky_vong:.4f})")
lệch chéo  : IoU = 0.2195  (tính tay 0.2195)
trùng khít : IoU = 1.0000  (tính tay 1.0000)
tách rời   : IoU = 0.0000  (tính tay 0.0000)
nằm trong  : IoU = 0.2500  (tính tay 0.2500)
chạm cạnh  : IoU = 0.0000  (tính tay 0.0000)

Cặp "lệch chéo": phần giao là hình vuông 30×30 = 900, mỗi hộp 50×50 = 2500, hợp = 2500 + 2500 − 900 = 4100, IoU ≈ 0,22. Hai hộp nhìn khá gần nhau nhưng IoU rất thấp: IoU là thước đo khắt khe. Cặp "nằm trong": hộp nhỏ nằm trọn trong hộp lớn vẫn chỉ có IoU 0,25, vì IoU phạt cả việc hộp dự đoán quá to.

Khi đánh giá, ta cần IoU giữa mọi dự đoán và mọi nhãn thật. Viết bằng phép toán mảng (broadcasting) nhanh hơn vòng lặp hàng trăm lần, và kiểm tra chéo với torchvision.ops.box_iou:

import numpy as np
import torch
from torchvision.ops import box_iou


def iou_ma_tran(A, B):
    """A: (N, 4), B: (M, 4) → ma trận IoU (N, M)."""
    A, B = np.asarray(A, float)[:, None], np.asarray(B, float)[None]
    rong = (np.minimum(A[..., 2], B[..., 2]) - np.maximum(A[..., 0], B[..., 0])).clip(0)
    cao = (np.minimum(A[..., 3], B[..., 3]) - np.maximum(A[..., 1], B[..., 1])).clip(0)
    giao = rong * cao
    dt = lambda X: (X[..., 2] - X[..., 0]) * (X[..., 3] - X[..., 1])
    return giao / (dt(A) + dt(B) - giao)


rng = np.random.default_rng(0)
xy = rng.uniform(0, 500, (200, 2))
A = np.hstack([xy, xy + rng.uniform(10, 80, (200, 2))])
B = A[:150] + rng.normal(0, 8, (150, 4))
M = iou_ma_tran(A, B)
M_tv = box_iou(torch.tensor(A), torch.tensor(B)).numpy()
print("shape", M.shape, "| lệch lớn nhất so với torchvision:", float(np.abs(M - M_tv).max()))
print("vòng lặp cho cùng kết quả:", np.isclose(M[3, 7], iou(A[3], B[7])))
shape (200, 150) | lệch lớn nhất so với torchvision: 0.0
vòng lặp cho cùng kết quả: True

IoU khắt khe với vật nhỏ

Cùng một sai lệch 2 pixel (cỡ sai số gán nhãn bằng tay) ảnh hưởng tới IoU rất khác nhau tùy kích thước vật:

for canh in (8, 16, 32, 64, 128):
    that = (100, 100, 100 + canh, 100 + canh)
    lech = (102, 102, 102 + canh, 102 + canh)              # dịch 2 px theo cả x và y
    print(f"vật {canh:3d} px: IoU khi lệch 2 px = {iou(that, lech):.2f}")
vật   8 px: IoU khi lệch 2 px = 0.39
vật  16 px: IoU khi lệch 2 px = 0.62
vật  32 px: IoU khi lệch 2 px = 0.78
vật  64 px: IoU khi lệch 2 px = 0.88
vật 128 px: IoU khi lệch 2 px = 0.94

Vật 8 px lệch 2 px chỉ còn IoU 0,39: dưới ngưỡng 0,5, dự đoán gần như đúng vẫn bị tính là sai. Đây là lý do mAP của vết lỗi nhỏ luôn thấp hơn vật lớn, và vì sao quy ước gán nhãn nhất quán (hộp ôm sát hay chừa viền) quan trọng với vật nhỏ: sai lệch giữa hai người gán nhãn đã đủ làm tụt chỉ số.

Ba định dạng hộp hay gặp

Cùng một hộp, mỗi bộ dữ liệu và thư viện lưu một kiểu; nhầm định dạng là nguồn lỗi kinh điển (hộp lệch nửa kích thước, IoU thấp bất thường):

Định dạngÝ nghĩaDùng ở
xyxy(x1, y1, x2, y2) theo pixelPascal VOC, torchvision, bài này
xywh(x1, y1, rộng, cao) theo pixelMS COCO
cxcywh chuẩn hóa(tâm x, tâm y, rộng, cao) chia cho kích thước ảnh, trong [0, 1]File nhãn YOLO
import torch
from torchvision.ops import box_convert

coco = torch.tensor([[50.0, 60.0, 100.0, 80.0]])                 # COCO: x, y, w, h (pixel)
xyxy = box_convert(coco, in_fmt="xywh", out_fmt="xyxy")
print(xyxy)                                                       # tensor([[ 50.,  60., 150., 140.]])
cxcywh = box_convert(xyxy, in_fmt="xyxy", out_fmt="cxcywh")
W, H = 640, 480
print(cxcywh / torch.tensor([W, H, W, H]))                        # dạng YOLO: tensor([[0.1562, 0.2083, 0.1562, 0.1667]])

2. NMS: bỏ các hộp trùng lặp

Đa số mô hình phát hiện (YOLOv8, YOLO11, Faster R-CNN…) dự đoán nhiều hộp chồng lên nhau cho cùng một vật. NMS (Non-Maximum Suppression) dọn dẹp: sắp xếp hộp theo điểm tin cậy giảm dần, giữ hộp cao nhất, bỏ mọi hộp cùng lớp có IoU với nó vượt ngưỡng (thường 0,45–0,7), lặp lại với các hộp còn lại. YOLO26 là mô hình end-to-end, tự học cách chỉ trả một hộp cho mỗi vật nên không cần bước này; nhưng bạn vẫn gặp NMS khi dùng các mô hình khác, khi ghép kết quả từ nhiều ô ảnh (tiling) chồng lấn, và khi đọc code triển khai cũ.

import numpy as np
import torch
from torchvision.ops import nms


def nms_numpy(hop, diem, nguong_iou=0.5):
    """Trả về chỉ số các hộp được giữ, theo thứ tự điểm giảm dần (một lớp)."""
    thu_tu = np.argsort(-diem)
    giu = []
    while len(thu_tu):
        i = thu_tu[0]
        giu.append(int(i))
        con_lai = thu_tu[1:]
        thu_tu = con_lai[iou_ma_tran(hop[[i]], hop[con_lai])[0] <= nguong_iou]
    return giu


hop = np.array([[100, 100, 140, 140], [102, 98, 143, 141], [97, 103, 138, 139],    # 3 hộp cho 1 con vít
                [300, 120, 340, 160], [305, 125, 345, 165]], float)               # 2 hộp cho con vít khác
diem = np.array([0.91, 0.88, 0.62, 0.75, 0.80])
print("NumPy      giữ:", nms_numpy(hop, diem, 0.5))
print("torchvision giữ:", nms(torch.tensor(hop), torch.tensor(diem), 0.5).tolist())
NumPy      giữ: [0, 4]
torchvision giữ: [0, 4]

Còn đúng hai hộp, một cho mỗi vít. Ngưỡng IoU của NMS là một tham số thật sự: quá thấp thì hai vật sát nhau (vít trong khay xếp khít) bị gộp làm một, quá cao thì một vật ra hai hộp và bị đếm hai lần.

Trong thư viện, NMS theo từng lớp có sẵn ở torchvision.ops.batched_nms(boxes, scores, labels, iou_threshold): hộp chỉ loại hộp cùng lớp. Khi vật đứng sát nhau (đám đông, linh kiện xếp khít), NMS có thể xóa nhầm một vật thật; biến thể Soft-NMS giảm điểm của hộp chồng lấn thay vì xóa hẳn.

import torch
from torchvision.ops import batched_nms

boxes = torch.tensor([[10., 10., 60., 60.], [12., 12., 62., 62.], [11., 11., 61., 61.]])
scores = torch.tensor([0.9, 0.8, 0.7])
lop = torch.tensor([0, 0, 1])                                     # hộp thứ ba khác lớp
print(batched_nms(boxes, scores, lop, iou_threshold=0.5))         # tensor([0, 2]): chỉ hộp cùng lớp mới loại nhau

3. Ghép dự đoán với nhãn thật: TP, FP, FN

Để chấm điểm, mỗi dự đoán được ghép với tối đa một nhãn thật theo quy tắc (giống COCO):

  1. Xét từng lớp riêng. Sắp các dự đoán của lớp đó (trên toàn bộ tập ảnh) theo điểm tin cậy giảm dần.
  2. Với mỗi dự đoán, tìm trong cùng ảnh nhãn thật cùng lớp chưa được ghép có IoU lớn nhất. Nếu IoU ≥ ngưỡng (ví dụ 0,5): TP (True Positive), đánh dấu nhãn đó đã ghép. Nếu không: FP (False Positive).
  3. Nhãn thật không được dự đoán nào ghép: FN (False Negative, bỏ sót).

Hệ quả: hộp trùng lặp cho một vật đã có TP sẽ thành FP; hộp đúng vị trí nhưng sai lớp là FP của lớp này và để lại FN ở lớp kia. Tập dữ liệu nhỏ dưới đây (3 ảnh, 2 lớp như bài trước) được thiết kế để có đủ các tình huống: hộp tốt, hộp lệch, hộp trùng, báo nhầm trên nền, bỏ sót.

import numpy as np

TEN = {0: "vit", 1: "dai_oc"}
# nhãn thật: (ảnh, lớp, x1, y1, x2, y2)
GT = [(1, 0, 10, 10, 50, 50), (1, 0, 100, 20, 140, 60), (1, 1, 200, 100, 250, 150),
      (2, 0, 30, 30, 70, 70), (2, 1, 120, 80, 170, 130), (2, 1, 220, 40, 270, 90),
      (3, 0, 50, 100, 90, 140), (3, 0, 150, 150, 190, 190)]
# dự đoán: (ảnh, lớp, điểm, x1, y1, x2, y2)
PRED = [(1, 0, .95, 11, 11, 51, 49), (1, 0, .80, 104, 22, 146, 62), (1, 0, .40, 12, 8, 52, 50),
        (1, 1, .90, 202, 103, 252, 148),
        (2, 0, .85, 36, 36, 76, 76), (2, 1, .70, 121, 82, 168, 131), (2, 1, .30, 300, 10, 340, 50),
        (3, 0, .60, 50, 100, 90, 140), (3, 0, .55, 160, 160, 200, 200), (3, 0, .35, 250, 20, 280, 50)]


def ghep(lop, nguong_iou):
    """Trả về (điểm, cờ TP, IoU tốt nhất) của các dự đoán lớp `lop` theo điểm giảm dần, và số nhãn thật."""
    gt = [g for g in GT if g[1] == lop]
    pr = sorted([p for p in PRED if p[1] == lop], key=lambda p: -p[2])
    da_ghep = [False] * len(gt)
    diem, tp, iou_tot = [], [], []
    for anh, _, s, *hop in pr:
        ung_vien = [(iou(hop, g[2:]), k) for k, g in enumerate(gt) if g[0] == anh and not da_ghep[k]]
        v, k = max(ung_vien, default=(0.0, -1))
        la_tp = v >= nguong_iou
        if la_tp:
            da_ghep[k] = True
        diem.append(s); tp.append(la_tp); iou_tot.append(v)
    return np.array(diem), np.array(tp), np.array(iou_tot), len(gt)


s, tp, v, n_gt = ghep(0, 0.5)
tp_cum, fp_cum = np.cumsum(tp), np.cumsum(~tp)
P, R = tp_cum / (tp_cum + fp_cum), tp_cum / n_gt
print(f"Lớp vit, IoU ≥ 0.5, {n_gt} nhãn thật")
print(" điểm | IoU  | kết quả | TP tích lũy | FP tích lũy | precision | recall")
for k in range(len(s)):
    print(f" {s[k]:.2f} | {v[k]:.2f} | {'TP' if tp[k] else 'FP':7s} | {tp_cum[k]:11d} | {fp_cum[k]:11d} |"
          f" {P[k]:9.3f} | {R[k]:.3f}")
print("FN (bỏ sót) =", n_gt - tp.sum())
Lớp vit, IoU ≥ 0.5, 5 nhãn thật
 điểm | IoU  | kết quả | TP tích lũy | FP tích lũy | precision | recall
 0.95 | 0.90 | TP      |           1 |           0 |     1.000 | 0.200
 0.85 | 0.57 | TP      |           2 |           0 |     1.000 | 0.400
 0.80 | 0.72 | TP      |           3 |           0 |     1.000 | 0.600
 0.60 | 1.00 | TP      |           4 |           0 |     1.000 | 0.800
 0.55 | 0.39 | FP      |           4 |           1 |     0.800 | 0.800
 0.40 | 0.00 | FP      |           4 |           2 |     0.667 | 0.800
 0.35 | 0.00 | FP      |           4 |           3 |     0.571 | 0.800
FN (bỏ sót) = 1

Đọc từng dòng: dự đoán 0,85 lệch 6 px so với vít thật, IoU 0,57, vẫn qua ngưỡng 0,5. Dự đoán 0,55 lệch 10 px, IoU 0,39: là FP, và vít thật ở ảnh 3 trở thành FN. Dự đoán 0,40 trùng với vít đã được hộp 0,95 ghép, không còn nhãn thật nào chưa ghép ở gần (cột IoU vì vậy bằng 0): FP (nếu có NMS, hoặc với YOLO26 end-to-end, hộp này thường đã không còn). Dự đoán 0,35 nằm trên nền: FP.

4. Precision, recall theo ngưỡng tin cậy và đường cong PR

Mỗi dòng của bảng trên tương ứng với một ngưỡng tin cậy: đặt ngưỡng bằng điểm của dòng đó thì chỉ các dự đoán từ dòng đầu đến dòng đó được giữ lại.

  • Precision = TP / (TP + FP): trong các hộp mô hình báo, bao nhiêu phần là đúng. Precision thấp = báo nhầm nhiều.
  • Recall = TP / số nhãn thật: trong các vật thật, bao nhiêu phần được tìm thấy. Recall thấp = bỏ sót nhiều.

Hạ ngưỡng thì recall chỉ tăng hoặc giữ nguyên, precision thường giảm. Vẽ các cặp (recall, precision) được đường cong PR. Ở bảng trên, ngưỡng 0,60 cho precision 1,0 và recall 0,8; hạ xuống 0,35 recall không tăng thêm (vít thứ năm bị hộp lệch, không hộp nào ghép được) mà precision rơi xuống 0,57. Cách chọn ngưỡng theo các con số này (và theo chi phí của nhà máy) đã học ở bài "Precision, Recall và ma trận nhầm lẫn" của chặng 5.

5. AP và mAP50

AP (Average Precision) tóm tắt cả đường cong PR của một lớp thành một số: diện tích dưới đường cong, sau khi làm "phẳng" đường cong bằng cách thay precision ở mỗi mức recall bằng precision lớn nhất ở mọi mức recall lớn hơn hoặc bằng (bao trên, envelope). Hai cách tính phổ biến:

  • Nội suy toàn điểm (Pascal VOC từ 2010): cộng diện tích các bậc thang của bao trên.
  • Nội suy 101 điểm (COCO): lấy trung bình precision của bao trên tại 101 mức recall 0; 0,01; …; 1.

Mức recall chưa đạt được (ở đây trên 0,8) có precision 0, nên bỏ sót làm AP giảm trực tiếp. mAP là trung bình AP của các lớp; mAP50 là mAP khi ngưỡng IoU để tính TP là 0,5.

import numpy as np


def ap_tu_pr(P, R, cach="toan_diem"):
    r = np.concatenate([[0.0], R, [1.0]])
    p = np.concatenate([[1.0], P, [0.0]])
    p = np.maximum.accumulate(p[::-1])[::-1]                     # bao trên: precision không tăng theo recall
    if cach == "toan_diem":
        i = np.where(r[1:] != r[:-1])[0]
        return float(np.sum((r[i + 1] - r[i]) * p[i + 1]))
    muc = np.linspace(0, 1, 101)                                 # COCO 101 điểm
    chi_so = np.searchsorted(r, muc, side="left")                # điểm đầu tiên có recall ≥ mức
    return float(np.mean([p[k] if k < len(p) else 0.0 for k in chi_so]))


def ap_lop(lop, nguong_iou, cach="toan_diem"):
    s, tp, _, n_gt = ghep(lop, nguong_iou)
    tp_cum, fp_cum = np.cumsum(tp), np.cumsum(~tp)
    return ap_tu_pr(tp_cum / (tp_cum + fp_cum), tp_cum / n_gt, cach)


for cach in ("toan_diem", "101_diem"):
    ap = {TEN[k]: ap_lop(k, 0.5, cach) for k in TEN}
    print(f"{cach:9s}: AP50 " + ", ".join(f"{t} = {v:.4f}" for t, v in ap.items())
          + f" → mAP50 = {np.mean(list(ap.values())):.4f}")
toan_diem: AP50 vit = 0.8000, dai_oc = 0.6667 → mAP50 = 0.7333
101_diem : AP50 vit = 0.8020, dai_oc = 0.6634 → mAP50 = 0.7327

Kiểm tra bằng tay lớp vit: bao trên có precision 1,0 từ recall 0 đến 0,8, rồi 0 sau đó; diện tích = 0,8 × 1,0 = 0,8. Cách 101 điểm cho 81/101 ≈ 0,802 vì đếm cả mức recall 0 và 0,8. Lớp dai_oc: precision 1,0 đến recall 2/3, AP ≈ 0,667. Các thư viện khác nhau dùng cách nội suy và quy tắc ghép hơi khác nhau, nên cùng một tập dự đoán có thể cho mAP lệch nhau ở chữ số thập phân thứ hai hoặc thứ ba. Chỉ so sánh mAP tính bằng cùng một công cụ, trên cùng một tập dữ liệu.

Kiểm tra chéo với scikit-learn: average_precision_score tính AP không nội suy (Σ ΔR·P tại mỗi TP) trên các dự đoán đã có, nên chỉ cần nhân thêm tỉ lệ nhãn thật được tìm thấy:

import numpy as np
from sklearn.metrics import average_precision_score

for lop in TEN:
    s, tp, _, n_gt = ghep(lop, 0.5)
    tp_cum, fp_cum = np.cumsum(tp), np.cumsum(~tp)
    P, R = tp_cum / (tp_cum + fp_cum), tp_cum / n_gt
    ap_tay = float(np.sum(np.diff(np.r_[0, R]) * P))                     # không nội suy
    ap_sk = average_precision_score(tp, s) * tp.sum() / n_gt
    print(f"{TEN[lop]:7s}: tự tính {ap_tay:.4f} | scikit-learn {ap_sk:.4f}")
vit    : tự tính 0.8000 | scikit-learn 0.8000
dai_oc : tự tính 0.6667 | scikit-learn 0.6667

6. mAP50-95: đòi hỏi hộp khít

mAP50 chỉ cần hộp trùng một nửa. mAP50-95 (COCO gọi là AP, Ultralytics ghi mAP50-95) lấy trung bình mAP ở 10 ngưỡng IoU 0,50; 0,55; …; 0,95. Hộp phải khít mới giữ được TP ở ngưỡng cao:

import numpy as np

nguong = np.round(np.arange(0.50, 0.96, 0.05), 2)
bang = {TEN[k]: [ap_lop(k, t) for t in nguong] for k in TEN}
print("IoU  | " + " | ".join(f"{t:6s}" for t in bang) + " | mAP")
for i, t in enumerate(nguong):
    hang = [bang[ten][i] for ten in bang]
    print(f"{t:.2f} | " + " | ".join(f"{v:6.3f}" for v in hang) + f" | {np.mean(hang):.3f}")
map50 = np.mean([bang[t][0] for t in bang])
map50_95 = np.mean([np.mean(bang[t]) for t in bang])
print(f"mAP50 = {map50:.3f} | mAP75 = {np.mean([bang[t][5] for t in bang]):.3f} | mAP50-95 = {map50_95:.3f}")
IoU  | vit    | dai_oc | mAP
0.50 |  0.800 |  0.667 | 0.733
0.55 |  0.800 |  0.667 | 0.733
0.60 |  0.500 |  0.667 | 0.583
0.65 |  0.500 |  0.667 | 0.583
0.70 |  0.500 |  0.667 | 0.583
0.75 |  0.300 |  0.667 | 0.483
0.80 |  0.300 |  0.667 | 0.483
0.85 |  0.300 |  0.167 | 0.233
0.90 |  0.300 |  0.000 | 0.150
0.95 |  0.050 |  0.000 | 0.025
mAP50 = 0.733 | mAP75 = 0.483 | mAP50-95 = 0.459

Ở IoU 0,6 hộp lệch 6 px (IoU 0,57) của lớp vit rớt khỏi TP, AP vit giảm; từ 0,85 trở lên chỉ còn các hộp gần như trùng khít; ở 0,95 chỉ hộp trùng hoàn toàn còn được tính. mAP50-95 thấp hơn mAP50 nhiều cho biết: mô hình tìm được vật, nhưng khoanh chưa khít.

Chỉ số nào cho bài toán nàoĐếm vật, kiểm tra có/không: mAP50 (và precision, recall ở ngưỡng chạy) là đủ, vì hộp lệch vài pixel không đổi kết quả đếm. Định vị cho robot gắp, đo vị trí: cần mAP75 hoặc mAP50-95 cao, và tốt hơn nữa là đo trực tiếp sai số tâm hộp theo mm. Ultralytics chọn best.pt theo chỉ số tổng hợp thiên về mAP50-95.

Công cụ đánh giá chuẩn COCO (pycocotools, torchmetrics) còn báo APs, APm, APl: mAP50-95 riêng cho vật nhỏ (diện tích dưới 32² pixel), vừa và lớn (trên 96² pixel). Lỗi bề mặt thường là "vật nhỏ", nên APs thấp là tín hiệu cần tăng độ phân giải ảnh vào hoặc cắt ô (tiling). Lưu ý các chuẩn nội suy khác nhau (VOC2007 dùng 11 điểm, VOC2010 trở đi dùng mọi điểm, COCO dùng 101 điểm), nên chỉ so sánh mAP khi cùng công cụ đánh giá.

Đọc sâu hơn: Object Detection cơ bản: bounding box, IoU, NMS và mAP (trang 141–154 của slide, có ví dụ AP tính tay = 0,867).

7. Đọc kết quả của Ultralytics

# (minh họa) cần ultralytics và best.pt từ bài trước
from ultralytics import YOLO

metrics = YOLO("runs_vit/yolo26n_640/weights/best.pt").val(data="data.yaml", split="val")
b = metrics.box
print(f"P = {b.mp:.3f}, R = {b.mr:.3f}")                    # precision, recall trung bình các lớp
print(f"mAP50 = {b.map50:.3f}, mAP75 = {b.map75:.3f}, mAP50-95 = {b.map:.3f}")
print("mAP50-95 từng lớp:", b.maps)                         # mảng, theo thứ tự names
Cột Ultralytics in raÝ nghĩaTương ứng trong bài
Images, InstancesSố ảnh, số nhãn thật của lớpn_gt
Box(P), RPrecision, recall tại một ngưỡng tin cậy do thư viện chọn (ngưỡng cho F1 trung bình cao nhất), ở IoU 0,5Một dòng của bảng mục 3
mAP50Trung bình AP ở IoU 0,5 trên mọi ngưỡng tin cậyMục 5
mAP50-95Trung bình mAP ở 10 ngưỡng IoUMục 6

Lưu ý: P và R in ra không phải ở ngưỡng conf bạn sẽ dùng trong sản xuất, và mAP được tính trên mọi ngưỡng tin cậy, kể cả những ngưỡng thấp bạn không bao giờ dùng. Khi val, thư viện mặc định dùng ngưỡng conf rất thấp để vẽ được đầy đủ đường PR; đừng so mAP này với kết quả predict ở conf=0.5.

8. Chẩn đoán mô hình từ chỉ số

Để thấy mỗi "bệnh" để lại dấu vết gì trên các chỉ số, ta mô phỏng một tập kiểm tra 300 ảnh vết móp (150 ảnh OK không có lỗi, 150 ảnh NG có 1–2 vết, cỡ 20–60 px) và bốn mô hình giả lập: A tốt; B tìm đúng vật nhưng hộp lỏng lẻo (lệch trung bình 12% kích thước); C bỏ sót 35% vết; D báo nhầm nhiều trên nền. Chỉ số tính bằng các hàm vừa viết.

import numpy as np


def mo_phong(p_thay, lech, p_bao_nham, seed=0):
    rng = np.random.default_rng(seed)
    gt, pred = [], []
    for anh in range(300):
        for _ in range(0 if anh < 150 else int(rng.integers(1, 3))):
            x, y, c = rng.uniform(0, 400), rng.uniform(0, 400), rng.uniform(20, 60)
            gt.append((anh, 0, x, y, x + c, y + c))
            if rng.random() < p_thay:
                d = rng.normal(0, lech * c, 4)
                pred.append((anh, 0, rng.uniform(0.6, 0.99), x + d[0], y + d[1], x + c + d[2], y + c + d[3]))
        if rng.random() < p_bao_nham:                       # báo nhầm trên nền
            x, y, c = rng.uniform(0, 400), rng.uniform(0, 400), rng.uniform(20, 60)
            pred.append((anh, 0, rng.uniform(0.3, 0.9), x, y, x + c, y + c))
    return gt, pred


def danh_gia(gt, pred, nguong_conf=0.5):
    global GT, PRED
    GT, PRED = gt, pred                                      # dùng lại hàm ghep() ở trên
    ap = [ap_lop(0, t) for t in np.round(np.arange(0.5, 0.96, 0.05), 2)]
    s, tp, _, n_gt = ghep(0, 0.5)
    giu = s >= nguong_conf
    P, R = tp[giu].sum() / max(giu.sum(), 1), tp[giu].sum() / n_gt
    co_loi = {g[0] for g in gt}                               # ảnh NG
    bao_ng = {p[0] for p in pred if p[2] >= nguong_conf}      # ảnh có ít nhất 1 hộp ≥ ngưỡng
    escape = len(co_loi - bao_ng) / len(co_loi)
    overkill = len(bao_ng - co_loi) / (300 - len(co_loi))
    return P, R, ap[0], float(np.mean(ap)), escape, overkill


mo_hinh = {"A tốt": (0.97, 0.03, 0.03), "B hộp lỏng": (0.97, 0.12, 0.03),
           "C bỏ sót": (0.65, 0.03, 0.03), "D báo nhầm": (0.97, 0.03, 0.40)}
print("mô hình    |  P@0.5 |  R@0.5 | mAP50 | mAP50-95 | escape ảnh | overkill ảnh")
for ten, tham_so in mo_hinh.items():
    P, R, m50, m5095, esc, ovk = danh_gia(*mo_phong(*tham_so))
    print(f"{ten:10s} | {P:6.3f} | {R:6.3f} | {m50:5.3f} | {m5095:8.3f} | {esc:10.1%} | {ovk:12.1%}")
mô hình    |  P@0.5 |  R@0.5 | mAP50 | mAP50-95 | escape ảnh | overkill ảnh
A tốt      |  0.968 |  0.968 | 0.948 |    0.790 |       1.3% |         2.0%
B hộp lỏng |  0.946 |  0.946 | 0.903 |    0.316 |       1.3% |         2.0%
C bỏ sót   |  0.958 |  0.624 | 0.617 |    0.522 |      24.7% |         2.0%
D báo nhầm |  0.778 |  0.978 | 0.887 |    0.739 |       0.0% |        24.7%

Bảng chẩn đoán rút ra từ mô phỏng (và đúng với mô hình thật):

Dấu hiệuBệnhHướng xử lý
mAP50 cao, mAP50-95 thấp hơn nhiều (B)Tìm đúng nhưng hộp không khítRà quy ước và chất lượng hộp khi gán nhãn; tăng imgsz nếu vật nhỏ; nếu chỉ cần đếm thì có thể chấp nhận
Recall thấp, precision cao (C)Bỏ sót: vật nhỏ, bị che, điều kiện chụp chưa có trong trainXem các FN, thêm ảnh đúng loại đó; tăng imgsz; kiểm tra nhãn thiếu trong train (vật không được gán nhãn dạy mô hình bỏ qua)
Precision thấp, recall cao (D)Báo nhầm nền, vân, phản xạ thành vậtThêm ảnh nền và ảnh "dễ gây nhầm" (file nhãn rỗng); xem ma trận nhầm lẫn cột "background"
Một lớp thấp hẳnÍt dữ liệu, nhãn không nhất quán, hai lớp quá giống nhauMa trận nhầm lẫn; rà nhãn lớp đó; cân nhắc gộp lớp nếu nhà máy không cần phân biệt
mAP val cao, thực tế kémVal không đại diện (rò rỉ từ train, cùng lô)Tập test chụp ngày khác, lô khác

Hai cột cuối là ngôn ngữ của nhà máy: ở ngưỡng 0,5, ảnh NG không có hộp nào là escape (lọt lỗi), ảnh OK có hộp là overkill (loại nhầm). Mô hình B có mAP50-95 tệ nhất nhưng escape và overkill như mô hình A: nếu trạm chỉ cần biết "có vết móp hay không", hộp lỏng không phải vấn đề. Mô hình C có precision gần như mô hình A, nhưng lọt khoảng một phần tư số ảnh lỗi, không thể chấp nhận. Mô hình D không lọt ảnh lỗi nào nhưng loại nhầm một phần tư số sản phẩm tốt. Đó là lý do báo cáo cho nhà máy phải là escape, overkill ở ngưỡng chạy thật, trên tập test đủ lớn, kèm số mẫu (bài "Chọn ngưỡng và đánh giá theo escape, overkill, cycle time"); mAP dùng để so sánh mô hình trong lúc phát triển.

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

  • So mAP giữa hai công cụ (Ultralytics, pycocotools, code tự viết) hoặc giữa hai tập val khác nhau: cách nội suy và ghép khác nhau làm lệch kết quả.
  • Báo cáo mAP50 cho bài toán định vị cần độ chính xác vị trí: hộp lệch 40% vẫn là TP ở IoU 0,5.
  • Đọc P, R của Ultralytics như thể ở ngưỡng sản xuất.
  • Lấy ngưỡng NMS quá thấp khi vật xếp sát nhau: vật bị gộp, đếm thiếu.
  • Đánh giá vật nhỏ bằng IoU 0,5 mà không xem ảnh: vài pixel sai lệch nhãn đủ biến TP thành FP + FN.
  • Quên ảnh OK trong tập đánh giá: mAP chỉ tính trên các vật có thật, không phản ánh overkill trên ảnh sạch nếu tập test ít ảnh nền.

10. Bài tập

Bài 1. (Thực hành của bài) Dùng hàm iou ở mục 1, tính IoU cho ba cặp hộp sau, so với kết quả tính tay: (a) (0, 0, 40, 40) và (20, 0, 60, 40); (b) (10, 10, 110, 60) và (10, 10, 60, 110); (c) (0, 0, 50, 50) và (0, 0, 25, 25).

Xem đáp án

Tính tay: (a) giao 20×40 = 800, hợp 1600 + 1600 − 800 = 2400, IoU = 1/3. (b) giao 50×50 = 2500, mỗi hộp 100×50 = 5000, hợp 7500, IoU = 1/3. (c) hộp nhỏ nằm trong: 625 / 2500 = 0,25.

cap = [((0, 0, 40, 40), (20, 0, 60, 40), 800 / 2400),
       ((10, 10, 110, 60), (10, 10, 60, 110), 2500 / 7500),
       ((0, 0, 50, 50), (0, 0, 25, 25), 625 / 2500)]
for a, b, tay in cap:
    print(f"{a} vs {b}: {iou(a, b):.4f} (tay {tay:.4f})", "khớp" if abs(iou(a, b) - tay) < 1e-9 else "SAI")
(0, 0, 40, 40) vs (20, 0, 60, 40): 0.3333 (tay 0.3333) khớp
(10, 10, 110, 60) vs (10, 10, 60, 110): 0.3333 (tay 0.3333) khớp
(0, 0, 50, 50) vs (0, 0, 25, 25): 0.2500 (tay 0.2500) khớp

Cặp (b) đáng nhớ: hai hộp cùng diện tích, cùng góc trên trái, chỉ xoay hướng dài, IoU chỉ còn 1/3. Đó là lý do vật dài, mảnh và nghiêng (vết xước chéo) đánh giá bằng hộp thẳng rất kém; bài "Phân đoạn ảnh: semantic, instance, panoptic" của chặng này giới thiệu phân đoạn.

Bài 2. Một lớp có 4 nhãn thật. Năm dự đoán theo điểm giảm dần có kết quả TP, FP, TP, TP, FP. Tính precision, recall sau mỗi dự đoán và AP (nội suy toàn điểm) bằng tay, rồi kiểm tra bằng ap_tu_pr.

Xem đáp án

TP tích lũy 1, 1, 2, 3, 3; FP tích lũy 0, 1, 1, 1, 2. Precision 1; 0,5; 0,667; 0,75; 0,6. Recall 0,25; 0,25; 0,5; 0,75; 0,75. Bao trên: precision 1 đến recall 0,25; 0,75 từ 0,25 đến 0,75; sau đó 0. AP = 0,25 × 1 + 0,5 × 0,75 = 0,625.

import numpy as np

tp = np.array([1, 0, 1, 1, 0], bool)
tc, fc = np.cumsum(tp), np.cumsum(~tp)
print("AP =", round(ap_tu_pr(tc / (tc + fc), tc / 4), 4))
AP = 0.625

Bài 3. Hai mô hình phát hiện vết lỗi trên cùng tập val: M1 có mAP50 0,91, mAP50-95 0,52, P 0,93, R 0,78; M2 có mAP50 0,88, mAP50-95 0,66, P 0,80, R 0,90. Trạm chỉ cần loại sản phẩm có lỗi (không đo kích thước), khách hàng ưu tiên không lọt lỗi. Chọn mô hình nào để thử tiếp, và cần đo thêm gì trước khi quyết định?

Xem đáp án

M2 đáng thử hơn: recall cao hơn (bỏ sót ít hơn) quan trọng nhất khi ưu tiên không lọt lỗi; mAP50-95 của M1 thấp cho thấy hộp kém khít nhưng điều đó không quan trọng với trạm chỉ cần có/không. Precision thấp của M2 có thể bù bằng chọn ngưỡng hoặc thêm ảnh nền. Tuy nhiên P, R in ra ở ngưỡng do thư viện chọn, nên phải: (1) tính escape và overkill cấp ảnh của cả hai mô hình theo nhiều ngưỡng trên tập test có đủ ảnh OK và NG; (2) chọn ngưỡng cho escape bằng 0 (hoặc ≤ mức khách cho phép) rồi so overkill tại đó; (3) kiểm tra cycle time. Quyết định theo kết quả đó, không theo mAP.

Tóm tắt

  • IoU = giao / hợp; khắt khe với vật nhỏ (lệch 2 px trên vật 8 px còn 0,39); tính dạng ma trận để nhanh, kiểm tra chéo với torchvision.ops.box_iou.
  • NMS giữ hộp điểm cao nhất, bỏ hộp trùng có IoU vượt ngưỡng; YOLO26 end-to-end không cần NMS nhưng bạn vẫn gặp nó ở mô hình khác và khi ghép ô ảnh.
  • Ghép dự đoán theo điểm giảm dần với nhãn thật chưa ghép có IoU lớn nhất: TP, FP, FN; hộp trùng là FP.
  • Precision, recall thay đổi theo ngưỡng tin cậy; AP là diện tích dưới bao trên của đường PR (toàn điểm hoặc 101 điểm); mAP là trung bình các lớp.
  • mAP50 dễ dãi về vị trí; mAP50-95 đòi hộp khít. Chẩn đoán: mAP50-95 ≪ mAP50 là hộp lỏng; R thấp là bỏ sót; P thấp là báo nhầm.
  • mAP để so mô hình; nhà máy cần escape, overkill cấp ảnh ở ngưỡng chạy thật.

Bài tiếp theo: Từ R-CNN đến Faster R-CNN, bên trong một detector hai giai đoạn: anchor, RoI Pooling, Region Proposal Network. Sau đó là Phân đoạn ảnh: semantic, instance, panoptic, khi hộp bao không đủ và cần biết chính xác từng pixel thuộc vết lỗi nào.

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

  1. Tự viết hàm tính IoU giữa hai hộp và kiểm tra với 3 cặp hộp cho trước.

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