从RPN到RoI Align:图解Faster R-CNN中那些容易被忽略的细节(附PyTorch代码片段)

第一次接触Faster R-CNN时,我被它的两阶段设计深深吸引——先用RPN生成候选框,再对候选框进行分类和微调。但当我真正动手实现时,才发现那些看似简单的概念背后藏着无数魔鬼细节。比如,为什么RPN的锚框要设计成特定比例?RoI Align到底比RoI Pooling强在哪里?这些问题在论文和教程中往往一笔带过,却直接影响模型的实际表现。本文将用图解+代码的方式,带你深入这些关键但常被忽视的技术细节。

1. RPN中的锚框生成:不只是滑动窗口那么简单

大多数教程会告诉你RPN在特征图上滑动窗口生成锚框,但很少解释这些锚框是如何在特征图上分布的。实际上,锚框的生成涉及三个关键参数:基础尺寸(base size)、比例(ratios)和步长(strides)。

1.1 锚框的几何特性

假设我们使用ResNet作为backbone,在FPN的P3层(stride=8)上生成锚框。典型的配置如下:

anchor_generator = dict(
    type='AnchorGenerator',
    scales=[8],  # 基础尺寸
    ratios=[0.5, 1.0, 2.0],  # 宽高比
    strides=[8]  # 对应特征图的步长
)

这会产生三种不同形状的锚框:

  • 宽高比0.5:宽是高的两倍
  • 宽高比1.0:正方形
  • 宽高比2.0:高是宽的两倍

关键点:这些锚框的绝对尺寸是在原图空间计算的。例如,当基础尺寸为8且步长为8时,锚框在原图上的基础面积为8×8=64像素。

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

锚框的位置由特征图坐标决定。假设特征图上某点的坐标是(x', y'),对应原图坐标为:

x = x' * stride + stride // 2
y = y' * stride + stride // 2

这个+stride//2的偏移量常被忽略,但它确保了锚框中心对齐特征图的感受野中心。下图展示了这种对应关系:

特征图位置 (2,3) → 原图位置 (20,28) [当stride=8时]
       +---------------------+
       |        锚框         |
       |      中心(20,28)    |
       +---------------------+

2. 正负样本分配:MaxIoUAssigner的运作细节

RPN需要为每个锚框分配标签(正样本、负样本或忽略)。这个过程由MaxIoUAssigner完成,但它的实现细节往往被简化描述。

2.1 IoU阈值与样本分配规则

典型的配置参数如下:

assigner=dict(
    type='MaxIoUAssigner',
    pos_iou_thr=0.7,  # 正样本阈值
    neg_iou_thr=0.3,  # 负样本阈值
    min_pos_iou=0.3,  # 最低正样本要求
    match_low_quality=True  # 是否允许低质量匹配
)

分配过程实际上分为两个阶段:

  1. 初步匹配:每个真实框(GT)优先匹配与其IoU最高的锚框
  2. 阈值过滤:然后根据IoU值进行最终分配

注意:当match_low_quality=True时,即使IoU低于pos_iou_thr但属于最高IoU匹配的锚框仍会被标记为正样本

2.2 代码实现解析

以下是MaxIoUAssigner核心逻辑的简化实现:

def assign(self, anchors, gt_bboxes):
    overlaps = bbox_overlaps(anchors, gt_bboxes)  # 计算IoU矩阵
    max_overlaps, argmax_overlaps = overlaps.max(dim=1)
    
    # 第一阶段:确保每个GT至少有一个匹配
    gt_max_overlaps, _ = overlaps.max(dim=0)
    for i in range(len(gt_bboxes)):
        if gt_max_overlaps[i] > 0:
            j = (overlaps[:, i] == gt_max_overlaps[i]).nonzero()[0]
            assigned_labels[j] = 1  # 强制为正样本
    
    # 第二阶段:根据阈值分配
    pos_inds = max_overlaps >= self.pos_iou_thr
    neg_inds = max_overlaps < self.neg_iou_thr
    assigned_labels[pos_inds] = 1
    assigned_labels[neg_inds] = 0
    assigned_labels[(max_overlaps >= self.neg_iou_thr) & 
                   (max_overlaps < self.pos_iou_thr)] = -1  # 忽略
    return assigned_labels

3. RoI Pooling vs RoI Align:量化误差从何而来

RoI Pooling被广泛认为是Faster R-CNN的瓶颈之一,而RoI Align的改进常被简单归结为"避免了量化误差"。但量化误差具体是如何产生的?RoI Align又如何解决这个问题?

3.1 RoI Pooling的两次量化问题

假设我们有一个665×665的RoI要pooling到7×7的输出:

  1. 第一次量化:将665/7≈94.98量化为94
  2. 第二次量化:在划分的每个bin内做max pooling时再次取整

这种量化会导致RoI与提取的特征错位,尤其对小目标影响显著。

3.2 RoI Align的双线性插值方案

RoI Align的核心改进是:

  1. 避免任何坐标量化,保留浮点数计算
  2. 在每个bin内采样固定点(通常是4个)
  3. 使用双线性插值计算采样点的值

PyTorch实现关键代码:

# RoI Align前向传播示例
def forward(ctx, features, rois, output_size):
    h = (rois[:, 4] - rois[:, 2]) / output_size[0]  # 保持浮点精度
    w = (rois[:, 3] - rois[:, 1]) / output_size[1]
    
    for i in range(output_size[0]):
        for j in range(output_size[1]):
            y = rois[:, 2] + i * h  # 不取整
            x = rois[:, 1] + j * w
            
            # 对每个采样点进行双线性插值
            output_val = bilinear_interpolate(features, y, x)
            output[:, :, i, j] = output_val.mean(dim=0)  # 平均多个采样点

下表对比了两种方法的差异:

特性 RoI Pooling RoI Align
坐标计算 两次量化取整 保留浮点精度
采样方式 每个bin取最大值 多个点双线性插值
对小目标效果 较差 较好
计算复杂度 较低 较高

4. RPN与Fast R-CNN的联合训练策略

Faster R-CNN的一个精妙设计是RPN和Fast R-CNN共享特征提取网络,但两者的训练目标不同,如何协调?

4.1 四步交替训练法

原始论文采用的四步训练策略:

  1. 单独训练RPN
  2. 用RPN proposals训练Fast R-CNN
  3. 用Fast R-CNN的权重初始化RPN(固定共享卷积层)
  4. 微调RPN特有的层

现代实现通常采用端到端联合训练,关键在于梯度停止(stop_gradient)的应用:

# 伪代码展示联合训练流程
def forward(self, img, gt_bboxes):
    # 共享特征提取
    features = self.backbone(img)
    
    # RPN分支
    rpn_loss, proposals = self.rpn_head(features, gt_bboxes)
    
    # 注意:proposals的生成过程不需要梯度
    proposals = proposals.detach()
    
    # Fast R-CNN分支
    roi_loss = self.roi_head(features, proposals, gt_bboxes)
    
    return rpn_loss + roi_loss

4.2 正样本比例控制

两个阶段的正样本比例需要仔细平衡:

  • RPN阶段:通常保持正负样本1:1(通过RandomSampler)

    sampler=dict(
        type='RandomSampler',
        num=256,  # 总样本数
        pos_fraction=0.5,  # 正样本占比
        neg_pos_ub=-1,
        add_gt_as_proposals=False
    )
    
  • Fast R-CNN阶段:正样本比例通常更低(如1:3)

    sampler=dict(
        type='RandomSampler',
        num=512,
        pos_fraction=0.25,  # 仅25%正样本
        neg_pos_ub=-1,
        add_gt_as_proposals=True
    )
    

这种差异设计是因为RPN需要大量负样本抑制背景提议,而Fast R-CNN阶段已经过滤了大部分低质量提议。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐