1. 从分类到分割:为什么CNN要搞Encoder-Decoder这套结构
很多人学CNN都是从图像分类入门的,输入一张图,输出一个类别标签,比如“猫”或者“狗”。但语义分割完全是另一回事——它要求对图像里每一个像素都给出类别判断。换句话说,分类任务输出的是一个向量,分割任务输出的是和原图分辨率一样的标签图。这个差异直接决定了网络结构的设计思路完全不同。
我第一次接触语义分割的时候,脑子里第一反应是:分类网络最后不是有全连接层吗?把全连接层去掉,后面接几个上采样不就完了?实测下来,事情没这么简单。分类网络在逐层卷积和池化过程中,空间分辨率不断下降,感受野不断增大,语义信息越来越抽象,但位置信息丢得很厉害。你最后拿到的是一个高维语义特征图,但原始图像里哪个像素对应哪个位置,已经模糊了。
Encoder-Decoder结构就是来解决这个矛盾的。Encoder负责“看懂图”,通过卷积和降采样提取多尺度语义特征;Decoder负责“还原图”,通过上采样和特征融合逐步恢复空间分辨率,最终输出逐像素的预测结果。这个思路最早在FCN(Fully Convolutional Network)里被系统化提出,后来U-Net、SegNet、DeepLab系列都在这个框架上做文章。
为什么这个结构能work?核心在于skip connection(跳跃连接)。Encoder在降采样过程中,不同层级的特征图保留了不同粒度的信息:浅层特征分辨率高、包含边缘纹理等细节,深层特征分辨率低、包含语义类别信息。Decoder在上采样时,如果只靠深层特征,恢复出来的边界会非常粗糙;如果把浅层特征也拿过来融合,边界就能精细很多。这就像画油画,先用大笔刷铺大色块,再用小笔刷勾勒细节,两者缺一不可。
注意:Encoder-Decoder不是语义分割的唯一方案,还有基于空洞卷积(Dilated Convolution)的方案如DeepLab,以及基于Transformer的方案如SegFormer。但Encoder-Decoder是最直观、最容易理解、也最适合入门实现的架构,没有之一。
2. 核心组件拆解:Encoder、Decoder和Skip Connection到底怎么设计
2.1 Encoder选型:VGG、ResNet还是轻量级网络
Encoder本质上就是一个分类网络的骨干部分。常见的选择有VGG16、ResNet50、MobileNet等。VGG结构简单,全是3x3卷积堆叠,特征提取能力够用,但参数量大、计算量高。ResNet引入了残差连接,训练更稳定,深层网络也能收敛。MobileNet用深度可分离卷积,适合移动端部署。
我个人的建议是:入门阶段用VGG16或ResNet34就够了。原因很简单,这两个网络结构清晰,预训练权重容易获取,社区资料多,踩坑成本低。等你把整个pipeline跑通了,再考虑换更高效的骨干网络。
以VGG16为例,它的结构分为5个stage,每个stage后接最大池化降采样。假设输入图像是512x512,经过5次降采样后,特征图变成16x16,通道数从3增加到512。这个16x16x512的特征图就是整个图像的“语义摘要”,每个位置都包含了丰富的类别信息,但空间精度已经损失了32倍。
2.2 Decoder设计:上采样方式的选择
Decoder的核心任务是把低分辨率特征图逐步恢复到原图尺寸。上采样的方式主要有三种:
- 最近邻插值(Nearest Neighbor):最简单,直接复制最近的像素值。计算量最小,但效果粗糙,容易产生棋盘格伪影。
- 双线性插值(Bilinear):考虑周围4个像素的加权平均,效果比最近邻平滑,计算量也不大。
- 转置卷积(Transposed Convolution):也叫反卷积,通过可学习的卷积核进行上采样。理论上效果最好,但参数量增加,且容易产生棋盘格伪影,需要仔细调参。
实测下来,双线性插值+卷积的组合性价比最高。先用双线性插值把特征图放大2倍,再用一个3x3卷积调整通道数和特征表达,这样既避免了转置卷积的棋盘格问题,又比纯插值多了可学习的参数。U-Net就是用的转置卷积,SegNet用的是最大池化索引来上采样,各有优劣。
2.3 Skip Connection:融合浅层和深层特征
Skip connection是Encoder-Decoder结构的灵魂。具体怎么做?假设Encoder有5个stage,输出特征图分别为F1(1/2分辨率)、F2(1/4)、F3(1/8)、F4(1/16)、F5(1/32)。Decoder从F5开始,每次上采样后与对应的Encoder特征图拼接(Concatenation)或相加(Addition)。
拼接和相加的区别:拼接保留了两部分特征的独立性,通道数翻倍,后续卷积可以学习如何融合;相加则是直接逐元素相加,要求通道数一致,融合方式更“强硬”。U-Net用的是拼接,FCN用的是相加。我一般推荐拼接,因为可学习性更强,但显存占用会大一些。
实操心得:Skip connection不是越多越好。浅层特征虽然分辨率高,但噪声也多,如果全部融合进去,反而会干扰深层语义信息。实践中,我通常只融合最后3个stage的特征,前两个stage的特征太底层了,对分割精度的贡献有限。
3. 完整实现流程:从数据准备到模型训练
3.1 数据集准备与预处理
语义分割的数据集标注方式和分类完全不同。分类是一张图一个标签,分割是一张图对应一张掩码图(Mask),掩码图的每个像素值代表该位置的类别。常见的公开数据集有Pascal VOC、Cityscapes、ADE20K等。
自己准备数据的话,推荐用LabelMe或CVAT标注工具。标注完成后,你会得到JSON或XML格式的标注文件,需要转换成单通道的PNG掩码图。这里有个坑:类别ID必须从0开始连续编号,因为后续计算损失函数时,CrossEntropyLoss要求类别索引在[0, num_classes-1]范围内。如果中间有跳号,训练时会直接报错。
数据增强方面,分割任务比分类任务更讲究。因为图像和掩码必须同步变换,所以不能用普通的ImageNet增强pipeline。常用的增强手段包括:
- 随机水平翻转(图像和掩码同时翻转)
- 随机裁剪(确保裁剪区域包含有效类别)
- 随机缩放(注意缩放后掩码要用最近邻插值,避免引入不存在的类别)
- 颜色抖动(只对图像做,掩码不变)
import albumentations as A transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomCrop(height=256, width=256, p=1.0), A.ColorJitter(brightness=0.2, contrast=0.2, p=0.3), ], additional_targets={'mask': 'mask'})3.2 模型搭建:以U-Net为例的完整代码
U-Net是Encoder-Decoder结构里最经典的实现,结构对称,代码清晰。下面是我常用的一个简化版实现:
import torch import torch.nn as nn 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 UNet(nn.Module): def __init__(self, in_ch=3, num_classes=2): super().__init__() # Encoder 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) # Bottleneck self.bottleneck = DoubleConv(512, 1024) # Decoder self.up4 = nn.ConvTranspose2d(1024, 512, 2, stride=2) self.dec4 = DoubleConv(1024, 512) self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec3 = DoubleConv(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = DoubleConv(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = DoubleConv(128, 64) self.out = 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.bottleneck(self.pool(e4)) d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)这个实现里,Encoder有4次降采样,Bottleneck在1/16分辨率,Decoder有4次上采样恢复到原分辨率。每次上采样后与对应Encoder特征拼接,通道数翻倍后再经过DoubleConv融合。
3.3 损失函数选择:CrossEntropy还是Dice
语义分割最常用的损失函数是交叉熵损失(CrossEntropyLoss),它对每个像素独立计算分类损失,然后取平均。但交叉熵有个问题:当类别极度不平衡时(比如背景占90%,目标只占10%),模型会倾向于全部预测为背景,因为这样loss也能降得很低。
这时候就需要Dice Loss或Focal Loss来救场。Dice Loss直接优化预测掩码和真实掩码的重叠度,对类别不平衡不敏感。Focal Loss则通过降低易分类样本的权重,让模型聚焦于难分类的样本。
我的经验是:CrossEntropy + Dice组合使用效果最稳。CrossEntropy提供稳定的梯度信号,Dice负责拉高重叠度。权重比例一般设为1:1或1:0.5,具体看数据集。
class DiceLoss(nn.Module): def __init__(self, smooth=1e-6): super().__init__() self.smooth = smooth def forward(self, pred, target): pred = torch.softmax(pred, dim=1) target_onehot = torch.nn.functional.one_hot(target, pred.shape[1]) target_onehot = target_onehot.permute(0, 3, 1, 2).float() intersection = (pred * target_onehot).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + target_onehot.sum(dim=(2, 3)) dice = (2 * intersection + self.smooth) / (union + self.smooth) return 1 - dice.mean()3.4 训练配置与调参经验
优化器首选Adam或AdamW,学习率从1e-3或1e-4开始。如果用了预训练Encoder,Encoder部分的学习率可以设小一点(比如1e-4),Decoder部分设大一点(比如1e-3),这叫差分学习率,能加速收敛。
Batch size在显存允许的情况下尽量大,因为分割任务对BatchNorm的统计量比较敏感,batch太小会导致训练不稳定。如果显存不够,可以用梯度累积来模拟大batch。
学习率调度方面,CosineAnnealingLR或ReduceLROnPlateau都行。我一般用CosineAnnealing,配合Warmup,前5个epoch线性升温,后面余弦下降。
踩坑记录:有一次我用VGG16做Encoder,忘记加载预训练权重,结果训练了50个epoch,mIoU只有0.3。后来加载了ImageNet预训练权重,10个epoch就到了0.6。预训练权重对分割任务太重要了,能省大量时间和数据。
4. 评估指标与结果分析:mIoU到底怎么算
4.1 核心指标解析
语义分割最核心的评估指标是mIoU(Mean Intersection over Union)。计算方式:对每个类别,计算预测区域和真实区域的交集除以并集,得到该类别的IoU;然后对所有类别取平均。
公式很简单:IoU = TP / (TP + FP + FN)。其中TP是预测正确的像素数,FP是预测为该类但实际不是的像素数,FN是实际为该类但预测不是的像素数。
除了mIoU,还有Pixel Accuracy(像素准确率)和Dice Coefficient。Pixel Accuracy就是预测正确的像素占总像素的比例,但在类别不平衡时参考价值有限。Dice Coefficient和IoU本质上是等价的,只是计算方式略有不同。
| 指标 | 公式 | 适用场景 | 注意事项 |
|---|---|---|---|
| mIoU | 各类IoU平均 | 通用评估 | 对类别不平衡敏感 |
| Pixel Acc | 正确像素/总像素 | 快速评估 | 不平衡时虚高 |
| Dice | 2TP/(2TP+FP+FN) | 医学图像 | 与IoU高度相关 |
| Boundary F1 | 边界匹配度 | 精细分割 | 计算复杂 |
4.2 可视化分析:怎么看出模型哪里不行
光看数字不够,必须把预测结果可视化出来。我通常会把原图、真实掩码、预测掩码三张图并排显示,用不同颜色区分类别。这样一眼就能看出模型在哪些区域容易出错。
常见的错误模式有:
- 边界模糊:目标边缘预测不准确,通常是Skip Connection融合不够或Decoder容量不足。
- 小目标漏检:小物体被忽略,可能是降采样倍数太大,或者损失函数对小目标不友好。
- 类别混淆:两个相似类别互相误判,需要增加类间区分度,比如用Focal Loss或增加难例挖掘。
import matplotlib.pyplot as plt def visualize_prediction(image, mask, pred, num_classes): fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(image) axes[0].set_title('Input Image') axes[1].imshow(mask, vmin=0, vmax=num_classes-1) axes[1].set_title('Ground Truth') axes[2].imshow(pred, vmin=0, vmax=num_classes-1) axes[2].set_title('Prediction') for ax in axes: ax.axis('off') plt.show()4.3 提升精度的实用技巧
如果mIoU卡在某个值上不去,可以尝试以下手段:
- 多尺度训练:训练时随机缩放图像,让模型适应不同尺度的目标。
- 测试时增强(TTA):推理时对图像做翻转、多尺度缩放,把多次预测结果平均,通常能涨1-2个点。
- 后处理CRF:用条件随机场对预测结果做边缘细化,能显著改善边界质量,但速度会慢。
- 换更强的Encoder:从VGG换到ResNet或EfficientNet,或者用ImageNet预训练权重。
- 增加数据:分割标注成本高,但可以用半监督或弱监督方法利用无标注数据。
实操心得:TTA是性价比最高的涨点手段,几乎不增加训练成本,推理时多跑几次就行。但要注意,TTA对实时性要求高的场景不适用。
5. 常见问题与排查技巧实录
5.1 训练不收敛或Loss震荡
这是最常见的问题。排查顺序如下:
- 检查学习率:太大导致震荡,太小导致收敛慢。先用1e-4试,不行再调。
- 检查数据标注:掩码图里是否有非法类别ID?是否有全黑或全白的掩码?
- 检查损失函数:CrossEntropy的ignore_index设置是否正确?如果背景类别是255,需要设置ignore_index=255。
- 检查BatchNorm:batch size太小时,BatchNorm统计量不准,可以换成GroupNorm或SyncBN。
5.2 显存不足(OOM)
分割任务因为要保留高分辨率特征图,显存占用比分类大得多。解决方案:
- 减小batch size,用梯度累积补偿。
- 用混合精度训练(AMP),显存直接省一半。
- 减小输入分辨率,或者用随机裁剪训练。
- 用更轻量的Encoder,比如MobileNetV3。
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() for images, masks in dataloader: optimizer.zero_grad() with autocast(): outputs = model(images) loss = criterion(outputs, masks) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()5.3 预测结果全是一个类别
这是类别不平衡的典型症状。模型发现全部预测为背景就能获得很低的loss,于是“躺平”了。解决方法:
- 用Dice Loss或Focal Loss替代纯CrossEntropy。
- 对少数类别进行过采样。
- 在损失函数里给少数类别更高的权重。
- 检查数据里是否真的存在少数类别样本。
5.4 边界预测粗糙
边界问题是分割任务的“老大难”。除了前面提到的Skip Connection和CRF后处理,还可以:
- 在损失函数里增加边界损失(Boundary Loss)。
- 用更高的输入分辨率。
- 在Decoder里增加更多的卷积层来细化特征。
- 用注意力机制让模型关注边界区域。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Loss不下降 | 学习率不当/数据问题 | 打印梯度范数 | 调LR/检查标注 |
| 显存OOM | 分辨率太高/batch太大 | nvidia-smi监控 | AMP/梯度累积 |
| 全预测背景 | 类别不平衡 | 统计类别分布 | Dice/Focal Loss |
| 边界粗糙 | 浅层特征丢失 | 可视化特征图 | Skip Connection/CRF |
| 训练慢 | 模型太大/数据加载慢 | 计时各环节 | 换轻量模型/多进程加载 |
6. 从入门到进阶:后续可以怎么扩展
把基础的Encoder-Decoder跑通之后,你会发现语义分割这个领域还有很多可以深挖的方向。比如实例分割,不仅要区分类别,还要区分同一类别的不同个体,代表工作是Mask R-CNN。全景分割则把语义分割和实例分割统一起来,对每个像素同时给出类别和实例ID。
如果追求更高的精度,可以研究DeepLab系列的空洞卷积和ASPP模块,或者SegFormer的Transformer架构。如果追求实时性,可以看BiSeNet、STDC这些轻量级分割网络。
工程落地方面,分割模型部署到移动端需要做量化、剪枝、蒸馏。ONNX和TensorRT是常用的推理加速工具。我实测过把U-Net量化到INT8,推理速度能提升2-3倍,精度只掉1个点左右。
数据标注这块,如果预算有限,可以先用少量精细标注数据训练一个基础模型,然后用模型去预标注新数据,人工只需要修正错误部分,这叫主动学习,能大幅降低标注成本。
最后再分享一个小技巧:训练分割模型时,先用小分辨率(如256x256)快速验证模型结构和损失函数是否合理,确认能收敛后再用大分辨率(如512x512)精调。这样能省很多调试时间,避免一上来就跑大图,等半天发现代码有bug。