FCN-8s语义分割实战:原理、跳跃结构与PyTorch实现
1. 为什么语义分割入门绕不开FCN-8s语义分割的入门路径到今天依然绕不开FCN这套思路。大概几年前我做第一个像素级分类项目的时候跑通的第一个模型就是FCN-8s。当时最直观的感受是它没有那么多花哨模块只要你理解了VGG和上采样整个网络怎么搭、怎么训练、怎么调参基本都能串起来。先解释一下这个标题里的两个关键词。FCN全称Fully Convolutional Network全卷积网络核心动作是把分类网络里最后几个全连接层全部替换成卷积层。FCN-8s这个后缀里的“8s”指的是网络最终的预测结果只需要再放大8倍就能回到原图分辨率。它不像FCN-32s那样只从最深层粗暴放大也不像FCN-16s只融合一层中间特征而是把pool3之后的浅层信息也用上了所以边缘细节是三兄弟里最好的。这篇文章适合两种人。一种是从图像分类、目标检测刚转过来想搞懂分割任务的完整流程另一种是跑过FCN但一直没弄明白跳跃结构为什么要这样设计的。读完以后你不仅能说清楚FCN-8s的原理还能直接跑通一个VOC数据集的训练Demo知道踩坑时该往哪个方向排查。1.1 分类网络和分割网络的核心差异图像分类网络最后得到的是整个图像属于哪个类别的概率这是一个全局判断。比如一张图里有一只猫分类网络说“这是猫”就够了不需要知道猫在图像哪个位置。但语义分割要求给每一个像素都打上类别标签既要认识“这是猫”又要定位“猫的轮廓落在哪些像素上”。这两个任务对网络结构的要求完全不同。第一点分割网络不能丢失空间位置信息。分类网络里常用的全连接层会展平特征图把二维空间结构压成一条很长的向量。向量一旦产生位置感就没了。FCN给出的解法是用1×1或7×7卷积替换全连接层让特征图从头到尾保持二维结构。第二点输出尺寸必须和输入保持一致。分类任务最后输出一个一维概率分割任务最后却要输出一张和原图同样大小的像素级标签图所以必须引入上采样操作。打个比方全连接层像一个只记总数、不看分布的管理员卷积层则像一张带坐标的地图。分类只看总数分割必须对照地图。这也是为什么没有空间结构就做不了分割。1.2 FCN三兄弟的定位区别FCN-32s、FCN-16s、FCN-8s骨干结构完全一样区别只在上采样路径和跳跃结构。32s最简单把pool5之后的预测直接放大32倍网络轻但细节基本靠脑补16s在32s基础上把pool4的中间预测和pool5上采样后的结果相加放大16倍输出细节好一些8s则再把pool3加进来经过两级融合后放大8倍输出。你可以把这三个版本理解成不同精度的拼图。32s只有最大的那几块拼图拼完轮廓都是模糊的16s加了中等块能看出大概形状8s把最小块也加上了边缘能对上缝。所以FCN论文里最终推荐的就是8s版本这也是系列教程选它作为实战对象的原因——它完整展示了跳跃结构但又没有引入注意力、空洞卷积这些后来的复杂概念是理解语义分割最干净的一堂课。版本融合的特征层最终上采样倍数边缘细节计算开销FCN-32s仅pool532倍粗糙最低FCN-16spool5 pool416倍中等中等FCN-8spool5 pool4 pool38倍精细最高2. 拆解FCN-8s的关键设计FCN-8s看起来只是一个VGG16的改装版但里面三个设计点值得反复琢磨全卷积化改造、上采样策略、跳跃结构。把这三点吃透后续看DeepLab、PSPNet那些模型都会轻松很多。2.1 全连接层怎么改成卷积层VGG16原本的结构包括五段卷积和三个全连接层。全连接层fc6接收池化后的特征在输入为224×224时pool5输出的尺寸是7×7×512展平后得到25088个数值。fc6把它映射到4096维fc7继续映射到4096维fc8最终映射到1000类。FCN的改造分三步。第一步把fc6直接替换成一个7×7卷积输入通道512输出通道4096padding设为3这样7×7的特征图经过卷积后尺寸不变。第二步fc7替换成1×1卷积把特征从4096维映射到4096维这里的“维”依然保留在通道维度上。第三步fc8替换成1×1卷积输出通道改为类别数VOC数据集就是21。为什么一定要换成卷积因为全连接层的参数量是固定的它要求输入尺寸固定卷积层则可以在任意尺寸上滑动计算。换成卷积之后模型就可以接受任意分辨率的输入这在推理时非常实用。更关键的是卷积操作保留了每个位置对应的空间关系刚才那张7×7的特征图虽然经过多次映射但每个点依然对应着原图的一小块区域这就是“位置没有被压扁”的含义。2.2 上采样从7×7回到244×224级别的思路先说明这里用244是笔误实际以224×224输入为例。上采样在FCN里的正经名字叫转置卷积也叫反卷积。它不是把像素复制粘贴而是通过学习找到一种映射方式把低分辨率特征放大到高分辨率。虽然名字里带“反”但它并不是卷积的严格逆运算更像一个可学习的插值器。FCN作者在论文里提醒过一个关键点上采样层的卷积核可以用双线性插值来初始化然后再让网络自己微调。这个做法很聪明因为如果随机初始化网络一开始会输出一堆噪声训练很久才能学会基本插值如果用双线性插值初始化相当于模型出生就自带一套不错的放大方案后面只是在这个基础上补细节。如果你用PyTorch的nn.ConvTranspose2d实现上采样需要自己写一个双线性初始化。核心思路是构造一个kernel_size×kernel_size的二维权重矩阵中心位置权重最高向四周线性衰减。实际训练中很多人也会直接用F.interpolate配modebilinear做上采样那个是不可学习的但胜在简单稳定入门时先用它跑通流程没有太大问题。2.3 跳跃结构细节来自浅层FCN-8s的跳跃结构是整篇论文最精华的部分。深层特征经过多次卷积和池化后语义信息很丰富但分辨率极低比如pool5输出只有原图的1/32。浅层特征恰恰相反pool3还保留着原图的1/8分辨率边缘轮廓清楚但语义抽象程度不够。FCN的做法是给不同深度的特征分别加一个1×1卷积先把通道数统一到类别数这样每层特征都变成“每个位置对各类别的一份预测”。然后按顺序相加pool5的输出先上采样2倍和pool4的预测相加相加结果再上采样2倍和pool3的预测相加最后整体上采样8倍得到和输入同尺寸的预测结果。这里有一个细节很容易被忽略相加的前提是两张特征图尺寸、通道数完全一致。所以每个跳跃分支都要先做1×1卷积把浅层特征的512通道、256通道全部压成21通道上采样尺寸也要对齐才能执行逐元素相加。如果直接拼接再卷积参数量会更大效果也不如这种“先打分再累加”的方式干净。3. 环境准备与数据预处理FCN-8s代码本身不复杂但训练环境、数据组织方式却是新手最容易卡壳的地方。我见过不少同学代码写得没问题最后载入数据时标签读成一整张彩色图损失函数直接报错。这一节把环境、数据处理和损失函数的细节讲透。3.1 环境配置清单先说最低硬件条件。FCN-8s用的是VGG16骨干参数规模不小训练时占用显存也相对夸张。如果你只有一张8GB显存的卡建议输入尺寸降到256×256batch size设为4然后开启混合精度如果显存有12GB以上可以直接上512×512和batch size 8。软件环境建议用PyTorch 1.10以上的版本配套CUDA 11.x。有两件事务必提前确认一是PyTorch和CUDA版本要匹配二是安装完成后先跑一段torch.cuda.is_available()确认GPU被识别。很多训练中断的案例不是模型写错而是环境里压根没启用GPU。如果只有CPU环境理论上也能跑但训练速度会慢几十倍不建议做完整训练。3.2 VOC数据集怎么组织FCN最经典的数据集是PASCAL VOC 2012。它包含20个物体类别加1个背景共21类。数据组织方式有两大部分JPEGImages里放原始图片SegmentationClass里放对应的分割标签图。标签图是PNG格式每个像素的值直接是类别编号比如0表示背景1表示飞机15表示人。读取分割标签时有一个最常见的坑cv2.imread默认会把PNG读成三通道BGR图但分割标签本质是单通道索引图。如果你直接用三通道去算损失交叉熵会报维度错误。正确做法是用cv2.imread(path, cv2.IMREAD_GRAYSCALE)或PIL读完后转成灰度然后再把标签变成torch.long型。数据增强方面分割任务有四个操作足够用随机水平翻转、随机缩放然后裁剪到固定尺寸、随机亮度饱和度调整、最后按ImageNet的均值和标准差做归一化。注意缩放时要保证图像和标签做同样的变换如果图像缩放了而标签没跟着变模型会学到错误对应关系。3.3 损失函数和评估指标的选择分割任务最常用的损失是像素级交叉熵。每个像素的预测都算一个交叉熵最后取平均。PyTorch里直接调nn.CrossEntropyLoss即可它会自动在类别维上做softmax。VOC标签中会有一部分区域被标记为255这些是标注时难以确定的边界像素训练时要通过ignore_index255把它们排除掉否则模型会被这些噪声像素干扰。评估指标不要只看准确率。比如一张图里背景占90%飞机只占10%就算全部预测成背景也有90%准确率但实际毫无用处。语义分割的标准指标是mIoU计算方式是先统计每个类别的混淆矩阵然后用交集除以并集。比如飞机这个类别模型预测出来的飞机像素和真实飞机的像素求交集和并集比值就是该类IoU全部类别平均之后就是mIoU。这个指标对类别不平衡更敏感是分割论文里最常见的评价标准。4. FCN-8s的PyTorch代码实现理论说再多不如代码跑一遍。这一节我会给出一个能直接运行的FCN-8s实现并对关键代码逐段解释。代码基于torchvision的VGG16预训练权重只改网络结构不重新训练骨干。4.1 骨干网络搭建VGG16的features部分是一个连续的nn.Sequential。要提取pool3、pool4、pool5三层的输出最清晰的方式是按池化层切分成五个阶段。切分时注意索引features[0:4]是前两个卷积加ReLU不含pool1features[5:9]是conv2层的卷积不含pool2依此类推。每个stage后面再接一个MaxPool2d保证输出逐级减半。import torch import torch.nn as nn import torch.nn.functional as F from torchvision import models class FCN8s(nn.Module): def __init__(self, num_classes21, pretrainedTrue): super().__init__() # 加载VGG16并取出卷积部分 vgg models.vgg16(pretrainedpretrained) features list(vgg.features.children()) # 按池化层切分骨干网络 self.stage1 nn.Sequential(*features[0:4]) # conv1_x输出1/2前 self.pool1 nn.MaxPool2d(2, 2) self.stage2 nn.Sequential(*features[5:9]) # conv2_x self.pool2 nn.MaxPool2d(2, 2) self.stage3 nn.Sequential(*features[10:16]) # conv3_x self.pool3 nn.MaxPool2d(2, 2) self.stage4 nn.Sequential(*features[17:23]) # conv4_x self.pool4 nn.MaxPool2d(2, 2) self.stage5 nn.Sequential(*features[24:30]) # conv5_x self.pool5 nn.MaxPool2d(2, 2) # 将VGG16的全连接层改造为卷积形式 self.fc6 nn.Conv2d(512, 4096, kernel_size7, padding3) self.fc7 nn.Conv2d(4096, 4096, kernel_size1) self.score_fr nn.Conv2d(4096, num_classes, kernel_size1) # 跳跃结构的1x1卷积统一通道数 self.score_pool4 nn.Conv2d(512, num_classes, kernel_size1) self.score_pool3 nn.Conv2d(256, num_classes, kernel_size1) # 加载预训练全连接权重到卷积层 if pretrained: clf vgg.classifier self.fc6.weight.data.copy_( clf[0].weight.data.view(4096, 512, 7, 7)) self.fc6.bias.data.copy_(clf[0].bias.data) self.fc7.weight.data.copy_( clf[3].weight.data.view(4096, 4096, 1, 1)) self.fc7.bias.data.copy_(clf[3].bias.data)这里要解释一下clf[0]和clf[3]是怎么回事。VGG16的classifier模块里索引0是第一个全连接层索引1是ReLU索引2是Dropout索引3是第二个全连接层。所以从索引3取权重才是fc7。很多新手复制网上的代码时直接写clf[1]结果维度对不上训练时默默报错这一处值得格外注意。4.2 前向传播和跳跃连接forward部分严格按FCN-8s流程走先通过五段卷积池化拿到pool3、pool4的输出然后过fc6、fc7和最后的1×1分类卷积接着做两次2倍上采样和一次8倍上采样中间插入pool4和pool3的预测结果。def forward(self, x): h self.pool1(self.stage1(x)) # 1/2 h self.pool2(self.stage2(h)) # 1/4 h self.pool3(self.stage3(h)) # 1/8 pool3_out h h self.pool4(self.stage4(h)) # 1/16 pool4_out h h self.pool5(self.stage5(h)) # 1/32 # 模拟原VGG的全连接层 h F.relu(self.fc6(h)) h F.relu(self.fc7(h)) h self.score_fr(h) # 21 × 1/32尺寸 # 第一段跳跃上采样2倍后与pool4预测融合 h F.interpolate(h, scale_factor2, modebilinear, align_cornersTrue) h h self.score_pool4(pool4_out) # 第二段跳跃上采样2倍后与pool3预测融合 h F.interpolate(h, scale_factor2, modebilinear, align_cornersTrue) h h self.score_pool3(pool3_out) # 最终上采样8倍回原图分辨率 h F.interpolate(h, scale_factor8, modebilinear, align_cornersTrue) return h用224×224输入走一遍流程pool3输出是256×28×28pool4输出是512×14×14经过fc6后还是7×7尺寸score_fr变成21×7×7。第一次上采样成21×14×14与score_pool4相加第二次上采样成21×28×28与score_pool3相加最后8倍上采样回21×224×224。每一步尺寸都能对上这就是跳跃结构能成立的关键。如果你想更贴近原始论文可以把三次F.interpolate换成nn.ConvTranspose2d分别用4倍、4倍、16倍尺寸的卷积核并用双线性插值初始化卷积核。这样上采样过程就变成可学习的参数理论上有更强的拟合能力但训练也更容易震荡。入门阶段用双线性插值稳定性更好我也一直建议先把流程跑通再考虑升级。4.3 训练脚本与验证循环模型定义好了训练代码反而简单。关键配置如下优化器用Adam初始学习率设为1e-4比分类任务的常用学习率低一个数量级损失函数用带ignore_index255的交叉熵batch size按显存调整通常4到8。model FCN8s(num_classes21, pretrainedTrue).cuda() criterion nn.CrossEntropyLoss(ignore_index255) optimizer torch.optim.Adam(model.parameters(), lr1e-4) for epoch in range(epochs): model.train() running_loss 0.0 for images, masks in train_loader: images images.cuda() masks masks.long().cuda() outputs model(images) # (B, 21, H, W) loss criterion(outputs, masks) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() print(fEpoch {epoch1}/{epochs}, Loss: {running_loss/len(train_loader):.4f})这里有个常见的困惑为什么交叉熵的输入不需要手动做softmax因为nn.CrossEntropyLoss内部已经包含softmax计算你直接传原始logits就行自己再加softmax反而会导致梯度计算问题。5. 训练调参与常见问题排查训练分割模型和训练分类模型完全是两种体验。分类模型损失曲线一般很顺分割模型却经常遇到“看起来在学但效果一塌糊涂”的情况。这一节把我踩过的坑按症状整理出来方便你对照排查。5.1 显存不够用的应急方案FCN-8s最吃显存的地方不是卷积计算而是最后那张21通道的高分辨率特征图。输入512×512时输出也是21×512×512光这块就占掉几十MB叠加上中间梯度显存压力可想而知。三个有效手段第一把输入裁剪到256×256这是最直接的办法第二打开PyTorch的混合精度训练用torch.cuda.amp的GradScaler能在几乎不影响精度的前提下省一半显存第三冻结骨干网络的前几层将stage1、stage2参数的requires_grad设为False这部分参数不再参与梯度计算显存占用大幅下降。冻结前几层的逻辑不只为了省显存。VGG浅层学到的都是边缘、纹理这类通用特征在分割任务里同样适用完全没必要从头再训。很多实战项目直接把整个骨干都冻结只训练分类层和跳跃分支也能有不错的收敛效果。5.2 损失一直在降预测结果却是花的这个症状最常见的原因是归一化不一致。训练时你用的是ImageNet均值标准差做归一化推理时如果忘了对输入做同样处理模型看到的分布完全不同输出自然一团乱。排查办法很简单把推理代码里的预处理部分和训练代码逐行对比尤其是transforms.Normalize那一行。另一个原因出现在标签对齐上。数据加载时如果开启了随机裁剪图像裁到左上角标签裁到右下角那模型永远学不到对应关系。我习惯把图像和标签拼成一个tuple同时传给同一个随机变换函数这样能保证它们经历完全相同的几何变换不会出现错位。5.3 预测结果全是黑色或者全是背景预测输出会经过softmax再取argmax得到类别索引。如果你直接把输出拿到cv2.imshow或者保存成图片看见的几乎是一张黑图这不是模型坏了而是你看到的只是索引图。背景类的索引是0所以大部分区域显示为黑色是正常的。正确可视化流程是取到argmax结果后再用VOC官方调色板把每个索引映射成对应的RGB颜色。比如索引15映射成特定的人体颜色索引0映射成黑色背景。如果调色板映射正确整张图看起来就是彩色的。如果整张图几乎全是一个类才需要怀疑模型有没有真正收敛。5.4 边缘细碎和类别不平衡刚跑出来的FCN-8s边缘大概率是锯齿状的这是双线性插值和池化丢失位置细节的必然结果。解决办法一般是加大训练轮数让跳跃结构中的浅层分支充分拟合。如果你发现锯齿特别严重可以检查一下是不是pool3之后的跳跃分支权重没有正确参与更新——打印model.score_pool3.weight.grad看看是不是有梯度如果梯度为None说明反向传播没有走到这一层。类别不平衡的问题在VOC上不算严重但如果是自建数据集某个类占比极小损失会被大类别主导。这时可以给交叉熵加类别权重把占比小的类别的损失权重调高。计算方式建议统计训练集每个类别的像素占比然后按占比的倒数归一化使用。为了让你快速对照我把常见问题整理成一张速查表症状首要排查点次要排查点损失不下降学习率是否过大标签是否有ignore区域干扰预测全黑是否缺少调色板映射模型是否真的收敛显存溢出输入尺寸是否过大是否开了混合精度训练和验证效果差距大数据增强是否过强是否冻结了过多层带返回NaN学习率是否过大数据里是否存在异常标签6. 推理、可视化与mIoU评估训练完成只是第一步。分割模型上线或提交结果之前一定要跑一遍可视化再计算mIoU。没有mIoU的算法终稿基本等于没有评估。6.1 一次完整的推理流程推理时模型切到eval模式用torch.no_grad()包裹计算。输入图像预处理成和训练时一致resize到固定尺寸、转Tensor、归一化。拿到模型输出后在类别维度上做softmax得到每个像素属于每个类别的概率然后用argmax取概率最大的那个类别。model.eval() with torch.no_grad(): output model(image.unsqueeze(0)) # (1, 21, H, W) prob F.softmax(output, dim1) # 在类别维做softmax pred prob.argmax(dim1).squeeze(0) # (H, W)每个像素是0-20整数然后定义一个调色板长度等于类别数每个索引对应一个RGB三元组。VOC官方调色板有具体定义网上可以直接查到完整列表。拿到pred后把它从索引图映射到彩色图color_mask torch.zeros(3, pred.size(0), pred.size(1)) for cls_idx in range(num_classes): color_mask[0][pred cls_idx] palette[cls_idx][0] color_mask[1][pred cls_idx] palette[cls_idx][1] color_mask[2][pred cls_idx] palette[cls_idx][2]最后把彩色图和原图按一定透明度叠加就能非常直观地看到哪些区域被分到了哪个类别。6.2 mIoU计算方法和代码逻辑mIoU的计算思路不复杂建立混淆矩阵统计每个类别真实值和预测值的组合关系。用sklearn的confusion_matrix快速实现即可但要注意标签中的255要过滤掉不能参与统计。对于每一个类别IoU等于IoU 该类预测正确像素数 / (该类真实像素数 该类预测像素数 - 该类预测正确像素数)这里的“预测正确像素数”就是混淆矩阵对角线上对应位置。把所有21个类别的IoU求平均得到mIoU。一个训练充分的FCN-8s在VOC验证集上大约能到65%左右这个数字作为入门基准很合理。如果比这个值低很多优先检查数据预处理和训练配置模型结构本身出问题的概率不大。6.3 FCN-8s与后续分割算法的对比跑通FCN-8s以后可以横向看看后续算法。DeepLab系列引入了空洞卷积在不降低分辨率的前提下扩大感受野让网络能捕获更全局的上下文信息PSPNet加入了金字塔池化模块用多个尺度的池化来汇总全局场景U-Net则把跳跃结构做成了编码器解码器完全对称的形状在医学图像领域尤其流行。它们各自的思路都可以追溯到FCN打下的基础。FCN告诉你“全卷积化上采样跳跃结构”是可行的后来的工作大多是在回答同一个问题的不同侧面如何保留更多细节如何获得更大感受野如何更高效融合多层次特征。这就是为什么我说FCN是语义分割的必修课——它不是最先进的却是最值得从零手写的。7. 从入门到实战的几点心得如果让我给刚入门语义分割的同学一个建议那就是不要一上来就追求刷高mIoU先把FCN-8s在一张图上跑通再逐步加复杂模块。我当初第一次训练时损失函数降得不错但可视化出来边缘全是糊的一度怀疑代码写错了。后来对照论文和开源实现才发现只是训练轮数太少pool3层的融合权重还没学到足够细节。还有一个好用的小技巧在训练过程中定期保存checkpoint同时把当前epoch的可视化结果保存到本地。这样哪怕训练中断你也能从最近的图像里看出模型是在稳定改善还是已经发散。分割训练比分类慢得多跑一次完整实验可能要几个小时没有好的断点续训习惯会浪费很多时间。最后补充一点FCN-8s的实战价值直到今天也没有完全归零。很多工业项目里如果类别少、数据量不大、算力有限这个模型依然是一个可靠基线。你可以用它快速验证一套数据的可分割性再决定有没有必要上更重的模型。先跑通它你就拥有了整个语义分割领域的入场券。