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,可以按以下步骤操作:

  1. 去掉全连接层:把最后几个全连接层替换成卷积层
  2. 添加上采样层:用转置卷积或双线性插值把特征图放大到原图尺寸
  3. 添加跳跃连接:从中间层引出特征,与上采样后的特征融合
  4. 修改损失函数:从交叉熵改为像素级交叉熵
  5. 调整输出通道:输出通道数等于类别数(包括背景)

我的习惯:上采样我一般先用双线性插值初始化转置卷积的权重,这样训练初期更稳定。等模型收敛差不多了,再放开权重让网络自己学习。

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,看看它怎么在医学图像分割中大放异彩。