前置知识: 计算机视觉

目标检测YOLO

10 minAdvanced

检测是分类加定位。YOLO将两者统一为单次前向传播,用网格回归取代了区域提议。

目标检测YOLO

检测是分加定位。YOLO将两者统一为单次前向传播,用网格回归取代了区域提议。

类型: 构建 语言: Python 前置知识: Phase 4 Lesson 02 (卷积从零实现), Phase 4 Lesson 03 (CNN) 时间: 约90分钟

学习目标

  • 解释两阶段检测器(Faster R-CNN)和单阶段检测器(YOLO)之间的区别,以及为什么YOLO更快
  • 实现锚框、IoU计算、非极大值抑制和mAP评估
  • 从零构建一个微型YOLO检测头,输出网格单元预测、边界框偏移和别概率
  • 理解从YOLOv1到YOLOv8的演进以及每个版本改变的内容

问题所在

告诉你像中有什么。检测告诉你它在哪里。在自动驾驶、机器人、监控、零售分析和医学影像中,你需要两者:每个物体的别和边界框。

两阶段检测器(Faster R-CNN)首先生成区域提议,然后分每个提议。准确但慢——每张像约100ms。YOLO(You Only Look Once)将检测视为单次回归问题:将像划分为网格,每个网格单元直接预测边界框和别概率。一次前向传播,没有提议阶段,约10ms推理。

速度-准确率权衡是真实的:Faster R-CNN在小型物体上更准确,YOLO在实时应用中更快。从YOLOv1到YOLOv8,每个版本都在缩小差距。

核心概念

检测输出格式

输出每张像一个别标签。检测输出每个检测到的物体一个别标签加一个边界框:

分类: [(class_id, confidence)]
检测: [(class_id, confidence, x_center, y_center, width, height), ...]

边界框格式各不相同。YOLO使用(x_center, y_center, width, height),归一化到像尺寸。COCO使用(x_top_left, y_top_left, width, height),绝对像素。Pascal VOC使用(x_min, y_min, x_max, y_max)。转换是检测代码中bug的首要来源。

网格预测范式

YOLO将输入像划分为S x S网格。每个网格单元预测B个边界框和C个别概率。总输出:S x S x (B * 5 + C),其中5 = (x, y, w, h, confidence)。

输入图像 (416 x 416)
  |
  v
划分为 13 x 13 网格 (stride=32)
  |
  v
每个网格单元预测:
  - B 个边界框 (x, y, w, h, objectness)
  - C 个类别概率
  |
  v
输出张量: (13, 13, B*5 + C)

如果物体的中心落入某个网格单元,该单元负责检测它。一个单元可以预测多个框(B > 1),但每个框只有一个别。

锚框(先验框)

不同物体有不同的宽高比。锚框是数据集上K-means聚的典型框尺寸。YOLO不是从零预测框,而是预测相对于锚框的偏移:

预测框 = 锚框 + 学习的偏移

bx = sigma(tx) + cx     (网格单元内的x偏移)
by = sigma(ty) + cy     (网格单元内的y偏移)
bw = pw * exp(tw)       (相对于锚框宽度的宽度缩放)
bh = ph * exp(th)       (相对于锚框高度的高度缩放)

其中(cx, cy)是网格单元坐标,(pw, ph)是锚框尺寸,sigma是sigmoid函数。exp确保宽度和高度始终为正。

IoU(交并比)

评估检测质量的核心指标:

IoU = 交集面积 / 并集面积

         预测框
       +--------+
       |  交集  |
       +--------+
              真实框

IoU = 1.0: 完美重叠
IoU = 0.5: 勉强可接受的检测 (COCO阈值)
IoU = 0.0: 完全不重叠

IoU是边界框回归损失和mAP评估的基础。

非极大值抑制(NMS)

一个物体可能在多个网格单元和多个锚框中被检测到。NMS移除冗余检测:

1. 按置信度排序所有检测
2. 选择最高置信度的检测
3. 移除与所选检测IoU > 阈值的所有其他检测
4. 重复直到没有检测剩下

NMS是后处理,不是可学习的。它是一个启发式方法,偶尔会抑制真正靠近的物体(人群、密集停车场)。Soft-NMS衰减而不是抑制,是现代YOLO版本的默认。

mAP(平均精度均值)

检测的标准评估指标:

对于每个类别:
  1. 按置信度排序检测
  2. 计算不同IoU阈值下的精确率-召回率曲线
  3. 计算曲线下面积 (AP)

mAP = 所有类别AP的平均
mAP@0.5 = IoU阈值0.5时的mAP
mAP@0.5:0.95 = IoU阈值从0.5到0.95(步长0.05)的平均mAP

mAP@0.5是”宽松”指标(大致正确的框即可)。mAP@0.5:0.95是”严格”指标(框必须精确)。COCO排行榜报告两者。

YOLO演进

版本年份关键创新
YOLOv12016单次检测,网格预测
YOLOv22017锚框,批归一化,多尺度训练
YOLOv32018多尺度检测(3个检测头),Darknet-53
YOLOv42020Bag of freebies(Mosaic、CutMix、CIoU损失)
YOLOv52020PyTorch原生,易于训练,生产就绪
YOLOv72022E-ELAN架构,辅助头
YOLOv82023Anchor-free,解耦头,分布焦点损失
YOLOv92024PGI + GELAN,信息瓶颈理论
YOLOv102024无NMS训练,一致双重分配

趋势:anchor-based到anchor-free,耦合头到解耦头,手工NMS到学习的一致性。

构建它

步骤1:IoU计算

import torch

def box_iou(boxes1, boxes2):
    """计算两组框之间的IoU。
    boxes: (N, 4) 格式为 (x1, y1, x2, y2)
    """
    area1 = (boxes1[:, 2] - boxes1[:, 0]) * (boxes1[:, 3] - boxes1[:, 1])
    area2 = (boxes2[:, 2] - boxes2[:, 0]) * (boxes2[:, 3] - boxes2[:, 1])

    lt = torch.max(boxes1[:, None, :2], boxes2[None, :, :2])
    rb = torch.min(boxes1[:, None, 2:], boxes2[None, :, 2:])

    wh = (rb - lt).clamp(min=0)
    inter = wh[:, :, 0] * wh[:, :, 1]
    union = area1[:, None] + area2[None, :] - inter
    return inter / union.clamp(min=1e-6)

广播技巧:boxes1[:, None, :]boxes2[None, :, :]创建所有N x M对,无需循环。

步骤2:非极大值抑制

def nms(boxes, scores, iou_threshold=0.5):
    """对单个类别的检测执行NMS。"""
    order = scores.argsort(descending=True)
    keep = []

    while order.numel() > 0:
        i = order[0].item()
        keep.append(i)
        if order.numel() == 1:
            break
        ious = box_iou(boxes[i:i+1], boxes[order[1:]])[0]
        mask = ious <= iou_threshold
        order = order[1:][mask]

    return torch.tensor(keep, dtype=torch.long)

步骤3:微型YOLO检测头

一个最小检测头,附加到骨干的3个特征上。

import torch.nn as nn
import torch.nn.functional as F

class TinyYOLOHead(nn.Module):
    def __init__(self, num_classes=10, num_anchors=3):
        super().__init__()
        self.num_classes = num_classes
        self.num_anchors = num_anchors
        # 每个锚框: (x, y, w, h, objectness) + num_classes
        self.out_channels = num_anchors * (5 + num_classes)

        # 3个尺度的检测头
        self.head_small = nn.Conv2d(128, self.out_channels, 1)
        self.head_medium = nn.Conv2d(256, self.out_channels, 1)
        self.head_large = nn.Conv2d(512, self.out_channels, 1)

    def forward(self, features):
        """features: [small_feat, medium_feat, large_feat]"""
        outputs = []
        for feat, head in zip(features, [self.head_small, self.head_medium, self.head_large]):
            out = head(feat)
            B, C, H, W = out.shape
            out = out.view(B, self.num_anchors, 5 + self.num_classes, H, W)
            out = out.permute(0, 1, 3, 4, 2).contiguous()
            outputs.append(out)
        return outputs

三个检测头对应三个尺度:小物体在高分辨率特征上,大物体在低分辨率特征上。

步骤4:解码YOLO输出

将原始网络输出转换为边界框坐标。

def decode_yolo_output(output, stride, anchors):
    """解码单个尺度的YOLO输出。
    output: (B, A, H, W, 5+C)
    anchors: (A, 2) 先验框尺寸
    stride: 该尺度的下采样倍率
    """
    B, A, H, W, _ = output.shape
    device = output.device

    # 创建网格坐标
    y_grid, x_grid = torch.meshgrid(torch.arange(H, device=device),
                                      torch.arange(W, device=device), indexing="ij")
    x_grid = x_grid.view(1, 1, H, W).float()
    y_grid = y_grid.view(1, 1, H, W).float()

    # 解码边界框
    tx = output[..., 0].sigmoid()
    ty = output[..., 1].sigmoid()
    tw = output[..., 2]
    th = output[..., 3]
    objectness = output[..., 4].sigmoid()
    class_probs = output[..., 5:].sigmoid()

    anchors = torch.tensor(anchors, device=device).view(1, A, 1, 1, 2)
    bw = anchors[..., 0] * torch.exp(tw)
    bh = anchors[..., 1] * torch.exp(th)

    bx = tx + x_grid
    by = ty + y_grid

    # 转换为绝对坐标
    bx = bx * stride
    by = by * stride
    bw = bw * stride
    bh = bh * stride

    # 转换为 (x1, y1, x2, y2)
    x1 = bx - bw / 2
    y1 = by - bh / 2
    x2 = bx + bw / 2
    y2 = by + bh / 2

    boxes = torch.stack([x1, y1, x2, y2], dim=-1)
    scores = objectness.unsqueeze(-1) * class_probs

    return boxes, scores

sigmoid将中心偏移限制在网格单元内。exp确保宽度和高度始终为正。stride将网格坐标映射回像坐标。

步骤5:合成检测数据集

import numpy as np

def make_detection_sample(img_size=416, num_objects=5, num_classes=10):
    img = np.random.uniform(0, 0.3, (3, img_size, img_size)).astype(np.float32)
    boxes = []
    labels = []
    for _ in range(num_objects):
        w = np.random.randint(32, 128)
        h = np.random.randint(32, 128)
        x = np.random.randint(0, img_size - w)
        y = np.random.randint(0, img_size - h)
        c = np.random.randint(0, num_classes)
        color = np.random.uniform(0.5, 1.0, (3,))
        img[:, y:y+h, x:x+w] = color[:, None, None]
        boxes.append([x, y, x + w, y + h])
        labels.append(c)
    return img, np.array(boxes), np.array(labels)

彩色矩形在暗背景上。足够简单,微型模型可以学习,足够结构化,需要真正的检测逻辑。

使用它

对于生产检测,使用Ultralytics YOLOv8:

from ultralytics import YOLO

# 加载预训练模型
model = YOLO("yolov8n.pt")

# 在自定义数据上训练
model.train(data="coco.yaml", epochs=100, imgsz=640)

# 推理
results = model("image.jpg")
for result in results:
    boxes = result.boxes
    for box in boxes:
        x1, y1, x2, y2 = box.xyxy[0].tolist()
        conf = box.conf[0].item()
        cls = box.cls[0].item()
        print(f"Class {cls}: confidence={conf:.2f}, box=({x1:.0f},{y1:.0f},{x2:.0f},{y2:.0f})")

YOLOv8是anchor-free的——没有锚框超参数需要调整。解耦头分别预测分和定位,使每个任务更容易学习。

发布它

本课产出:

  • outputs/prompt-detection-pipeline.md — 一个提示,根据物体尺寸、密度和延迟要求设计检测管线。
  • outputs/skill-iou-nms-toolkit.md — 一个技能,实现IoU变体(GIoU、DIoU、CIoU)和NMS变体(Soft-NMS、别感知NMS)。

练习

  1. (简单) 实现三种边界框格式之间的转换函数:YOLO格式(cx, cy, w, h归一化)、COCO格式(x, y, w, h绝对)、VOC格式(x1, y1, x2, y2绝对)。用往返测试验证。
  2. (中等) 在合成数据集上训练微型YOLO模型20个epoch。报告mAP@0.5。可视化预测框与真实框对比。
  3. (困难) 实现CIoU损失(考虑重叠面积、中心距离和宽高比)并替换MSE边界框损失。在相同设置下比较收敛速度和最终mAP。

关键术语

术语人们怎么说实际含义
边界框”框”像中物体的矩形定位,格式为(x1,y1,x2,y2)或(cx,cy,w,h)
IoU”重叠度”交并比;衡量两个框重叠程度的指标,0到1
NMS”去重”非极大值抑制;移除同一物体的冗余检测的后处理
mAP”检测准确率”平均精度均值;所有别和IoU阈值上的平均检测质量
锚框”先验框”从数据集统计得出的预定义框尺寸,检测器预测相对于它们的偏移
网格预测”YOLO方式”像划分为网格,每个单元直接预测框和
Objectness”有东西吗”框包含任何物体的概率,独立于具体
Anchor-free”无锚框”直接预测框坐标而不参考锚框;YOLOv8及以后版本的默认

延伸阅读