FPN这篇论文(Feature Pyramid Networks for Object Detection),是我反复读了很多遍的目标检测经典。前阵子做工业质检项目,场景里既有几千像素的大零件,又有几十像素的划痕缺陷,用常规检测框架折腾了快两周,小目标AP始终上不去。后来把骨干特征提取改成FPN结构重跑,小目标AP直接抬了几个点,很多之前完全漏掉的缺陷开始能被检出来。这篇笔记把我从这篇论文里沉淀下来的东西完整整理一遍:它到底解决了什么问题、每个模块为什么这么设计、消融实验证明了什么,以及工程落地时容易踩的坑。
1. 多尺度检测的困局:FPN到底在解决什么问题
1.1 尺度差异为什么是检测框架的“心腹之患”
做目标检测的人应该都有体会:一张工业质检图里,大零件能占半个画面,旁边一道划痕可能只有三四十个像素宽。常规的CNN分类网络为了提取高层语义,会反复下采样,原图里30像素的目标经过4次2倍降采样之后,只剩下不到2个像素的信息,已经很难在特征图上留下可靠的响应。
早期Faster R-CNN这类框架就直接用骨干网络最后一层特征做区域建议和分类。最后一层语义信息最丰富,但分辨率最低,小目标的细节基本被“压扁”了。COCO这种数据里小目标占比又很高,所以单尺度高层特征的框架在小目标上普遍拉胯,这也是当时社区里一个公认痛点。
另一个让人头疼的点是:不同尺度的目标,适合它们的特征层级其实完全不同。小目标需要高分辨率、细节丰富的浅层特征,大目标需要语义充分、感受野大的深层特征。过去大多数检测器把所有目标都粗暴地丢到同一个特征金字塔层级上处理,等于让一家公司同时服务大客户和小客户,却只开了一个业务窗口。
1.2 四种多尺度方案的真实对比
在FPN之前,业界已经有几种多尺度处理的思路,但它们各有各的问题。我把常见的四种方案放在一起对比,能更清楚看出FPN的定位:
| 方案 | 核心做法 | 主要代价 | 实际效果 |
|---|---|---|---|
| 图像金字塔 | 把输入图缩放到多个尺寸,每个尺寸分别过一遍CNN | 计算量成倍增加,训练和推理都撑不住 | 效果上限高,但工程上几乎无法落地 |
| CNN金字塔特征逐层预测 | 用骨干网络不同stage的输出分别做预测,比如SSD | 浅层语义不足,深层分辨率不够,层级间特征质量不齐 | 能改善多尺度,但各层预测能力差异很大 |
| 只用单一高层特征 | 只在骨干网络最后一层做分类和回归,比如早期Faster R-CNN | 小目标信息已经被严重稀释 | 大目标还行,小目标AP很难看 |
| FPN自顶向下融合 | 把深层语义逐级“往回传”,与浅层细节逐层融合 | 增加一部分计算量,但远小于图像金字塔 | 小目标AP提升明显,各尺度均衡 |
FPN真正聪明的地方在于:它没有重新设计一个特征提取网络,而是把骨干网络原本就天然存在的多尺度金字塔结构利用起来,再通过一条自顶向下的路径把高层语义广播下去,让金字塔每一层都同时拥有“高分辨率”和“强语义”。这是一种结构上的重新组织,而不是暴力堆计算量。
这个思路听起来简单,但落地细节不少。论文里每一个改动背后都有对应实验支撑,这也是为什么它比一般的“炼丹技巧”更值得反复读。
2. FPN结构逐层拆解:自顶向下路径和横向连接的设计逻辑
2.1 自底向上路径:为什么只挑C2到C5
FPN的骨架依然是一个普通的CNN,前向传播过程中,特征图尺寸每缩小一半,就算是金字塔的一级。论文拿ResNet这类残差网络举例,把每个stage输出的最后一组特征分别叫作C2、C3、C4、C5,对应原图的4倍、8倍、16倍、32倍下采样。
这里有个值得琢磨的细节:为什么不从C1就开始做金字塔?C1的分辨率最高,包含的细节最原始,但它的分辨率太高、通道数多,直接把C1拉进来会吃掉大量显存和计算量,而且C1距离语义信息太远,即使融合了高层语义,提升也有限。工程上更合理的起点是C2。我自己做项目时也验证过:把C2换成C1参与金字塔,显存占用明显上升,AP收益却很微小,属于典型的“性价比为负”的改动。
另一个问题是:为什么金字塔只到C5为止?因为再往下走,特征图已经缩小到原图的32倍,更大尺度的目标并不是常见场景,继续下采样不仅丢细节,还浪费算力。FPN论文里会额外构造一个P6,主要用于区域建议网络去覆盖更大的目标,但参与分类回归的金字塔层级,实际还是P2到P5为主。
2.2 横向连接:1x1卷积做的事情比“对齐维度”多
横向连接是FPN容易被忽视但极其关键的一环。每个自底向上的特征层C2到C5,通道数各不相同。拿ResNet-50举例,C2到C5的输出通道数分别是256、512、1024、2048。如果不做任何处理就把这些特征和上采样后的高层特征直接相加,维度对不上,统计分布差异也会很大,融合结果基本不可用。
论文的做法是在每一层先接一个1x1卷积,把通道统一到一个固定值,论文里用的是256。1x1卷积在这里不只是对齐维度,它也相当于对每一层特征做一次线性投影,让不同层级的特征有相近的分布,之后相加才不会被某一层的高响应值带偏。
为什么不用3x3卷积来做横向连接?因为3x3卷积的感受野大,特征已经被高层语义“污染”了一部分,再去做横向融合反而会混入本层不需要的信息。1x1卷积只做通道级重投影,空间信息保持得最干净。这个细节是我一开始读的时候忽略掉的,后来自己做对比实验,把1x1换成3x3,AP反而小幅下降,也印证了论文里这个选择的合理性。
2.3 自顶向下路径:最近邻上采样、逐元素相加与3x3平滑
自顶向下路径的每一级操作是固定的三步:先对上一层特征做2倍最近邻上采样,再和当前层的横向连接输出做逐元素相加,最后过一层3x3卷积生成最终的特征图P2到P5。
上采样为什么用最近邻而不是转置卷积?这一点我最早也有疑惑。转置卷积能学参数,理论上有更强的表达能力。但在FPN这个场景里,我们需要的只是把特征图尺寸翻倍,以便和下一层对齐,并不需要引入新的可学习映射。转置卷积在很多任务里容易带来棋盘伪影,训练也不够稳定,参数还白占显存。论文的实验也表明,在特征金字塔的融合场景里,最近邻上采样已经足够。工程上它不是“最牛的选择”,而是“不会被干扰的选择”。
逐元素相加而不是通道拼接,也是一个值得品味的决定。拼接会保留两组特征的独立信息,理论上信息量更大,但代价是通道数翻倍、后续计算量上升。相加相当于默认两组特征在相同空间位置上共享语义,这会让融合后的特征更紧凑。论文用实验说明,在这个框架里相加带来的简洁性大于拼接带来的额外信息。
最后的3x3卷积常被忽略,但它负责一个很实际的问题:上采样会带来混叠效应,特征图在空间上出现不自然的重复块,3x3卷积能起到平滑作用,同时把融合后的特征做一次局部重编码。论文里管这一步叫“减少上采样的混叠效应”,它不是可有可无的装饰。
2.4 一个最小的FPN模块长什么样
把上面这些设计落成代码,其实非常短。下面是一个我经常在原型里用的最小PyTorch实现,思路和论文保持一致:
import torch import torch.nn as nn import torch.nn.functional as F class FPN(nn.Module): def __init__(self, in_channels=(256, 512, 1024, 2048), out_channels=256): super().__init__() # 横向连接:统一通道数 self.lateral_convs = nn.ModuleList([ nn.Conv2d(ic, out_channels, 1) for ic in in_channels ]) # 3x3平滑卷积:减少上采样混叠 self.smooth_convs = nn.ModuleList([ nn.Conv2d(out_channels, out_channels, 3, padding=1) for _ in in_channels ]) def forward(self, c2, c3, c4, c5): # 自顶向下路径 m5 = self.lateral_convs[3](c5) m4 = self.lateral_convs[2](c4) + F.interpolate(m5, scale_factor=2, mode='nearest') m3 = self.lateral_convs[1](c3) + F.interpolate(m4, scale_factor=2, mode='nearest') m2 = self.lateral_convs[0](c2) + F.interpolate(m3, scale_factor=2, mode='nearest') # 平滑后输出 p5 = self.smooth_convs[3](m5) p4 = self.smooth_convs[2](m4) p3 = self.smooth_convs[1](m3) p2 = self.smooth_convs[0](m2) return p2, p3, p4, p5这段代码去掉注释大概二十多行,却把一个多尺度检测框架的核心结构完整表达出来了。这也是我推荐所有做检测的人都手动推一遍的原因,结构上的理解一旦到位,换成其他backbone或检测头都很快。
3. 把FPN接到Faster R-CNN上:RPN和ROI分支的适配细节
3.1 RPN改造:每层只有一个anchor尺度的滑窗
原来的RPN在单层特征图上用3x3滑窗,每种位置同时预测多个尺度和多个长宽比的anchor。FPN接进来之后,RPN的改造比想象中更直接:每个金字塔层级P2到P5上仍然跑3x3滑窗,但每一层只负责一种anchor尺度。
具体来说,P2到P5对应的anchor边长基准分别是32、64、128、256像素,再配合三种长宽比,每层就是3个anchor。因为金字塔本身已经覆盖了尺度维度,所以单层不再需要多个尺度,所有层级合起来的总覆盖范围反而比原来更大。
这里有一个容易踩坑的地方:RPN的候选框来自多个不同分辨率的层级,最后要合并在一起做跨层非极大值抑制。不同层级的输出在空间位置和尺度上天然错开,如果只在单层内做NMS,两个重叠度很高的proposal可能会被重复保留。工程上一般把各层RPN输出合并后再统一做NMS,阈值可以按实际数据微调,对最终检测精度影响很大。
3.2 ROI按尺寸分配金字塔层:公式背后的直觉
区域建议网络产出的proposal尺度不一,Fast R-CNN分支需要从不同金字塔层级上取特征来做ROI Pooling。FPN论文给了一个非常优雅的分配规则,用ROI的宽高信息决定它应该从哪一层取特征:
k = floor(k0 + log2(√(wh) / 224))
这里的wh是ROI的真实宽高,√(wh)相当于一个几何平均边长,k0默认取4,224是ImageNet预训练时常用的输入尺寸。ROI平均边长接近224像素时,k4对应P4;边长缩小一半,k就减1,对应到更低层的P3;边长翻倍,k加1,对应更高层的P5。算出来的k会被限制在2到5之间。
这个公式背后的直觉是:大目标在深层特征上语义充分,不需要太高分辨率;小目标在浅层特征上才能保留足够的细节。用一句话概括就是“人尽其才,尺尽其用”。我把这个逻辑翻译成一个可以直接用的函数:
def assign_fpn_level(roi_w, roi_h, k0=4, canonical_size=224): # roi_w, roi_h: 目标在原始图像上的宽高 roi_size = torch.sqrt(roi_w * roi_h) k = torch.floor(k0 + torch.log2(roi_size / canonical_size)) return k.clamp(min=2, max=5)一个很容易忽略的点是:ROI在某个层上被采样之后,这一层的特征已经经过FPN融合,包含了更丰富的信息。所以ROI分配更多的是决定“从哪一层下手”,而不是决定“只能看到哪一层的信息”。这也是FPN和普通多尺度特征最本质的区别。
3.3 跨层共享参数的坑与收益
FPN里不同金字塔层级的预测头是共享参数的。也就是说,P2上的分类头和P5上的分类头,用的是同一套卷积和全连接权重。这样设计最直接的收益是参数量不会随金字塔层数线性膨胀。
共享参数在理论上也站得住脚:FPN已经把各层特征做了一定程度的对齐和融合,不同层级的输出具备相近的语义表达能力,这时候共享预测头不会明显拖累精度。论文里也通过实验对比过共享与不共享,结论是共享参数几乎不损失性能,收益却是参数和显存的大幅下降。
我自己在复现时发现,共享参数真正需要注意的是初始化尺度。预测头如果初始化过大,低层特征上的梯度容易把参数带偏。实操时给新加的预测头设置一个较小的标准差,比如0.01,训练会稳定很多。
4. 消融实验教我的事:哪些设计改动是真金白银
4.1 横向连接与自顶向下路径各自的贡献
FPN论文里最值得反复读的是消融实验。它把结构拆开来验证每一部分的必要性,结果很有启示意义。
只去掉横向连接,保留自顶向下路径时,高层特征被直接上采样并逐级相加,但每一层原有的自身特征没有参与融合。这时候深层语义虽然被传播下去了,但和浅层细节没有进行校准,特征在每个层级上都有一种说不上来的“错位感”,AP下降幅度非常明显。这说明横向连接不是单纯为了对齐通道,而是让高层语义和浅层细节在空间上产生真正的交互。
只去掉自顶向下路径,保留横向连接时,就退化成了一种多尺度独立预测的结构:每个层级直接用自己原有的特征做预测。这比单层特征好一些,但问题的根源还在,浅层特征仍然缺乏全局语义,深层特征仍然分辨率不足。两条路径同时存在时,AP是最高的。对做检测的人来说,这个实验的教训很直接:不要在结构上做减法减到“看起来差不多”,差的那一点往往就是小目标和高层语义之间的鸿沟。
4.2 去掉P2带来的AP_s曲线变化
P2是金字塔里分辨率最高的一层,对应原图4倍下采样,专门负责小目标。论文的消融里有一项是看顶层的覆盖范围从P2到P5改成从P3到P5会怎样,相当于砍掉P2。
砍掉P2之后,整体AP肯定下降,其中小目标AP的下降尤其显著。原因很好理解:P2的分辨率最高,那些在P3上只有几个像素响应的目标,在P2上还能保有相对完整的空间信息。P2也被认为是为检测器开启“高分辨率窗口”的关键层级。
这对工程选型很有参考价值。我的实际经验是,如果显存有限或者目标平台速度要求很苛刻,可以先砍P2换速度。但在目标普遍偏小的业务里,砍P2等于自断一臂。如果既想要P2,又不想增加过多计算,可以考虑把输入分辨率适当提高,让目标在产生金字塔特征之前就已经有足够像素保底。
4.3 换backbone和任务的泛化表现
FPN论文不仅把结构用在Faster R-CNN的检测分支上,还在语义分割和实例分割任务里做了验证。它是少数在检测、分割两开花的结构设计。后续很多工作都以它为底座,比如经典的实例分割框架里的特征提取部分基本就是FPN的直系后代。
换backbone时,FPN的表现也相当稳。用更深的残差网络时,FPN带来的提升依然在,这是因为结构本身与backbone深度解耦,它只依赖“金字塔各级特征都存在”这个前提。我在不同业务里试过多种轻量backbone,只要骨干网络还保留了多级下采样的结构,FPN都能稳定带来提升,区别只是幅度大小。
这个泛化能力很关键。它说明FPN抓住了多尺度问题里一个更普遍的结构性规律,而不是针对某个具体网络修的补丁。做工程选型时,FPN基本可以当作一个“默认存在”的模块,除非场景本身有强烈的单尺度性质,否则加了通常都是赚的。
5. 工程落地的几个硬坑:上采样、BN、小目标排查
5.1 上采样方式:工程选择不是越高级越好
很多初学者会在FPN里用带学习的上采样,比如转置卷积或者像素重排。我从实际经验出发的建议是:不要跟风换。最近邻上采样在FPN里已经够用,而且没有任何参数、不会引入棋盘伪影、训练时梯度传播路径更短更稳。
我做过一组对比:把最近邻换成双线性插值,效果几乎没差别,但速度略慢;换成转置卷积,训练波动明显变大,最终AP也没有变好。FPN里每个阶段都要做一次上采样,任何一个环节的不稳定都会在融合中放大。跑这类实验时建议固定随机种子,多做几次对比再下结论,否则很容易被单次run的噪声误导。
5.2 batch size很小的时候,BN千万别随便动
FPN一般接在pretrained backbone后面微调,这个阶段最容易被忽视的问题是BatchNorm的状态。如果batch size只有2或者4,在新加的FPN分支里开BN反向传播更新,统计量的震荡会非常剧烈,因为每一层金字塔拿到的有效样本数量本来就少,再除以小batch,很容易把训练搞出不稳定的损失曲线。
我的做法是在复现阶段把backbone里的BN统计量冻结,只让它在前向传播中做推理;FPN新分支如果加了BN,也建议先用同步BN或者干脆用GroupNorm替代。GroupNorm在小batch下表现得比BN稳很多,这是我调FPN时学到的最实用的一招。
冻结BN还有一个好处,是训练能更快进入稳定状态,因为一开始梯度信号不会被BN统计量的波动淹没。等loss明显下降之后,再决定要不要解冻BN做精细微调,这个顺序比较安全。
5.3 小目标漏检,先从这三个环节排查
如果落地一个FPN检测器后小目标依然漏检,我的排查顺序是固定的。
第一步看输入分辨率。如果原始图像里小目标只有几十像素,预处理阶段又把整图缩到五六百像素,那FPN结构再强也救不回来。输入分辨率是最容易被忽视的上限。
第二步看ROI分配公式。如果业务里小目标特别密集,可以把公式里的k0微调,或者把ROI分配范围下限改成2,让更多中小目标流向P2和P3。实际上就是给公式加一点“偏小目标”的倾向。
第三步看anchor设置。P2层的anchor边长默认是32,如果业务里的目标全是十几二十像素的车牌、缺陷、码点,这个默认值完全不够用。可以往下降,或者引入更小的自定义anchor集合,FPN把尺度维度拆到层上之后,anchor设计变得非常直观。
这三个环节检查完,大部分小目标漏检问题都能找到明确方向。我自己碰到的案例里,有七成是卡在输入分辨率上,剩下三成才是结构或参数问题。
5.4 从零训练比微调更容易踩的梯度雷
FPN论文场景下大家都默认用pretrained backbone微调,但实际有一些业务数据分布偏离ImageNet很远,可能要从头训练。这种情况下的FPN有自己特有的麻烦。
从零训练时,骨干网络的浅层和深层在同一时期都处于剧烈变化状态,FPN融合机制会把这种变化快速传播到所有层级。训练初期经常出现loss飙升或震荡。我的对策是加长warmup,把头几千个iterations的学习率压到很低,同时把新加的横向连接和预测头的初始化scale调小,给结构一个先稳定、再学习的缓冲期。
另一个容易被忽略的点是FPN分支的学习率。微调阶段通常整个网络用一个学习率就能稳定,但从零训练时,FPN新分支产生的梯度往往比骨干网络更深处的梯度更剧烈,可以给它单独设一个较小倍率。这个细节在主流开源框架里没有统一处理,需要自己根据loss曲线来调。
6. 从FPN到后续架构:它如何改变了检测器的设计习惯
6.1 PANet、BiFPN到底改了哪根弦
FPN之后,特征金字塔这块很快成了一片试验田。理解这些后续工作,反过来能看出FPN的设计边界在哪里。
PANet做的改造是,在自顶向下路径之后又加了一条自底向上的增强路径。FPN把高层语义传给了浅层,但浅层的精确空间信息并没有很好地回流到高层。PANet相当于补了一条“从底层向高层汇报”的通路,让大目标也能拿到更精确的位置细节。
BiFPN则更进一步,它删掉了部分冗余连接,只保留关键跨尺度连接,并且给每条连接分配一个可学习的权重,让网络自己决定哪些尺度信息在融合时更重要。这让融合过程从“所有人平等发言”变成了“重要角色多发言”。我用一个生活化的类比:FPN像组织内部单向传达上级精神,PANet像上下级双向沟通,BiFPN则是开会时每个人带着自己的权重,话语权由网络学出来。
这些演进本质上都在围绕同一件事做文章:跨尺度信息怎么融合才是最有效率的。FPN提供了第一版可靠答案,后续工作是把它向更多方向和更深层次推。
6.2 什么时候其实可以不用FPN
作为一个FPN的忠实用户,我也得诚实说一句:不是所有场景都非它不可。
如果任务里目标的尺度分布非常集中,比如固定距离的文档OCR、车牌识别、流水线上的标准件检测,一张图里所有目标都在很窄的尺寸范围内,这时候单层或两三层特征通常够用,FPN带来的收益可能只有零点几个点,却白白增加了几十毫秒的推理耗时和显存占用。
另外在移动端或者边缘设备上,算力本身就紧张,FPN的融合计算带来的额外开销需要仔细权衡。这种情况下可以考虑简化版金字塔,比如只从C3、C4、C5做三层,或者去掉P2,用速度换回可接受的精度。
要不要上FPN,本质上是先看数据再看算力。数据没有明显多尺度问题,就物理上不需要多尺度结构;数据有极端尺度跨度,再贵也得上。这个判断顺序,是我做过多个落地项目之后最深刻的体会。
我自己的第一习惯是把FPN当作一个默认思考的起点,但绝不会把默认当作真理。每个检测任务的合理结构,最终都是通过数据和实验说出来的,论文给了我们可靠的地基,而我们真正要做的是在上面盖出适合自己的房子。