ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

RPN区域建议网络原理与实战:从Anchor设计到正负样本划分

RPN区域建议网络原理与实战:从Anchor设计到正负样本划分

1. RPN到底在做什么:从一张图到一堆候选框

Region Proposal Network,简称RPN,这玩意儿最早是跟着Faster R-CNN一起出来的。很多人第一次看论文的时候会被“区域建议”这四个字唬住,觉得是个多高深的模块。其实你把它的职责拆开看,就一句话:给后续的检测头喂一堆“可能有东西”的框,并且告诉它这些框里大概是什么前景还是背景。

我刚开始接触RPN的时候,脑子里一直有个疑问:为什么不能直接让分类网络在全图上滑一遍?后来自己动手复现了一遍才明白,全图滑窗的代价太大了。假设输入是一张1000×600的图,如果用3×3的滑窗、步长1,那就是将近60万个位置,每个位置都要过一遍分类器,算力根本扛不住。RPN的思路是先用一个轻量的卷积网络在特征图上滑,每个位置只出k个预设框(anchor),然后对这些框做一次二分类(前景/背景)和一次回归(微调位置),把明显不靠谱的框扔掉,剩下的才交给后面的检测头。

这个设计的好处在于:把“找目标”和“认目标”这两件事解耦了。RPN只负责找,不负责认。它不需要知道这是猫还是狗,只需要知道“这里有个东西,大概在这个位置”。这种分工让整个检测流程变得非常高效,也是两阶段检测器能跑出实时速度的关键。

适合谁来读这篇内容?如果你正在复现Faster R-CNN,或者想自己搭一个自定义的检测框架,RPN是绕不过去的一环。哪怕你用的是YOLO这类单阶段检测器,理解RPN的anchor机制和正负样本划分逻辑,对你调参也有直接帮助。下面我会从设计思路、核心细节、实操流程、常见问题四个维度,把RPN拆干净。

2. 整体设计思路与方案选型

2.1 为什么是“卷积滑窗+Anchor”而不是“选择性搜索”

在RPN出现之前,主流的两阶段检测器(比如Fast R-CNN)用的是选择性搜索来生成候选框。选择性搜索是个纯CPU算法,一张图要跑好几秒,根本没法端到端训练。RPN的核心突破就是把候选框生成也变成一个可学习的神经网络模块,直接挂在特征图上,用GPU并行算。

具体做法是在骨干网络输出的特征图上,用一个3×3的卷积核滑一遍,每个位置输出一个256维(或者512维,取决于骨干通道数)的特征向量。然后这个向量分两路:一路接1×1卷积做二分类,输出2k个分数(k是anchor数量,每个anchor两个分数:前景、背景);另一路接1×1卷积做回归,输出4k个坐标偏移量。

这里有个设计细节值得说:为什么用3×3而不是1×1或者5×5?3×3的感受野刚好能覆盖一个中等大小的目标区域,同时计算量可控。1×1的话感受野太小,每个位置只能看到自己,缺乏上下文信息;5×5计算量上去了,但收益不明显。这个3×3卷积相当于在特征图上做了一次局部特征聚合,让每个位置的表示更有判别力。

2.2 Anchor的设计哲学:用先验框覆盖所有可能

Anchor本质上是一组预设的矩形框,每个位置都有一组。假设你设了9个anchor(3种尺度×3种长宽比),那特征图上每个位置都会生成9个框。这些框的中心点就是当前特征图位置映射回原图的坐标。

为什么要有多个尺度和长宽比?因为目标的大小和形状是多样的。一个128×128的anchor适合检测中等大小的物体,256×256的适合大物体,64×64的适合小物体。长宽比1:1、1:2、2:1分别对应正方形、竖长条、横长条。这9个组合基本能覆盖常见的目标形态。

注意:anchor的尺度和长宽比不是随便设的,要根据你的数据集统计来。如果你检测的是行人,长宽比集中在1:2到1:3之间,那你就没必要设2:1的横长条anchor,纯属浪费算力。

2.3 正负样本划分:谁该负责哪个框

RPN训练的核心问题之一就是:哪些anchor算正样本,哪些算负样本?论文里的策略是:

  • 正样本:和某个真实框的IoU大于0.7的anchor,或者和某个真实框IoU最大的anchor(即使没到0.7)
  • 负样本:和所有真实框的IoU都小于0.3的anchor
  • 剩下的(0.3到0.7之间)直接扔掉,不参与训练

这个策略的逻辑是:IoU大于0.7的框已经和真实框很接近了,让它们负责回归是合理的;IoU最大的那个框即使质量不高,也得给它一个正样本身份,否则某些小目标可能一个正样本都没有。0.3到0.7之间的框处于“模棱两可”的状态,强行训练反而会引入噪声。

我实测下来,正负样本比例控制在1:1到1:3之间比较稳。如果负样本太多,模型会倾向于把所有框都预测成背景,召回率上不去。如果正样本太多,误检会明显增加。Faster R-CNN原论文里是随机采样256个anchor,正负各一半,这个策略在大多数场景下都够用。

3. 核心细节解析与实操要点

3.1 特征图到原图的坐标映射

RPN输出的回归量是相对于anchor的偏移量,格式通常是(dx, dy, dw, dh)。要把这些偏移量还原成原图上的框,需要做两步:

第一步,把特征图上的位置映射回原图。假设骨干网络的总步长是16(比如VGG16的conv4_3之后),那特征图上的一个点(x_f, y_f)对应原图的中心就是(x_f * 16, y_f * 16)。这个16就是感受野的步长,也叫stride。

第二步,用偏移量调整anchor。公式是这样的:

x_center = x_anchor + dx * w_anchor y_center = y_anchor + dy * h_anchor w = w_anchor * exp(dw) h = h_anchor * exp(dh)

这里dx和dy是中心点的偏移,dw和dh是宽高的缩放。用exp是为了保证宽高永远是正数,而且缩放是对称的——dw=0时宽不变,dw>0时宽变大,dw<0时宽变小。

实操心得:很多人在复现的时候会忘记做clip操作,导致预测出来的框跑到图像外面去了。记得在还原坐标之后,把框的边界裁剪到[0, W]和[0, H]范围内,否则后续的ROI Pooling会报错。

3.2 损失函数:分类损失+回归损失

RPN的损失函数是两部分加权和:

L = (1/N_cls) * Σ L_cls + λ * (1/N_reg) * Σ L_reg

分类损失用的是二分类交叉熵,只对正负样本计算,被扔掉的样本不参与。回归损失用的是smooth L1,只对正样本计算。λ通常设成10,用来平衡两个损失的量级。

为什么回归损失只对正样本算?因为负样本根本没有对应的真实框,你让它回归到哪儿去?强行算的话只会让模型混乱。这个细节在复现的时候特别容易搞错,我见过不少人把负样本也塞进回归损失里,结果训练loss一直震荡。

smooth L1的定义是这样的:

|x| < 1: 0.5 * x^2 |x| >= 1: |x| - 0.5

它在零点附近是二次函数,远离零点时是线性的。这个设计比纯L2鲁棒,因为L2对离群点太敏感,一个预测偏得离谱的框会把整个loss拉爆。smooth L1相当于给梯度加了个上限,训练更稳定。

3.3 Anchor的生成与筛选

在实际代码里,anchor的生成通常是在特征图上按位置遍历,每个位置生成k个框。假设特征图大小是H×W,那总共就是H×W×k个anchor。对于一张1000×600的图,stride=16,特征图大概是63×38,9个anchor的话就是21546个框。

这2万多个框不可能全部送进后续网络,所以RPN会先做一次筛选:

  1. 按分类分数排序,取前N个(比如12000个)
  2. 用NMS(非极大值抑制)去掉重叠度高的框,IoU阈值通常设0.7
  3. 再取前M个(比如2000个)作为最终的候选框

训练的时候取2000个,推理的时候可以取300个或者1000个,看你对速度的要求。NMS的作用是去掉冗余框,因为同一个目标附近可能会有很多个高分框,保留一个就够了。

注意:NMS的阈值不要设太低,否则相邻的两个目标会被误删。比如两个人站得很近,IoU可能到0.5,如果你把NMS阈值设成0.3,那其中一个就被干掉了。0.7是个比较安全的默认值。

4. 实操过程与核心环节实现

4.1 环境准备与骨干网络选择

我这次复现用的是PyTorch,骨干网络选了ResNet-50。为什么不用VGG16?因为ResNet有残差连接,训练更稳定,而且同样的层数下参数量更少。如果你追求速度,可以用MobileNetV2或者ShuffleNet,但精度会掉一些。

环境配置如下:

pip install torch torchvision pip install numpy opencv-python pip install matplotlib tqdm

数据集我用的是一个自定义的小规模检测数据集,大概2000张图,5个类别。如果你手头没有数据,可以用COCO或者VOC的先跑通流程。

4.2 特征图提取与RPN头部搭建

骨干网络输出的是C×H×W的特征图,我这里是1024×63×38(ResNet-50的conv4_x输出)。RPN头部的结构是这样的:

class RPNHead(nn.Module): def __init__(self, in_channels=1024, mid_channels=512, num_anchors=9): super().__init__() self.conv = nn.Conv2d(in_channels, mid_channels, 3, padding=1) self.cls_logits = nn.Conv2d(mid_channels, num_anchors * 2, 1) self.bbox_pred = nn.Conv2d(mid_channels, num_anchors * 4, 1) def forward(self, x): t = F.relu(self.conv(x)) cls_logits = self.cls_logits(t) bbox_pred = self.bbox_pred(t) return cls_logits, bbox_pred

这里3×3卷积的输出通道我设成了512,和原论文一致。分类分支输出18个通道(9个anchor×2),回归分支输出36个通道(9个anchor×4)。注意输出是逐anchor排列的,后面reshape的时候要小心维度顺序。

4.3 Anchor生成的具体实现

Anchor的生成我写了一个函数,输入是特征图大小、stride、尺度和长宽比列表:

def generate_anchors(feature_size, stride, scales, ratios): anchors = [] for y in range(feature_size[0]): for x in range(feature_size[1]): cx = x * stride + stride / 2 cy = y * stride + stride / 2 for scale in scales: for ratio in ratios: w = scale * np.sqrt(1.0 / ratio) h = scale * np.sqrt(ratio) anchors.append([cx - w/2, cy - h/2, cx + w/2, cy + h/2]) return np.array(anchors)

尺度和长宽比我设的是[64, 128, 256]和[0.5, 1.0, 2.0],一共9个组合。这个配置在大多数数据集上都能work。如果你的目标特别小(比如小于32像素),可以把64换成32,再加一个16的尺度。

4.4 正负样本划分与损失计算

正负样本划分我写了一个函数,输入是anchor和真实框,输出是每个anchor的标签和回归目标:

def assign_targets(anchors, gt_boxes, pos_thresh=0.7, neg_thresh=0.3): ious = compute_iou(anchors, gt_boxes) max_iou = ious.max(axis=1) labels = np.zeros(len(anchors)) labels[max_iou < neg_thresh] = 0 labels[max_iou >= pos_thresh] = 1 # 每个真实框对应的最大IoU anchor也设为正样本 max_idx = ious.argmax(axis=0) labels[max_idx] = 1 # 计算回归目标 reg_targets = compute_regression(anchors, gt_boxes, max_idx) return labels, reg_targets

这里有个细节:max_idx = ious.argmax(axis=0)得到的是每个真实框对应的最佳anchor索引。这个操作保证了每个真实框至少有一个正样本anchor,避免小目标被漏掉。

损失计算的时候,我采样了256个anchor,正负各128个。如果正样本不够128个,就用负样本补齐。分类损失用BCEWithLogitsLoss,回归损失用smooth_l1_loss,权重λ设成10。

4.5 训练过程与参数设置

训练参数如下:

参数值说明
优化器SGDmomentum=0.9
初始学习率0.001每5个epoch衰减0.1
batch size2显存限制
epoch20小数据集够用
权重衰减0.0005防止过拟合
正负样本比例1:1各128个

训练到第10个epoch的时候,RPN的分类loss大概降到0.15左右,回归loss在0.08附近。这个水平已经能生成质量不错的候选框了。我可视化了一下,对于中等大小的目标,RPN生成的框和真实框的IoU基本都在0.6以上。

实操心得:如果你的loss不下降,先检查anchor的尺度和你的数据集是否匹配。我一开始用默认的[128, 256, 512],结果我的数据集里全是小目标,RPN一个正样本都匹配不上,loss直接卡在0.69不动。后来把尺度改成[32, 64, 128]才正常。

5. 常见问题与排查技巧实录

5.1 Loss不下降或者震荡

这是最常见的问题,原因通常有三个:

第一,anchor尺度和数据集不匹配。解决办法是统计你数据集中所有真实框的宽高分布,用k-means聚类出几个代表性的尺度和长宽比。这个操作在YOLO系列里叫“anchor聚类”,对RPN同样适用。

第二,正负样本划分阈值设得不对。如果你的数据集里目标特别密集,0.7的正样本阈值可能太高了,导致正样本太少。可以适当降到0.6或者0.5。反过来,如果目标很稀疏,可以升到0.8。

第三,学习率太大。RPN的回归分支对学习率很敏感,我试过用0.01的学习率,loss直接飞了。0.001是个比较安全的起点。

5.2 生成的候选框大量重叠

这个问题通常是NMS没做好。检查你的NMS阈值是不是设得太高(比如0.9),或者NMS之前有没有按分数排序。另外,如果你的分类分数普遍偏低(比如都在0.5左右),说明模型没学好,需要回头检查训练过程。

还有一个容易被忽略的点:NMS是在每个类别内独立做的,还是所有类别一起做的?RPN阶段只有前景和背景两类,所以直接对前景分数做NMS就行。但如果你在RPN之后还接了别的分类器,那NMS要在那个阶段按类别分别做。

5.3 小目标检测效果差

小目标在RPN里天然吃亏,因为经过多次下采样之后,小目标在特征图上可能只剩几个像素,anchor根本覆盖不到。解决办法有几个:

  • 用FPN(特征金字塔)把浅层特征也利用起来,浅层特征图分辨率高,小目标信息保留得更好
  • 降低stride,比如把骨干网络的总步长从16降到8
  • 增加小尺度的anchor,比如加一个32甚至16的尺度

我实测下来,FPN是最有效的方案。加了FPN之后,小目标的召回率能从40%左右提到70%以上。代价是计算量增加大概30%,但收益远大于成本。

5.4 训练和推理结果不一致

这个问题我踩过坑。训练的时候RPN输出的框看起来很正常,但推理的时候框的位置全偏了。后来发现是训练时用了随机采样,推理时用了全部anchor,两者的分布不一致。

解决办法是在推理的时候也做一次筛选,按分数取前N个,再做NMS。另外,训练时的回归目标计算和推理时的坐标还原要用同一套公式,不要一个用log一个用exp。

5.5 常见问题速查表

问题现象可能原因排查方法解决方案
Loss卡在0.69正样本数为0打印正样本数量调整anchor尺度或降低正样本阈值
Loss震荡学习率过大观察loss曲线降低学习率到0.0001
候选框大量重叠NMS阈值过高可视化NMS前后框数量降低NMS阈值到0.7
小目标漏检特征图分辨率不足检查小目标在特征图上的尺寸加FPN或降低stride
训练推理不一致采样策略不同对比训练和推理的框分布统一采样和筛选逻辑
回归框跑出图像缺少clip操作检查框坐标范围还原坐标后做clip

6. 一些个人体会和后续扩展方向

RPN这个模块我前前后后复现过三四次,每次都有新的理解。第一次是照着论文硬撸,结果各种维度对不上,调了一周才跑通。第二次是读别人的开源代码,发现很多实现细节论文里根本没提,比如anchor的生成顺序、正负样本的采样策略、NMS的执行时机。第三次是自己从头写了一遍,才真正把每个环节的逻辑理顺。

我个人觉得,RPN最核心的设计思想不是某个具体的网络结构,而是“用先验框+可学习偏移”这套范式。这个范式后来被几乎所有的检测器继承了,包括单阶段的SSD、RetinaNet,甚至Anchor-Free的FCOS和CenterNet,本质上都是在解决“如何从特征图上生成高质量候选框”这个问题。

如果你已经把RPN跑通了,后续可以往这几个方向扩展:

一是换不同的骨干网络,比如用EfficientNet或者ConvNeXt,看看精度和速度的权衡。二是加FPN或者PAN,提升多尺度检测能力。三是把RPN的输出接到不同的检测头上,比如Cascade R-CNN的多级检测头,看看能不能进一步提升精度。四是尝试Anchor-Free的方案,对比一下和Anchor-Based的差异,理解各自的优劣。

最后分享一个小技巧:调试RPN的时候,一定要把中间结果可视化出来。把anchor画在原图上,把正样本框标红,把负样本框标蓝,把最终生成的候选框画出来。很多时候你看loss曲线看不出问题,但一可视化就一目了然了。我当初就是靠可视化才发现anchor尺度设错了,白白浪费了两天时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进