论文信息

  • 标题:Faster R-CNN: Towards Real-Time Object Detection with Region Proposal Networks
  • 会议:NIPS 2015
  • 单位:微软研究院、Facebook AI Research、中国科学技术大学
  • 代码:github.com/shaoqingren/faster_rcnn (MATLAB)、github.com/rbgirshick/py-faster-rcnn (Python)
  • 论文:https://arxiv.org/pdf/1506.01497.pdf

一、引言:目标检测的"等餐难题"终于被解决了

如果你在2015年之前做过目标检测,一定有过这样的痛苦经历:

  • 跑一张图要2秒,其中1.5秒都花在生成区域提议上
  • 检测网络只需要300ms,却要等提议生成完才能工作
  • 就像你去餐厅吃饭,等餐要1小时,吃饭只要10分钟

这就是当时两阶段目标检测的最大痛点:区域提议成为了计算瓶颈

从R-CNN到Fast R-CNN,检测网络的速度已经提升了几十倍,但区域提议还是依赖传统的计算机视觉方法:

  • Selective Search:2秒/张图(CPU)
  • EdgeBoxes:0.2秒/张图(CPU)

这些方法不仅慢,还不能利用GPU加速,更不能和检测网络共享计算。

而Faster R-CNN的出现彻底改变了这一切。它提出了区域提议网络(RPN),让区域提议和检测网络共享卷积特征,生成提议只需要10毫秒!这使得两阶段检测第一次达到了接近实时的速度(5fps),同时精度还更高。

毫不夸张地说,Faster R-CNN是目标检测领域的一个里程碑,它奠定了之后几乎所有两阶段检测模型的基础,也是2015年ImageNet和COCO比赛多个赛道冠军的核心技术。

二、Faster R-CNN核心架构:两个网络,一个灵魂

Faster R-CNN由两个模块组成:

  1. 区域提议网络(RPN):快速生成高质量的候选框
  2. Fast R-CNN检测器:对候选框进行分类和精修

最关键的是,这两个模块共享同一套卷积特征。这意味着生成提议几乎没有额外的计算成本。

用一个形象的比喻:

  • RPN就像警察局的"巡警",快速在全城巡逻,找出可疑的人
  • Fast R-CNN就像"法官",对这些可疑的人进行详细审问,最终定罪
  • 两者共享"城市地图"(卷积特征),不需要各自重新绘制
    在这里插入图片描述

图1:Faster R-CNN整体架构(来源:论文Figure 2)

从图1可以看出,RPN和Fast R-CNN共享前面的卷积层。RPN在卷积特征图上滑动,生成候选框,然后这些候选框被送入RoI池化层,提取固定长度的特征,最后进行分类和回归。

三、RPN:最天才的设计——锚点机制

RPN的核心创新是锚点(Anchor)机制。它解决了一个困扰目标检测多年的问题:如何高效地检测不同尺度和长宽比的物体。

3.1 传统多尺度方法的痛点

在Faster R-CNN之前,处理多尺度物体主要有两种方法:
在这里插入图片描述

图2:不同多尺度处理方案对比(来源:论文Figure 1)

  • 图像金字塔(图2a):把图像缩放到不同大小,分别提取特征。效果好,但速度极慢。
  • 滤波器金字塔(图2b):用不同大小的卷积核检测不同大小的物体。参数多,训练困难。

这两种方法都有一个共同的问题:计算量随着尺度数量线性增长。

3.2 锚点金字塔:一次计算,多尺度检测

Faster R-CNN提出了第三种方法:锚点金字塔(图2c)

通俗易懂的解释
我们在卷积特征图的每个位置,预先画好9个不同大小和形状的方框,这些方框就叫"锚点"。

  • 3个尺度:128×128、256×256、512×512
  • 3个长宽比:1:1、1:2、2:1

然后网络不需要从零开始预测物体的位置和大小,只需要预测:

  1. 每个锚点里有没有物体(分类)
  2. 这个锚点需要怎么调整才能刚好框住物体(回归)

这样一来,我们只需要对原图做一次卷积,就能检测所有尺度和长宽比的物体,计算量几乎没有增加!

3.3 锚点的两大优势

平移不变性

如果一个物体在图像中平移了,对应的锚点也会平移,网络仍然能检测到它。这是锚点机制的固有属性,不需要额外的训练。

而之前的MultiBox方法用k-means生成锚点,不具备平移不变性。如果物体平移了,可能就检测不到了。
在这里插入图片描述

参数极少

RPN的输出层只有(4+2)×9=54个卷积核,参数数量只有2.8×10⁴个。而MultiBox的输出层有6.1×10⁶个参数,是RPN的200多倍!

这意味着RPN不容易过拟合,在小数据集上也能表现很好。

四、损失函数:让RPN学会找物体

为了训练RPN,我们需要给每个锚点分配一个标签,然后定义一个多任务损失函数。

4.1 锚点标签分配规则

  • 正样本:满足以下两个条件之一的锚点
    1. 与某个真实框的IoU最高的锚点
    2. 与任意真实框的IoU大于0.7的锚点
  • 负样本:与所有真实框的IoU都小于0.3的锚点
  • 忽略样本:既不是正也不是负的锚点,不参与损失计算

4.2 多任务损失函数

RPN的损失函数同时优化分类和回归两个任务:
L({pi},{ti})=1Ncls∑iLcls(pi,pi∗)+λ1Nreg∑ipi∗Lreg(ti,ti∗) L\left( \left\{ p_{i}\right\} ,\left\{ t_{i}\right\} \right) =\frac {1}{N_{cls }}\sum _{i}L_{cls }\left( p_{i},p_{i}^{*}\right) +\lambda \frac {1}{N_{reg }}\sum _{i}p_{i}^{*}L_{reg }\left( t_{i},t_{i}^{*}\right) L({pi},{ti})=Ncls1iLcls(pi,pi)+λNreg1ipiLreg(ti,ti)

其中:

  • iii:一个mini-batch中锚点的索引
  • pip_ipi:锚点iii包含物体的预测概率
  • pi∗p_i^*pi:锚点iii的真实标签,正样本为1,负样本为0
  • tit_iti:预测的边界框坐标偏移量,是一个4维向量
  • ti∗t_i^*ti:真实的边界框坐标偏移量
  • LclsL_{cls}Lcls:分类损失,二分类的对数损失
  • LregL_{reg}Lreg:回归损失,使用smooth L₁损失
  • NclsN_{cls}Ncls:分类损失的归一化因子,等于mini-batch大小(256)
  • NregN_{reg}Nreg:回归损失的归一化因子,等于锚点位置的数量(约2400)
  • λ\lambdaλ:平衡两个损失的权重,默认取10

通俗易懂的解释
这个损失函数就像给RPN的考试打分,分类和回归各占50分。只有正样本需要考回归,负样本只需要考分类。λ\lambdaλ就是用来调整两门课的权重,让它们对总损失的贡献差不多。

4.3 边界框回归参数化

边界框回归不是直接预测框的绝对坐标,而是预测相对于锚点的偏移量:
tx=(x−xa)/wa,ty=(y−ya)/ha,tw=log(w/wa),th=log(h/ha),tx∗=(x∗−xa)/wa,ty∗=(y∗−ya)/ha,tw∗=log(w∗/wa),th∗=log(h∗/ha), \begin{array} {rl}{t_{x}}&{=(x-x_{a})/w_{a}, t_{y}=(y-y_{a})/h_{a},}\\ {t_{w}}&{=log (w/w_{a}), t_{h}=log (h/h_{a}),}\\ {t_{x}^{*}}&{=(x^{*}-x_{a})/w_{a}, t_{y}^{*}=(y^{*}-y_{a})/h_{a},}\\ {t_{w}^{*}}&{=log \left(w^{*}/w_{a}\right) , t_{h}^{*}=log \left(h^{*}/h_{a}\right) ,}\end{array} txtwtxtw=(xxa)/wa,ty=(yya)/ha,=log(w/wa),th=log(h/ha),=(xxa)/wa,ty=(yya)/ha,=log(w/wa),th=log(h/ha),

其中:

  • x,y,w,hx, y, w, hx,y,w,h:预测框的中心坐标和宽高
  • xa,ya,wa,hax_a, y_a, w_a, h_axa,ya,wa,ha:锚点的中心坐标和宽高
  • x∗,y∗,w∗,h∗x^*, y^*, w^*, h^*x,y,w,h:真实框的中心坐标和宽高

通俗易懂的解释
这就像你在地图上找一个地方,别人告诉你"从这个路口往东走100米,往北走50米",而不是直接告诉你经纬度。这样更容易理解,也更容易学习。

五、共享特征训练:让两个网络默契配合

RPN和Fast R-CNN都需要卷积特征,但如果分别训练,它们会修改卷积层的参数,导致无法共享。

Faster R-CNN提出了4步交替训练法,完美解决了这个问题:

  1. 第一步:用ImageNet预训练的权重初始化RPN,训练RPN
  2. 第二步:用第一步训练好的RPN生成提议,训练一个独立的Fast R-CNN
  3. 第三步:用第二步训练好的Fast R-CNN初始化RPN,固定共享的卷积层,只训练RPN特有的层
  4. 第四步:固定共享的卷积层,只训练Fast R-CNN特有的层

经过这四步,两个网络就共享了同一套卷积特征,形成了一个统一的检测系统。

有趣的类比
这就像一对双人滑冰运动员,先各自练习自己的动作,然后再配合练习,最后达到完美的同步。

六、实验结果:速度和精度的双重飞跃

Faster R-CNN在PASCAL VOC和MS COCO数据集上进行了全面的评估,结果令人震撼。

6.1 PASCAL VOC 2007结果对比

表1:不同区域提议方法的结果对比(ZF网络,来源:论文Table 2)

训练时提议方法 测试时提议方法 提议数量 mAP (%)
Selective Search Selective Search 2000 58.7
EdgeBoxes EdgeBoxes 2000 58.6
RPN+ZF(共享) RPN+ZF(共享) 300 59.9

结果分析

  • RPN只用300个提议,就达到了59.9%的mAP,比Selective Search的2000个提议还高1.2%
  • 这说明RPN生成的提议质量比传统方法高得多

6.2 VGG16网络的性能

表2:VGG16网络的检测结果(来源:论文Table 3)

方法 提议数量 训练数据 mAP (%)
SS+Fast R-CNN 2000 VOC 07 66.9
SS+Fast R-CNN 2000 VOC 07+12 70.0
RPN+VGG(共享) 300 VOC 07 69.9
RPN+VGG(共享) 300 VOC 07+12 73.2
RPN+VGG(共享) 300 COCO+07+12 78.8

结果分析

  • 用VGG16网络,Faster R-CNN达到了73.2%的mAP,比Fast R-CNN高3.2%
  • 如果用COCO数据集预训练,mAP可以达到惊人的78.8%

6.3 速度对比

表3:不同检测系统的速度对比(K40 GPU,来源:论文Table 5)

模型 系统 卷积时间(ms) 提议时间(ms) 区域处理时间(ms) 总时间(ms) 帧率(fps)
VGG SS+Fast R-CNN 146 1510 174 1830 0.5
VGG RPN+Fast R-CNN 141 10 47 198 5
ZF RPN+Fast R-CNN 31 3 25 59 17

震撼的结果

  • Faster R-CNN比SS+Fast R-CNN快了近10倍!
  • 提议时间从1.5秒缩短到了10毫秒,几乎可以忽略不计
  • 用ZF网络可以达到17fps,已经可以用于很多实时场景了

6.4 锚点设置的消融实验

表4:不同锚点设置的结果对比(VGG16,来源:论文Table 8)

锚点设置 尺度数量 长宽比数量 mAP (%)
1尺度1长宽比 1 1 65.8
1尺度3长宽比 1 3 68.8
3尺度1长宽比 3 1 69.8
3尺度3长宽比 3 3 69.9

结果分析

  • 多尺度和多长宽比都能显著提升性能
  • 只用3个尺度就达到了69.8%的mAP,和3尺度3长宽比几乎一样
  • 这说明尺度是影响性能的主要因素,长宽比的影响相对较小

七、核心代码实现

下面是Faster R-CNN最核心的RPN模块实现,包括锚点生成和损失函数计算:

"""
Faster R-CNN核心RPN模块实现
基于PyTorch,简化版,保留所有关键逻辑
"""

import torch
import torch.nn as nn
import torch.nn.functional as F
import numpy as np

# -------------------------- 核心超参数 --------------------------
ANCHOR_SCALES = [128, 256, 512]  # 锚点尺度
ANCHOR_RATIOS = [0.5, 1, 2]       # 锚点长宽比
FEATURE_STRIDE = 16                # 特征图相对于原图的步长
# ---------------------------------------------------------------

def generate_anchors(base_size=16, scales=ANCHOR_SCALES, ratios=ANCHOR_RATIOS):
    """生成基础锚点模板"""
    base_anchor = np.array([1, 1, base_size, base_size]) - 1
    ratio_anchors = _ratio_enum(base_anchor, ratios)
    anchors = np.vstack([_scale_enum(ratio_anchors[i, :], scales) 
                         for i in range(ratio_anchors.shape[0])])
    return anchors

def _ratio_enum(anchor, ratios):
    """枚举不同长宽比的锚点"""
    w, h, x_ctr, y_ctr = _whctrs(anchor)
    size = w * h
    size_ratios = size / ratios
    ws = np.round(np.sqrt(size_ratios))
    hs = np.round(ws * ratios)
    anchors = _mkanchors(ws, hs, x_ctr, y_ctr)
    return anchors

def _scale_enum(anchor, scales):
    """枚举不同尺度的锚点"""
    w, h, x_ctr, y_ctr = _whctrs(anchor)
    ws = w * scales
    hs = h * scales
    anchors = _mkanchors(ws, hs, x_ctr, y_ctr)
    return anchors

def _whctrs(anchor):
    """将(x1,y1,x2,y2)转换为(宽,高,中心x,中心y)"""
    w = anchor[2] - anchor[0] + 1
    h = anchor[3] - anchor[1] + 1
    x_ctr = anchor[0] + 0.5 * (w - 1)
    y_ctr = anchor[1] + 0.5 * (h - 1)
    return w, h, x_ctr, y_ctr

def _mkanchors(ws, hs, x_ctr, y_ctr):
    """根据宽高和中心坐标生成锚点(x1,y1,x2,y2)"""
    ws = ws[:, np.newaxis]
    hs = hs[:, np.newaxis]
    anchors = np.hstack((x_ctr - 0.5 * (ws - 1),
                         y_ctr - 0.5 * (hs - 1),
                         x_ctr + 0.5 * (ws - 1),
                         y_ctr + 0.5 * (hs - 1)))
    return anchors

class RPN(nn.Module):
    """区域提议网络"""
    def __init__(self, in_channels=512, mid_channels=512):
        super().__init__()
        # 3x3卷积提取特征
        self.conv = nn.Conv2d(in_channels, mid_channels, 3, 1, 1)
        # 分类头:每个锚点2个输出(物体/背景)
        self.cls_head = nn.Conv2d(mid_channels, len(ANCHOR_SCALES)*len(ANCHOR_RATIOS)*2, 1, 1, 0)
        # 回归头:每个锚点4个输出(坐标偏移)
        self.reg_head = nn.Conv2d(mid_channels, len(ANCHOR_SCALES)*len(ANCHOR_RATIOS)*4, 1, 1, 0)
        
        # 生成基础锚点
        self.anchors = torch.from_numpy(generate_anchors()).float()
        
    def forward(self, x, img_size):
        """
        Args:
            x: 卷积特征图 [B, C, H, W]
            img_size: 原图尺寸 (H, W)
        Returns:
            cls_scores: 分类分数 [B, H*W*9, 2]
            reg_preds: 回归预测 [B, H*W*9, 4]
            anchors: 所有锚点 [1, H*W*9, 4]
        """
        B, C, H, W = x.shape
        
        # 特征提取
        x = F.relu(self.conv(x))
        
        # 分类和回归预测
        cls_scores = self.cls_head(x)  # [B, 18, H, W]
        reg_preds = self.reg_head(x)   # [B, 36, H, W]
        
        # 调整形状
        cls_scores = cls_scores.permute(0, 2, 3, 1).reshape(B, -1, 2)
        reg_preds = reg_preds.permute(0, 2, 3, 1).reshape(B, -1, 4)
        
        # 生成所有位置的锚点
        shift_x = np.arange(0, W) * FEATURE_STRIDE
        shift_y = np.arange(0, H) * FEATURE_STRIDE
        shift_x, shift_y = np.meshgrid(shift_x, shift_y)
        shifts = np.vstack((shift_x.ravel(), shift_y.ravel(), 
                            shift_x.ravel(), shift_y.ravel())).transpose()
        shifts = torch.from_numpy(shifts).float()
        
        A = self.anchors.shape[0]
        K = shifts.shape[0]
        anchors = self.anchors.reshape(1, A, 4) + shifts.reshape(K, 1, 4)
        anchors = anchors.reshape(1, K*A, 4)
        
        return cls_scores, reg_preds, anchors

def rpn_loss(cls_scores, reg_preds, anchors, gt_boxes, img_size):
    """计算RPN损失函数"""
    B = cls_scores.shape[0]
    total_loss = 0
    
    for b in range(B):
        # 计算每个锚点与真实框的IoU
        ious = compute_iou(anchors[0], gt_boxes[b])
        
        # 分配标签
        labels = torch.full((anchors.shape[1],), -1, dtype=torch.long)
        max_iou, max_gt = ious.max(dim=1)
        
        # 正样本1:IoU>0.7
        labels[max_iou >= 0.7] = 1
        # 正样本2:每个真实框对应的IoU最大的锚点
        gt_max_iou, gt_max_anchor = ious.max(dim=0)
        labels[gt_max_anchor] = 1
        # 负样本:IoU<0.3
        labels[max_iou < 0.3] = 0
        
        # 采样256个锚点,正负比例1:1
        pos_idx = torch.where(labels == 1)[0]
        neg_idx = torch.where(labels == 0)[0]
        num_pos = min(128, len(pos_idx))
        num_neg = 256 - num_pos
        
        if len(pos_idx) > 0:
            pos_idx = pos_idx[torch.randperm(len(pos_idx))[:num_pos]]
        if len(neg_idx) > 0:
            neg_idx = neg_idx[torch.randperm(len(neg_idx))[:num_neg]]
        
        keep_idx = torch.cat([pos_idx, neg_idx])
        
        # 分类损失
        cls_loss = F.cross_entropy(cls_scores[b, keep_idx], labels[keep_idx])
        
        # 回归损失(只计算正样本)
        reg_loss = 0
        if len(pos_idx) > 0:
            # 计算真实偏移量
            gt_reg = compute_targets(anchors[0, pos_idx], gt_boxes[b, max_gt[pos_idx]])
            reg_loss = F.smooth_l1_loss(reg_preds[b, pos_idx], gt_reg)
        
        total_loss += cls_loss + 10 * reg_loss
    
    return total_loss / B

def compute_iou(boxes1, boxes2):
    """计算两组框的IoU"""
    N = boxes1.shape[0]
    M = boxes2.shape[0]
    
    x1 = torch.max(boxes1[:, 0].unsqueeze(1), boxes2[:, 0].unsqueeze(0))
    y1 = torch.max(boxes1[:, 1].unsqueeze(1), boxes2[:, 1].unsqueeze(0))
    x2 = torch.min(boxes1[:, 2].unsqueeze(1), boxes2[:, 2].unsqueeze(0))
    y2 = torch.min(boxes1[:, 3].unsqueeze(1), boxes2[:, 3].unsqueeze(0))
    
    w = torch.clamp(x2 - x1 + 1, min=0)
    h = torch.clamp(y2 - y1 + 1, min=0)
    inter = w * h
    
    area1 = (boxes1[:, 2] - boxes1[:, 0] + 1) * (boxes1[:, 3] - boxes1[:, 1] + 1)
    area2 = (boxes2[:, 2] - boxes2[:, 0] + 1) * (boxes2[:, 3] - boxes2[:, 1] + 1)
    union = area1.unsqueeze(1) + area2.unsqueeze(0) - inter
    
    return inter / union

def compute_targets(anchors, gt_boxes):
    """计算真实的边界框偏移量"""
    w_a = anchors[:, 2] - anchors[:, 0] + 1
    h_a = anchors[:, 3] - anchors[:, 1] + 1
    x_ctr_a = anchors[:, 0] + 0.5 * (w_a - 1)
    y_ctr_a = anchors[:, 1] + 0.5 * (h_a - 1)
    
    w_gt = gt_boxes[:, 2] - gt_boxes[:, 0] + 1
    h_gt = gt_boxes[:, 3] - gt_boxes[:, 1] + 1
    x_ctr_gt = gt_boxes[:, 0] + 0.5 * (w_gt - 1)
    y_ctr_gt = gt_boxes[:, 1] + 0.5 * (h_gt - 1)
    
    tx = (x_ctr_gt - x_ctr_a) / w_a
    ty = (y_ctr_gt - y_ctr_a) / h_a
    tw = torch.log(w_gt / w_a)
    th = torch.log(h_gt / h_a)
    
    return torch.stack([tx, ty, tw, th], dim=1)

# -------------------------- 示例用法 --------------------------
if __name__ == '__main__':
    # 初始化RPN
    rpn = RPN()
    rpn.eval()
    
    # 模拟输入:VGG16的conv5_3特征图
    x = torch.randn(1, 512, 38, 50)  # 对应原图600×800
    img_size = (600, 800)
    
    # 模拟真实框
    gt_boxes = torch.tensor([[[100, 100, 300, 300], [400, 200, 600, 500]]])
    
    # RPN前向传播
    cls_scores, reg_preds, anchors = rpn(x, img_size)
    
    # 计算损失
    loss = rpn_loss(cls_scores, reg_preds, anchors, gt_boxes, img_size)
    
    print(f"分类输出形状: {cls_scores.shape}")  # [1, 17100, 2]
    print(f"回归输出形状: {reg_preds.shape}")  # [1, 17100, 4]
    print(f"锚点数量: {anchors.shape[1]}")     # 17100
    print(f"RPN损失: {loss.item():.4f}")

八、结论与影响

Faster R-CNN是目标检测领域的一个划时代的工作,它的主要贡献有三点:

  1. 提出了RPN网络,让区域提议和检测网络共享卷积特征,生成提议几乎免费
  2. 发明了锚点机制,高效解决了多尺度和长宽比物体的检测问题
  3. 实现了第一个接近实时的两阶段检测系统,速度比之前的方法快了10倍

Faster R-CNN的影响是深远的:

  • 它成为了之后几乎所有两阶段检测模型的基础,包括Mask R-CNN、Cascade R-CNN等
  • 锚点机制被广泛应用于目标检测、实例分割、人脸检测等领域
  • 在2015年ImageNet和COCO比赛中,基于Faster R-CNN的方法获得了多个赛道的冠军
  • 被Pinterest等公司用于商业系统,显著提升了用户参与度

虽然现在已经有了YOLO、RT-DETR等更快的单阶段检测器,但Faster R-CNN的思想仍然在影响着这个领域。它证明了深度学习可以端到端地解决目标检测的所有问题,为后来的研究指明了方向。

Logo

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

更多推荐