4、全卷积网络(FCN):从分类到分割的华丽转身
各位同学,咱们今天聊聊FCN。说实话,我第一次接触图像分割时,脑子里全是问号:分类网络好好的,干嘛非要改成全卷积?后来在项目中踩了坑才明白——分类网络输出的是一个类别标签,而分割需要的是每个像素的标签。这完全是两码事。
4.1 分类网络与分割网络的本质区别
先看个简单的对比:
| 对比项 | 分类网络 | 分割网络 |
|---|---|---|
| 输入 | 整张图像 | 整张图像 |
| 输出 | 一个类别标签(如:猫) | 与输入同尺寸的像素级标签图 |
| 核心问题 | 是什么? | 每个像素是什么? |
| 空间信息 | 丢弃(通过全连接层) | 保留(通过卷积层) |
说白了,分类网络最后那几个全连接层,把空间结构全毁了。你想想看,一张猫的图片,经过全连接层后,猫在左边还是右边,这个信息就丢了。但分割任务需要知道「猫的耳朵在左上角,眼睛在中间」这种精细的空间位置。
核心思想:FCN把分类网络最后的全连接层替换成卷积层,这样输出就不再是一个向量,而是一张「热力图」——每个位置对应原图的一个区域,值表示该区域属于某个类别的概率。
4.2 FCN原理:从全连接到全卷积
我记得第一次看FCN论文时,最震撼的就是这个转变。以VGG16为例,原本的结构是:
卷积层 → 卷积层 → ... → 全连接层(4096) → 全连接层(4096) → 全连接层(1000)
FCN的做法很简单粗暴:
卷积层 → 卷积层 → ... → 卷积层(4096) → 卷积层(4096) → 卷积层(1000) → 上采样
嗯,这里要注意:全连接层本质上就是卷积核大小等于输入特征图尺寸的卷积。所以替换后,参数数量不变,但输出变成了空间结构。
我的经验:实际项目中,我建议直接用预训练的分类模型权重来初始化FCN。这样能省下大量训练时间。我曾经试过从头训练,结果收敛慢得让人抓狂。
4.3 跳跃连接结构:为什么需要它?
直接替换全连接层后,有个问题:输出的热力图尺寸太小了。比如输入224x224的图像,经过VGG16的5次下采样后,特征图只有7x7。直接上采样32倍到原图大小,结果就是马赛克级别的粗糙分割。
为什么会这样?因为深层特征虽然语义强,但空间信息丢失严重。我刚开始做时,看到32倍上采样的结果,差点以为模型坏了——边界糊成一团。
FCN的解决方案是跳跃连接:
- FCN-32s:直接32倍上采样,效果最差
- FCN-16s:把pool4的特征(16倍下采样)和上采样2倍后的结果融合,再16倍上采样
- FCN-8s:进一步融合pool3的特征(8倍下采样),效果最好
说白了,就是把浅层的细节信息(边缘、纹理)和深层的语义信息(类别)结合起来。浅层特征分辨率高,但语义弱;深层特征语义强,但分辨率低。两者互补。
避坑指南:我曾经在融合时直接把特征图相加,结果发现浅层特征噪声太大,反而干扰了深层特征。后来改成先对浅层特征做1x1卷积降维,再相加,效果明显提升。
4.4 从分类网络到分割网络的转变步骤
如果你想把一个现成的分类网络改成FCN,可以按以下步骤操作:
- 去掉全连接层:把最后几个全连接层替换成卷积层
- 添加上采样层:用转置卷积或双线性插值把特征图放大到原图尺寸
- 添加跳跃连接:从中间层引出特征,与上采样后的特征融合
- 修改损失函数:从交叉熵改为像素级交叉熵
- 调整输出通道:输出通道数等于类别数(包括背景)
我的习惯:上采样我一般先用双线性插值初始化转置卷积的权重,这样训练初期更稳定。等模型收敛差不多了,再放开权重让网络自己学习。
4.5 代码示例:FCN-8s的核心实现
这里给一个简化的PyTorch实现,重点看跳跃连接的结构:
import torch.nn as nn
import torch.nn.functional as F
class FCN8s(nn.Module):
def __init__(self, num_classes):
super().__init__()
# 这里用预训练的VGG16作为backbone
# 省略具体卷积层定义...
# 分类器部分:把全连接层改成卷积
self.classifier = nn.Sequential(
nn.Conv2d(512, 4096, 7),
nn.ReLU(inplace=True),
nn.Dropout2d(),
nn.Conv2d(4096, 4096, 1),
nn.ReLU(inplace=True),
nn.Dropout2d(),
nn.Conv2d(4096, num_classes, 1)
)
# 跳跃连接的1x1卷积
self.score_pool4 = nn.Conv2d(512, num_classes, 1)
self.score_pool3 = nn.Conv2d(256, num_classes, 1)
def forward(self, x):
# 前向传播,记录pool3和pool4的特征
# ... 省略具体前向代码 ...
# 32倍上采样
score = self.classifier(pool5)
score = F.interpolate(score, scale_factor=2, mode='bilinear')
# 融合pool4
score_pool4 = self.score_pool4(pool4)
score = score + score_pool4
score = F.interpolate(score, scale_factor=2, mode='bilinear')
# 融合pool3
score_pool3 = self.score_pool3(pool3)
score = score + score_pool3
score = F.interpolate(score, scale_factor=8, mode='bilinear')
return score
注意:上采样时用align_corners=True还是False,会影响结果。我建议统一用True,这样像素对齐更准确。曾经因为这个参数没调好,分割结果偏移了半个像素,排查了半天。
4.6 FCN的局限性
虽然FCN是里程碑式的工作,但它也有明显缺点:
- 上采样粗糙:简单的双线性插值或转置卷积,无法恢复精细的边界
- 感受野固定:每个像素的预测只依赖局部区域,缺乏全局上下文
- 对小目标不友好:多次下采样后,小目标可能消失
不过话说回来,FCN奠定了「编码器-解码器」这个基本框架。后来的U-Net、DeepLab、SegFormer等模型,本质上都是在FCN的基础上做改进。你想想看,没有FCN这个开山之作,后面的分割网络可能要走很多弯路。
总结一下:FCN的核心贡献就三点——全卷积化、跳跃连接、端到端训练。这三个思想至今仍在影响着整个计算机视觉领域。我个人建议,初学者一定要亲手实现一遍FCN,哪怕只是跑通一个简单的demo。这个过程会让你对「空间信息」和「语义信息」的理解上一个台阶。
好了,FCN的内容就到这里。下一章咱们聊聊U-Net,看看它怎么在医学图像分割中大放异彩。