目标检测任务中最大的挑战之一就是如何处理图像中不同尺度的目标。想象一下你要在一张街景照片中同时识别远处的行人和近处的汽车——前者可能只有几十个像素大小,后者则占据了大半个画面。传统卷积神经网络在处理这种多尺度问题时往往会顾此失彼:深层网络擅长捕捉大目标的语义特征,却会丢失小目标的细节;浅层网络保留了丰富的空间信息,但缺乏高级语义理解。
ResNet通过残差连接解决了深层网络的梯度消失问题,让网络可以延伸到上百层。但即便这样,单靠ResNet提取的特征金字塔仍然存在语义鸿沟——深层特征虽然语义丰富但空间分辨率低,浅层特征分辨率高但语义表达能力弱。这就好比用望远镜看远处很清晰,但换成显微镜就找不到原来的观察目标了。
FPN(特征金字塔网络)的聪明之处在于构建了一条"信息高速公路",让高层特征可以顺畅地回流到低层。具体来说,它通过三个关键设计实现了多尺度特征的和谐统一:
实测表明,这种结构对小目标检测的提升尤为明显。在COCO数据集上,ResNet-50+FPN相比单尺度ResNet在小目标检测AP上能提高8-10个点,相当于把识别准确率从"大概猜得出"提升到"一眼就能确认"的水平。
残差模块的核心思想可以用一个公式概括:H(x) = F(x) + x。这里的x是输入特征,F(x)是卷积层要学习的变化量,H(x)是最终输出。这种设计巧妙地让网络只需要学习"差值"而不是完整的变换,就像教你骑自行车时,教练只需要纠正你偏离平衡的部分,而不需要重新教整个平衡技巧。
实际应用中有两种典型的残差块:
python复制# BasicBlock(用于ResNet-18/34)
class BasicBlock(nn.Module):
def __init__(self, in_channels, out_channels, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, out_channels, 3, stride, padding=1)
self.bn1 = nn.BatchNorm2d(out_channels)
self.conv2 = nn.Conv2d(out_channels, out_channels, 3, padding=1)
self.bn2 = nn.BatchNorm2d(out_channels)
# 当输入输出维度不一致时需要投影 shortcut
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != out_channels:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, out_channels, 1, stride),
nn.BatchNorm2d(out_channels)
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = self.bn2(self.conv2(out))
out += self.shortcut(x)
return F.relu(out)
对于更深的ResNet-50/101/152,则使用了Bottleneck结构来平衡计算量:
python复制class Bottleneck(nn.Module):
expansion = 4 # 最终输出通道是中间层的4倍
def __init__(self, in_channels, planes, stride=1):
super().__init__()
self.conv1 = nn.Conv2d(in_channels, planes, 1)
self.bn1 = nn.BatchNorm2d(planes)
self.conv2 = nn.Conv2d(planes, planes, 3, stride, padding=1)
self.bn2 = nn.BatchNorm2d(planes)
self.conv3 = nn.Conv2d(planes, planes * self.expansion, 1)
self.bn3 = nn.BatchNorm2d(planes * self.expansion)
self.shortcut = nn.Sequential()
if stride != 1 or in_channels != planes * self.expansion:
self.shortcut = nn.Sequential(
nn.Conv2d(in_channels, planes * self.expansion, 1, stride),
nn.BatchNorm2d(planes * self.expansion)
)
def forward(self, x):
out = F.relu(self.bn1(self.conv1(x)))
out = F.relu(self.bn2(self.conv2(out)))
out = self.bn3(self.conv3(out))
out += self.shortcut(x)
return F.relu(out)
这种"压缩-处理-扩展"的设计就像先浓缩咖啡再加水调节浓度,既保持了特征的表达能力,又大幅减少了3x3卷积的计算量。在ImageNet实验中,ResNet-50虽然比ResNet-34多了16层,但浮点运算量仅增加了约30%。
FPN的金字塔构建过程就像搭乐高积木,自底向上的部分(ResNet)生产各种形状的积木块,自顶向下的部分则把这些积木按特定规则组装起来。具体来说:
python复制# FPN的核心实现代码段
def _upsample_add(self, x, y):
"""上采样并相加两个特征图"""
_,_,H,W = y.size()
return F.interpolate(x, size=(H,W), mode='bilinear') + y
def forward(self, x):
# 自底向上路径
c2,c3,c4,c5 = self.backbone(x) # ResNet前向传播
# 自顶向下路径
p5 = self.toplayer(c5)
p4 = self._upsample_add(p5, self.latlayer1(c4))
p3 = self._upsample_add(p4, self.latlayer2(c3))
p2 = self._upsample_add(p3, self.latlayer3(c2))
# 平滑处理
p4 = self.smooth1(p4)
p3 = self.smooth2(p3)
p2 = self.smooth3(p2)
return p2, p3, p4, p5
FPN提升小目标检测的关键在于它实现了"鱼与熊掌兼得":
在具体实现时有个细节容易忽略:每个Pi生成后要经过3x3卷积消除上采样带来的混叠效应。这就像照片放大后要做锐化处理,消除边缘模糊。
下面是用PyTorch实现ResNet-50+FPN的完整代码框架:
python复制class ResNetFPN(nn.Module):
def __init__(self, num_layers=50):
super().__init__()
# 选择ResNet配置
if num_layers == 50:
block = Bottleneck
layers = [3, 4, 6, 3]
elif num_layers == 101:
block = Bottleneck
layers = [3, 4, 23, 3]
# ResNet基础部分
self.inplanes = 64
self.conv1 = nn.Conv2d(3, 64, 7, 2, 3, bias=False)
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)
self.maxpool = nn.MaxPool2d(3, 2, 1)
self.layer1 = self._make_layer(block, 64, layers[0])
self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
self.layer4 = self._make_layer(block, 512, layers[3], stride=2)
# FPN部分
self.fpn = FPN(block, layers)
def _make_layer(self, block, planes, blocks, stride=1):
downsample = None
if stride != 1 or self.inplanes != planes * block.expansion:
downsample = nn.Sequential(
nn.Conv2d(self.inplanes, planes * block.expansion, 1, stride),
nn.BatchNorm2d(planes * block.expansion)
)
layers = []
layers.append(block(self.inplanes, planes, stride, downsample))
self.inplanes = planes * block.expansion
for _ in range(1, blocks):
layers.append(block(self.inplanes, planes))
return nn.Sequential(*layers)
def forward(self, x):
# ResNet前向
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
c1 = self.maxpool(x)
c2 = self.layer1(c1)
c3 = self.layer2(c2)
c4 = self.layer3(c3)
c5 = self.layer4(c4)
# FPN前向
p2, p3, p4, p5 = self.fpn([c2,c3,c4,c5])
return p2, p3, p4, p5
在实际训练ResNet+FPN模型时,有几个关键配置点需要注意:
学习率策略:由于FPN的加入,初始学习率可以比纯ResNet稍大
python复制optimizer = torch.optim.SGD(model.parameters(),
lr=0.02, # 通常用0.01-0.02
momentum=0.9,
weight_decay=1e-4)
scheduler = torch.optim.lr_scheduler.MultiStepLR(optimizer,
milestones=[8, 11],
gamma=0.1)
特征层级选择:不同检测算法对金字塔层级的利用不同
归一化处理:FPN各层输出建议进行L2归一化
python复制for p in [p2,p3,p4,p5]:
p = p / (p.norm(dim=1, keepdim=True) + 1e-6)
Anchor设计:每个金字塔层级对应不同的anchor尺度
python复制# 典型配置
anchor_scales = [32, 64, 128, 256, 512] # 对应P2-P6
aspect_ratios = [0.5, 1.0, 2.0] # 每个位置3个anchor
在COCO数据集上的训练经验表明,合理调整这些参数可以使mAP提升2-3个百分点。特别是在小目标检测任务上,适当增加P2层的权重往往能带来意想不到的效果提升。