第一部分:FPN实例教学
1. 问题:鱼与熊掌不可兼得
现在我们有一个普通的卷积神经网络(像ResNet),我们用它来处理一张图片。这张图片里有一辆很大的卡车,还有一个很小的行⼈。
网络在处理图片时,会产生不同层次的"特征图":
浅层特征图 (e.g., C3) :尺寸很大 (比如 32x32
),保留了很多细节信息,比如物体的边缘、颜色、纹理。它的优点是定位精准,知道行人在哪个像素点附近。但缺点是它不知道这个纹理组合起来是个"行人"。
深层特征图 (e.g., C5) :尺寸很小 (比如 8x8
),经过了多次压缩,细节信息丢失严重。但优点是,它具有高度浓缩的语义信息,能认出"这是一辆卡车"和"这是一个行人"。但缺点是定位粗糙,它只知道行人大致在左上角那块区域,具体在哪说不清。
负责定位的浅层特征,不认识物体。负责识别的深层特征,定不准物体。
这导致检测小物体(如那个行人)时非常困难。我们需要一种方法,让浅层特征图,也能获得深层特征图的智慧。FPN就是解决这个问题的
2. FPN的解决方案:自顶向下的信息传递
FPN通过一个"自顶向下"的路径,让深层特征把智慧传递给浅层特征。
实例:
输入图片 256x256
。
Backbone网络产生了三层特征图:
C3 : 尺寸 32x32
,通道数 512
。
C4 : 尺寸 16x16
,通道数 1024
。
C5 : 尺寸 8x8
,通道数 2048
。
融合步骤 :
步骤一:先整理C5
C5虽然智慧(语义信息强),但它的信息太多(通道数2048)。我们先用一个1x1
卷积给它"降维",把通道数从 2048
减少到 256
。
这个降维后的特征图,我们称之为 P5 。现在 P5 的尺寸是 8x8x256
。它是我们金字塔的顶层,专门用来检测大物体(比如那辆卡车)。
步骤二:C5向C4传递信息
上采样 (Upsample): C5的 P5 要和 C4 联系融合到一起,得先把自己的尺寸变得和C4一样大。我们通过上采样操作,将 P5 从 8x8
放大到 16x16
。
C4整理: C4自己也要用一个1x1
卷积,把通道数从 1024
降到 256
,方便待会儿和 P5 融合
融合 (Fusion): 现在,上采样后的 P5 和降维后的 C4 尺寸完全一样了(都是16x16x256
)。我们把它们按元素相加 (Element-wise Addition)。
这"相加"的一步,就是特征融合的核心,它意味着C4的特征图,每一个点都融入了来自C5的更高级的语义信息。融合后的结果,我们称之为 P4。
现在 P4 (16x16x256
) 既有C4的较好定位能力,又有C5的强大识别能力。它很适合用来检测中等大小的物体。
步骤三:C4向C3传递信息
这个过程完全一样:
将刚刚生成的新P4上采样,从 16x16
放大到 32x32
。
对原始的 C3 进行1x1
卷积,通道数从 512
降到 256
。
把两者相加,得到 P3 (32x32x256
)。
最终成果: 我们得到了一个新的特征金字塔:P3, P4, P5。
P5 (8x8
): 主要来自C5,负责检测大物体。
P4 (16x16
): 融合了C5和C4,负责检测中等物体。
P3 (32x32
): 融合了P4和C3(间接也融合了C5),它既有C3本身超强的细节定位能力,又被赋予了来自高层的识别能力。因此,它现在能够轻松地识别并定位出那个很小的行人!
这就是FPN特征融合的魅力所在。
第二部分:FPN代码教学
下面我们用 PyTorch 来实现上面描述的整个过程。我会写一个非常简化的例子,让读者看到数据的维度是如何变化的。
- 模拟一个骨干网络 (Backbone) 真实的骨干网络会是ResNet等,这里我们用几个简单的卷积层来模拟,它的作用是输入一张图片,输出我们在实例中提到的 C3, C4, C5 特征图
python
import torch
import torch.nn as nn
import torch.nn.functional as F
class ToyBackbone(nn.Module):
def __init__(self):
super().__init__()
# 模拟从图片到C3的过程
self.conv_to_c3 = nn.Sequential(
nn.Conv2d(3, 128, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.Conv2d(128, 256, kernel_size=3, stride=2, padding=1),
nn.ReLU(),
nn.Conv2d(256, 512, kernel_size=3, stride=2, padding=1),
nn.ReLU()
)
# 模拟从C3到C4
self.conv_to_c4 = nn.Sequential(
nn.Conv2d(512, 1024, kernel_size=3, stride=2, padding=1),
nn.ReLU()
)
# 模拟从C4到C5
self.conv_to_c5 = nn.Sequential(
nn.Conv2d(1024, 2048, kernel_size=3, stride=2, padding=1),
nn.ReLU()
)
def forward(self, x):
c3 = self.conv_to_c3(x)
c4 = self.conv_to_c4(c3)
c5 = self.conv_to_c5(c4)
return c3, c4, c5
- 实现FPN
python
class FPN(nn.Module):
def __init__(self, c3_channels, c4_channels, c5_channels, out_channels=256):
super().__init__()
self.out_channels = out_channels
# 建立横向连接 (Lateral Connection)
# 这是给C3, C4, C5整理的1x1卷积
self.lat_c5 = nn.Conv2d(c5_channels, self.out_channels, kernel_size=1)
self.lat_c4 = nn.Conv2d(c4_channels, self.out_channels, kernel_size=1)
self.lat_c3 = nn.Conv2d(c3_channels, self.out_channels, kernel_size=1)
# 建立平滑层
# 融合后的特征图可以用一个3x3卷积来消除上采样带来的混叠效应
self.smooth = nn.Conv2d(self.out_channels, self.out_channels, kernel_size=3, padding=1)
def forward(self, c3, c4, c5):
# 自顶向下 (Top-Down) 的路径
# 1. 处理最高层 C5,得到 P5
p5 = self.lat_c5(c5)
# 2. P5上采样,与处理后的C4融合,得到 P4
p5_upsampled = F.interpolate(p5, scale_factor=2, mode='nearest')
p4 = self.lat_c4(c4) + p5_upsampled # <- 核心的特征融合
# 3. P4上采样,与处理后的C3融合,得到 P3
p4_upsampled = F.interpolate(p4, scale_factor=2, mode='nearest')
p3 = self.lat_c3(c3) + p4_upsampled # <- 核心的特征融合
# 对最终的金字塔层进行平滑处理,得到最终输出
p3 = self.smooth(p3)
p4 = self.smooth(p4)
# p5也经过一次平滑,保持一致性
p5 = self.smooth(p5)
return p3, p4, p5
实例运行:
python
if __name__ == '__main__':
# 假设输入一张 1x3x256x256 的图片 (Batch, Channel, Height, Width)
dummy_input = torch.randn(1, 3, 256, 256)
# 1. 通过骨干网络,得到C3, C4, C5
backbone = ToyBackbone()
c3, c4, c5 = backbone(dummy_input)
print("--- Backbone输出 ---")
print(f"C3 shape: {c3.shape}") # 预期: [1, 512, 32, 32]
print(f"C4 shape: {c4.shape}") # 预期: [1, 1024, 16, 16]
print(f"C5 shape: {c5.shape}") # 预期: [1, 2048, 8, 8]
print("-" * 20)
# 2. 将C3, C4, C5送入FPN,得到新的特征金字塔 P3, P4, P5
fpn_model = FPN(c3_channels=512, c4_channels=1024, c5_channels=2048, out_channels=256)
p3, p4, p5 = fpn_model(c3, c4, c5)
print("--- FPN输出 ---")
print(f"P3 shape: {p3.shape}") # 预期: [1, 256, 32, 32]
print(f"P4 shape: {p4.shape}") # 预期: [1, 256, 16, 16]
print(f"P5 shape: {p5.shape}") # 预期: [1, 256, 8, 8]
print("-" * 20)
print("可以看到,所有输出特征图的通道数都统一为了256,且尺寸与输入对应层相同。")
print("这些P3, P4, P5就是被送去预测的、融合了多尺度信息的全新特征图。")
第三部分:为什么还需要"自底向上?
我们先回顾一下FPN(自顶向下)做了什么:它让具有高层语义信息的特征图,去"帮助"具有高分辨率的低层特征图,解决了"低层特征不认识物体"的问题。
但是,这里面隐藏着一个信息传递的"小瑕疵":
信息路径过长 :我们想让最底层的特征C3(32x32
,定位最准)的精确位置信息,去帮助最高层的预测P5(8x8
)。在FPN架构中,这个信息需要先经过整个Backbone一路向上到C5,再经过FPN的路径一路向下传回到P3,路径非常长,途中的信息可能会丢失。
FPN的"自顶向下"C5把信息传达给C3。但是,C3发现了一个非常紧急、非常具体的本地情报,比如一个精确的像素级边缘,他需要一个快速上报通道"直接反馈给C5
"自底向上"的路径,就是这个为"定位信息"建立的快速上报通道。
第四部分:PANet - 实例教学
这个新增的路径,其思想主要来源于 PANet (Path Aggregation Network)。它在FPN生成的特征金字塔(P3, P4, P5)的基础上,立即开始工作。
FPN已经为我们生成了融合后的特征金字塔:
P3 : 尺寸 32x32x256
P4 : 尺寸 16x16x256
P5 : 尺寸 8x8x256
新增的"自底向上"步骤
步骤一:P3向P4"上报"信息
下采样 (Downsample): 我们从最底层的P3开始,因为它包含了最丰富的定位信息。我们通过一个步长为2的3x3
卷积,将P3的尺寸从32x32
缩小到16x16
。
融合 (Fusion): 将下采样后的P3,与FPN已经生成的P4进行拼接 (Concatenate)。
注意: 这里的融合方式通常是拼接 ,而不是相加。拼接可以看作是把两个信息渠道"并排"放在一起,保留了各自更完整的信息,然后再通过一个1x1
卷积进行降维和真正的融合。
融合后的结果,我们称之为N4 。现在 N4 (16x16x256
) 不仅拥有来自高层的语义信息(继承自P4),还拥有了从底层P3直接传递过来的精确定位信息。它比原来的P4更强大。
步骤二:新N4向P5"上报"信息
过程完全一样:
-
将刚刚生成的新N4通过一个步长为2的卷积,从
16x16
缩小到8x8
。 -
将下采样后的N4,与FPN生成的P5进行拼接和融合。
-
最终得到的结果,我们称之为N5 (
8x8x256
)。
最终成果: 经过"自顶向下(FPN)" + "自底向上(PANet)"这一个来回,我们得到了一套全新的、用于最终预测的特征金字塔:N3, N4, N5。
N3: 就是原始的P3。
N4: 是P4融合了来自N3的定位信息。
N5: 是P5融合了来自N4的(间接也包含了N3的)定位信息。
核心优势: 这条新增的路径,极大地缩短了精确定位信息(来自底层)到高层语义特征的传递路径。现在,无论是负责检测大、中、小物体的哪个预测头,都能同时享受到最好的语义信息和定位信息。
完整的颈部 (Neck) 流程如下: Backbone -> FPN (自顶向下) -> PANet (自底向上) -> 最终的特征金字塔 (N3, N4, N5)
第三部分:PAnet代码讲解
我们在上一节的代码基础上,扩展FPN
类,让它完整地包含PANet的路径。
骨干网络 ToyBackbone 和上一节完全一样,这里省略以保持简洁:
python
import torch
import torch.nn as nn
import torch.nn.functional as F
class ToyBackbone(nn.Module):
def __init__(self):
super().__init__()
self.conv_to_c3 = nn.Sequential(nn.Conv2d(3, 512, kernel_size=8, stride=8))
self.conv_to_c4 = nn.Sequential(nn.Conv2d(512, 1024, kernel_size=2, stride=2))
self.conv_to_c5 = nn.Sequential(nn.Conv2d(1024, 2048, kernel_size=2, stride=2))
def forward(self, x):
c3 = self.conv_to_c3(x); c4 = self.conv_to_c4(c3); c5 = self.conv_to_c5(c4)
return c3, c4, c5
实现一个包含 FPN + PANet 的完整颈部:
通过torch.cat
将两个特征图在通道维度上堆叠起来,然后立即使用一个1x1
卷积,一方面将翻倍的通道数降回原来的维度,另一方面更重要的是,通过可学习的权重,对来自不同源头的信息进行智能的、加权的融合,从而产生一个全新的、信息更丰富的特征图。
python
class FPN_PANet_Neck(nn.Module):
def __init__(self, c3_channels, c4_channels, c5_channels, out_channels=256):
super().__init__()
self.out_channels = out_channels
# --- FPN (自顶向下) 部分的层 ---
self.lat_c5 = nn.Conv2d(c5_channels, self.out_channels, kernel_size=1)
self.lat_c4 = nn.Conv2d(c4_channels, self.out_channels, kernel_size=1)
self.lat_c3 = nn.Conv2d(c3_channels, self.out_channels, kernel_size=1)
self.fpn_smooth = nn.Conv2d(self.out_channels, self.out_channels, kernel_size=3, padding=1)
# --- PANet (自底向上) 部分的层 ---
# 用于下采样的卷积
self.pan_downsample1 = nn.Conv2d(self.out_channels, self.out_channels, kernel_size=3, stride=2, padding=1)
self.pan_downsample2 = nn.Conv2d(self.out_channels, self.out_channels, kernel_size=3, stride=2, padding=1)
# 拼接后用于融合的卷积
self.pan_fuse1 = nn.Conv2d(self.out_channels * 2, self.out_channels, kernel_size=1)
self.pan_fuse2 = nn.Conv2d(self.out_channels * 2, self.out_channels, kernel_size=1)
前向传播:
python
def forward(self, c3, c4, c5):
# --- 1. FPN: 自顶向下路径 ---
p5 = self.lat_c5(c5)
p5_upsampled = F.interpolate(p5, scale_factor=2, mode='nearest')
p4 = self.lat_c4(c4) + p5_upsampled
p4_upsampled = F.interpolate(p4, scale_factor=2, mode='nearest')
p3 = self.lat_c3(c3) + p4_upsampled
# FPN 路径的输出(经过平滑)
p3 = self.fpn_smooth(p3)
p4 = self.fpn_smooth(p4)
p5 = self.fpn_smooth(p5)
# --- 2. PANet: 自底向上路径 ---
# P3 就是最终的 N3
n3 = p3
# 从 N3 到 N4
n3_downsampled = self.pan_downsample1(n3)
# 拼接 P4 和下采样后的 N3
n4_concatenated = torch.cat([n3_downsampled, p4], dim=1) # dim=1 是通道维度
n4 = self.pan_fuse1(n4_concatenated)
# 从 N4 到 N5
n4_downsampled = self.pan_downsample2(n4)
# 拼接 P5 和下采样后的 N4
n5_concatenated = torch.cat([n4_downsampled, p5], dim=1)
n5 = self.pan_fuse2(n5_concatenated)
# 返回最终用于预测的特征图
return n3, n4, n5
实例运行:
python
if __name__ == '__main__':
dummy_input = torch.randn(1, 3, 256, 256)
backbone = ToyBackbone()
c3, c4, c5 = backbone(dummy_input)
print("--- Backbone输出 ---")
print(f"C3 shape: {c3.shape}")
print(f"C4 shape: {c4.shape}")
print(f"C5 shape: {c5.shape}")
print("-" * 30)
# 实例化完整的颈部
neck = FPN_PANet_Neck(c3_channels=512, c4_channels=1024, c5_channels=2048, out_channels=256)
n3, n4, n5 = neck(c3, c4, c5)
print("--- FPN + PANet Neck 输出 ---")
print("这些是最终送入预测头的特征图:")
print(f"N3 (for small objects) shape: {n3.shape}")
print(f"N4 (for medium objects) shape: {n4.shape}")
print(f"N5 (for large objects) shape: {n5.shape}")
print("-" * 30)
print("虽然维度和单独使用FPN时一样,但N4和N5现在包含了从底层'快速上报'的更精准的定位信息。")