FPN核心原理:自顶向下的路径与横向连接,如何构建特征金字塔
好,咱们接着聊。上一节我讲了为什么YOLO需要多尺度特征融合,说白了就是小目标太容易丢了。那FPN到底是怎么解决这个问题的?它的核心机制就两个:自顶向下的路径和横向连接。今天我就把这两个东西掰开揉碎了讲清楚。
一、先想想:为什么叫“金字塔”?
你想想看,一张图片输入网络,经过卷积和池化,特征图会越来越小。比如输入是640×640,经过几层下采样,可能变成40×40、20×20、10×10。这就形成了一个天然的“金字塔”——底层分辨率高、语义弱,顶层分辨率低、语义强。
但问题来了:底层特征虽然分辨率高,但语义信息不够。比如你要检测一个小猫的脸,底层特征能看到胡须的纹理,但它不知道那是不是猫。顶层特征知道那是猫,但位置信息已经模糊了。FPN要做的,就是把这两者的优势结合起来。
核心思想:让每一层特征图既保留高分辨率的细节,又融合高层的语义信息。
二、自顶向下的路径:语义信息往下传
这个路径,说白了就是从高层往低层做上采样。我习惯用最近邻插值或者双线性插值,把高层特征图放大到和下一层一样的大小。
举个例子:
- 顶层特征图大小:10×10(通道数256)
- 下一层特征图大小:20×20(通道数256)
- 操作:把10×10上采样到20×20
这样,顶层学到的“这是猫”的语义信息,就能传递到分辨率更高的层上。嗯,这里要注意:上采样不能太粗暴。我曾经在项目里直接用反卷积做上采样,结果引入了很多棋盘格噪声,小目标反而更难检测了。后来我改用最近邻插值,效果稳定很多。
我的经验:上采样方式选最近邻插值或双线性插值就够了。反卷积虽然可学习,但容易过拟合,尤其在数据量不大的时候。
三、横向连接:细节信息往上补
光有自顶向下还不够。你想想,上采样后的特征图虽然有了语义,但位置信息还是模糊的。这时候就需要横向连接——把底层特征图中保留的细节信息,直接加到上采样后的特征图上。
具体做法:
- 对底层特征图做一个1×1卷积,调整通道数(通常统一成256)
- 把上采样后的高层特征图和这个1×1卷积后的底层特征图相加
- 相加后再接一个3×3卷积,消除混叠效应
为什么要用1×1卷积?说白了就是为了对齐通道数。底层特征图的通道数可能很大(比如512、1024),直接相加维度不匹配。1×1卷积就像个“适配器”,把通道数统一到256。
避坑指南:我曾经在横向连接里忘了加1×1卷积,直接拿底层特征图去加,结果程序报错。后来我养成了习惯:每次做FPN前,先检查各层通道数是否一致。
四、构建特征金字塔的完整流程
好,咱们把整个流程串起来。假设骨干网络有4个阶段,输出特征图记为C2、C3、C4、C5(分辨率依次减半)。FPN会生成对应的P2、P3、P4、P5。
| 特征图 | 来源 | 分辨率(相对输入) | 通道数 |
|---|---|---|---|
| C5 / P5 | 骨干网络最后一层 | 1/32 | 256 |
| C4 / P4 | C4 + 上采样(P5) | 1/16 | 256 |
| C3 / P3 | C3 + 上采样(P4) | 1/8 | 256 |
| C2 / P2 | C2 + 上采样(P3) | 1/4 | 256 |
你看,每一层P都是“横向连接 + 自顶向下”的结果。这样P2既有高分辨率的位置细节,又融合了P5的语义信息。我习惯把P2、P3、P4、P5都作为检测头输入,每个尺度负责检测不同大小的目标。
五、代码实现:FPN模块的核心逻辑
光说不练假把式。我写一个简单的FPN实现,你感受一下:
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().__init__()
# 横向连接:1x1卷积调整通道数
self.lateral_convs = nn.ModuleList()
for in_ch in in_channels_list:
self.lateral_convs.append(
nn.Conv2d(in_ch, out_channels, 1)
)
# 输出卷积:3x3消除混叠
self.output_convs = nn.ModuleList()
for _ in in_channels_list:
self.output_convs.append(
nn.Conv2d(out_channels, out_channels, 3, padding=1)
)
def forward(self, inputs):
# inputs: [C2, C3, C4, C5] 从低到高
# 先对每一层做横向连接
laterals = []
for conv, feat in zip(self.lateral_convs, inputs):
laterals.append(conv(feat))
# 自顶向下路径
for i in range(len(laterals)-1, 0, -1):
# 上采样后相加
laterals[i-1] += F.interpolate(
laterals[i],
size=laterals[i-1].shape[-2:],
mode='nearest'
)
# 输出卷积
outputs = []
for conv, feat in zip(self.output_convs, laterals):
outputs.append(conv(feat))
return outputs # [P2, P3, P4, P5]
这段代码很简洁,但核心逻辑都在了。你注意看:for i in range(len(laterals)-1, 0, -1) 这个循环,就是从顶层往底层做上采样和相加。我刚开始写的时候,经常把循环方向搞反,结果特征图越加越小。嗯,这个细节要注意。
六、FPN在YOLO中的实际应用
YOLOv3开始就用类似FPN的结构了。不过YOLO的FPN有个特点:它只用了3个尺度(对应下采样8倍、16倍、32倍)。为什么?因为YOLO的骨干网络(Darknet)输出就是3个尺度的特征图。
我个人习惯在YOLOv5里做一点改动:把P2也加进来,变成4个尺度。这样对小目标的召回率能提升2-3个点。代价是计算量增加了一些,但我觉得值得。
关键点总结:
- 自顶向下路径:传递语义信息
- 横向连接:保留位置细节
- 1×1卷积:对齐通道数
- 3×3卷积:消除混叠效应
七、一个常见的误解
很多人以为FPN就是简单的“特征图拼接”。其实不是。FPN的核心是特征融合,不是拼接。拼接只是把特征图堆在一起,而融合是通过加法(或加权加法)让不同尺度的信息互相补充。
我记得有一次跟同事讨论,他说“FPN不就是把不同层的特征图拼起来吗?”我当场给他画了个图,解释了横向连接和自顶向下的区别。他听完恍然大悟。所以你看,理解原理比记住代码更重要。
八、小结
FPN的原理其实不复杂,就两个操作:上采样和相加。但它的设计思想很巧妙——让每一层特征图都成为“多尺度信息的融合体”。在YOLO里,FPN让模型既能看清大目标的全貌,又能抓住小目标的细节。
下一节我会讲如何在YOLOv5里具体实现FPN,包括怎么修改配置文件、怎么调整anchor。到时候咱们再细聊。