Học AI & Lập trình

Object Detection cơ bản: bounding box, IoU, NMS và mAP

Định dạng bounding box, sliding window, selective search, IoU, Non-maximum Suppression tự cài và cách tính AP, mAP theo chuẩn VOC và COCO.

Object Detection cơ bản: bounding box, IoU, NMS và mAP
Nội dung bài viết
  1. Classification và Object Detection
  2. Bounding box: ba định dạng hay gặp
  3. Bước đầu: định vị một đối tượng duy nhất
  4. Nhiều đối tượng: Sliding Window
  5. Region Proposals: Selective Search
  6. Intersection over Union (IoU)
  7. Non-maximum Suppression (NMS)
  8. Đánh giá detector: Precision, Recall, AP và mAP
  9. Bản đồ các họ detector
  10. Tóm tắt
  11. Tài liệu tham khảo

Phân loại ảnh trả lời câu hỏi "ảnh này là gì?". Nhưng camera giám sát cần biết có bao nhiêu người và mỗi người ở đâu; máy kiểm tra ngoại quan cần biết vết xước nằm chỗ nào trên sản phẩm. Đó là Object Detection (phát hiện đối tượng), bài toán được dùng nhiều nhất trong thị giác máy tính công nghiệp. Bài này xây nền móng: bài toán là gì, các khái niệm bounding box, IoU, Non-maximum Suppression, và cách chấm điểm mô hình bằng mAP.

Số trang (ví dụ "trang 81") trong bài chỉ tới slide của bài giảng Deep Learning – Computer Vision – NLP (Thang Nguyen, Viet Nguyen); bài này giải thích chi tiết, kiểm chứng và đính chính các slide đó.

Classification và Object Detection

Image ClassificationObject Detection
Đầu vàoMột ảnh RGBMột ảnh RGB
Đầu raMột nhãn: "CAT"Một tập đối tượng: mỗi đối tượng gồm nhãn, bounding box và độ tin cậy, ví dụ "CAT, CAT, DOG, DUCK"
Số lượng đầu raCố địnhThay đổi theo từng ảnh (0, 1 hay 50 đối tượng)

Điểm khó cốt lõi nằm ở dòng cuối: mạng nơ-ron thường có đầu ra kích thước cố định, còn số đối tượng trong ảnh thì không biết trước (trang 145).

Bounding box: ba định dạng hay gặp

Bounding box (khung bao) là hình chữ nhật thẳng trục bao quanh đối tượng. Cùng một box nhưng mỗi bộ dữ liệu/thư viện lưu một kiểu, nhầm lẫn là nguồn lỗi kinh điển:

Định dạngÝ nghĩaDùng ở
xyxy(x_min, y_min, x_max, y_max), đơn vị pixelPascal VOC, torchvision
xywh(x_min, y_min, width, height), đơn vị pixelMS COCO
cxcywh chuẩn hoá(tâm x, tâm y, width, height) chia cho kích thước ảnh, thuộc [0, 1]YOLO (Ultralytics)
import torch
from torchvision.ops import box_convert

coco_box = torch.tensor([[50., 60., 100., 80.]])            # x, y, w, h
print(box_convert(coco_box, in_fmt="xywh", out_fmt="xyxy"))  # tensor([[ 50.,  60., 150., 140.]])

Toạ độ ảnh có gốc ở góc trên bên trái, trục y hướng xuống dưới.

Bước đầu: định vị một đối tượng duy nhất

Nếu biết chắc ảnh có đúng một đối tượng, bài toán khá đơn giản (trang 144): dùng một CNN trích ra vector đặc trưng (ví dụ 4096 chiều), rồi gắn hai "đầu":

  • What? FC 4096 → số lớp, cho điểm các lớp; huấn luyện bằng softmax / cross-entropy loss.
  • Where? FC 4096 → 4, cho toạ độ box \((x, y, w, h)\); huấn luyện bằng L2 loss (MSE) so với box đúng \((x', y', w', h')\).

Hai loss được cộng lại thành multi-task loss \(L = L_{cls} + \lambda L_{box}\), với \(\lambda\) cân bằng hai thành phần.

import torch.nn as nn
from torchvision import models


class SingleObjectLocalizer(nn.Module):
    def __init__(self, num_classes):
        super().__init__()
        backbone = models.resnet18(weights=models.ResNet18_Weights.DEFAULT)
        backbone.fc = nn.Identity()                      # lấy vector đặc trưng 512 chiều
        self.backbone = backbone
        self.cls_head = nn.Linear(512, num_classes)      # What?
        self.box_head = nn.Linear(512, 4)                # Where? (toạ độ chuẩn hoá về [0, 1])

    def forward(self, x):
        f = self.backbone(x)
        return self.cls_head(f), self.box_head(f).sigmoid()


# loss = nn.CrossEntropyLoss()(cls_logits, labels) + lam * nn.MSELoss()(box_pred, box_gt)

Cách này dùng tốt cho các bài toán như định vị biển số trong ảnh đã cắt sẵn, nhưng không mở rộng được cho nhiều đối tượng.

Nhiều đối tượng: Sliding Window

Ý tưởng ngây thơ (trang 146): trượt một cửa sổ khắp ảnh, mỗi vị trí cắt vùng đó ra và hỏi bộ phân loại "đây là đối tượng nào, hay chỉ là nền (background)?". Vấn đề là đối tượng có thể ở mọi vị trí, mọi kích thước, mọi tỉ lệ khung. Số hình chữ nhật có thể có trong ảnh \(W\times H\) là

\[ \frac{W(W+1)}{2}\cdot\frac{H(H+1)}{2} \]

Với ảnh 800×600, con số này khoảng 57,8 tỉ cửa sổ. Chạy CNN trên từng cửa sổ là bất khả thi.

Giải pháp: chỉ xét những vùng "có vẻ chứa vật thể". Selective Search (Uijlings et al., 2013; trang 147) bắt đầu bằng phân đoạn ảnh thành rất nhiều vùng nhỏ (superpixel), rồi gộp dần các vùng giống nhau về màu, kết cấu, kích thước và độ khít, sinh ra các bounding box ứng viên ở nhiều thang đo. Kết quả: khoảng 2.000 vùng đề xuất (region proposals) mỗi ảnh, chạy vài giây trên CPU, nhưng vẫn bao phủ được hầu hết đối tượng thật (recall cao). Đây là bước đầu tiên của R-CNN, xem bài Từ R-CNN đến Faster R-CNN.

Intersection over Union (IoU)

Làm sao biết một box dự đoán "đúng" hay "sai"? Không thể đòi khớp từng pixel. Ta đo độ chồng lấn bằng IoU (trang 152):

dự đoánground truth IoU = Diện tích phần giao Diện tích phần hợp 0 = không chạm nhau · 1 = trùng khít
Hình minh hoạ: phần tô đậm là phần giao (intersection)
\[ \text{IoU}(A, B) = \frac{|A \cap B|}{|A \cup B|} = \frac{|A\cap B|}{|A| + |B| - |A\cap B|} \]

Ví dụ \(A = (0, 0, 4, 4)\), \(B = (2, 2, 6, 6)\): phần giao là ô 2×2 = 4, phần hợp \(16 + 16 - 4 = 28\), IoU \(= 4/28 \approx 0{,}14\). Theo ví dụ ở trang 152, IoU 0,40 là kém, 0,73 là tốt, 0,93 là rất tốt. Ngưỡng phổ biến để coi một dự đoán là đúng là IoU ≥ 0,5.

def iou(box_a, box_b):
    """box = (x1, y1, x2, y2). Trả về IoU giữa hai box."""
    ix1, iy1 = max(box_a[0], box_b[0]), max(box_a[1], box_b[1])
    ix2, iy2 = min(box_a[2], box_b[2]), min(box_a[3], box_b[3])
    inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)          # = 0 nếu không giao nhau
    area_a = (box_a[2] - box_a[0]) * (box_a[3] - box_a[1])
    area_b = (box_b[2] - box_b[0]) * (box_b[3] - box_b[1])
    return inter / (area_a + area_b - inter)


print(iou((0, 0, 4, 4), (2, 2, 6, 6)))       # 0.142857...
# Bản vector hoá: torchvision.ops.box_iou(boxes1, boxes2) trả về ma trận IoU (N, M)

IoU xuất hiện ở khắp nơi trong detection: gán nhãn cho anchor/proposal khi huấn luyện, lọc trùng bằng NMS, và chấm điểm mô hình.

Non-maximum Suppression (NMS)

Detector thường bắn ra nhiều box chồng lên nhau cho cùng một đối tượng (trang 153: người đi bộ có các box 96%, 87%, 82%; con chó có 98%, 90%, 82%). NMS giữ lại box tốt nhất và loại các box trùng lặp:

  1. Bỏ các box có điểm dưới ngưỡng tin cậy (ví dụ 0,05 khi đánh giá, 0,25–0,5 khi triển khai).
  2. Chọn box có điểm cao nhất, đưa vào kết quả.
  3. Xoá mọi box còn lại có IoU với box vừa chọn lớn hơn ngưỡng (thường 0,5).
  4. Lặp lại bước 2–3 với các box còn lại cho đến hết.

NMS thường chạy riêng cho từng lớp: box "người" không xoá box "chó" dù chồng lấn nhau.

import numpy as np


def nms_numpy(boxes, scores, iou_thr=0.5):
    """boxes: (N, 4) dạng x1y1x2y2, scores: (N,). Trả về chỉ số các box được giữ."""
    order = np.argsort(scores)[::-1]          # sắp xếp điểm giảm dần
    keep = []
    while order.size > 0:
        i = order[0]                          # box điểm cao nhất
        keep.append(i)
        rest = order[1:]
        ious = np.array([iou(boxes[i], boxes[j]) for j in rest])
        order = rest[ious <= iou_thr]         # bỏ các box chồng lấn quá nhiều
    return keep


boxes = np.array([[100, 100, 210, 210], [105, 110, 215, 220],
                  [300, 80, 400, 200], [102, 95, 200, 205]], dtype=float)
scores = np.array([0.90, 0.75, 0.80, 0.60])
print(nms_numpy(boxes, scores))                # [0, 2]: box 1 và 3 trùng với box 0 (IoU 0.77, 0.82)

# Thực tế dùng bản tối ưu (chạy được trên GPU):
# from torchvision.ops import nms, batched_nms
# keep = batched_nms(boxes, scores, labels, iou_threshold=0.5)   # NMS theo từng lớp
Giới hạn của NMSKhi hai đối tượng thật đứng sát nhau (đám đông), NMS có thể xoá nhầm một trong hai. Các biến thể như Soft-NMS giảm điểm thay vì xoá hẳn. Một số detector mới (DETR, YOLOv10, YOLO26) được thiết kế để không cần NMS, mỗi đối tượng chỉ sinh đúng một dự đoán.

Đánh giá detector: Precision, Recall, AP và mAP

TP, FP, FN trong detection

Duyệt các dự đoán của một lớp theo thứ tự điểm giảm dần. Mỗi dự đoán được ghép với box thật (ground truth) cùng lớp có IoU cao nhất:

  • True Positive (TP): IoU ≥ ngưỡng và box thật đó chưa bị dự đoán nào khác ghép.
  • False Positive (FP): IoU dưới ngưỡng, hoặc box thật đã được ghép rồi (dự đoán trùng lặp).
  • False Negative (FN): box thật không có dự đoán nào ghép được (bỏ sót).
\[ \text{Precision} = \frac{TP}{TP + FP}\ \ (\text{trong các box đã báo, bao nhiêu đúng}), \qquad \text{Recall} = \frac{TP}{TP + FN} = \frac{TP}{\#\text{ground truth}}\ \ (\text{tìm được bao nhiêu}) \]

Ví dụ ở trang 154: AP của lớp "dog"

Có 3 con chó thật, mô hình đưa ra 5 dự đoán. Duyệt lần lượt theo điểm, tính precision và recall tích luỹ:

ScoreKết quả (IoU > 0,5)TP tích luỹFP tích luỹPrecisionRecall
0,99TP101/1 = 1,01/3 = 0,33
0,95TP202/2 = 1,02/3 = 0,67
0,90FP212/3 = 0,670,67
0,50FP222/4 = 0,500,67
0,10TP323/5 = 0,603/3 = 1,0

Vẽ các điểm (recall, precision) được đường Precision–Recall. Trước khi tính diện tích, ta nội suy: precision tại mỗi mức recall \(r\) được thay bằng precision lớn nhất ở mọi recall \(\ge r\) (làm đường cong thành dạng bậc thang đi xuống). Average Precision (AP) là diện tích dưới đường đó:

\[ AP = (0{,}33 - 0)\cdot1{,}0 + (0{,}67 - 0{,}33)\cdot1{,}0 + (1{,}0 - 0{,}67)\cdot0{,}6 \approx 0{,}867 \]

(Hình ở trang 154 ghi "Dog AP = 0.86", làm tròn xuống từ 0,8667.)

import numpy as np


def average_precision(is_tp, num_gt):
    """is_tp: danh sách True/False của các detection đã sắp theo score giảm dần."""
    tp = np.cumsum(is_tp)
    fp = np.cumsum(~np.asarray(is_tp))
    recall = tp / num_gt
    precision = tp / (tp + fp)
    # Nội suy: precision tại recall r = max precision với recall >= r
    mrec = np.concatenate([[0.0], recall, [1.0]])
    mpre = np.concatenate([[0.0], precision, [0.0]])
    for k in range(len(mpre) - 2, -1, -1):
        mpre[k] = max(mpre[k], mpre[k + 1])
    idx = np.where(mrec[1:] != mrec[:-1])[0]
    return np.sum((mrec[idx + 1] - mrec[idx]) * mpre[idx + 1])


is_tp = np.array([True, True, False, False, True])   # 0.99, 0.95, 0.90, 0.5, 0.1
print(average_precision(is_tp, num_gt=3))             # 0.8667

mAP và các chuẩn đánh giá

mAP (mean Average Precision) là trung bình AP của tất cả các lớp. Các chuẩn khác nhau ở ngưỡng IoU và cách nội suy, nên con số không so sánh trực tiếp được với nhau:

ChuẩnĐịnh nghĩa
Pascal VOC (mAP@0.5)IoU ≥ 0,5; VOC2007 dùng nội suy 11 điểm, VOC2010+ dùng mọi điểm (như ví dụ trên)
COCO (mAP@[.5:.95])Trung bình AP tại 10 ngưỡng IoU 0,50; 0,55; …; 0,95, nội suy 101 điểm. Khắt khe hơn nhiều vì thưởng box định vị chính xác
COCO AP50, AP75AP tại một ngưỡng IoU 0,5 hoặc 0,75
COCO APs, APm, APlAP cho đối tượng nhỏ (< 32² px), vừa, lớn (> 96² px)

Khi đi làm, đừng tự viết hàm mAP để báo cáo: dùng pycocotools, torchmetrics.detection.MeanAveragePrecision, hoặc công cụ đánh giá sẵn của Ultralytics. Ngoài mAP, còn theo dõi tốc độ (FPS, độ trễ mỗi ảnh) vì đây luôn là bài toán đánh đổi giữa độ chính xác và tốc độ.

Bản đồ các họ detector

HọÝ tưởngVí dụ
Two-stageBước 1 đề xuất vùng, bước 2 phân loại + tinh chỉnh từng vùng. Chính xác, chậm hơnR-CNN, Fast R-CNN, Faster R-CNN, Mask R-CNN
One-stageDự đoán trực tiếp box và lớp trên lưới feature map trong một lần chạy. Nhanh, phù hợp thời gian thựcYOLO, SSD, RetinaNet, FCOS
TransformerXem detection như bài toán dự đoán tập hợp, không cần anchor và NMSDETR, RT-DETR

Tóm tắt

  • Detection = phân loại + định vị, với số đối tượng thay đổi theo ảnh.
  • Cẩn thận với định dạng box: xyxy, xywh, cxcywh chuẩn hoá.
  • Sliding window quá đắt; region proposal (Selective Search) giảm còn khoảng 2.000 vùng.
  • IoU = giao / hợp; NMS giữ box điểm cao nhất, xoá box trùng IoU > ngưỡng.
  • AP = diện tích dưới đường PR (đã nội suy); mAP = trung bình AP các lớp; COCO lấy trung bình trên IoU 0,5–0,95.

Đọc tiếp: Từ R-CNN đến Faster R-CNN, hành trình giảm thời gian xử lý từ 50 giây xuống 0,2 giây mỗi ảnh.

Tài liệu tham khảo

  • Uijlings et al. (2013). Selective Search for Object Recognition.
  • Everingham et al. (2010). The PASCAL Visual Object Classes (VOC) Challenge.
  • Lin et al. (2014). Microsoft COCO: Common Objects in Context; trang đánh giá cocodataset.org/#detection-eval.
  • Bodla et al. (2017). Soft-NMS.
  • Tài liệu torchvision: torchvision.ops (box_iou, nms, batched_nms, box_convert).
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í.