FPN核心原理:自顶向下的路径与横向连接,如何构建特征金字塔

好,咱们接着聊。上一节我讲了为什么YOLO需要多尺度特征融合,说白了就是小目标太容易丢了。那FPN到底是怎么解决这个问题的?它的核心机制就两个:自顶向下的路径横向连接。今天我就把这两个东西掰开揉碎了讲清楚。

一、先想想:为什么叫“金字塔”?

你想想看,一张图片输入网络,经过卷积和池化,特征图会越来越小。比如输入是640×640,经过几层下采样,可能变成40×40、20×20、10×10。这就形成了一个天然的“金字塔”——底层分辨率高、语义弱,顶层分辨率低、语义强。

但问题来了:底层特征虽然分辨率高,但语义信息不够。比如你要检测一个小猫的脸,底层特征能看到胡须的纹理,但它不知道那是不是猫。顶层特征知道那是猫,但位置信息已经模糊了。FPN要做的,就是把这两者的优势结合起来。

核心思想:让每一层特征图既保留高分辨率的细节,又融合高层的语义信息。

二、自顶向下的路径:语义信息往下传

这个路径,说白了就是从高层往低层做上采样。我习惯用最近邻插值或者双线性插值,把高层特征图放大到和下一层一样的大小。

举个例子:

  • 顶层特征图大小:10×10(通道数256)
  • 下一层特征图大小:20×20(通道数256)
  • 操作:把10×10上采样到20×20

这样,顶层学到的“这是猫”的语义信息,就能传递到分辨率更高的层上。嗯,这里要注意:上采样不能太粗暴。我曾经在项目里直接用反卷积做上采样,结果引入了很多棋盘格噪声,小目标反而更难检测了。后来我改用最近邻插值,效果稳定很多。

我的经验:上采样方式选最近邻插值或双线性插值就够了。反卷积虽然可学习,但容易过拟合,尤其在数据量不大的时候。

三、横向连接:细节信息往上补

光有自顶向下还不够。你想想,上采样后的特征图虽然有了语义,但位置信息还是模糊的。这时候就需要横向连接——把底层特征图中保留的细节信息,直接加到上采样后的特征图上。

具体做法:

  1. 对底层特征图做一个1×1卷积,调整通道数(通常统一成256)
  2. 把上采样后的高层特征图和这个1×1卷积后的底层特征图相加
  3. 相加后再接一个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。到时候咱们再细聊。