YOLO 目标检测


文档摘要

YOLO 目标检测 本节摘要:检测,就是「分类 + 回归」在特征图的每个位置上各跑一次,再用非极大值抑制(NMS)清理重复框。本节从零搭建一个 YOLO 风格的检测头,讲清网格与锚框如何把检测变成密集预测问题、输出张量里每个数是什么意思、 如何解码成像素框、IoU 与 NMS 怎么手写、以及由「框回归 + objectness + 分类」三段损失构成的 YOLO 损失如何加权。读完本节,你能读懂 precision@0.5、recall、mAP@0.5、mAP@0.5:0.95 这一行检测指标,并知道下一步该拧哪个旋钮。 对应原课程:Phase 4 · Lesson 06 · (原英文 )。

YOLO 目标检测

本节摘要:检测,就是「分类 + 回归」在特征图的每个位置上各跑一次,再用非极大值抑制(NMS)清理重复框。本节从零搭建一个 YOLO 风格的检测头,讲清网格与锚框如何把检测变成密集预测问题、输出张量里每个数是什么意思、tx/ty/tw/th 如何解码成像素框、IoU 与 NMS 怎么手写、以及由「框回归 + objectness + 分类」三段损失构成的 YOLO 损失如何加权。读完本节,你能读懂 precision@0.5、recall、mAP@0.5、mAP@0.5:0.95 这一行检测指标,并知道下一步该拧哪个旋钮。

对应原课程:Phase 4 · Lesson 06 · object-detection-yolo(原英文 phases/04-computer-vision/06-object-detection-yolo/docs/en.md)。

学习目标

阅读完本节,你应当能够:

  1. 解释把检测变成密集预测问题的网格与锚框设计,说出输出张量里每个数的含义。
  2. 计算两个框之间的 IoU,并从零实现非极大值抑制(NMS)
  3. 在预训练骨干上搭一个最小的 YOLO 风格头,包括分类、objectness、框回归三段损失。
  4. 读懂一行检测指标(precision@0.5、recall、mAP@0.5、mAP@0.5:0.95),并挑出下一个该拧的旋钮。

一、问题与直觉

分类说「这张图是一只狗」。检测说「在像素 (112, 40, 280, 210) 处有一只狗,在 (400, 180, 560, 310) 处有一只猫,画面里没有别的东西」。这一个结构变化——预测可变数量的带标签框而非每图一个标签——是每一个自动驾驶系统、监控产品、文档版面解析器、工厂视觉产线都依赖的东西。

检测也是视觉里所有工程权衡同时显现的地方:你要框准(回归头)、给每个框对的类别(分类头)、让模型知道什么时候没有目标(objectness 分数)、还要让每个真实物体恰好预测一次(NMS)。漏掉任何一环,流水线要么漏检,要么报出幻觉框,要么把同一个物体在略微不同的位置预测十五次。

YOLO(You Only Look Once,Redmon 等 2016)是让这一切用一次卷积网络前向就实时跑起来的设计,它的结构决策至今仍是现代检测器(YOLOv8、YOLOv9、YOLO-NAS、RT-DETR)的骨架。学会核心,所有变体都是同样零件的重新排列。

检测即密集预测

分类器每图输出 C 个数。YOLO 风格检测器每图输出 (S × S × (5 + C)) 个数,S 是空间网格尺寸。

S × S 个网格单元,每个预测 B 个框。每个框:

  • 4 个数描述几何:tx, ty, tw, th
  • 1 个数是 objectness 分数:「这个单元里是否中心有一个物体?」
  • C 个数是类别概率。

每个单元共 B * (5 + C) 个数。VOC 上 S=13, B=2, C=20,每单元 50 个数。

为什么用网格与锚框

朴素回归会给每个物体预测绝对坐标 (x, y, w, h)。这对卷积网络很难,因为平移图像不应该让所有预测平移同样多——每个物体在空间上各自锚定。网格的回答是:把每个真实框分配给它中心点落进去的那个网格单元,只有那个单元对这个物体负责。

锚框解决第二个问题。一个 3×3 卷积很难从一个 16 像素感受野的特征单元回归出 500 像素宽的框。于是我们为每个单元预定义 B 种先验框形(锚框 anchor),预测相对于每个锚框的小偏移。模型学会挑对的锚框并微调,而不是从零回归。

锚框先验(以 416x416 输入为例): small: (30, 60) medium: (75, 170) large: (200, 380) 每个网格单元,每个锚框输出 (tx, ty, tw, th, obj, c_1, ..., c_C)。

现代检测器常用 FPN,每个分辨率配不同锚框集——浅层高分辨率图用小锚框,深层低分辨率图用大锚框。思路一样,尺度更多。

解码预测

原始的 tx, ty, tw, th 不是框坐标,而是要先变换才能画出来的回归目标:

中心 x = (sigmoid(tx) + cell_x) * stride 中心 y = (sigmoid(ty) + cell_y) * stride 宽 = anchor_w * exp(tw) 高 = anchor_h * exp(th)

sigmoid 把中心偏移限制在单元内。exp 让宽相对锚框自由缩放而无符号翻转。stride 把网格坐标换算回像素。这套解码从 v2 起每个 YOLO 版本都一样。

IoU

检测中两个框之间的通用相似度度量:

IoU(A, B) = area(A 与 B 的交集) / area(A 与 B 的并集)

IoU = 1 表示完全重合;IoU = 0 表示无重叠。预测与真实框的 IoU 决定这个预测算不算真正例(通常 IoU ≥ 0.5)。两个预测之间的 IoU 是 NMS 去重用的。

非极大值抑制(NMS)

在相邻锚框上训练的卷积网络常会为同一物体预测出重叠的框。NMS 保留最高置信度的预测,删掉任何 IoU 超过阈值的其它预测。

NMS(boxes, scores, iou_threshold): 按分数降序排框 keep = [] while boxes 非空: 取分数最高的框,加入 keep 删掉所有与该框 IoU > iou_threshold 的框 return keep

典型阈值:目标检测 0.45。最近的检测器用 soft-NMSDIoU-NMS 替代标准 NMS,或直接学抑制(RT-DETR),但结构目的一致。

损失

YOLO 损失是三个损失加权相加:

L = lambda_coord * L_box(预测, 目标, obj=1 处) + lambda_obj * L_obj(预测, 1, obj=1 处) + lambda_noobj * L_obj(预测, 0, obj=0 处) + lambda_cls * L_cls(预测, 目标, obj=1 处)

只有含物体的单元贡献框回归与分类损失。不含物体的单元只贡献 objectness 损失(教模型保持沉默)。lambda_noobj 通常很小(约 0.5),因为绝大多数单元是空的,否则会主导总损失。

现代变体把 MSE 框损失换成 CIoU/DIoU(直接优化 IoU),用 focal loss 处理类别不平衡,用 quality focal loss 平衡 objectness。三分量结构不变。

检测指标

准确率迁移不到检测上。能迁移的四个数:

  • Precision@IoU=0.5 —— 在被算作正例的预测里,有多少真对。
  • Recall@IoU=0.5 —— 在真实物体里,我们找到了多少。
  • AP@0.5 —— IoU 阈值 0.5 处的精确率-召回率曲线下面积,每类一个数。
  • mAP@0.5:0.95 —— IoU 阈值 0.5、0.55、……、0.95 上 AP 的平均。COCO 指标;最严格也最有信息量。

四个都报告。一个检测器 mAP@0.5 强但 mAP@0.5:0.95 弱,说明定位粗但不够紧——用更好的框回归损失修。一个 precision 高 recall 低的检测器太保守——降置信度阈值或加大 objectness 权重。

二、从零实现

步骤 1:IoU

整节课的主力。作用于 (x1, y1, x2, y2) 格式的两组框。

import numpy as np def box_iou(boxes_a, boxes_b): ax1, ay1, ax2, ay2 = boxes_a[:, 0], boxes_a[:, 1], boxes_a[:, 2], boxes_a[:, 3] bx1, by1, bx2, by2 = boxes_b[:, 0], boxes_b[:, 1], boxes_b[:, 2], boxes_b[:, 3] inter_x1 = np.maximum(ax1[:, None], bx1[None, :]) inter_y1 = np.maximum(ay1[:, None], by1[None, :]) inter_x2 = np.minimum(ax2[:, None], bx2[None, :]) inter_y2 = np.minimum(ay2[:, None], by2[None, :]) inter_w = np.clip(inter_x2 - inter_x1, 0, None) inter_h = np.clip(inter_y2 - inter_y1, 0, None) inter = inter_w * inter_h area_a = (ax2 - ax1) * (ay2 - ay1) area_b = (bx2 - bx1) * (by2 - by1) union = area_a[:, None] + area_b[None, :] - inter return inter / np.clip(union, 1e-8, None)

返回 (N_a, N_b) 的成对 IoU 矩阵。要和单个真实框比,就把其中一个数组 shape 设成 (1, 4)

步骤 2:非极大值抑制

def nms(boxes, scores, iou_threshold=0.45): order = np.argsort(-scores) keep = [] while len(order) > 0: i = order[0] keep.append(i) if len(order) == 1: break rest = order[1:] ious = box_iou(boxes[[i]], boxes[rest])[0] order = rest[ious <= iou_threshold] return np.array(keep, dtype=np.int64)

确定性,排序带来 O(N log N),在相同输入上行为与 torchvision.ops.nms 一致。

步骤 3:框的编码与解码

在像素坐标与网络真正回归的 (tx, ty, tw, th) 目标之间转换。

def encode(box_xyxy, cell_x, cell_y, stride, anchor_wh): x1, y1, x2, y2 = box_xyxy cx = 0.5 * (x1 + x2) cy = 0.5 * (y1 + y2) w = x2 - x1 h = y2 - y1 tx = cx / stride - cell_x ty = cy / stride - cell_y tw = np.log(w / anchor_wh[0] + 1e-8) th = np.log(h / anchor_wh[1] + 1e-8) return np.array([tx, ty, tw, th]) def decode(tx_ty_tw_th, cell_x, cell_y, stride, anchor_wh): tx, ty, tw, th = tx_ty_tw_th cx = (sigmoid(tx) + cell_x) * stride cy = (sigmoid(ty) + cell_y) * stride w = anchor_wh[0] * np.exp(tw) h = anchor_wh[1] * np.exp(th) return np.array([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2]) def sigmoid(x): return 1.0 / (1.0 + np.exp(-x))

测试:编码一个框再解码,应该能非常接近原始框(sigmoid 逆不在 tx 后 sigmoid 范围内时并非完美可逆)。

步骤 4:一个最小 YOLO 头

特征图上一个 1×1 卷积,reshape 成 (B, S, S, num_anchors, 5 + C)

import torch import torch.nn as nn class YOLOHead(nn.Module): def __init__(self, in_c, num_anchors, num_classes): super().__init__() self.num_anchors = num_anchors self.num_classes = num_classes self.conv = nn.Conv2d(in_c, num_anchors * (5 + num_classes), kernel_size=1) def forward(self, x): n, _, h, w = x.shape y = self.conv(x) y = y.view(n, self.num_anchors, 5 + self.num_classes, h, w) y = y.permute(0, 3, 4, 1, 2).contiguous() return y

输出形状:(N, H, W, num_anchors, 5 + C)。最后一维是 [tx, ty, tw, th, obj, cls_0, ..., cls_{C-1}]

步骤 5:真实框分配

对每个真实框,决定哪个 (单元, 锚框) 负责。

def assign_targets(boxes_xyxy, classes, anchors, stride, grid_size, num_classes): num_anchors = len(anchors) target = np.zeros((grid_size, grid_size, num_anchors, 5 + num_classes), dtype=np.float32) has_obj = np.zeros((grid_size, grid_size, num_anchors), dtype=bool) for box, cls in zip(boxes_xyxy, classes): x1, y1, x2, y2 = box cx, cy = 0.5 * (x1 + x2), 0.5 * (y1 + y2) gx, gy = int(cx / stride), int(cy / stride) bw, bh = x2 - x1, y2 - y1 ious = np.array([ (min(bw, aw) * min(bh, ah)) / (bw * bh + aw * ah - min(bw, aw) * min(bh, ah)) for aw, ah in anchors ]) best = int(np.argmax(ious)) aw, ah = anchors[best] target[gy, gx, best, 0] = cx / stride - gx target[gy, gx, best, 1] = cy / stride - gy target[gy, gx, best, 2] = np.log(bw / aw + 1e-8) target[gy, gx, best, 3] = np.log(bh / ah + 1e-8) target[gy, gx, best, 4] = 1.0 target[gy, gx, best, 5 + cls] = 1.0 has_obj[gy, gx, best] = True return target, has_obj

锚框选择是「与真实框形状 IoU 最好」——一个廉价代理,与 YOLOv2/v3 分配一致。v5 及之后用更复杂策略(任务对齐匹配、动态 k),精化同一想法。

步骤 6:三个损失

def yolo_loss(pred, target, has_obj, lambda_coord=5.0, lambda_obj=1.0, lambda_noobj=0.5, lambda_cls=1.0): has_obj_t = torch.from_numpy(has_obj).bool() target_t = torch.from_numpy(target).float() # 框回归损失:只在含物体的单元上 box_pred = pred[..., :4][has_obj_t] box_true = target_t[..., :4][has_obj_t] loss_box = torch.nn.functional.mse_loss(box_pred, box_true, reduction="sum") # objectness 损失 obj_pred = pred[..., 4] obj_true = target_t[..., 4] loss_obj_pos = torch.nn.functional.binary_cross_entropy_with_logits( obj_pred[has_obj_t], obj_true[has_obj_t], reduction="sum") loss_obj_neg = torch.nn.functional.binary_cross_entropy_with_logits( obj_pred[~has_obj_t], obj_true[~has_obj_t], reduction="sum") # 含物体单元上的分类损失 cls_pred = pred[..., 5:][has_obj_t] cls_true = target_t[..., 5:][has_obj_t] loss_cls = torch.nn.functional.binary_cross_entropy_with_logits( cls_pred, cls_true, reduction="sum") total = (lambda_coord * loss_box + lambda_obj * loss_obj_pos + lambda_noobj * loss_obj_neg + lambda_cls * loss_cls) return total, {"box": loss_box.item(), "obj_pos": loss_obj_pos.item(), "obj_neg": loss_obj_neg.item(), "cls": loss_cls.item()}

五个超参数,每个 YOLO 教程要么硬编码要么扫。比例要紧:lambda_coord=5, lambda_noobj=0.5 镜像原 YOLOv1 论文,至今仍是合理默认。

步骤 7:推理流水线

把头输出解码、套 sigmoid/exp、按 objectness 阈值、NMS。

def postprocess(pred_tensor, anchors, stride, img_size, conf_threshold=0.25, iou_threshold=0.45): pred = pred_tensor.detach().cpu().numpy() grid_h, grid_w = pred.shape[1], pred.shape[2] num_anchors = len(anchors) boxes, scores, classes = [], [], [] for gy in range(grid_h): for gx in range(grid_w): for a in range(num_anchors): tx, ty, tw, th, obj, *cls = pred[0, gy, gx, a] score = sigmoid(obj) * sigmoid(np.array(cls)).max() if score < conf_threshold: continue cls_idx = int(np.argmax(cls)) cx = (sigmoid(tx) + gx) * stride cy = (sigmoid(ty) + gy) * stride w = anchors[a][0] * np.exp(tw) h = anchors[a][1] * np.exp(th) boxes.append([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2]) scores.append(float(score)) classes.append(cls_idx) if not boxes: return np.zeros((0, 4)), np.zeros((0,)), np.zeros((0,), dtype=int) boxes = np.array(boxes) scores = np.array(scores) classes = np.array(classes) keep = nms(boxes, scores, iou_threshold) return boxes[keep], scores[keep], classes[keep]

这就是完整的评估路径:头 → 解码 → 阈值 → NMS。

三、框架对比

torchvision.models.detection 提供生产级检测器,概念结构一致。加载预训练模型三行。

import torch from torchvision.models.detection import fasterrcnn_resnet50_fpn_v2 model = fasterrcnn_resnet50_fpn_v2(weights="DEFAULT") model.eval() with torch.no_grad(): predictions = model([torch.randn(3, 400, 600)]) print(predictions[0].keys()) print(f"boxes: {predictions[0]['boxes'].shape}") print(f"scores: {predictions[0]['scores'].shape}") print(f"labels: {predictions[0]['labels'].shape}")

实时推理流水线,ultralytics(YOLOv8/v9)是标准:from ultralytics import YOLO; model = YOLO('yolov8n.pt'); model(img)。模型内部处理解码与 NMS,返回的就是你上面手写的同一个 boxes / scores / labels 三元组。

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-detection-metric-reader.md:一个提示词——把一行 precision, recall, AP, mAP@0.5:0.95 转成一句诊断和最有用的下一个实验。
  • skill-anchor-designer.md:一个技能——给定一个真实框数据集,在 (w, h) 上跑 k-means,返回每个 FPN 层的锚框集,以及你挑选锚框数所需的覆盖统计。

五、练习

  1. (简单) 实现 box_iou,在 1000 对随机框上对 torchvision.ops.box_iou 验证,最大绝对差低于 1e-6
  2. (中等)yolo_loss 改成用 CIoU 框损失替代 MSE。在 100 张合成图上证明 CIoU 在相同 epoch 内收敛到更好的 mAP@0.5:0.95。
  3. (困难) 实现多尺度推理:同一张图以三种分辨率过模型,合并框预测,最后跑一次 NMS。在留出集上测多尺度相对单尺度的 mAP 提升。

本节要点回顾

  1. 检测 = 分类 + 回归,逐位置跑一次——输出张量 (S, S, B*(5+C)),再 NMS 清理。
  2. 网格让物体空间锚定——每物体只由其中心点所在单元负责。
  3. 锚框让回归变可行——预定义 B 种框形,预测小偏移而非从零回归;FPN 配多尺度锚框。
  4. 解码公式——中心用 sigmoid 限单元内,宽高用 exp 自由缩放,stride 换算回像素;v2 起各版本一致。
  5. IoU 是通用度量——决定真正例(IoU≥0.5)与 NMS 去重。
  6. NMS 贪心去重——按分数降序,保留最高,删 IoU>阈值;典型 0.45。
  7. 三段损失加权——框回归(λ_coord=5)、objectness(有/无物体分开,无物体 λ_noobj=0.5)、分类;现代版换 CIoU/focal。
  8. 四个检测指标——precision@0.5、recall@0.5、mAP@0.5、mAP@0.5:0.95(COCO,最严格)。
  9. mAP@0.5 强但 0.5:0.95 弱——定位粗,改框回归损失;precision 高 recall 低——太保守,降阈值或加 objectness 权重。
  10. 生产级选择——torchvision Faster R-CNN、ultralytics YOLOv8/v9,结构与手写一致。

下一节,我们从「给框打标签」升级到「给每个像素打标签」——用 U-Net 讲清语义分割如何用编码器-解码器与跳跃连接恢复空间分辨率。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U