ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

语义分割入门:从FCN到U-Net的结构原理与实战指南

语义分割入门:从FCN到U-Net的结构原理与实战指南

做图像分割绕不开两个名字:全卷积神经网络和U-Net。全卷积神经网络第一次把分类网络真正改造成逐像素的密集预测模型,U-Net则在数据量并不宽裕的场景下把分割精度抬到了实用级别。这篇文章不打算贴一堆论文公式,而是从我自己跑通分割项目的角度,把这两条经典路线的结构原理、关键参数和踩坑记录整理出来,适合刚接触语义分割、或者想在某个小数据集上快速搭出可用分割模型的同学参考。

先说个普遍现象:很多人第一次看FCN论文时,最困惑的不是模型结构,而是“为什么分类网络不能直接用来分割”。你让一个分类网络输出“这是一只猫”很简单,但要让网络输出“这个像素属于猫的舌头、这个像素属于背景”,它就需要保留空间位置信息,而常规分类网络恰恰在最后把空间信息全部压平了。FCN解决的就是这件事,方法也很朴素:把全连接层扔掉,换成卷积层。后面所有的变形,包括U-Net,本质上都是在修补这个方案丢掉的细节。

1. 先搞清楚FCN到底要解决什么问题

1.1 分类和分割的差别不是“多一个类别”

分类任务输入一张图,输出一个标签,网络只需要知道“图里有什么”,不需要知道“在哪儿”。所以常见分类网络会在最后接一个全局池化或全连接层,把特征图压缩成固定长度的向量,空间坐标信息在这个阶段基本被丢掉。

分割任务不一样。它输出的是一张和输入尺寸相同的图,每个像素都有自己的类别。这要求网络在计算过程中既要有足够的语义信息(知道这个区域是猫还是狗),又要有足够的空间信息(知道猫的边界具体在哪个像素)。换句话说,分割是一个“既要知道是什么,又要知道在哪”的任务。

如果用分类网络强行改成分割模型,最直接的做法是把最后的全连接层去掉,保留前面的卷积特征图,但问题立刻出现:经过多次下采样后,特征图只有原图的1/32大小,直接往上放大,边界细节早就丢了。FCN的核心贡献不是发明了什么新模块,而是明确提出一套“全卷积化+跨层融合”的解决框架。

1.2 为什么非要把网络改成“全卷积”

FCN的全称是Fully Convolutional Network,关键在“全”字。常规分类网络最后用全连接层输出类别概率,全连接层要求输入尺寸固定,所以训练时必须把图像缩放到统一大小。全卷积网络里所有层都是卷积,卷积对输入尺寸没有硬性要求,理论上可以接受任意大小的图。

这个特性对分割非常重要。分割模型推理时面对的真实图像尺寸往往五花八门,如果模型只能在固定尺寸上工作,就得强行resize,结果就是物体比例变形、边界错位。全卷积化让模型天然具备尺度灵活性,虽然实际工程里还会因为显存原因固定输入尺寸,但结构上不再被全连接层锁死。

另一个原因是计算效率。全连接层的参数数量巨大,占了分类网络一大半参数量,但计算出的特征却高度抽象、缺乏位置信息。换成1x1卷积之后,参数量大幅下降,还能让网络在特征图上滑动计算,效率高得多。FCN-32s就是在VGG16的基础上把最后两个全连接层换成卷积层得到的,结构改动很小,效果却是质变。

1.3 下采样带来的空间信息丢失问题

全卷积化只是第一步。VGG这类主干网络为了获得更大的感受野,会连续做池化或步长卷积,特征图尺寸一路缩小到1/32。这时候网络对“图像里有什么”已经非常清楚,但对“东西在哪”越来越模糊。

FCN的做法是反卷积还原尺寸,但直接从1/32放大回原图,边缘会非常粗糙,因为每个输出像素都受到特征图上很大一块区域的影响,相当于把信息糊在一起了。为了补救,FCN引入了跳跃结构,把浅层的高分辨率特征图融合进来,利用浅层特征保留的边界信息把分割结果修细。

这条“深层语义+浅层细节”的组合思路,后来几乎成了所有分割网络的基本盘。

2. FCN的三大核心操作:卷积化、上采样、跳跃连接

2.1 1x1卷积代替全连接

FCN把VGG16最后的两个全连接层分别改成了7x7卷积和1x1卷积,这一步看起来很轻,但意义重大。全连接层把特征图拉伸成向量后,神经元之间是密集连接,每个位置的权重都不同,相当于强行把空间结构打散。卷积层则保持特征图的二维结构,1x1卷积还在但权重共享,让网络继续以空间滑动的方式理解图像。

实际操作中,有人会直接把全连接层删掉,在主干网络后面接一个1x1卷积把通道数映射到类别数,再接上采样。这样更省参数,但语义特征可能不够强。FCN原文保留了第一层全连接的卷积化版本,让网络保持足够大的感受野和语义抽象能力,再输出分割图。工程上你按哪种方式做都行,差异主要在精度和显存之间权衡。

2.2 转置卷积:把特征图放大回去

FCN里最直观的新概念是转置卷积(也叫反卷积)。普通卷积把特征图越卷越小,转置卷积则相反,通过补零和稀疏计算把特征图放大。比如输入7x7的特征图,用64x64的卷积核、步长32、不加填充,输出的尺寸就是 (7-1)*32+64=256,正好放大了32倍。

转置卷积的权重是可以学习的,网络会在训练中自己决定怎么把高层特征“铺”回原图尺寸。但要注意,它不是真正意义上的逆卷积,只是形状上的逆操作,数值上不保证还原原来的特征图。也正因为它和插值放大不一样,是网络自己学出来的上采样方式,FCN才能在不同数据集上自适应地恢复结构。

我用过一个直观类比:双线性插值像用画笔把低分辨率草图放大涂色,颜色过渡是平滑的;转置卷积更像一个复杂的喷绘程序,它能学习到物体的纹理和边界特征,放大后的结果可以有明显的边缘和内部结构。当然,转置卷积也有副作用,后面会单独讲棋盘伪影的问题。

2.3 三种跳跃结构:32s、16s、8s

只看FCN-32s的话,分割结果语义清晰但边界很烂。原因很简单:最后那层特征图经过了5次池化,一个像素对应原图32x32的区域,边缘位置被平均掉了。FCN作者随后提出融合浅层特征,于是有了FCN-16s和FCN-8s。

FCN-32s:最后一层特征图直接上采样32倍到原图尺寸。FCN-16s:最后一层先上采样2倍,然后和池化第4层(原图1/16分辨率)的特征图相加,再一起上采样16倍。FCN-8s:在上面基础上再把融合后的结果上采样2倍,和池化第3层(原图1/8分辨率)的特征图相加,最终上采样8倍。

融合的浅层特征越多,边界细节越好,但也不是越浅越好。池化第3层已经很靠近输入,特征里低级纹理信息占比高,再往前的特征对语义分类帮助不大,反而可能引入噪声。所以FCN通常止步于8s,实际项目中我更多直接用8s模型,32s基本只用来理解原理。

跳跃连接的融合方式在FCN里是逐元素相加,U-Net里改成了通道拼接,后者保留的信息更完整,但参数量和显存占用也更大。

2.4 FCN三个版本的实测感受

我自己在某个城市道路分割数据集上做过对比:FCN-32s的mIoU大概只有60出头,边界处错分同样严重;换成FCN-8s之后mIoU能到68左右,道路两侧的锯齿状边缘明显改善。语义上三者差别不大,真正常见的问题反而是背景像素太多,导致类别不平衡。所以FCN层面我更关注的不是跳连版本,而是损失函数设计和类别权重调整。

3. U-Net:为小数据和精细分割设计的编码-解码结构

3.1 U形结构的本质是“对称的信息通道”

U-Net的结构画出来就是字母U:左边是编码器,逐层下采样,通道数翻倍;右边是解码器,逐层上采样,通道数减半;中间通过跳跃连接把编码器每一层的特征直接拼到解码器对应层。

如果把FCN比作一条信息单向流动的高速公路,U-Net就是一条有“回程匝道”的双向路网。编码器负责把图像压缩成语义强的特征,解码器负责把这些特征逐步还原成精细的分割图,跳跃连接则把编码器在每一层看到的细节原样送到解码器手中。这样解码器在做上采样的过程中,不仅依赖高层语义,还能直接看到同尺度的低层特征,边缘和纹理信息不再需要从零重建。

这个设计的厉害之处在于:即使训练数据很少,解码器也能依靠跳跃连接“抄近路”获取定位信息,而不需要完全靠高层语义硬猜边界。医学图像分割里经常只有几百张训练图,U-Net依然能训练得很好,很大程度就是靠这个结构。

3.2 裁剪拼接:边界效应的处理细节

U-Net论文里有个细节经常被忽略:每次上采样后和编码器特征拼接前,需要把编码器特征做中心裁剪。原因很简单,下采样时碰到奇数尺寸,特征图会多出一圈边缘像素,这些像素对应的感受野不完整,直接拼接会让解码器学到错误的空间对应关系。

现在很多框架的U-Net实现已经规避了这个问题,通过调整卷积填充方式保持尺寸完全对齐,就不需要裁剪了。但理解裁剪的动机很重要:它反映了U-Net设计者对空间对齐的严格要求,也解释了为什么在工程上用U-Net时,输入尺寸一般要设置成16的倍数,这样4次下采样后尺寸正好是整数。

3.3 为什么U-Net不用复杂的池化策略

对比一下就知道,U-Net的下采样非常朴素,就是2x2的最大池化,没有空洞卷积,没有可变形卷积。原因有两个:一是医学和工业图像分割任务里,目标尺寸相对稳定,不需要处理特别大的尺度变化;二是朴素结构参数少、训练稳定,在数据量有限的情况下不容易过拟合。

我自己的经验是,U-Net这类模型先别急着堆高级模块,把基础结构训练收敛后,再根据具体问题逐项加改进,比如在编码器最后接空洞卷积扩大感受野,或在跳跃连接上加注意力模块。一上来就塞一堆新玩意,反而分不清是哪个改动起作用。

3.4 原始U-Net与现代实现的差异

原始U-Net并没有使用批量归一化(BatchNorm),因为医学图像batch通常很小,BN在小batch下统计量不稳定。现代实现里很多人会加BatchNorm或InstanceNorm,效果整体是有提升的。二分类分割任务中,如果把输入图归一化到0~1,网络对初始化和学习率比较敏感,适当加BN会明显改善收敛速度。

此外很多实现把原始U-Net里的“上卷积”(转置卷积)改成了双线性插值+3x3卷积,用来避免转置卷积带来的棋盘伪影。两种做法各有支持者,实测中插值+卷积的方式在图像尺寸较大的任务里更稳,转置卷积在小目标分割上略好,建议两类都试一下再定。

4. 实操:从零训练一个U-Net分割模型

4.1 数据准备:掩码怎么做、尺寸怎么设

分割训练数据是图像加掩码,掩码就是一张和原图同尺寸的图,每个像素的灰度值代表类别索引。做二分类分割时,背景是0,目标是1。做多分类时,目标类别从1开始递增,背景保持0。注意掩码不要用RGB颜色图直接喂给网络,要先转成索引图。

输入尺寸建议设置成16的倍数,比如256x256或512x512。尺寸太小边界细节损失严重,尺寸太大显存吃不消。我在某个卫星遥感项目里试过切patch训练:把原图切成256x256的小块,每块之间留一定重叠,推理时再把结果拼回完整图。实践证明这种方法比全图resize靠谱得多,尤其是目标较小、密度较高的场景。

4.2 损失函数:交叉熵和Dice的取舍

像素级交叉熵是最常用的分割损失,它对每个像素独立计算损失,反向传播简单,收敛稳定。但分割任务里背景像素通常远多于前景,普通交叉熵会被背景主导,导致模型把所有像素都预测成背景。

解决办法有三类:第一类是给交叉熵加上类别权重,权重一般按类别像素比例的反比计算。第二类是用Dice损失,它直接优化预测区域和真实区域的重叠度,天然对类别不平衡不敏感。第三类是把两者组合起来,比如用0.5倍的Dice损失加上0.5倍的加权交叉熵。我在实践中推荐直接用组合损失,尤其在目标只占图像几个百分点的情况下,效果比单独用任何一种都稳。

4.3 训练代码核心片段

以下是一个基于PyTorch的U-Net训练循环核心代码,二分类分割场景,输入尺寸256x256,batch size 8。

import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class SimpleUNet(nn.Module): def __init__(self, in_ch=3, num_classes=1): super().__init__() self.enc1 = DoubleConv(in_ch, 64) self.enc2 = DoubleConv(64, 128) self.enc3 = DoubleConv(128, 256) self.enc4 = DoubleConv(256, 512) self.pool = nn.MaxPool2d(2) self.bridge = DoubleConv(512, 1024) self.up1 = nn.ConvTranspose2d(1024, 512, 2, stride=2) self.dec1 = DoubleConv(1024, 512) self.up2 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec2 = DoubleConv(512, 256) self.up3 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec3 = DoubleConv(256, 128) self.up4 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec4 = DoubleConv(128, 64) self.out_conv = nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) b = self.bridge(self.pool(e4)) d1 = self.dec1(torch.cat([self.up1(b), e4], dim=1)) d2 = self.dec2(torch.cat([self.up2(d1), e3], dim=1)) d3 = self.dec3(torch.cat([self.up3(d2), e2], dim=1)) d4 = self.dec4(torch.cat([self.up4(d3), e1], dim=1)) return self.out_conv(d4)

训练损失部分,用Dice加加权交叉熵的组合:

def dice_loss(pred, target, smooth=1.0): pred = torch.sigmoid(pred) pred_flat = pred.view(pred.size(0), -1) target_flat = target.view(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) union = pred_flat.sum(dim=1) + target_flat.sum(dim=1) dice = (2.0 * intersection + smooth) / (union + smooth) return 1.0 - dice.mean() def combined_loss(pred, target): bce = F.binary_cross_entropy_with_logits(pred, target) dice = dice_loss(pred, target) return 0.5 * bce + 0.5 * dice

训练时的几个关键参数,我自己跑下来的参考值:学习率1e-4,优化器Adam,ReduceLROnPlateau在验证集loss连续5轮不降时把学习率乘0.5,最大训练轮次100到150。Batch size在8G显存上256x256输入可以开到8,更大尺寸就相应缩小。训练时每轮记录训练loss和验证mIoU,模型保存验证mIoU最高的一次权重,而不是最后一轮的权重。

4.4 数据增强是分割项目的胜负手

分割模型特别容易在训练集上过拟合,数据增强的收益比换模型结构还明显。我常用的增强组合包括随机水平翻转、随机旋转90度、随机亮度对比度调整、随机裁剪以及弹性形变。

弹性形变在医学图像增强里尤其有效,它模拟了器官组织的形变方式,让模型学到更鲁棒的形状特征。注意做增强时,图像和掩码必须用同一套随机参数变换,否则掩码和图像就错位了。工程上最好把图像和掩码拼成一个多维数组一起操作,或者用现成库的同步增强接口,避免手写出错。

5. 实际项目中踩过的坑与排查

5.1 预测结果全是背景:类别不平衡的极端情况

第一次跑分割训练,模型把所有像素都预测成背景,验证集mIoU是0,这种问题几乎每个人都遇到过。常见原因有三个:一是损失函数没有加权,背景占了95%以上;二是掩码数据有错,目标区域的像素值不是1而是别的值;三是学习率过大,模型一开始就进入错误的收敛区域。

我排查的顺序是先可视化几张掩码,确认像素值正确;然后把训练loss打印出来,看它有没有下降趋势;最后把损失函数临时换成纯Dice损失,确认模型至少能学到目标形状。实际项目中,掩码数据错位的情况远比想象中多,有一回发现是数据生成脚本里忘了把类别索引加1,所有目标都跟背景混在了一起。

5.2 边界锯齿严重:转置卷积棋盘伪影和浅层特征丢失

分割图边界出现棋盘格子状的伪影,这是转置卷积的经典问题。原因是转置卷积的kernel size不能被stride整除时,相邻像素计算会产生重叠不均匀,形成周期性亮暗条纹。

解决办法有三个:把转置卷积的kernel size设为stride的整数倍,比如2x2配stride2;先用双线性插值放大再卷积;或者在转置卷积后面加一个3x3卷积层。我个人更推荐插值加卷积的做法,肉眼效果更好,模型参数还少一些。

另一种边界问题来源于浅层特征丢失。如果模型结构里跳跃连接太少,或者输入尺寸过小,边界会很平滑,像是被磨掉一层。这种情况需要加大输入尺寸,或者检查是不是用了过强的高斯模糊做数据增强。

5.3 训练loss一直震荡不收敛怎么办

U-Net训练初期loss震荡很常见,但总是不收敛就要找原因了。最先检查学习率,1e-3以上对分割任务来说往往偏大;然后检查BN层,如果batch size只有2或4,BN统计量会很不稳定,可以考虑换成InstanceNorm或GroupNorm;再检查掩码是否有类别数不一致的情况。

我还遇到过一个诡异问题:训练时loss正常下降,但验证集的mIoU始终不涨。后来发现是验证集评估代码里用了不同的resize方式,导致图像和掩码尺寸不一致,评估结果完全失真。分割任务的评估环节非常容易出低级错误,建议在训练前先写一个简单的单样本测试,把tensor经过模型后输出尺寸打印出来,确认和掩码尺寸一致再开始跑。

5.4 常见问题速查表

现象可能原因解决办法
预测全为背景类别不平衡、掩码错误加权损失或Dice损失,检查掩码索引
边缘有棋盘格转置卷积kernel和stride不匹配插值+卷积替代转置卷积
小目标完全丢失下采样过深、感受野问题加跳跃连接,使用多尺度输入
训练loss下降但验证mIoU低过拟合、验证集评估错误加强数据增强,修复评估代码
显存不够输入过大、batch太大切patch训练、减小batch、用梯度累积
模型对所有类预测一致学习率过高、损失函数失效降低学习率,检查损失函数实现

6. FCN和U-Net怎么选:结构对比与扩展思路

6.1 两张网络的核心差异

维度FCNU-Net
结构特点分类主干+上采样+跳跃融合(相加)对称编码-解码+跳跃拼接
跳跃连接方式高层特征与部分浅层特征相加每一层都做通道拼接
参数量依赖主干网络,通常很大相对轻量,约几十兆参数
对数据量要求需要较多数据小数据也能训练得不错
边界细节8s版本尚可,32s版本粗糙边界细节更好
训练稳定性对学习率较敏感更稳定,易收敛

这里的参数量对比以标准配置为例:FCN基于VGG16主干时整体参数在100M以上,单卡训练比较吃力;标准U-Net以3通道输入、二分类输出计算,大约在30M左右,8G显存足够训练。

6.2 场景选型建议

FCN更像是一个思想框架,它的价值在于开创了语义分割的基本范式。如果你要做的是通用场景的大规模分割,并且对速度要求不高,可以考虑以FCN的思路为基础,替换更强的主干网络,比如用ResNet或EfficientNet替代VGG,效果会有明显提升。

U-Net则更适合数据量有限、目标形态相对稳定的场景。医学图像、卫星遥感、工业质检、视频对象分割这类任务,U-Net及其变体几乎都是默认起步方案。如果任务里目标特别小,还可以在U-Net基础上加一个深监督分支,让中间的每一层都参与损失计算,能有效缓解小目标梯度消失的问题。

6.3 从经典网络延伸出去的几个方向

理解了FCN和U-Net之后,再去看现阶段的SOTA模型会轻松很多。现在主流的分割方案大致走三条路线:一是U-Net加注意力机制和Transformer模块,比如各类混合架构;二是基于视觉Transformer的编码器直接输出分割结果;三是使用掩码学习的思想,先让模型学会“这个东西长什么样”,再逐实例分割。

我现在的经验是,新模型未必适合所有场景。如果数据量只有几百张图,老老实实用U-Net调损失函数和数据增强,效果往往比强行上Transformer好得多。就算项目最终要部署新模型,也建议先用U-Net跑通baseline,拿到一套稳定的评估流程和损失函数配置,再逐步替换编码器。这样每一步改动效果都可以量化,不会一堆变量混在一起分不清是谁起了作用。

最后再分享一个小技巧:无论你最终选FCN还是U-Net,先把评估指标mIoU的实现写对。mIoU计算时要按类别分别计算交并比再取平均,很多代码里把背景类别也算进去了,导致指标虚高。我在实际项目中因为这个问题多调了整整一天参数,才发现不是模型不行,是评估脚本里类别遗漏。先把评估跑通,再谈模型改进,这个顺序永远不会错。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进