1. FPN到底解决了什么问题
第一次接触FPN这个概念,是在做一个多尺度目标检测的小项目时。当时用的是一个标准的卷积神经网络骨干,在单一尺度上做预测,结果发现小目标的召回率低得离谱——大目标检测得挺好,小目标几乎全军覆没。后来换了一种做法,把不同深度的特征图都拿来做预测,效果一下子就不一样了。这个“把不同深度的特征图都拿来做预测”的思路,就是FPN(Feature Pyramid Network,特征金字塔网络)要解决的核心问题。
FPN最早是在目标检测领域被提出的,但它的影响远不止检测。语义分割、实例分割、关键点检测、甚至一些图像分类的变体任务,都从FPN的设计中获益。它的核心价值可以用一句话概括:让神经网络同时拥有“语义强”和“分辨率高”的特征。
为什么这件事难?因为卷积网络有一个天然的矛盾。浅层特征图分辨率高,位置信息精确,但语义信息弱——它只知道“这里有边缘、有纹理”,不知道“这是猫还是狗”。深层特征图语义强,能识别“这是一只猫”,但经过多次下采样后,分辨率已经很低了,小目标在上面可能只剩一两个像素,位置信息也模糊了。传统的做法是只拿最后一层做预测,那自然小目标就吃亏。FPN的做法是把浅层和深层通过自顶向下的路径和横向连接融合起来,让每一层既有足够的语义,又有足够的分辨率。
这个思路听起来简单,但实现细节里有很多门道。下面我会从整体设计、核心细节、实操过程、常见问题几个方面,把FPN拆开来讲清楚。无论你是刚入门的新手,还是已经用过FPN但没深究过原理的从业者,应该都能从中找到有用的东西。
2. FPN整体架构与设计思路拆解
2.1 为什么是“金字塔”而不是“单层”
在FPN出现之前,特征金字塔在传统计算机视觉里已经用了很多年。经典的做法是:对同一张图构建图像金字塔,每层缩放不同比例,然后在每层上分别提取特征做预测。这个方法效果不错,但计算量巨大——每层都要跑一遍网络,推理时间成倍增加。在深度学习时代,这种做法显然不划算。
另一种做法是只利用网络内部天然存在的多尺度特征。卷积网络本身就有层级结构,不同深度的特征图分辨率不同,这本身就是一种“金字塔”。但问题是,如果直接拿这些特征图各自做预测,浅层的语义太弱,效果不好。FPN的贡献就在于,它设计了一个轻量级的融合模块,把深层语义“注入”到浅层,同时保持浅层的高分辨率。
这里有一个关键认知:FPN不是简单地“多尺度预测”,而是“多尺度特征融合后再预测”。前者只是利用现有特征,后者是主动构造更强的特征。
2.2 自顶向下路径与横向连接的分工
FPN的结构可以拆成三个部分:自底向上的路径、自顶向下的路径、横向连接。
自底向上的路径就是骨干网络本身的前向传播。以ResNet为例,通常会取四个阶段的输出,记为C2、C3、C4、C5,对应的下采样倍数分别是4、8、16、32。这些特征图的通道数也不同,通常是256、512、1024、2048。
自顶向下的路径从最深层C5开始,先经过一个1x1卷积把通道数统一到256,得到P5。然后P5上采样2倍,与C4经过1x1卷积后的结果逐元素相加,得到P4。依此类推,直到P2。最终得到P2到P5四个融合后的特征图,分别对应4、8、16、32的下采样倍数。
横向连接的作用是让浅层特征在融合前先调整通道数,保证相加时维度一致。这里用1x1卷积而不是3x3,是因为1x1不改变空间尺寸,只做通道变换,计算量小,而且不会引入额外的空间混叠。
2.3 为什么用加法而不是拼接
融合方式有两种常见选择:逐元素相加和通道拼接。FPN用的是相加。为什么?
相加的好处是通道数不变,后续处理的计算量可控。拼接会让通道数翻倍,如果每层都拼接,通道数会迅速膨胀,显存和计算量都吃不消。另外,相加可以看作是一种“信息叠加”,让网络自己学习如何组合不同来源的特征。拼接则更偏向“信息并列”,需要后续的卷积层来融合,多了一层间接性。
当然,这不是绝对的。后来的一些变体(比如PANet)在FPN基础上增加了自底向上的路径,用的也是相加。而BiFPN则引入了加权相加,让不同来源的特征有不同的权重。这些改进都是在FPN基础上的优化,核心思路没有变。
2.4 输出层为什么统一通道数
FPN的每一层输出通道数都是256。这个选择不是随意的。统一通道数有两个好处:一是后续的预测头(比如分类头和回归头)可以在所有层上共享权重,减少参数量;二是不同层的特征在通道维度上对齐,方便做后续的融合或比较。
如果通道数不统一,比如P2是256、P5是512,那么共享预测头就没法做了,每层都得单独设计一个头,参数量会大幅增加。而且不同通道数的特征在语义空间上可能不在同一个“坐标系”里,融合效果也会打折扣。
3. 核心细节解析与实操要点
3.1 骨干网络的特征层选择
不是所有骨干网络都适合直接拿来做FPN。选择哪些层作为C2到C5,需要根据任务和输入分辨率来定。
以ResNet-50为例,通常取conv2_x、conv3_x、conv4_x、conv5_x的输出。对应的下采样倍数是4、8、16、32。如果输入是800x800,那么C2是200x200,C3是100x100,C4是50x50,C5是25x25。对于小目标检测,P2的分辨率200x200已经足够精细了。但如果输入只有320x320,C2就只有80x80,小目标可能还是不够。
有些任务会额外加入C1或C6。C1的分辨率太高(下采样2倍),语义太弱,而且计算量很大,通常不用。C6是在C5基础上再下采样一次,用于检测超大目标,在一些检测框架里会用到。
实操心得:如果你的任务里小目标特别多,可以考虑把P2也纳入预测,甚至把输入分辨率调大。但要注意,P2的计算量是P5的很多倍,显存和速度要权衡。
3.2 上采样方式的选择
FPN里上采样用的是最近邻插值,而不是双线性插值或转置卷积。为什么?
最近邻插值最简单,不引入额外参数,计算速度快。双线性插值会引入一些平滑效果,可能模糊边缘。转置卷积有可学习参数,但容易产生棋盘效应,而且增加参数量。在FPN的语境下,上采样的目的是把深层的语义信息“放大”到浅层的空间尺度上,然后与浅层特征相加。最近邻插值虽然粗糙,但保留了原始值,不会引入额外的噪声。而且后续的相加操作和3x3卷积会进一步平滑和整合信息。
当然,这不是说双线性或转置卷积不能用。有些实现会用双线性插值,效果差异不大。但最近邻是默认选择,也是经过大量实验验证的稳妥方案。
3.3 3x3卷积的作用
在每一层融合之后,FPN会加一个3x3卷积。这个卷积的作用是消除上采样带来的混叠效应。最近邻插值会把一个像素复制成四个,形成块状结构。3x3卷积可以在局部范围内把这些块状结构平滑掉,让特征图更自然。
另外,3x3卷积也增加了非线性,提升了特征的表达能力。如果没有这个卷积,融合后的特征可能比较“生硬”,直接拿去做预测效果会差一些。
3.4 预测头的设计
FPN本身只是一个特征提取结构,最终的预测还需要接预测头。在目标检测里,通常会在每个P层上接一个共享的分类头和回归头。分类头输出每个锚框的类别概率,回归头输出边界框的偏移量。
共享权重的好处是参数量小,而且不同层学到的特征可以互相促进。但也有一些实现会用不共享的权重,让每层有自己的预测头。这样做参数量大,但可能对特定尺度的目标更友好。选择哪种,取决于你的任务和数据量。数据量小的时候,共享权重更稳妥,不容易过拟合。
4. 实操过程与核心环节实现
4.1 从零搭建一个FPN模块
下面用PyTorch写一个简化的FPN模块。假设骨干网络已经提供了C2到C5四个特征图,通道数分别是256、512、1024、2048。
import torch import torch.nn as nn import torch.nn.functional as F class FPN(nn.Module): def __init__(self, in_channels_list, out_channels=256): super(FPN, self).__init__() # 横向连接的1x1卷积,用于统一通道数 self.lateral_convs = nn.ModuleList() for in_channels in in_channels_list: self.lateral_convs.append( nn.Conv2d(in_channels, out_channels, kernel_size=1) ) # 融合后的3x3卷积 self.output_convs = nn.ModuleList() for _ in range(len(in_channels_list)): self.output_convs.append( nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=1) ) def forward(self, inputs): # inputs: [C2, C3, C4, C5] laterals = [] for i, lateral_conv in enumerate(self.lateral_convs): laterals.append(lateral_conv(inputs[i])) # 自顶向下融合 for i in range(len(laterals) - 1, 0, -1): # 上采样后与浅层相加 upsampled = F.interpolate( laterals[i], size=laterals[i-1].shape[-2:], mode='nearest' ) laterals[i-1] = laterals[i-1] + upsampled # 3x3卷积输出 outputs = [] for i, lateral in enumerate(laterals): outputs.append(self.output_convs[i](lateral)) return outputs # [P2, P3, P4, P5]这段代码的核心逻辑很清晰:先做横向1x1卷积,然后从最深层开始,逐层上采样并相加,最后每层过一个3x3卷积。注意上采样时用了size参数而不是scale_factor,这样可以精确匹配浅层的空间尺寸,避免因取整导致的尺寸不一致。
4.2 与检测框架的集成
在实际检测框架里,FPN通常作为骨干网络和检测头之间的中间模块。以Faster R-CNN为例,RPN(区域提议网络)和ROI Head都需要用到FPN的输出。
RPN会在每个P层上生成锚框。不同层负责不同尺度的目标:P2负责最小的目标,P5负责最大的目标。锚框的尺度通常按层设置,比如P2用32x32,P3用64x64,P4用128x128,P5用256x256。每个尺度再配几个不同的长宽比。
ROI Head在拿到RPN输出的候选框后,需要从对应的P层上做ROI Align。这里有一个关键操作:根据候选框的尺度决定从哪一层取特征。通常用公式k = k0 + log2(sqrt(wh)/224)来计算,其中k0是基准层,wh是候选框的宽高。这个公式的意思是,候选框越大,从越深的层取特征;越小,从越浅的层取。
注意事项:ROI Align的采样点数会影响精度和速度。默认是2x2,如果对精度要求高可以调到4x4,但速度会慢一些。
4.3 训练时的正负样本分配
FPN训练时,正负样本分配策略对最终效果影响很大。在RPN阶段,通常用IoU阈值来判定正负样本:IoU大于0.7的锚框为正样本,小于0.3的为负样本,中间的忽略。但在多尺度预测下,不同层的锚框尺度不同,同一个目标可能在不同层上都有正样本。这会导致正样本数量增多,训练更充分,但也可能引入重复。
有些实现会用“尺度感知”的分配策略,让每个目标只分配给最合适的那一层。比如,如果一个目标的尺度更接近P3的锚框尺度,就只在P3上标记为正样本,其他层忽略。这样可以减少重复,让每层专注于自己擅长的尺度范围。
4.4 推理时的后处理
推理时,FPN会在所有P层上生成预测结果,然后做非极大值抑制(NMS)。这里有一个细节:不同层的预测结果可能有重叠,NMS需要在所有层的合并结果上做,而不是每层单独做。否则,同一个目标可能在多个层上都被检测到,合并时会出现重复框。
NMS的IoU阈值通常设0.5。如果目标比较密集,可以适当调低,比如0.4,减少漏检。如果目标比较稀疏,可以调高到0.6,减少误删。
5. 常见问题与排查技巧实录
5.1 小目标检测效果差怎么办
小目标检测是FPN最常被问到的问题。如果用了FPN但小目标效果还是不好,可以从几个方面排查。
第一,检查P2是否被纳入预测。有些实现只用了P3到P5,P2被丢弃了。P2的分辨率最高,对小目标最友好。如果输入分辨率是800x800,P2是200x200,对于32x32的小目标,在P2上还有6x6的大小,足够检测。如果不用P2,小目标在P3上只有3x3,信息损失很大。
第二,检查锚框尺度是否匹配。如果小目标的实际尺度是16x16,但锚框最小是32x32,那IoU会很低,正样本很少,训练不充分。可以增加更小的锚框尺度,或者调整锚框的生成策略。
第三,检查数据增强。小目标在缩放、裁剪等增强操作后可能变得更小,甚至消失。可以针对小目标做专门的增强,比如过采样、复制粘贴等。
5.2 显存不够用怎么优化
FPN会增加显存占用,因为要保存多个尺度的特征图,而且P2的分辨率很高。如果显存不够,可以尝试以下方法。
降低输入分辨率。这是最直接的方法,但会影响小目标检测。可以把800x800降到600x600,显存能省不少。
减少P层数量。如果任务里没有特别小的目标,可以去掉P2,只用P3到P5。这样显存和计算量都会下降。
用梯度检查点。PyTorch提供了torch.utils.checkpoint,可以在前向传播时不保存中间激活,反向传播时重新计算。这会增加计算时间,但能显著降低显存。
混合精度训练。用torch.cuda.amp做自动混合精度,显存能省30%到50%,速度也有提升。但要注意,某些操作在FP16下可能不稳定,需要加损失缩放。
5.3 融合后特征图出现网格状伪影
这个问题通常出现在上采样方式选择不当的时候。如果用了转置卷积,很容易出现棋盘效应,表现为特征图上出现规律的网格状纹理。解决方法是改用最近邻插值或双线性插值。
如果用了最近邻插值还有轻微伪影,可以检查3x3卷积是否被正确添加。有些实现为了省计算量,去掉了融合后的3x3卷积,这会导致上采样的块状结构没有被平滑掉。
另外,如果横向连接的1x1卷积初始化不当,也可能导致融合后的特征数值范围差异大,出现视觉上的伪影。可以用标准的Kaiming初始化,或者对1x1卷积的输出做归一化。
5.4 不同层预测结果不一致
有时候会发现,同一个目标在不同层上被检测到,但类别或位置有差异。这通常是因为不同层的特征语义强度不同。深层语义强,分类更准;浅层位置精,回归更准。
解决方法是在训练时让不同层的预测头共享权重,这样它们学到的特征会更一致。另外,可以在损失函数里加一个一致性约束,让不同层的预测结果尽量接近。不过这种做法会增加复杂度,一般任务里共享权重就够了。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决思路 |
|---|---|---|---|
| 小目标漏检严重 | P2未使用或锚框尺度不匹配 | 检查预测层和锚框设置 | 加入P2,调整锚框尺度 |
| 显存溢出 | 输入分辨率过高或P层过多 | 用nvidia-smi监控显存 | 降低分辨率,减少P层,用混合精度 |
| 特征图有网格伪影 | 上采样方式不当或缺少3x3卷积 | 可视化特征图 | 改用最近邻插值,补上3x3卷积 |
| 不同层预测不一致 | 语义强度差异大 | 对比各层输出 | 共享预测头权重,加一致性损失 |
| 训练不收敛 | 学习率过大或初始化不当 | 观察损失曲线 | 降低学习率,检查初始化 |
| 推理速度慢 | P2计算量大或NMS耗时 | 用profiler分析 | 去掉P2,优化NMS实现 |
6. FPN的变体与扩展思路
6.1 PANet:增加自底向上路径
FPN只有自顶向下的路径,深层语义可以传到浅层,但浅层的位置信息传不到深层。PANet在FPN基础上增加了一条自底向上的路径,让浅层的位置信息也能传到深层。具体做法是在FPN输出后,再从P2开始,逐层下采样并与深层相加。这样深层的特征既有强语义,又有精确的位置信息。
PANet在实例分割任务上表现很好,因为实例分割对位置精度要求高。后来很多检测框架都集成了PANet,作为FPN的增强版。
6.2 BiFPN:加权特征融合
BiFPN的核心思想是,不同来源的特征应该有不同的权重。FPN里用的是等权相加,但实际中某些来源可能更重要。BiFPN引入可学习的权重,让网络自己决定每个来源的贡献。
具体做法是,对每个融合节点,把不同来源的特征乘以一个权重,然后相加。权重经过归一化,保证总和为1。BiFPN还简化了网络结构,去掉了只有一个输入的节点,减少了计算量。
BiFPN在EfficientDet里被提出,配合EfficientNet骨干,在检测任务上取得了很好的效果。它的设计思路对后续的多尺度融合研究影响很大。
6.3 NAS-FPN:用神经架构搜索找融合结构
NAS-FPN用神经架构搜索来自动设计FPN的融合结构。它把融合操作(相加、拼接等)和连接方式作为搜索空间,用强化学习或进化算法来搜索最优结构。搜索出来的结构可能比人工设计的更复杂,但效果更好。
不过NAS-FPN的搜索成本很高,需要大量计算资源。对于普通从业者来说,直接用人设计的FPN或BiFPN就够了,没必要自己搜。
6.4 在分割任务中的应用
FPN在语义分割里也很常用。分割任务需要高分辨率的输出,FPN的P2层分辨率最高,适合做精细分割。通常会把P2到P5上采样到同一分辨率,然后拼接或相加,最后接一个分割头。
在实例分割里,FPN的输出会送给ROI Align,提取每个实例的特征。因为不同实例的尺度不同,FPN的多尺度特性正好派上用场。
7. 一些实操中的经验体会
FPN这个结构,看起来简单,但真正用好需要不少经验。我踩过的最大的坑是:一开始只关注结构本身,忽略了骨干网络的特征层选择。后来发现,如果骨干网络本身不够强,FPN融合出来的特征也好不到哪去。所以,选一个预训练好的骨干网络,比在FPN结构上折腾半天更有效。
另一个体会是,FPN的输出层通道数不一定要固定256。如果任务比较简单,128也够用,还能省显存。如果任务很复杂,512可能更好。这个参数可以根据实际情况调,不用死守论文里的设置。
还有一点,FPN的训练需要足够的数据。如果数据量小,FPN的多尺度融合可能会过拟合。这时候可以考虑冻结骨干网络的前几层,只训练FPN和预测头。或者用更强的数据增强,比如Mosaic、MixUp等。
最后分享一个小技巧:在可视化FPN特征图时,可以把不同层的特征图归一化到同一尺度,然后并排显示。这样能直观地看到每层关注的是什么。P2通常关注边缘和纹理,P5关注整体形状和语义。这个观察对调试很有帮助。