
最近重看了一遍 simple-faster-rcnn-pytorch 的源码好几处当年囫囵吞枣跳过去的细节这次总算想明白了。这个库可太适合拿来啃了——它没有把网络结构塞进一个几千行的 mega 文件里RPN、RoI Head、Proposal 筛选这些都是单独模块代码量小命名直白连工具函数都拆得清清楚楚。如果你也想搞懂 Faster R-CNN 而不是只会调现成接口或者你已经用成熟检测框架跑过很多实验却一直觉得“这个框到底是怎么挑出来的”“那些标签到底是谁分配的”像黑盒那这篇源码解析应该能帮到你。这篇笔记我会按一条完整的阅读路线走先从数据怎么进网络开始再顺着 Feature Map 往下看 RPN 和 RoI Head最后落到训练循环和推理后处理。会尽量讲清楚每个模块“为什么这样做”而不是逐行翻译注释。1. 为什么选择 simple-faster-rcnn-pytorch 作为 Faster R-CNN 源码入口1.1 在众多实现里为什么会挑这一份代码我最早接触检测源码时第一反应是去翻那些大型检测库。结果打开一看几十个文件之间有复杂的继承关系一个build函数能绕三次弯。新手进去很容易迷路最后变成“好像看懂了又好像啥也没懂”。simple-faster-rcnn-pytorch 走的完全是另一个路数。它用纯 PyTorch 写没有花哨的分布式封装也不搞注册机制就是老老实实的类继承和函数调用。核心代码集中在model目录每个类只干一件事模块文件主要职责faster_rcnn.py定义 Faster R-CNN 整体模型结构、训练/预测入口faster_rcnn_vgg16.py组装 VGG16 backbone 与 RPN/Head加载预训练权重region_proposal_network.pyRPN生成 anchor、预测类别/偏移、输出 proposalroi_head.pyRoI Head对 proposal 做分类和精调 bboxroi_module.pyRoIPooling 的自定义实现model/utils/creator_tools.py训练时的标签分配器也就是AnchorTargetCreator和ProposalTargetCreatormodel/utils/bbox_tools.py坐标变换、NMS、IoU 计算等基础函数只要把这几个文件的调用关系看懂Faster R-CNN 的骨架就等于拿下来了。这份代码很适合做“第一个精读的检测源码”之后再去看大型框架会轻松很多。1.2 这份代码的结构选择为什么用 VGG16 而不是 ResNet项目使用的是 VGG16 作为 backbone。很多人会觉得奇怪都什么年代了怎么还在用 VGG换个 ResNet 不是更好吗。我的看法是选 VGG16 恰恰是这个项目作为教学源码最大的优点。VGG16 结构足够简单全是3x3 卷积加max pooling一层叠一层没有分支、没有残差连接特征图的通道数变化也很好算。你拿着笔推一遍就能知道每层之后图像尺寸缩半、通道翻倍这种确定性对理解“Feature Map 到底哪来的”非常重要。ResNet 虽然效果更好但涉及残差块和两条路径相加初学者读起来容易把注意力放到骨干网络的细节上反而忽略了真正要学的东西——RPN 的 anchor 机制和 Proposal 筛选。等这份代码读透了再自己动手把 VGG16 换成 ResNet心智负担就不会太大。1.3 读源码的正确姿势先跑通再打断点最后看细节我这几年带过不少同学总结出一条经验千万别上来就一行一行背代码。先按 README 把环境装好、把训练跑起来哪怕只跑几轮你也知道程序能正常走通。接下来再进调试器打断点或者在关键函数里print中间变量的形状。这一步比读十遍代码都有用。比如你想看 anchor 到底生成了多少直接在 RPN forward 里打印anchor 的 shape想看 proposal 经过 NMS 之后还剩多少就去ProposalCreator里打印输出长度。这种“亲眼见到中间变量”的过程会大幅降低抽象的代码带来的理解难度。2. 数据入口Dataset 类如何把图像和标注送进网络2.1 图像预处理缩放、归一化和 BGR 通道顺序这个项目沿用了当时常见检测实现的做法图像预处理看起来简单其实有几个坑。数据加载时图像会被缩放到短边 600 像素、长边不超过 1000 像素左右的尺度然后做减均值归一化。为什么要限定这个范围两层原因一是检测框架里 anchor 的尺度和比例是按固定分辨率设计的图像变化太大anchor 的覆盖策略会失真二是显存有限太大尺寸的 Feature Map 和 RoI 批处理都吃不消。需要注意加载出来的图像默认是 RGB 通道但 VGG 预训练权重通常按 BGR 顺序输入。所以代码里往往会有一步通道重排。如果你之后自己写数据加载经常在这个地方翻车——训练了十几个 epoch 发现 loss 不降查了一圈结果是通道顺序反了模型从头到尾都在看“颜色错乱”的图像。这个低级错误有多常见谁踩谁知道。2.2 标注坐标的约定xyxy 绝对坐标 vs 归一化坐标目标检测项目里最让人头疼的问题之一就是坐标体系不统一。有的数据集的标注是归一化的xywh有的是原图绝对像素的x1y1x2y2有的是中心点加宽高。这份代码内部使用的坐标格式是(x1, y1, x2, y2)的绝对坐标也就是左上角和右下角的像素位置。数据在进入模型前代码会统一转成这个内部格式。我建议你也养成这个习惯写任何检测代码之前先定好内部坐标格式然后在数据加载的入口统一转换。宁可花十分钟写一个转换函数也不要让后面的训练代码里到处出现x / width这种加减法。后续在处理 anchor、RPN 回归目标、RoI 对齐时全程用绝对坐标理解起来会省很多力气。2.3 Dataset 的数据流和 collate 问题当时这个项目遍历一张张图像、对每张图像生成一批 anchor 的方式和现在主流实现的padding batched tensor不太一样。它需要自己控制 batch 的组织每次迭代会拿一张图和对应标注送入网络。好处是代码好写不需要考虑变长 padding坏处是训练效率偏低GPU 可能吃不满。这也提醒了后来读代码的人一份教学代码的工程取舍不一定是“最优的”但它把逻辑展示得非常直白。你想看数据到底怎么流动就在 Dataset 里逐行跟进能很清楚地看到一张图如何变成 tensor、标注如何被对齐到 anchor、ROI 如何参与损失计算。等理解了这套逻辑再去写高效的 batch 组织就只是工程问题而不是原理障碍。3. 共享的 Feature MapVGG16 backbone 如何被检测模型复用3.1 VGG16 的哪一部分被保留哪一部分被丢掉经典分类网络最后都带几个全连接层但检测模型不直接需要它的分类输出需要的是“图像经过卷积池化之后形成的空间特征”。所以这份代码会加载 VGG16 的卷积层部分保留到conv5为止然后自己拼接后续结构。在faster_rcnn_vgg16.py里可以明显看到模型的组装步骤# 伪代码梳理 VGG16 backbone 的组装思路 class FasterRCNNVGG16(nn.Module): def __init__(self): super().__init__() # 使用 classification 模型的 features 部分作为特征提取器 self.features vgg16.features # 注意这里不含最后的分类层 # 在 VGG 之后增加一个下采样层把输出通道数调整成检测头用的大小 self.extractor nn.Sequential( self.features, nn.Conv2d(512, 512, 3, padding1), nn.ReLU(inplaceTrue), )保留卷积层并对最后一层做针对性修改后任意H x W的输入会变成512 x H/16 x W/16的特征图。这一步很关键因为 RPN 和 RoI Head 共享的正是这张下采样 16 倍的图。我经常跟人说“stride 16”这个词在检测里非常高频。每一个卷积层之后原始像素和特征图位置的对应关系就直接由这个 16 决定。3.2 从卷积层到 List 输出检测模型是怎么接住不同尺寸输入的有一类常见问题分类网络会要求输入固定尺寸因为全连接层要求输入维度固定。但检测任务里面输入图像的尺寸基本都不固定输出特征图也就跟着变。为什么 VGG 能处理任意输入尺寸呢因为卷积层是滑窗操作不限制宽高而池化层每池化一次尺寸减半也不要求固定输入。只要去掉后面的全连接层卷积网络就是“全卷积”结构什么尺寸都能接收。这份代码正是利用了这一点H x W的图像经过 VGG16 的特征提取输出H/16 x W/16的特征图。之后 RPN 在这个特征图上做小滑窗RoI Head 则通过 RoIPooling 把任意大小的区域拉成固定尺寸。理解了这个设计你后面读 RPN 的时候就会觉得特别顺——为什么 anchor 的中心都排在格点上为什么一张 800x1000 的图能出几千个 anchor答案都在这些格点上。每个特征图上的位置对应回原图是一个 16x16 像素的区域而 anchor 就在这个区域里以不同比例伸展。3.3 手动替换 backbone 的可行路径我自己曾经尝试过把这份代码的 VGG16 换成 ResNet18踩了些坑但整体路线是可行的。重点是注意三件事第一ResNet 第一层是 7x7 大卷积会导致最终特征图的 stride 变成 16 还是 32 有差异计算 anchor 映射要重新对表第二ResNet 的输出通道数是 512如果下采样倍数不同RPN 的输入通道也需要做相应调整第三RoIPooling 的尺寸是在 VGG 假设下设定的换成 ResNet 之后一般沿用也没问题但要注意感受野变化。如果你也想改建议先用一个最小的替换方案跑通ResNet18 的features部分输出之后接一个 1x1 卷积调整通道把 RPN 的输入通道改成 ResNet 的输出通道。先不管性能先看 loss 能不能降再逐步加细节。这个过程非常练兵。4. RPN 源码拆解从 anchor 生成到 proposal 筛选的完整链路4.1 anchor 的生成逻辑为什么是 9 个框RPN 是 Faster R-CNN 最核心的部分它的作用是在特征图上为“可能含有目标的区域”——也就是 proposal——打分并粗调位置。很多人一上来就被“anchor”这个概念卡住了所以我建议从生成 anchor 的代码读起。这份代码里最基础的 anchor 由base_size16、三种比例[0.5, 1, 2]和三种尺度[8, 16, 32]组合而来。为什么是 9 个3 种宽高比 x 3 种尺度 9。base_size 对应的是特征图上一个格子映射回原图的边长而 anchor 不会超出这个格子的概念——它是以这个格子为中心、按不同尺度和比例画出来的框。下面这段是生成基本 anchor 的典型逻辑def generate_anchor_base( base_size16, ratios[0.5, 1, 2], anchor_scales[8, 16, 32] ): 生成 base_size 对应的 9 个基础 anchor坐标为 x1,y1,x2,y2 py base_size / 2. px base_size / 2. anchor_base np.zeros((len(ratios) * len(anchor_scales), 4), dtypenp.float32) for i, ratio in enumerate(ratios): for j, scale in enumerate(anchor_scales): h base_size * scale * np.sqrt(ratio) w base_size * scale * np.sqrt(1. / ratio) idx i * len(anchor_scales) j anchor_base[idx, 0] py - h / 2. anchor_base[idx, 1] px - w / 2. anchor_base[idx, 2] py h / 2. anchor_base[idx, 3] px w / 2. return anchor_base每个 anchor 由(y1, x1, y2, x2)构成暂时假设原图上的一块 16x16 区域就是这个 anchor 的“视野”。之后随着特征图滑窗这个基础 anchor 会在每个位置被复制一份平移到对应格点上形成整张图密密麻麻的候选框。4.2 RPN 的 forward滑窗卷积与两条分支RPN 的 forward 接收 backbone 输出的特征图先做一次3x3卷积提取局部特征然后分两条路径分类分支每个位置输出 9 个值表示该位置 9 个 anchor 里包含目标的概率回归分支每个位置输出 9 x 4 个值表示这 9 个 anchor 到真实框的偏移。为什么要用3x3滑窗因为相邻区域之间的上下文信息对判断“这里有没有物体”很重要。你可以把它理解成一个小探照灯在整张特征图上扫过去每到一个位置就把这个格子附近的特征提取出来判断“这里该不该出框”。4.3 ProposalCreatorNMS 之前和之后的处理RPN 原始输出可能有好几万个框不能全扔给后面的分类器。ProposalCreator要做三件事按照分类分数从高到低排序先筛掉大部分低分框用非极大值抑制NMS去掉重复的、高度重叠的框将剩下的框缩放到图像边界内再挑前 N 个作为 proposal。我当时第一次看到 NMS 的代码时以为很高深其实就是一个“先挑分最高的再删掉所有和它 IoU 太高的框然后重复”的过程。你可以把它想象成一群人排队领奖分最高的先上台所有跟他长得太像的人就直接回家剩下的人里再重新排序、重新挑。这样能保证最终选出来的框各有各的位置不会十几个框同时罩住同一个物体。在这个项目里训练时一般挑出 2000 个 proposal推理时挑出 300 个。这个数量设置非常影响显存和精度proposal 太少会漏掉真框proposal 太多后面的 RoI Head 计算量爆炸。实际项目里这个数字值得你专门做一组实验调一下。4.4 RPN 分类分支为什么不直接用 Softmax 的结果有一个小细节RPN 的分类分支输出的是二分类分数但有些实现会用 softmax 归一化后再排序有些直接用原始 score 排序。这份代码的做法更偏向“logits 直接参与排序”然后在计算损失时才用交叉熵。为什么排序时用原始 logits 而不用 softmax因为 softmax 是单调变换它不会改变排序结果多算一步反而浪费运算。你只要记住“排序依赖的是相对大小不是绝对概率”就会发现很多检测代码里宁可直接用 logits 来做 top-k 筛选这也是工程上常见的取舍。5. 训练阶段的样本分配标签、回归目标与多任务损失的计算细节5.1 AnchorTargetCreator谁负责给几万个 anchor 决定“正/负样本”RPN 生成了几万个 anchor但训练的时候不能拿所有 anchor 一起算 loss因为绝大多数是背景直接算会淹没真正有目标的部分。因此需要规则的AnchorTargetCreator来给每个训练样本打标签。分配规则大致如下与任意 Ground Truth 框的 IoU 大于 0.7 的 anchor 视为正样本IoU 小于 0.3 的视为负样本剩下的既不是正也不是负不参与损失计算如果一个 Ground Truth 找不到大于 0.7 的 anchor就直接把 IoU 最大的那个 anchor 给他当正样本保证每个真框至少有一个 anchor 负责。这个“保底机制”很重要。一张图里如果目标很小或者目标之间极度拥挤只靠阈值可能一个正样本都选不出来。有了这个兜底规则每个 Ground Truth 至少能牵动一个 anchor训练才不会崩。5.2 回归目标Smooth L1 背后的尺度问题RPN 回归分支的预测值不是“直接预测框的坐标”而是预测“anchor 到 Ground Truth 的相对偏移”。为什么要预测偏移而不是绝对坐标因为绝对像素值跨度太大从几十像素到上千像素都有训练时神经网络很难学稳定。偏移做了归一化之后数值通常落在个位数区间收敛就快得多。回归目标的两种常见表示是中心坐标偏移和宽高对数偏移t_x (gt_cx - anchor_cx) / anchor_w t_y (gt_cy - anchor_cy) / anchor_h t_w np.log(gt_w / anchor_w) t_h np.log(gt_h / anchor_h)在损失这边这份代码使用的是 Smooth L1。它其实是个分段函数误差绝对值小于 1 的时候用平方项梯度会平滑地变小误差大的时候退化成绝对值梯度不会爆炸。你想想训练初期预测得不准误差可能很大如果用 MSE梯度会被误差放大得很离谱导致训练发散。Smooth L1 就是为这个场景设计的。5.3 ProposalTargetCreatorRoI Head 的训练样本怎么来RPN 输出 2000 个 proposal但 RoI Head 不能把 2000 个全拿来算分类因为其中很多是背景。这里的ProposalTargetCreator有点像一个“数据清洗器”它会从 proposal 里挑出和某个 Ground Truth IoU 较大的框作为 RoI Head 的正样本再随机挑一些 IoU 较小的框作为负样本最终组成一个几十到一百多数量的小批量 RoI 送去训练。我记得这个项目里一般会从 2000 个 proposal 中挑出约 128 个 RoI保持正负样本比例大致均衡。这跟 RPN 阶段一样都是为了不让“背景”淹没问题让分类器能学到真正的判别特征。5.4 损失相加一次迭代的总 loss 是怎么拼出来的训练时整张图的前向传播会产生两类损失RPN 的分类损失加回归损失、RoI Head 的分类损失加回归损失。整体 loss 就是这四个值按一定权重相加。简单来说网络一次前向就同时把“在哪里找框”和“框里是什么”两件事都学起来了。这也是 Faster R-CNN 名字里“Faster”的核心优势之一——RPN 和 Fast R-CNN 共享同一个特征提取器一次前向不重复算骨干网络。读完这一块你会发现虽然每个模块看起来简单但它们之间的样本传递环环相扣RPN 产出 proposal然后 RoI Head 用自己的正负采样规则再过滤一次。如果你在调试的时候发现某个 loss 一直降不下去可以先分别打印四个 loss 的曲线看看是哪个部分的贡献出了问题。这是最直接的排错思路比盲调学习率靠谱太多。6. 主训练循环里的实际参数与常见坑6.1 学习率、优化器与 batch 的配合这个项目训练时用的是 SGD配合 momentum学习率一般从 0.001 量级开始。为什么检测任务里 SGD 依然能打因为它在小 batch 下的泛化能力通常优于 Adam这在当时的目标检测社区几乎是共识。后来大家为了省事也会用 Adamonecycle 之类的方案但如果你想严格复现这份代码的实验最好先照着 SGD 阶梯下降学习率的套路来。训练循环本身不复杂对每一张图前向得到 loss反向传播更新参数。唯一要留意的是RPN 的回归分支只对正样本计算损失所以如果训练初期正样本太少整个模型的梯度信号都是弱的loss 可能久久不动。这时候不要急着加学习率先检查样本分配结果是否正常。6.2 显存不足时的应急手段与代价如果你是在普通显卡上跑这份代码一万多个 anchor 的中间结果很容易吃显存。我当时第一次跑时直接爆显存后来用的几个方案按推荐度排减小输入图像的缩放上限从 1000 缩到 800显存占用立刻掉一截代价是模型对大目标的检测精度会下降减小训练时 proposal 数量从 2000 降到 1200训练依然能稳定跑减小 batch size但这份代码本身对每张图单独过网络效果有限开启混合精度但当时的代码结构要额外加梯度缩放改动量不小。如果你只是学习建议直接走方案 1简单粗暴不影响理解原理。6.3 断点调试时最值得打印的几个中间量我有几个固定的调试动作每次都能迅速定位问题打印anchor的坐标范围和图像尺寸检查 anchor 有没有跑到图像外面太远打印正样本数量如果一张图才两三个正样本说明参数设置有问题打印 RPN 输出的 proposal 数量和 Ground Truth 的 IoU 分布检查 RPN 有没有学到东西打印 RoI Head 分类层的得分分布看是不是所有框都停在“背景”上。这些中间量配合起来效果比盯着总 loss 曲线发呆好得多。很多情况下四五个 epoch 之后总 loss 其实已经降得很慢了但你去看 RoI Head 对真实目标的分类分数会发现它已经有了不错的判别能力。7. 推理与可视化如何把模型输出变成最终的检测框7.1 推理阶段的前向流程少了什么多了什么推理和训练最大的区别在于推理没有 Ground Truth不需要计算损失也不需要样本分配器。RPN 在前向时还是照常生成 proposal但 RoI Head 不再做正负样本筛选而是把 NMS 后的 proposal 直接拉成固定尺寸做分类和回归。最后一层分类器会输出每个 proposal 对所有类别的得分回归器会输出对每个类别的框偏移。你需要做的是先根据得分筛掉低概率框再对每个类别单独做一次 NMS把冗余框去掉。注意“按类别分别做 NMS”这个小细节——如果不分开两个挨得很近的不同类别物体可能被误删。7.2 可视化代码画框的坐标尺度要对齐可视化时最容易范的错是忘记把坐标从训练尺度还原回原图尺度。之前提过数据在前处理时会做缩放模型输出的坐标都是在“缩放后的图像”上的。如果你直接把这些坐标画到原图上框的位置一定会偏。正确做法是记录下原图和缩放图的宽高比在输出坐标上除以这个比例再画框。这类小问题看着不起眼但排查起来特别浪费时间。我现在写完一段检测代码第一件事就是顺手跑一遍可视化如果有偏移八成是坐标系的坑。7.3 从这份源码里能带走的东西这份极简实现把 Faster R-CNN 的每个零件都摆得很清楚尤其是 anchor 机制和样本分配这两块。你在这份代码上积累的细节之后迁移到更大规模的检测框架时会非常有用。比如一看到AnchorTargetCreator就能明白很多大型框架里assigner和sampler的来历一看到ProposalTargetCreator就能理解现代框架里的RoIHead正负样本采集是怎么演进出来的。我个人的建议是读完这份源码别急着关掉试两件事一是把 VGG16 换成 ResNet18强迫自己动手重写 backbone 的接口二是写一个自定义数据集的加载器把整套训练流程用自己的数据跑通。只有当你自己动手改过、跑过、翻过车Faster R-CNN 的那些概念才会真正长在脑子里。最后分享一个小经验看源码解析文章的时候别只看结论最好把项目 clone 下来跟着文章里的路径走一遍打印一遍中间变量。哪怕只是改一个参数观察效果也比纯阅读印象深刻得多。希望这篇笔记能帮你省下一些绕路的时间。