
Fast R-CNN我第一次读论文的时候觉得它挺平淡的无非是把R-CNN里“每个候选框单独过一遍CNN”改成了“整图过一次CNN再在特征图上取对应区域”。但等我自己在PyTorch里完整复现一遍才发现RoI Pooling、多任务损失、分层采样这些设计合在一起才真正把目标检测从拼装式流程变成了端到端可训练的单模型。这篇博文就围绕目标检测中的经典网络Fast R-CNN把原理、复现步骤和我在复现过程中踩过的坑完整记录下来。适合正在学经典检测模型、准备做目标检测方向毕业设计或者单纯想搞懂“Fast R-CNN为什么能比R-CNN快上百倍”的同学参考。1. R-CNN的三阶段困局与Fast R-CNN的破局思路1.1 多阶段训练的重复计算到底有多浪费先回顾一下R-CNN的流程。它先用Selective Search生成约2000个候选框然后把这些候选框全部resize到227×227分别送入CNN提取特征再用SVM对特征做分类最后再训练一个bbox回归器修正候选框位置。这个流程最大的问题就是重复计算。一张图上2000个候选框彼此之间大量重叠CNN却对着重叠区域一遍又一遍做卷积。打一个不太严谨但好懂的比方你要检查一栋楼每一扇窗有没有破损正常做法是搭电梯到每一层再从窗口探出头看2000扇窗就要上楼下楼2000次。Fast R-CNN的思路是先拍一张全楼高清照然后对着照片放大检查每一扇窗整栋楼只需要拍一次。论文里给的数字很直接R-CNN在GPU上用VOC2007训练要84小时测试一张图要47秒。这个速度别说工程落地连做消融实验都让人崩溃。整个流程被拆成CNN特征提取、SVM分类、bbox回归三个独立阶段中间还要存下来大量中间特征工程实现非常繁琐。1.2 SPP-Net为什么只解决了一半问题SPP-Net是最早把“整图卷积一次”做出来的网络。它在最后一个卷积层后面接空间金字塔池化让不同尺寸的特征区域都能变成固定长度的向量从而解决了候选框尺寸不一致的问题测试速度也从47秒降到2.3秒左右。但它有一个致命问题训练时无法对SPP层之前的卷积层做反向传播。原因在于SPP是多个金字塔层级同时池化每个RoI的梯度要分别从不同bin里聚合回来操作上非常复杂所以作者选择只微调全连接层。这意味着底层特征还是ImageNet预训练模型的固有特征没有真正针对目标检测任务去适配。而且SPP-Net在训练流程上仍然是分离的CNN特征提取、SVM分类、回归器微调依然是多个阶段候选框对应的特征还是要保存下来反复处理。1.3 Fast R-CNN交出的一份端到端答案Fast R-CNN针对上面两个问题把三个关键设计合在了一起整图只过一次Backbone所有候选框共享卷积特征。用RoI Pooling代替SPP的多级金字塔使梯度可以传递给共享卷积层从而允许整个网络一起端到端微调。把分类和边框回归放到同一个多任务损失里一次训练搞定不再需要SVM和二阶段流程。这三点合起来带来了数量级的提升。论文用VGG16在VOC2007测试集上能做到66.9%的mAP使用VOC07VOC12训练训练时间从84小时压到9.5小时左右测试速度在排除Selective Search的情况下约0.32秒一张。方法训练流程训练耗时(GPU)测试耗时(s/img)VOC07 mAPR-CNNCNN特征SVM回归三阶段约84小时4766.0SPP-NetCNN特征SVM回归仍多阶段约25.5小时2.363.5Fast R-CNN端到端单阶段多任务训练约9.5小时0.32(不含SS)66.9看这个表能更直观感受到Fast R-CNN不仅速度上远超R-CNN精度还有微弱提升。真正让它成为经典的原因不只是速度而是把“目标检测”这件事从多阶段拼装变成了一个可微分的整体。2. Fast R-CNN核心机制拆解RoI Pooling与多任务损失2.1 RoI Pooling原理把任意候选框变成固定尺寸特征RoI Pooling有两个输入共享卷积层输出的feature map以及一组RoI坐标由Selective Search在原图上生成。它把每个RoI对应的特征区域均匀划分成H×W个格子论文中用的是7×7然后每个格子内做一次max pooling。这样一来不管RoI在原图上是什么尺寸经过RoI Pooling之后输出的都是7×7×C的固定维度特征后续接全连接层就不再受输入尺寸限制。类比相册平台的缩略图几百张不同尺寸的照片上传后平台会统一生成同样大小的封面。Fast R-CNN不是简单把照片整体压扁而是把每张照片横竖各切成7份共49个小格子每个格子取最有代表性的一块最后拼成一张7×7的“信息摘要”。需要注意这个过程是在feature map层面做的不是原图层。一个224×224的RoI映射到VGG16第4个maxpool之后的feature map上尺寸大约14×14因为此时stride是16。然后经过7×7的RoI Pooling相当于每个格子覆盖约2×2个特征点max得到的就是这个小区域里最强的特征响应。这里有个特别容易踩的细节如果你直接用torchvision.models.vgg16(pretrainedTrue).features作为backbone它包含第5个maxpool最终stride是32而Fast R-CNN原论文的做法是截到第4个maxpoolstride保持16。如果搞错了同一个RoI在特征图上对应的区域会明显缩水后面的检测框位置会出现系统性偏差。2.2 多任务损失分类与回归如何一起优化Fast R-CNN为每个RoI同时输出两个分支分类分支输出K1个类别的概率分布K是物体类别数1是背景回归分支输出每个类别对应的四个偏移量。训练时这两个分支被同一个多任务损失监督。[ L L_{cls}(p,u) \lambda \cdot [u \ge 1] \cdot L_{loc}(t^u, v) ](L_{cls})是负log似然损失也就是分类交叉熵。(L_{loc})对前景样本计算边界框回归损失背景样本不参与。原论文里(\lambda)直接取1没有额外调参。回归部分用的是smooth L1损失[ smooth_{L1}(x) \begin{cases} 0.5x^2 |x| 1 \ |x| - 0.5 otherwise \end{cases} ]这个损失比L2对离群点更鲁棒。如果用L2个别回归目标特别大的框会带来很大的梯度容易把整个网络拉崩。smooth L1在误差大时梯度恒为±1不会爆炸在误差小时又保持了L2的平滑特性。回归目标也不是直接用坐标差而是用proposal框P和真实框G之间的偏移量[ t_x (G_x - P_x)/P_w, \quad t_y (G_y - P_y)/P_h ] [ t_w \log(G_w/P_w), \quad t_h \log(G_h/P_h) ]计算完还要做均值方差归一化。这不是玄学是因为坐标偏移和log宽高比两个量的数值范围差很多不归一化的话回归损失会被某个维度主导。2.3 训练采样的工程细节与SVD加速Fast R-CNN训练时并不是把每张图的2000个proposal全部拿来做loss而是采用分层采样一个batch由2张图组成每张图采样64个RoI总共128个RoI。64个里面25%从与GT的IoU大于等于0.5的候选框中采样作为前景75%从IoU在0.1到0.5之间的候选框中采样作为背景。如果不做这一步2000个proposal里绝大多数是背景模型会迅速退化成“看到什么都判为背景”根本学不出真正的辨别能力。测试阶段还有一个加速技巧用截断SVD把全连接层的大权重矩阵分解成两个小矩阵相乘减少全连接层的计算量。论文里说这一步能节省大概30%的全连接层计算时间mAP几乎不掉。这个技巧后来被不少检测模型沿用如果你在做部署相关的工作这个思路仍然值得参考。3. 复现准备环境、VOC数据集与候选框生成3.1 复现环境与依赖版本我用的环境是Python 3.9、PyTorch 1.13、torchvision 0.14、OpenCV 4.8带contrib模块、numpy、matplotlib、tqdm。显卡是RTX 3060 12G显存在8G以上基本够用。如果手里只有CPU也能把流程跑通但完整训练VOC2007需要的时间会明显变长。OpenCV这里必须带contrib模块因为我们要用cv2.ximgproc.segmentation里的Selective Search实现。如果用opencv-python而不是opencv-contrib-python会找不到ximgproc这个子模块。3.2 VOC2007数据集准备与目录结构Fast R-CNN的经典实验环境是VOC2007和VOC2012数据集。为了快速验证整个流程我用的是VOC2007 trainval训练、VOC2007 test测试。如果你确定自己的复现流程没问题想冲论文里66.9那个数字可以再加上VOC2012 trainval扩大训练集。数据的目录结构长这样VOCdevkit/ VOC2007/ JPEGImages/ # 原图 Annotations/ # 同名xml标注 ImageSets/ Main/ # train.txt, val.txt, trainval.txt, test.txt下载解压之后要确认三个目录都存在。训练和测试的图像列表从ImageSets/Main下的txt读取而不是直接扫JPEGImages目录因为标注里有些difficult目标官方划分才是正式依据。3.3 Selective Search候选框的生成与缓存Selective Search在OpenCV里有现成接口用起来非常方便import cv2 img cv2.imread(image_path) ss cv2.ximgproc.segmentation.createSelectiveSearchSegmentation() ss.setBaseImage(img) ss.switchToSelectiveSearchFast() rects ss.process() proposals [] for (x, y, w, h) in rects: proposals.append([x, y, x w, y h]) # 转成x1,y1,x2,y2这里rects返回的是x, y, w, h格式需要手动转成x1, y1, x2, y2。Fast模式大概会生成1000到2000个候选框正好符合R-CNN系列一贯的量级。实际复现时一定要把候选框提前算好缓存下来不要在训练时实时生成。Selective Search本身不算慢但一张图也要1到2秒VOC2007 trainval有5000张图实时生成会让训练慢到没法看。我习惯把每张图的proposals保存在一个pkl文件里训练时直接加载。import pickle with open(proposals.pkl, rb) as f: proposals_dict pickle.load(f) # proposals_dict[image_id] list of [x1, y1, x2, y2]4. PyTorch完整复现从DataLoader到训练评估4.1 自定义Dataset与RoI标签分配Dataset的核心工作有三件事读取图像并resize、读取该图的候选框、为每个候选框分配类别标签和回归目标。我写代码时会把图像短边resize到600并记录缩放比例。所有proposal和GT框都要按同一比例缩放并裁剪到图像边界。类别标签分配的规则是与某个GT框的IoU大于等于0.5标记为前景类别为GT类别。与所有GT框的最大IoU在0.1到0.5之间标记为背景。最大IoU小于0.1的proposal不参与训练。回归目标按前面给的公式计算。为了数值稳定宽高小于1像素的proposal直接过滤掉避免log里出现0。4.2 VGG16骨干RoIPool的模型实现模型结构很直接VGG16的卷积部分作为共享backbone接一个RoIPool然后是两层4096全连接最后分出分类和回归两个head。import torch import torch.nn as nn from torchvision.models import vgg16 from torchvision.ops import RoIPool class FastRCNN(nn.Module): def __init__(self, num_classes21, pretrainedTrue): super().__init__() vgg vgg16(pretrainedpretrained) # 截到第4个maxpoolstride16 self.backbone vgg.features[:24] self.roi_pool RoIPool(output_size7, spatial_scale1.0 / 16.0) self.head nn.Sequential( nn.Linear(512 * 7 * 7, 4096), nn.ReLU(), nn.Dropout(), nn.Linear(4096, 4096), nn.ReLU(), nn.Dropout(), ) self.cls_score nn.Linear(4096, num_classes) self.bbox_pred nn.Linear(4096, num_classes * 4) def forward(self, images, rois): features self.backbone(images) pooled self.roi_pool(features, rois) pooled pooled.flatten(start_dim1) x self.head(pooled) cls_scores self.cls_score(x) bbox_deltas self.bbox_pred(x) return cls_scores, bbox_deltas几个容易错的地方rois的shape是(N, 5)每一行是[batch_index, x1, y1, x2, y2]其中坐标是相对于resize之后图像的像素坐标不是归一化坐标。spatial_scale必须和backbone的实际stride一致。backbone截到features[:24]时stride是16所以scale是1/16。如果直接使用完整的vgg.featuresstride变成32spatial_scale要改成1/32否则检测框会整体偏移且偏小。4.3 训练循环与参数配置训练时每次迭代取2张图像每张图采样64个RoI总共128个RoI。采样时需要把前景和背景分开前景比例约25%。def sample_rois(proposals, labels, reg_targets, num_samples64, pos_ratio0.25): num_pos int(num_samples * pos_ratio) pos_idx [i for i, lb in enumerate(labels) if lb 0] neg_idx [i for i, lb in enumerate(labels) if lb 0] chosen_pos np.random.choice(pos_idx, min(num_pos, len(pos_idx)), replaceFalse) if pos_idx else [] num_neg min(num_samples - len(chosen_pos), len(neg_idx)) chosen_neg np.random.choice(neg_idx, num_neg, replaceFalse) if neg_idx else [] chosen list(chosen_pos) list(chosen_neg) return chosen损失函数部分分类用交叉熵回归用smooth L1且只在前景样本上计算cls_loss nn.functional.cross_entropy(cls_scores, labels) pos labels 0 if pos.any(): idx (labels[pos] * 4).view(-1, 1) torch.arange(4).to(device) pred_deltas bbox_deltas[pos].gather(1, idx) reg_loss nn.functional.smooth_l1_loss( pred_deltas, reg_targets[pos], reductionsum ) / pos.sum().float() else: reg_loss torch.tensor(0.0).to(device) total_loss cls_loss reg_loss优化器我用的SGD初始学习率0.001momentum 0.9weight_decay 0.0005。有一个非常重要的经验VGG预训练backbone和随机初始化的head对学习率的敏感度差很多我给backbone单独乘了0.1的系数head保持0.001训练会稳定很多。如果只是想验证流程通不通建议先跑50个iter而不是直接跑完整训练。RTX 3060上完整训练VOC2007 trainval8到10个epoch大概一个小时能跑完。4.4 mAP评估与检测框可视化测试阶段做的事情是对每张测试图生成Selective Search候选框。模型前向得到每个候选框的类别得分和回归偏移。把得分大于0.05的候选框保留下来用回归偏移修正坐标。按得分降序排列对每个类别独立计算AP最后平均得到mAP。修正坐标时注意偏移量对应的是proposal的(x, y, w, h)tx, ty, tw, th bbox_deltas[idx] px, py, pw, ph proposal cx px pw * tx cy py ph * ty nw pw * torch.exp(tw) nh ph * torch.exp(th) x1 cx - nw / 2 y1 cy - nh / 2 x2 cx nw / 2 y2 cy nh / 2mAP计算的本质是对每个类别按置信度降序遍历预测框如果与某个尚未匹配的真实框IoU大于0.5记为TP否则记为FP。累加得到precision和recall曲线计算曲线下面积。简单可视化检测结果时用matplotlib或者cv2在图上画矩形框并把类别名和置信度写在框左上角。代码不复杂重点是要把类别索引映射回VOC类别名字符串否则看到的就是一串数字。我自己完整复现后在VOC2007 test上的mAP在65左右比论文报的66.9低一些。这是正常现象Selective Search的具体版本、候选框数量、训练尺度、是否用VOC2012扩充数据都会带来一两个点的波动。5. 复现过程中踩过的坑完整排查链路5.1 现象Loss正常但mAP卡在50%根因是类别索引错位我第一次跑完整训练损失曲线下降得很漂亮分类准确率也有60%多但测试集mAP一直卡在50%左右。可视化之后发现模型把所有候选框都判成了背景少数判成物体的框类别也是错乱的。排查时我检查了Dataset里的标签分配代码发现一个很低级的错误我用voc_categories.index(cls_name)来得到类别标签这个索引从0开始恰好和VOC的第一个类“aeroplane”对齐了但背景我也设成了0。于是“aeroplane”这个真类在模型眼里和背景没有区别模型自然学不到判别信息。正确做法是让背景占0号所有物体类别从1开始编号输出维度从20改成21。这是一个典型的off-by-one错误在自写pipeline时很容易出现。修复之后再训练mAP从50%直接跳到60%以上。排查这类问题一个好习惯是打印一个batch的标签分布确认有没有类别0、类别1到20是否都有覆盖。如果某个类别始终没出现多半是数据划分或标签映射出了问题。5.2 现象检测框全部偏移问题出在RoI坐标映射第二次训练后损失正常、mAP也到了60多但可视化时发现框整体偏小且位置往左上角偏移。挑一个目标仔细看框总是不能完整覆盖目标总是缩进去一圈。我逐步排查先打印同一个proposal在原图上的坐标、resize后的坐标、RoI Pooling在feature map上的对应区域发现特征图上的框比预期小了将近一半。问题很快定位出来——我偷懒直接用了vgg.features整个模型包含第5个maxpoolstride是32但RoIPool的spatial_scale却设成了1/16。同一个RoI被当成“只有实际一半大小”的区域从feature map里抠了出来框自然偏小。修复方法就是之前代码里写的backbone截到features[:24]让stride保持16或者保留完整VGG把spatial_scale改成1/32。这个小问题很隐蔽因为模型照样能收敛mAP也不会崩只是框的位置始终有偏差。建议在写模型时就把backbone的stride和RoIPool的scale写清楚最好加个注释。5.3 现象训练到一半Loss变NaN回归目标里有log(0)训练到某个iter之后loss突然变成NaN。我一开始怀疑是学习率太高把learning rate调低了重跑结果还是会在几百个iter之后复现。然后我打印了每一步的回归loss发现它在变成NaN之前会先涨到一个很大的值。顺着回归目标计算过程查发现Selective Search偶尔会返回宽或高为0的候选框。计算回归目标时log(Gw/Pw)里的Pw如果是0就会产生无穷大损失直接炸掉。修复方法很直接在标签分配阶段把宽高小于1的proposal直接过滤掉。另外在计算目标时给Pw、Ph加上一个极小值epsilon双保险。损失变NaN还有一种常见原因新head是随机初始化VGG预训练backbone的初始输出稳定但随机head可能在早期产生很大的梯度所以backbone学习率需要乘以0.1左右的系数。我在复现时就是这两种问题叠加逐个排查才定位到log(0)。5.4 现象训练慢到怀疑人生把Selective Search结果缓存起来最开始我写Dataset时图省事在__getitem__里实时调用OpenCV的Selective Search。结果训练速度慢到无法接受一个epoch要几十分钟根本没机会调参。排查时看了程序CPU占用发现大量时间花在图像分割算法上GPU反而在空等。解决办法就是前面反复提到的把所有图像的候选框预先算好存成pkl或者npy训练时直接用内存加载。这一步在论文里不占篇幅但实际复现是不可省略的工程优化。如果你在训练时发现GPU利用率很低先排查数据加载是不是瓶颈Selective Search缓存是提升效率最直接的一招。6. 复现完成后的再思考这个网络为检测模型留下了什么6.1 Selective Search成了新的速度瓶颈Fast R-CNN把候选框生成之前的流程全部做成了可训练的端到端模型但候选框本身仍然来自Selective Search这种传统图像分割算法。论文里报的0.32秒测试速度是不包含候选框生成的算上Selective Search大约要2秒左右。所以整个检测流程最慢的部分从卷积网络变成了候选框生成。这个问题直接催生了Faster R-CNN的思路用一个小型的卷积网络RPN来生成候选框让候选框生成也变成可学习的部分。从Fast R-CNN到Faster R-CNN的演进逻辑非常清晰就是沿着“把更多手工步骤替换成可学习网络”这条路继续走。6.2 RoI Pooling的精度问题与RoIAlign的改进Fast R-CNN使用的RoI Pooling存在两次量化误差。第一次是从原图坐标映射到feature map时要取整第二次是在把RoI划分成7×7格子时每个格子边界也可能落在小数位置仍然要取整。对大的物体来说这种误差可以忽略。但对小目标量化误差可能让框偏移好几个像素直接影响定位精度。后面Mask R-CNN提出的RoIAlign用双线性插值代替取整把这个误差基本消掉。如果你发现Fast R-CNN复现结果里小目标的AP明显偏低可以先想到这个因素。6.3 个人对Fast R-CNN复现的学习建议如果你准备自己动手复现我建议按这个顺序来先手动实现一遍RoI Pooling不要直接调torchvision.ops.RoIPool虽然调库方便但手动实现能让你真正理解feature map坐标和原图坐标之间的映射关系。然后把模型跑通用一小部分数据验证流程再上完整训练。写代码时务必在关键位置打印张量shape特别是回归目标、类别标签、RoI坐标这几个地方很多bug都是shape对不上或者索引错位造成的。Fast R-CNN本身结构不复杂数据流对了训练起来反而比很多分类网络都稳定。只要把这套数据流吃透再去看Faster R-CNN、Mask R-CNN这些后续网络会发现它们只是在Fast R-CNN的骨架上做了替换和扩展理解成本会低很多。