以前在商汤做 CV 的时候,Faster R-CNN、SSD、FPN、RetinaNet 这些名字是天天打交道的。模型训练、转换、部署,各种坑也踩了不少——TensorRT 版本间 softmax axis 默认值不一致、RoI Align 的 num_roi_per_image 默认 300 导致多 batch 结果错乱,这类问题当年 debug 到怀疑人生。
但几年不做 CV 之后,发现这些模型的结构细节已经忘得差不多了。最近重新复习 CNN 的时候想把目标检测重新过一遍,发现比起重新记住每个网络有几层、anchor 设了几种 ratio,更值得重新理解的是:这些模型到底在逐步解决什么问题,以及每一步架构变化删除了什么、改变了什么。
这篇文章就是重新梳理这条线索。不会重复以前写过的实现细节——那些旧文都还在,有需要的地方直接链过去。这篇更像是给那些零散笔记补一个统一的入口和框架。
检测比分类多出了什么#
图像分类的任务相对干净:输入一张图,输出一个固定维度的结果,比如"这是一只猫"。
目标检测不一样。它需要输出一个不定长的集合,每个元素包含类别、置信度、位置:
1(class, confidence, x1, y1, x2, y2) × N这里 N 不是固定的。一张图里可能有 1 个 object,可能有 50 个,也可能一个都没有。
把这个差异拆开来看,检测相比分类至少多出三个子问题:
- 分类(Classification) — 这个位置的东西属于什么类别?
- 定位(Localization) — 这个物体精确在哪里?具体的边界框坐标是什么?
- 候选区域生成(Candidate generation) — 到底应该在图像的哪些位置去尝试检测?
前两个问题相对直觉。第三个问题反而是我这次重新看下来觉得最关键的:到底在哪些位置做预测?
一张图里目标的数量不固定、位置不固定、尺度不固定、宽高比不固定。如果不知道该在哪里预测,怎么办?最简单的回答就是——到处都试。
滑动窗口:最暴力的起点#
最直接的思路:
1for every position:
2 for every scale:
3 for every aspect ratio:
4 crop a window → classifier(crop)在每个位置、每个尺度、每个宽高比上裁一块图,扔进分类器判断有没有 object。
问题很明显:组合空间太大,大量窗口之间高度重叠,CNN 对重复区域做了大量重复计算。
这部分以前单独记过,具体可以看《目标检测领域的滑动窗口算法》。
但滑动窗口引出一个很自然的想法:既然 CNN 本来就是在整张图上做卷积,feature map 天然覆盖了所有空间位置——那是不是应该先提取一次 feature map,然后在 feature map 上寻找 object,而不是对每个 crop 重复跑 CNN?
这就是 region-based detector 的出发点。
Faster R-CNN:把问题拆成两步#
Faster R-CNN 的核心不是"多了一个 RPN",而是它对 detection 问题做了一次 two-stage decomposition。
Stage 1(RPN) 回答的是:这里有没有一个值得进一步看的 object?
它不关心具体类别,只做两件事:objectness 二分类(前景还是背景)和粗糙的 bounding box regression。它的输出是大约 300 个 region proposals——从几万个 candidate 里筛选出的少量"可疑区域"。
Stage 2 针对这少量 proposal,用 RoI Pooling(或后来改进的 RoI Align)从 feature map 上裁出对应区域的特征,然后做更精细的分类和 box regression。
这个结构最值得记住的 insight 其实是一种非常经典的系统设计思路:coarse-to-fine。
1大量便宜判断(RPN:~20k anchors → ~300 proposals)
2 ↓
3少量昂贵判断(per-RoI classification)先用轻量级网络对所有位置做一轮粗筛,把候选从几万缩减到几百,然后再对这几百个做计算量大得多的精细判断。这不只是 CV 里的技巧,搜索引擎的召回-排序、推荐系统的多阶段漏斗,都是类似的 coarse-to-fine 结构。
Faster R-CNN 的具体实现——RPN 网络结构、RoI Pooling vs RoI Align、proposal layer 的后处理细节——以前都详细记过,参考《Faster Rcnn 目标检测算法》。那篇文章里还包括了我当年踩过的几个工程坑:anchor 不一致、RoI Align 默认参数、softmax reshape 维度等。这些细节这里就不再重复了。
Anchor 到底是什么#
理解 anchor 是理解后面 anchor-free 为什么会出现的前提。
很多介绍把 anchor 和最终的 predicted box 混在一起说,但实际上它们是不同的东西。
Anchor 不是预测框。Anchor 是 bounding box regression 的 reference——一个预先定义好的初始假设。
在 feature map 的每个位置上,会预先放置若干组不同尺度和宽高比的 anchor,比如:
1small square, large square, wide rectangle, tall rectangle, ...网络实际预测的不是 box 的绝对坐标,而是相对于这个 anchor 的偏移量:
1Δx, Δy, Δw, Δh也就是说,网络在回答:从这个 reference box 出发,应该怎么平移和缩放,才能变成 object 的 box?
训练的时候,每个 anchor 和 ground truth 算 IoU,根据阈值分成 positive / negative / ignore,positive 的 anchor 负责学习到对应 GT 的 regression offset。
这套机制其实引入了三个后面会反复出现的问题:
- Anchor 数量巨大 — 比如一个 feature map 上每个位置 9 种 anchor,整张图轻松几万个
- Foreground / background 极端不平衡 — 几万个 anchor 里真正 positive 的可能只有几百个
- Anchor 的 scale、ratio、数量是人工先验 — 需要根据数据集和任务手动调整
这三个问题分别在后面催生了 Focal Loss 和 Anchor-Free。
以前写过一篇关于 anchor 的记录,具体细节见《Anchor Box Algorithm》。
SSD:删除 proposal stage#
Faster R-CNN 精度不错,但 two-stage 的计算链路在部署时偏重。实际业务上也是这样——当时公有云上检测模型几乎都是 Faster R-CNN,但到了嵌入式设备和端侧盒子,就全换成 SSD 了。
SSD 做的关键变化不是"换了一个 backbone",而是删除了 proposal stage,把 detection 直接变成 dense prediction。
Faster R-CNN 是:
SSD 是:
区别一目了然:SSD 在多个 feature map 的每个位置上直接预测 class + bbox,不再经过一个专门的 proposal 筛选阶段。
需要注意的是,SSD 里的 prior box 和 Faster R-CNN 的 anchor 在思想上没有本质区别。都是预定义的 reference box,网络预测的都是相对于 reference 的 offset。SSD 并没有摆脱 anchor 机制。
SSD 的 prior box、decode box 实现、multi-scale feature map 的具体细节以前记过,参考《SSD: Single Shot MultiBox Detector 学习笔记》。那篇里也提到了 variance 这个原始 paper 没有详细说明的实现参数,以及 pytorch 和 caffe 之间 prior box 顺序不一致的问题。
为什么检测特别需要 feature hierarchy#
SSD 在多个不同 scale 的 feature map 上做检测,这不是随便选择的——是因为目标检测有一个分类任务中不存在的现实问题:object 的尺度差异非常大。
同一张图里可能同时出现占 300×300 像素的大 object 和只有 15×15 像素的小 object。
CNN 的不同深度天然对应不同的 scale 特性:
1浅层 → spatial resolution 高, receptive field 小, semantic abstraction 弱
2深层 → spatial resolution 低, receptive field 大, semantic abstraction 强SSD 的做法是直接在不同深度的 feature map 上分别做 detection:浅层检测小 object,深层检测大 object。
但这里有一个矛盾:浅层 feature map 虽然 resolution 高,适合定位小 object,但它的 semantic information 相对弱。也就是说,它看得清细节但"理解力"不够。
这个矛盾指向了下一个关键的架构变化。
FPN:不只是"多几个 feature map"#
我第一次看 FPN 的时候,直觉反应是"不就是用了多个 scale 的 feature map 嘛,SSD 也这么做了"。重新看一遍才意识到,FPN 真正解决的问题和 SSD 不一样。
SSD 确实用了多个 scale 的 feature map,但每一层只用自己的特征——浅层有 resolution 但缺 semantics,深层有 semantics 但丢了 resolution。
FPN 的做法是:通过 top-down pathway 把深层的 strong semantics 传回高 resolution 的 feature map。
Bottom-up 就是普通的 backbone forward,逐层降低分辨率、增强语义。Top-down 则反过来,把深层特征 upsample 后和浅层特征通过 lateral connection 做逐元素相加。
最终每一层 P_i 都同时拥有:对应尺度的 spatial resolution 和来自更深层传递下来的 strong semantics。
FPN 真正解决的是 resolution 与 semantics 之间的矛盾,而不是简单的"多尺度"。
具体的代码实现以前记过,包括 bottom-up / top-down 的 PyTorch 代码、FPN 作用于 RPN 和 Fast R-CNN 的方式、roi 到 feature level 的分配策略。参考《FPN:Feature Pyramid Networks 学习笔记》。
RetinaNet:one-stage 的真正瓶颈在哪#
很长一段时间里,one-stage detector 的精度一直追不上 two-stage。RetinaNet 这篇论文最重要的贡献不是提出了一个新的 detector 架构,而是回答了一个关键问题:
为什么 one-stage 比 two-stage 难训练?
答案不是 one-stage 的 model capacity 不够,而是 dense detection 中 foreground / background 的极端不平衡。
一个 dense detector 可能产生 100,000 个候选位置。其中真正的 positive 可能只有几百个,剩下 99% 以上都是 background。而且这些 background 中绝大多数是 easy negative——天空、墙壁、空白区域——模型早就学会了它们是背景,但因为数量太大,它们仍然占据了大量的 loss 和 gradient。
Focal Loss 的公式是:
$$ FL(p_t) = -(1-p_t)^\gamma \log(p_t) $$相比普通的 cross-entropy,多了一个 \((1-p_t)^\gamma\) 调制因子。
直觉很简单:如果 \(p_t\) 非常高(比如 0.999),说明模型对这个样本已经非常确信,\((1-p_t)^\gamma\) 就非常小,这个样本几乎不再贡献 loss。如果 \(p_t\) 比较低(比如 0.5),说明模型仍然困难,loss 基本保持不变。
也就是说:不让数量巨大的 easy samples 消耗训练预算,把学习 capacity 留给 hard examples。
Faster R-CNN 之所以没有这个问题,是因为 RPN 已经把绝大多数 easy background 在第一阶段就筛掉了——proposal 阶段本身就是一种 hard example mining。而 one-stage detector 没有这个筛选,所有候选位置直接参与最终 loss,不平衡就暴露出来了。
RetinaNet 的历史意义之一在于:它把 one-stage detector 的精度问题重新解释成了一个 class imbalance / optimization 问题,而不是 architecture capacity 问题。这是一种很重要的问题重构。
Focal Loss 的曲线和 RetinaNet 的整体架构(FPN backbone + class/box subnet)以前记过,参考《Focal Loss for Dense Object Detection(RetinaNet) 学习笔记》。
重新审视 Anchor:它真的必要吗?#
走到这一步,回头看 anchor 机制的全貌:
为了覆盖不同的 object,需要预先设计一整套 anchor template:
1scale: {32, 64, 128, 256, 512}
2aspect ratio: {1:1, 1:2, 2:1}
3FPN level: {P2, P3, P4, P5, P6}训练的时候还需要一堆匹配规则:
1IoU threshold for positive
2IoU threshold for negative
3matching strategy (max IoU? top-k?)这些全是 handcrafted prior。每换一个数据集、每换一种目标形态,可能都需要重新调。
自然就会想到一个问题:如果模型最终要预测的只是一个 bounding box 的位置,为什么一定要先人为放一个 box template,然后让网络去修正它?
能不能直接从 feature map 上的某个点出发,预测 object 的边界?
这就是 anchor-free 最自然的动机。它不是凭空出现的新技术,而是在逐步删除经典 detector 中的 handcrafted intermediate representation。
FCOS:从"修正一个 box"到"从一个 point 找四条边"#
FCOS(Fully Convolutional One-Stage Object Detection)是 anchor-free 这条路线上最有代表性的工作之一。
Anchor-based detection 里,网络在 anchor 的基础上预测 offset:
FCOS 换了一种 representation:对于 feature map 上的每个 point,直接预测从该 point 到 object bounding box 四条边的距离。
也就是从"这个预定义好的 anchor box 应该怎么偏移"变成了"从当前像素点出发,到目标物体四条边界的距离分别是多少"。
这是一次很干净的表征简化(Representation Simplification)。
但 anchor-free 并不意味着没有新的问题需要解决。
如果一个特征点只要落在真实标注框(GT box)内部就被当作正样本,那么靠近边界的点往往会产生很差的预测——它离目标中心太远,预测的左、上、右、下距离总有一些极大值,导致回归精度大幅下降。
因此 FCOS 引入了中心度(centerness)分支:用来衡量当前采样点距离目标中心的接近程度。推理时用中心度得分去乘分类置信度,自动抑制边缘位置低质量预测框的最终得分。
这里有一个很容易被过度简化的认知需要纠正:anchor-free 不等于没有先验(no prior)。
FCOS 依然保留了大量物理先验:
- 特征金字塔层级分配(不同尺寸的目标分发到不同的 FPN 层负责)
- 步长(stride,决定特征图上相邻采样点映射回原图的物理间距)
- 中心采样(center sampling,后续改进中只把目标中心小区域的点算作正样本)
- 尺度区间约束(每个 FPN level 明确划分负责的目标尺度范围)
它删除的是 explicit box template 和对应的 IoU matching 规则,但 detection 中的 spatial prior 并没有完全消失。
NMS:为什么经典 detector 最后还需要去重#
不管是 anchor-based 还是 anchor-free,dense detector 都有一个共同的输出特征:对于同一个 object,周围多个 anchor 或 point 都可能产生高置信度的预测。
1同一只猫:
2
3box A score 0.99
4box B score 0.97
5box C score 0.94
6box D score 0.90这些 box 彼此高度重叠,但 detector 并不会自动知道它们指的是同一个 object。于是需要 NMS(Non-Maximum Suppression)做一轮后处理:
1选最高分的 box
2 → 删除与它 IoU 超过阈值的其他 box
3 → 重复直到没有剩余NMS 本质上是 dense prediction 输出表示方式的自然后果——既然允许多个位置同时为同一个 object 产生预测,就需要在最后合并。
NMS 的具体算法和实现以前写过,参考《非极大值抑制(Non-Maximum Suppression,NMS)》。
回头看:五条演化线索#
重新过完这条从 Sliding Window 到 FCOS 的路线,我觉得经典目标检测的演化可以用五条线索来概括。
搜索空间(Search Space)的变化:到底在哪里做预测?
1滑动窗口 → 暴力枚举所有可能的位置与尺度
2Faster R-CNN → 两阶段筛选:先学习生成粗候选区,再精细分类(稠密转稀疏)
3SSD / FCOS → 抛弃候选区概念,直接在特征图的全图网格上稠密预测边界框表征(Bounding Box Representation)的变化:如何描述一个框?
1基于 Anchor → 预设基准参考框 + 回归坐标偏移量(offset regression)
2Anchor-free → 纯空间网格点 + 回归到物体四边的绝对距离 (l, t, r, b)多尺度表征(Multi-Scale Representation)的变化:如何处理大小不一的目标?
1SSD → 不同深度的特征层各自单打独斗做检测
2FPN → 自顶向下传导高层强语义,与高分辨率浅层融合,兼顾细节与语义训练优化(Optimization)的变化:如何让全图稠密预测稳定收敛?
1两阶段检测 → 候选区阶段自动做负样本过滤
2单阶段稠密检测 → 遭遇极端正负样本不平衡
3Focal Loss → 动态压制简单易分背景样本的 loss 权重,保护稀缺正样本梯度后处理(Post-Processing)的变化:如何从成千上万个候选框中拿到最终结果?
1稠密预测 → 同一个目标周围不可避免地产生一整簇重叠框 → NMS 非极大值抑制去重这五条线索并不是孤立割裂的。比如,Faster R-CNN 的候选区(RPN)阶段本质上已经是一次难例挖掘(hard example mining),所以它不需要单独搞 Focal Loss;SSD 拿掉了候选区直接在全图铺框,瞬间暴露了正负样本悬殊的矛盾,才倒逼出了 RetinaNet;而 anchor-free 虽然干掉了人工设计的 anchor 模板,但底层依然是稠密预测,所以末端依然甩不掉 NMS。
经典目标检测演进的一条清晰主线,就是不断剥离人工硬编码的中间表征(handcrafted intermediate representation)。从暴力枚举到可学习候选框,从固定比例的 anchor 模板到直接用网格点回归几何距离,每一步都在把人为强加的假设替换成端到端学习。
这还不是目标检测架构演化的终点。后来这个领域又开始重新思考 prediction set、后处理以及 detection 的整体 formulation,不过那已经是另一条线索了,等把相关工作重新学完之后再单独写。