3、PANet结构解析:自底向上的路径增强,缩短信息路径

好,咱们接着聊多尺度特征融合。上一节讲了FPN,它确实解决了高层语义信息向下传递的问题。但我在实际项目中很快发现,FPN有个“偏科”的毛病——它对小目标检测不太友好。为什么呢?说白了,FPN是自顶向下的,高层特征经过上采样后,位置信息已经模糊了。小目标需要的是底层精细的位置信息,但FPN给得不够。

所以,PANet来了。它干了一件很朴素但很有效的事:在FPN的基础上,再加一条自底向上的路径。你想想看,这不就是“双向车道”嘛。

3.1 PANet的核心思想:双向融合

FPN只有一条自上而下的路,语义信息流下来了,但位置信息流不上去。PANet的做法很简单——再加一条自底向上的路径,让底层的位置信息也能快速传递到高层。

我习惯把PANet的结构想象成一个“双通道高速公路”:

  • 自上而下通道:传递语义信息(FPN干的活)
  • 自底向上通道:传递位置信息(PANet新增的)

这两条路径在同一个特征金字塔上并行工作,最终每个层级的特征都同时拥有了丰富的语义信息和精确的位置信息。嗯,这里要注意,不是简单的相加,而是通过特定的连接方式实现的。

核心公式(简化版):

对于第i层特征,经过PANet增强后的特征N_i = f( P_i + 下采样(N_{i-1}) )

其中P_i是FPN输出的特征,N_{i-1}是PANet上一层的输出,f是卷积+激活函数。

3.2 自底向上路径的具体实现

咱们一步步拆解。假设FPN已经生成了{P2, P3, P4, P5}这四层特征,P2分辨率最高,P5语义最强。PANet的自底向上路径是这样走的:

  1. 起点N2:直接取P2作为N2,不做任何处理。因为P2本身已经包含了最底层的位置信息。
  2. 生成N3:对N2做3x3卷积(步长2)进行下采样,然后与P3相加,再经过一个3x3卷积得到N3。
  3. 生成N4:对N3做同样的下采样操作,与P4相加,卷积得到N4。
  4. 生成N5:同理,得到N5。

你看,这个过程就是“下采样+融合+卷积”的重复。我在项目中遇到过一个问题:如果下采样直接用最大池化,位置信息会丢失得更快。所以我建议用步长为2的卷积来做下采样,这样模型能学到更好的下采样方式。

我的小技巧:

在实际实现时,我习惯在每次融合后加一个BatchNorm和ReLU。虽然论文里没强调,但加了之后训练更稳定,收敛更快。你可以试试看。

3.3 为什么能缩短信息路径?

这个问题很有意思。你想想看,在FPN中,底层的位置信息要传到高层,需要走多远?

以P2的位置信息传到P5为例:

  • FPN中:P2 → P3 → P4 → P5(这是自底向上的路径,但FPN没有利用它做特征融合)
  • PANet中:N2 → N3 → N4 → N5(自底向上路径直接融合,每一步都保留了位置信息)

说白了,FPN中底层信息要经过4-5层卷积才能到顶层,而PANet通过逐层融合,信息路径缩短到了1-2层。路径越短,信息衰减越少,梯度消失问题也越轻。

我曾经在一个工业缺陷检测项目里对比过FPN和PANet。检测小划痕(只有几个像素宽)时,PANet的召回率比FPN高了12%。这就是缩短信息路径带来的实实在在的好处。

3.4 PANet在YOLOv4和YOLOv5中的应用

YOLOv4和YOLOv5都采用了PANet结构,但实现上略有不同。我整理了一个对比表:

特性 YOLOv4中的PANet YOLOv5中的PANet
特征层数 3层(P3, P4, P5) 3层(P3, P4, P5)
下采样方式 3x3卷积(步长2) 3x3卷积(步长2)
融合方式 相加(add) 拼接(concat)
额外操作 CSP结构

这里有个关键区别:YOLOv4用的是相加融合,YOLOv5用的是拼接融合。我个人更倾向于YOLOv5的做法,因为拼接保留了更多原始信息,让网络自己决定怎么组合这些特征。当然,计算量会大一些,但效果更好。

避坑指南:

我曾经在YOLOv5中尝试过把拼接改成相加,想减少参数量。结果mAP掉了3个点。后来我仔细分析,发现拼接融合能让梯度更直接地回传到底层,这对训练非常有利。所以,除非你的模型已经严重过拟合,否则别轻易改这个。

3.5 代码实现要点

如果你想自己实现PANet,核心代码其实不长。我贴一个简化版的PyTorch实现:

class PANet(nn.Module):
    def __init__(self, in_channels_list):
        super().__init__()
        # 自底向上的下采样卷积
        self.down_convs = nn.ModuleList()
        for i in range(len(in_channels_list) - 1):
            self.down_convs.append(
                nn.Conv2d(in_channels_list[i], in_channels_list[i+1], 
                          kernel_size=3, stride=2, padding=1)
            )
        # 融合后的卷积
        self.fuse_convs = nn.ModuleList()
        for i in range(len(in_channels_list)):
            self.fuse_convs.append(
                nn.Conv2d(in_channels_list[i], in_channels_list[i], 
                          kernel_size=3, padding=1)
            )
    
    def forward(self, fpn_features):
        # fpn_features: [P2, P3, P4, P5]
        panet_features = []
        # N2 = P2
        prev = fpn_features[0]
        panet_features.append(self.fuse_convs[0](prev))
        
        for i in range(1, len(fpn_features)):
            # 下采样 + 融合
            down = self.down_convs[i-1](prev)
            cur = fpn_features[i] + down
            prev = self.fuse_convs[i](cur)
            panet_features.append(prev)
        
        return panet_features  # [N2, N3, N4, N5]

这段代码很简洁,但实际项目中你还需要加上BatchNorm和激活函数。另外,注意输入特征图的通道数要匹配,否则会报错。

3.6 小结与思考

PANet的核心贡献就是那条自底向上的路径。它没有引入复杂的数学公式,也没有花哨的操作,就是朴实地把底层位置信息往上送。但正是这个简单的改动,让YOLO系列在小目标检测上迈了一大步。

最后留个思考题:PANet的自底向上路径和FPN的自顶向下路径,如果交换顺序会怎样?比如先做自底向上,再做自顶向下?我在一次实验中试过,效果反而下降了。为什么?你可以想想看,答案其实就在信息路径的长度上。

下一节,咱们聊聊更激进的BiFPN——它把双向融合玩出了新花样。