1. 从“找猫找狗”说起:Faster R-CNN到底在解决什么麻烦
如果你刚接触物体检测,大概率会有这样一个疑问:分类网络已经能把图片里的猫猫狗狗认出来了,为什么还要搞一个“检测”模型?答案其实很朴素——分类只告诉你“图里有什么”,而检测要告诉你“它在哪里,有几个”。这个“在哪里”就是边界框,这个“有几个”就是实例计数。Faster R-CNN 就是为解决这两个问题而生的经典两阶段检测器,它在 2015 年被提出后,迅速成为物体检测领域的地基式工作,直到今天,很多工业级检测系统里依然能看到它的影子。
我第一次跑通 Faster R-CNN 的时候,用的是某开源框架自带的一个预训练模型,输入一张街景图,输出一堆带标签和置信度的框。当时觉得“哇,好神奇”,但紧接着就懵了:为什么有些框叠在一起?为什么小目标漏检那么严重?为什么改一下输入尺寸结果就全变了?后来花了很长时间去啃论文、读源码、调参数,才慢慢把里面的门道摸清楚。这篇内容就是把我踩过的坑、想明白的原理、以及实际调优中真正管用的技巧,系统地梳理一遍。
Faster R-CNN 的核心贡献,是把“候选区域生成”这个步骤从外部算法(比如 Selective Search)变成了网络内部的一个可学习模块,也就是RPN(Region Proposal Network)。这个改动看起来只是“把一件事挪到网络里做”,但它带来的连锁反应非常大:整个检测流程第一次做到了端到端训练,速度从原来的秒级提升到接近实时,精度还涨了。理解 RPN 的设计动机,是理解整个模型的关键。
这篇文章适合谁看?如果你已经了解卷积网络的基本概念,知道什么是特征图、什么是锚点,但还没真正把 Faster R-CNN 跑起来或者调明白,那这篇内容就是为你准备的。我会从数据准备、骨干网络选择、RPN 的运作逻辑、ROI pooling 的细节、损失函数的设计、训练策略、推理后处理、常见报错与调优经验这几个维度,把整个模型拆开揉碎讲清楚。每个部分都会解释“为什么这么做”,而不是只给一堆参数让你抄。
2. 数据准备与骨干网络:别急着改模型,先把输入管道理顺
2.1 标注格式的坑:从 VOC 到 COCO 的字段差异
物体检测的数据标注,常见的有两种格式:Pascal VOC 的 XML 和 COCO 的 JSON。很多开源实现默认吃某一种,但你手头的数据可能是另一种,甚至是从标注工具里导出的自定义格式。我见过太多人卡在第一步——数据加载器报错,然后开始怀疑模型代码有问题,其实只是字段名对不上。
VOC 格式里,一个目标对应一个<object>节点,里面有<name>、<bndbox>(包含 xmin、ymin、xmax、ymax)。COCO 格式则把所有标注放在一个 JSON 里,用images、annotations、categories三个大数组组织,边界框是[x, y, width, height],注意这里是宽高而不是右下角坐标。如果你直接把 COCO 的框喂给按 VOC 逻辑写的代码,框会全部错位,而且不会报错,只是训练效果奇差。
我的建议是:不管你用什么框架,先把数据统一转成一种中间格式,比如一个列表,每个元素是(image_path, [(xmin, ymin, xmax, ymax, class_id), ...])。转换的时候一定要做可视化抽查,随机抽 20 张图把框画出来看一眼。这一步花十分钟,能省掉后面几小时的迷茫。
注意:边界框的坐标是绝对像素值还是归一化值,不同代码库要求不同。归一化值通常在 0 到 1 之间,绝对像素值则依赖原图尺寸。混用会导致框跑到图像外面去。
2.2 骨干网络选型:VGG、ResNet 还是轻量级网络
Faster R-CNN 原文用的是 VGG16 作为骨干网络,后来大家发现 ResNet 系列效果更好,尤其是 ResNet-50 和 ResNet-101 成了标配。再往后,为了速度,又出现了 MobileNet、ShuffleNet 等轻量级骨干。选哪个,取决于你的场景对精度和速度的权衡。
如果你在做学术复现或者对精度要求极高,ResNet-50 配 FPN(特征金字塔)是稳妥选择。FPN 能把不同尺度的特征图融合起来,对小目标检测帮助很大。如果你要在边缘设备上跑,MobileNetV2 或者 MobileNetV3 做骨干,配合适当的输入分辨率,能在保持可接受精度的同时把参数量压到几百万级别。
这里有一个容易被忽略的点:骨干网络是否冻结。如果你用的是预训练权重,前几个 stage 的特征是通用的(边缘、纹理),可以冻结;后面的 stage 是任务相关的,需要微调。我通常的做法是,先冻结骨干训练 RPN 和检测头几个 epoch,再解冻全部一起微调。这样训练更稳定,loss 不会一上来就炸。
2.3 输入尺寸与批量大小的联动关系
Faster R-CNN 对输入尺寸比较敏感。原文把短边缩放到 600,长边不超过 1000。这个设置是有道理的:太小了小目标看不清,太大了显存吃不消。但实际项目中,你得根据你的目标尺寸分布来调整。
举个例子,如果你检测的是遥感图像里的车辆,目标在原图里可能只有 20 个像素宽,那缩放到 600 之后可能只剩几个像素,RPN 根本提不出候选框。这时候要么提高输入分辨率,要么用 FPN 加更浅层的特征图。反过来,如果你检测的是人,目标很大,那 600 就够用,甚至可以用 480 来换速度。
批量大小和输入尺寸是绑死的。输入越大,显存占用越高,批量就得调小。批量太小(比如 1 或 2)会导致 BatchNorm 统计量不准,训练震荡。如果显存实在不够,可以考虑用梯度累积来模拟大batch,或者把 BatchNorm 换成 GroupNorm。我实测下来,在批量小于 4 的时候,GroupNorm 比 BatchNorm 稳得多。
3. RPN 的运作逻辑:候选框是怎么“想”出来的
3.1 锚点机制:一组预设框如何覆盖各种目标
RPN 的核心是锚点(anchor)。你可以把锚点想象成在特征图的每个位置上,预先放置了一组大小和长宽比不同的参考框。比如常见的配置是 3 种尺度(128、256、512)乘以 3 种长宽比(1:1、1:2、2:1),一共 9 个锚点。特征图上有 H×W 个位置,那总共就有 H×W×9 个锚点。
这些锚点不是随便设的,它们要覆盖你数据集中可能出现的所有目标尺寸和形状。如果你的目标都是细长条(比如电线杆),那长宽比应该多设几个纵向的;如果目标都是正方形,那 1:1 就够了。我见过有人直接套用默认锚点,结果训练 loss 一直不降,后来把锚点尺度改成跟数据匹配的,很快就收敛了。
锚点的生成是在特征图上进行的,但最终要映射回原图坐标。这个映射关系取决于骨干网络的下采样倍数。比如 ResNet-50 的 C4 特征图下采样了 16 倍,那特征图上一个像素对应原图 16 个像素。锚点的尺度也是相对于原图定义的,所以在特征图上计算时要除以这个倍数。
3.2 正负样本划分:谁该负责预测,谁该被忽略
有了锚点,接下来要给每个锚点打标签:是正样本(负责预测某个真实目标)、负样本(背景),还是忽略样本(不参与训练)。划分规则通常是这样的:对每个真实框,找出与它 IoU 最大的锚点,标为正;然后对剩下的锚点,如果与某个真实框的 IoU 超过 0.7,也标为正;如果 IoU 低于 0.3,标为负;介于 0.3 和 0.7 之间的,忽略。
这个 0.7 和 0.3 的阈值不是随便定的。阈值太高,正样本太少,模型学不到东西;阈值太低,正样本里混进太多定位不准的框,回归分支会学偏。我试过把正阈值降到 0.5,结果召回率上去了但精度掉了不少。后来还是老老实实用 0.7。
还有一个细节:正负样本的比例。一张图里负样本数量远多于正样本,如果全用,loss 会被负样本主导。常见做法是随机采样,保持正负比例在 1:3 左右。这个比例也不是绝对的,如果你的目标特别密集,可以适当提高正样本比例。
3.3 RPN 损失函数:分类和回归怎么一起算
RPN 的损失由两部分组成:分类损失和回归损失。分类损失是二分类(前景/背景)的交叉熵,回归损失是平滑 L1(smooth L1)。总损失是两者加权和,通常分类权重为 1,回归权重也为 1,但有些实现会调。
回归的目标不是直接预测框的绝对坐标,而是预测相对于锚点的偏移量。具体来说,对于锚点中心 (xa, ya)、宽 wa、高 ha,以及真实框中心 (x*, y*)、宽 w*、高 h*,回归目标是:
- tx = (x* - xa) / wa
- ty = (y* - ya) / ha
- tw = log(w* / wa)
- th = log(h* / ha)
预测的时候,网络输出这四个偏移量,再反算回绝对坐标。用相对偏移而不是绝对坐标,是因为不同位置的锚点尺度不同,绝对坐标的数值范围差异太大,网络很难学。相对偏移把数值范围归一化了,学习更稳定。
平滑 L1 损失在误差小的时候是平方增长,误差大的时候是线性增长,这样对离群值不那么敏感。相比均方误差,它在检测任务里更常用。
4. ROI 池化与检测头:从候选框到最终输出
4.1 ROI Pooling 和 ROI Align 的区别
RPN 输出一堆候选框之后,每个框对应原图上的一个区域。但检测头需要固定尺寸的特征向量,所以要把这些区域的特征图裁剪并缩放到统一大小,比如 7×7。这个过程就是 ROI Pooling。
ROI Pooling 的做法是把候选框对应的特征图区域划分成 7×7 的网格,每个网格做最大池化。但问题来了:候选框的坐标是浮点数,划分网格时会出现小数,ROI Pooling 直接取整,导致特征图和原图之间的像素不对齐。这个不对齐在分类任务里影响不大,但在分割和精确定位任务里会掉点。
ROI Align 就是来解决这个问题的。它不做取整,而是用双线性插值在四个采样点上计算特征值,再池化。这样特征图和原图严格对齐,精度更高。如果你用的是较新的代码库,默认应该已经是 ROI Align 了。如果还在用 ROI Pooling,建议换掉,尤其是做小目标检测的时候。
4.2 检测头的结构:全连接还是卷积
原始 Faster R-CNN 的检测头是两个全连接层(通常是 4096 维),然后接分类和回归两个分支。这个设计参数量很大,VGG16 版本光检测头就有上亿参数。后来大家发现,用卷积层代替全连接层,效果差不多但参数少很多,而且对输入尺寸更灵活。
现在常见的做法是:ROI Align 输出 7×7×C 的特征,然后经过几个 3×3 卷积(比如 1024 通道),再全局池化或者展平后接全连接。分类分支输出 N+1 个分数(N 个类别加背景),回归分支输出 4×(N+1) 个偏移量。注意回归分支是每个类别都有一组偏移量,而不是共享一组。这个设计是为了让不同类别的定位可以有不同的偏好。
4.3 多任务损失:分类和回归的平衡
检测头的损失也是分类加回归。分类用交叉熵,回归用平滑 L1。但这里有个问题:背景类不参与回归,只有正样本才计算回归损失。而且正样本数量远少于负样本,所以回归损失通常会被分类损失淹没。
常见的做法是给回归损失一个较大的权重,比如 10,让它在总损失里占一定比例。但这个权重也不是越大越好,太大了会导致定位过拟合,分类精度下降。我一般从 1 开始试,如果定位不准就慢慢加到 5 或 10。
另外,分类损失里背景类的权重也值得注意。如果背景类权重太高,模型会倾向于把什么都判成背景,召回率低;太低则误检多。有些实现会用 OHEM(在线难例挖掘)来自动调整样本权重,效果不错,但实现复杂度也高。
5. 训练策略与调参经验:从 loss 不降到模型收敛
5.1 学习率与预热:为什么一开始不能太大
Faster R-CNN 的训练对学习率很敏感。骨干网络是预训练的,检测头是随机初始化的,如果一上来就用大学习率,检测头的梯度会很大,把预训练特征也带偏。所以通常要用预热(warmup):前几百个迭代用很小的学习率(比如 0.001 倍的基础学习率),然后线性增加到基础学习率。
基础学习率一般设 0.001 或 0.002(批量大小为 2 或 4 时)。如果批量更大,可以适当提高。我试过用 0.01 的基础学习率,结果 loss 直接飞了,调回来才正常。预热阶段通常占总迭代数的 1/10 左右。
学习率衰减策略常用的是步进衰减,比如在 60% 和 80% 的迭代处各降 10 倍。也有用余弦退火的,效果也不错。关键是不要一直用同一个学习率,后期不降的话 loss 会在最小值附近震荡。
5.2 正负样本采样与难例挖掘
前面提到 RPN 阶段会采样正负样本,检测头阶段也有类似机制。但固定比例的随机采样有个问题:随着训练进行,简单样本的 loss 越来越小,难样本的 loss 占比越来越大,但随机采样可能采不到足够的难样本。
OHEM 的思路是:先算所有样本的 loss,然后挑 loss 最大的那些样本参与梯度更新。这样模型会专注于那些还没学好的样本。我实测下来,OHEM 能让收敛后的精度提升 1 到 2 个点,但训练时间会增加,因为要多算一遍 loss。
另一个技巧是使用 Focal Loss 的思想,给难样本更高的权重。不过 Faster R-CNN 本身是两阶段,正负样本已经经过 RPN 筛选了,Focal Loss 的收益没有单阶段检测器那么明显。
5.3 数据增强:什么时候该用,什么时候不该用
数据增强是提升检测模型泛化能力的利器,但不是所有增强都适合。水平翻转、随机裁剪、颜色抖动这些通用增强,大多数场景都能用。但垂直翻转要小心,如果你的目标有方向性(比如文字、行人),垂直翻转会破坏语义。
随机裁剪要注意:裁剪后的图里可能只剩部分目标,如果标注不更新,就会引入错误标签。正确的做法是裁剪后重新计算框与裁剪区域的交集,只保留 IoU 大于某个阈值的框,并调整坐标。
多尺度训练也很有效:每几个迭代换一次输入尺寸,让模型适应不同尺度的目标。但尺寸变化不能太剧烈,否则 BatchNorm 统计量会乱。我一般在一个范围内随机选,比如短边在 480 到 800 之间。
6. 推理与后处理:模型输出怎么变成最终结果
6.1 置信度阈值与 NMS 的配合
模型推理时会输出大量候选框,每个框有类别分数和坐标。第一步是过滤掉分数低于阈值的框。这个阈值通常设 0.05 到 0.1,因为后面还有 NMS,宁可多留一些。如果设太高,漏检会严重。
然后是 NMS(非极大值抑制)。NMS 的作用是去掉同一个目标上的重复框。做法是按分数排序,保留最高分的框,然后去掉与它 IoU 超过阈值的其他框。这个阈值通常设 0.5。设太低会误删相邻目标,设太高会留下重复框。
但 NMS 有个问题:如果两个同类目标靠得很近,它们的框 IoU 可能超过 0.5,NMS 会把其中一个删掉。这时候可以用 Soft-NMS,不直接删除,而是降低分数,让分数低的框有机会保留。或者用 IoU 感知的 NMS,根据目标密度动态调整阈值。
6.2 多尺度测试与结果融合
多尺度测试是提升精度的常用手段:把同一张图缩放到不同尺寸分别推理,然后把结果融合。融合的方式可以是把所有框放在一起做 NMS,也可以按尺度加权。我试过在测试时用 3 个尺度,精度能涨 1 到 2 个点,但推理时间也涨了 3 倍。
如果对速度有要求,可以只用一个尺度,但把训练时的多尺度增强用上,让模型本身对尺度更鲁棒。这样推理时不用多尺度,也能有不错的效果。
6.3 可视化与错误分析:怎么看出模型哪里不行
推理完一定要做可视化。把预测框和真实框画在一起,用不同颜色区分,然后随机看几十张。你会很快发现规律:是漏检多还是误检多?是小目标不行还是大目标不行?是某个类别特别差还是整体都差?
我习惯按类别统计 AP,然后挑 AP 最低的类别重点看。如果某个类别的 AP 明显低于其他,可能是训练样本太少,或者锚点尺寸不适合这个类别的目标。如果是小目标整体差,那就要考虑用 FPN 或者提高输入分辨率。
还有一个容易被忽略的点:框的回归质量。有时候分类分数很高,但框的位置偏了。这时候要看回归损失的收敛情况,可能是回归权重设得太低,或者 ROI Align 的采样点太少。
7. 常见报错与排查链路:那些让我熬夜的坑
7.1 Loss 变成 NaN:从数据到梯度的逐层排查
Loss 变 NaN 是最常见也最让人头疼的问题。排查顺序应该是:先看数据里有没有非法值(比如坐标超出图像范围、宽高为负),再看学习率是不是太大,然后看有没有除零操作(比如宽高为 0 的框参与回归),最后看梯度裁剪有没有开。
我遇到过一次,是因为数据里有个框的宽是 0,计算 log(w*/wa) 时变成负无穷,导致 loss 爆炸。后来在数据加载器里加了一行过滤,把宽高小于 1 像素的框丢掉,问题就解决了。
梯度裁剪也是必备的,通常把梯度范数限制在 10 或 5。这样即使某个 batch 有异常,也不会把参数带飞。
7.2 显存溢出:从输入尺寸到模型并行的取舍
显存溢出通常发生在输入尺寸太大或者批量太大的时候。排查步骤:先把批量降到 1,如果还溢出,那就是模型本身太大或者输入太大。这时候可以减小输入尺寸,或者换轻量级骨干。
还有一个隐藏的显存杀手:ROI Align 的输出特征图。如果候选框数量很多(比如 RPN 输出几千个),每个都做 ROI Align,显存占用会很高。可以在 RPN 之后先做一次 NMS,把候选框数量降到几百个,再送进检测头。这个操作叫 RPN NMS,训练和推理时都可以做。
如果单卡实在放不下,可以考虑模型并行或者梯度检查点。梯度检查点用时间换空间,把中间激活值不保存,反向传播时重新计算,能省不少显存,但训练速度会慢 20% 到 30%。
7.3 评估指标不涨:从数据分布到锚点匹配的检查清单
训练 loss 在降,但 mAP 不涨,这种情况最让人沮丧。我的检查清单是这样的:
- 数据标注有没有问题?随机抽 50 张图可视化,看框有没有标错、漏标。
- 锚点尺寸和长宽比跟数据匹配吗?统计一下真实框的宽高分布,跟锚点对比。
- 正负样本比例是否合理?打印每个 batch 的正样本数量,如果长期为 0,说明锚点匹配有问题。
- 学习率是不是太大了?看 loss 曲线,如果震荡厉害,降学习率。
- 评估代码有没有 bug?用官方评估脚本跑一遍,跟自己的实现对比。
我遇到过最隐蔽的一个问题:评估时用的类别映射跟训练时不一致,导致所有预测都被判为错误。后来统一了类别 ID 才解决。
8. 一些让我少走弯路的实操心得
第一,不要一上来就改模型结构。先把默认配置跑通,确认数据管道、损失函数、评估指标都没问题,再动模型。我见过太多人一上来就换骨干、加 FPN、改损失,结果出了问题根本不知道是哪一步引入的。
第二,训练日志要记全。除了 loss,还要记学习率、正样本数量、梯度范数、每个类别的 AP。这些信息在排查问题时非常有用。我习惯用 TensorBoard 或者简单的 CSV 记录,每几十个迭代存一次。
第三,可视化不是可选项,是必选项。训练前可视化数据,训练中可视化预测,训练后可视化错误。很多问题看一眼图就明白了,比盯着 loss 曲线猜半天高效得多。
第四,超参数没有银弹。别人调好的参数在你的数据上不一定好用。锚点尺寸、学习率、损失权重这些,都要根据你的数据分布和任务特点来调。调参的时候一次只动一个,记录结果,慢慢积累经验。
第五,如果时间允许,做消融实验。比如去掉 FPN 看精度掉多少,换 ROI Align 看涨多少。这样你能真正理解每个模块的贡献,而不是盲目堆砌。
最后说一个我自己的习惯:每次跑通一个新模型,我都会用一张固定的测试图,记录它的输出。以后每次改代码,都拿这张图跑一遍,看输出有没有变化。这个简单的回归测试,帮我抓到了好几次因为代码改动引入的隐蔽 bug。