语义分割这个方向,我从最早一批做遥感影像解译的时候就开始接触,中间踩过的坑、调过的模型、翻过的车,算下来能写好几页纸。FCN、UNet、DeepLab这三兄弟,基本是每一个入行图像分割的人绕不开的经典架构。不管你是做医疗影像、自动驾驶、工业质检,还是遥感地物分类,这三套网络的设计思路和演进逻辑,理解了之后你会发现后面出的各种新架构,本质上都是在它们的基础上做加减法。
这篇文章我打算把这三个架构从设计动机、核心原理、关键实现细节到实际训练中会遇到的问题,完整地拆一遍。适合有一定深度学习基础、想系统理解语义分割技术脉络的读者,也适合正在做相关项目、需要选型和调参的从业者。我会尽量把每个设计决策背后的“为什么”讲清楚,而不是只告诉你“是什么”。
1. 语义分割到底在解决什么问题
1.1 从像素分类到场景理解
语义分割说白了就是给图像里的每一个像素分配一个类别标签。输入一张RGB图像,输出一张同样尺寸的标签图,每个像素的值代表它属于哪个类别。这跟图像分类不一样,分类是整张图给一个标签;也跟目标检测不一样,检测是给每个目标画一个框。语义分割要求的是像素级的理解,精细到每个点的归属。
举个例子,一张街景图,分类任务告诉你“这是一张街道照片”,检测任务告诉你“这里有一辆车、那里有一个人”,而语义分割告诉你“这些像素是路、这些像素是车、这些像素是人、这些像素是天空”。这种密集预测的能力,是很多下游任务的基础。
为什么这个任务难?核心难点在于:既要理解全局语义(知道这是什么东西),又要保留空间精度(知道它在哪个位置、边界在哪里)。这两个需求在传统的卷积网络里是有矛盾的——深层特征语义强但分辨率低,浅层特征分辨率高但语义弱。FCN、UNet、DeepLab这三个架构,本质上都在用不同的方式解决这个矛盾。
1.2 语义分割与实例分割、全景分割的区别
这里顺便理清几个容易混淆的概念。语义分割不区分同类别的不同个体,比如图里有三辆车,语义分割会把它们都标成“车”这一类,不会区分车1、车2、车3。实例分割则要区分每个个体,输出的是“车1的mask、车2的mask、车3的mask”。全景分割是两者的结合,既区分类别又区分个体,同时还要处理“不可数”的背景类别(如天空、路面)。
理解这个区别很重要,因为它决定了你选什么架构。如果你只需要知道每个像素属于什么类别,不需要区分个体,那FCN、UNet、DeepLab这类语义分割网络就够了。如果需要区分个体,那就要走Mask R-CNN那条路线,或者用基于语义分割的实例分割方法。
1.3 主流架构的演进脉络
语义分割的发展脉络其实很清晰。2015年FCN首次把全卷积网络用于分割,开创了端到端训练的先河。同年UNet被提出,用编码器-解码器加跳跃连接的结构,在医学影像分割上大放异彩。2017年DeepLab v3引入空洞卷积和ASPP,解决了多尺度问题。之后的各种改进,基本都是在这些基础上做文章。
FCN解决了“怎么用卷积网络做像素级预测”的问题,UNet解决了“怎么恢复空间精度”的问题,DeepLab解决了“怎么处理多尺度目标”的问题。这三个问题至今仍然是语义分割的核心挑战,所以这三个架构的思路到现在依然有很强的参考价值。
2. FCN:全卷积网络的开山之作
2.1 FCN的核心设计思想
FCN的核心思想非常直接:把传统分类网络(如VGG、GoogLeNet)最后的全连接层换成卷积层,让网络可以接受任意尺寸的输入,输出同样空间尺寸的预测图。这个想法在当时是很有开创性的,因为此前的分割方法大多是基于区域提议的,速度慢、流程复杂。
具体来说,FCN把VGG16的最后三层全连接层(fc6、fc7、fc8)替换成卷积层。fc6用7×7的卷积核,fc7用1×1的卷积核,fc8也用1×1的卷积核,输出通道数等于类别数。这样整个网络就变成了全卷积结构,可以处理任意尺寸的输入。
但这样做有一个问题:经过多次下采样后,特征图的分辨率会变得很低。比如VGG16经过5次池化,特征图尺寸缩小了32倍。如果直接在这个低分辨率特征图上做预测,再上采样回原图尺寸,边界会非常粗糙。
2.2 跳跃连接与多尺度融合
FCN解决分辨率问题的办法是跳跃连接(skip connection)。它把不同深度的特征图融合起来,兼顾语义和空间精度。FCN提出了三种变体:
- FCN-32s:直接在pool5的特征图上做预测,然后上采样32倍。效果最粗糙,但结构最简单。
- FCN-16s:把pool5的预测上采样2倍,和pool4的特征融合,再上采样16倍。精度有明显提升。
- FCN-8s:进一步融合pool3的特征,上采样8倍。精度最高,但计算量也最大。
这个设计的逻辑是:深层特征语义强,适合判断“是什么”;浅层特征分辨率高,适合判断“在哪里”。融合两者,就能得到既准确又精细的分割结果。
2.3 FCN的局限性与后续改进
FCN虽然开创性强,但有几个明显的局限。第一,上采样用的反卷积(转置卷积)容易产生棋盘效应,边界不够平滑。第二,感受野固定,对多尺度目标的处理能力有限。第三,跳跃连接的方式比较粗糙,直接相加或拼接,没有做进一步的特征融合。
后续的改进大多围绕这些问题展开。比如用双线性插值替代反卷积做上采样,用空洞卷积扩大感受野,用更精细的融合模块替代简单的相加。但FCN奠定的“全卷积+跳跃连接”这个基本框架,至今仍然是很多分割网络的基础。
2.4 FCN的实操要点
如果你要复现FCN,有几个细节需要注意。首先,骨干网络的选择很关键,VGG16是最经典的,但也可以用ResNet。用ResNet的时候,要把最后的全连接层去掉,保留卷积部分。其次,上采样的方式,原论文用的是反卷积,但实践中双线性插值+卷积的组合往往效果更好,而且不容易出现棋盘伪影。
学习率方面,FCN的训练比较敏感。原论文用的是固定学习率,但实践中用poly衰减策略(学习率随迭代次数多项式衰减)效果更稳定。batch size一般设得比较小,因为分割任务的内存占用大,通常4到8就够了。损失函数用逐像素的交叉熵,如果类别不均衡严重,可以加类别权重或者用Dice loss。
注意:FCN对输入尺寸没有严格要求,但为了下采样整除,通常会把输入resize到32的倍数。比如513×513是常用的尺寸,因为513=32×16+1,这样上采样后能对齐。
3. UNet:编码器-解码器结构的经典范式
3.1 UNet的对称结构与跳跃连接
UNet是2015年针对医学影像分割提出的,结构非常优雅。它由一个收缩路径(编码器)和一个扩张路径(解码器)组成,整体呈U型,所以叫UNet。编码器通过卷积和池化逐步降低分辨率、增加通道数,提取语义特征;解码器通过上采样和卷积逐步恢复分辨率、减少通道数,恢复空间细节。
UNet最核心的设计是跳跃连接。它把编码器每一层的特征图直接拼接到解码器对应层的特征图上。注意是拼接(concatenation),不是相加。拼接保留了更多的信息,让解码器在恢复分辨率的时候能直接利用编码器的高分辨率特征。
这个设计跟FCN的跳跃连接思路类似,但UNet做得更彻底——每一层都连,而不是只连几层。这种密集的跳跃连接让UNet在少量数据上也能训练得很好,这是它在医学影像领域特别受欢迎的原因之一。
3.2 UNet的编码器与解码器细节
编码器的每一层通常是两个3×3卷积(padding=1,保持尺寸不变),每个卷积后面跟ReLU,然后是一个2×2的最大池化(stride=2),分辨率减半,通道数翻倍。原论文的通道数从64开始,依次是64、128、256、512、1024。
解码器的每一层是先上采样(原论文用2×2的反卷积,也可以用双线性插值),然后把编码器对应层的特征拼接过来,再跟两个3×3卷积+ReLU。最后一层用1×1卷积把通道数映射到类别数。
这里有个细节:拼接的时候,编码器的特征图尺寸可能跟解码器上采样后的尺寸差一个像素(因为奇数尺寸下采样再上采样会丢一个像素)。实践中通常用padding或者裁剪来对齐,或者直接把输入尺寸设成2的幂次。
3.3 UNet的变体与改进方向
UNet的变体非常多,比较有代表性的有:
- UNet++:在跳跃连接上加了嵌套的密集连接,让不同尺度的特征融合得更充分。
- Attention UNet:在跳跃连接上加了注意力门控,让网络自动关注重要的区域。
- ResUNet:把编码器的卷积块换成残差块,训练更稳定,能做得更深。
- 3D UNet:把2D卷积换成3D卷积,用于体积数据(如CT、MRI)。
这些改进的核心思路都是一样的:让特征融合更有效,让网络能更好地利用多尺度信息。选择哪个变体,取决于你的数据特点和任务需求。数据量小的时候,原始UNet或者Attention UNet往往就够了;数据量大、需要更深网络的时候,ResUNet更合适。
3.4 UNet的实操经验与调参技巧
UNet的训练有几个经验性的技巧。第一,数据增强非常重要,尤其是医学影像数据量通常很小。常用的增强包括随机旋转、缩放、弹性变形、随机裁剪、翻转等。弹性变形对医学影像特别有效,因为器官的形状变化是连续的。
第二,损失函数的选择。二分类常用Binary Cross Entropy + Dice Loss的组合,多分类用Cross Entropy + Dice Loss。Dice Loss对类别不均衡特别有效,因为它直接优化重叠度,不受背景像素过多的影响。
第三,初始化。UNet对初始化比较敏感,用He初始化(Kaiming初始化)通常比Xavier初始化效果好。如果训练不稳定,可以加Batch Normalization,但在小batch size下BN效果不好,可以换成Group Normalization。
第四,学习率策略。初始学习率一般设1e-3到1e-4,用ReduceLROnPlateau或者Cosine Annealing。如果验证集loss不下降,就降低学习率。
实操心得:UNet在训练初期loss下降很快,但后期容易过拟合。我的做法是在验证集loss连续5个epoch不下降就停,同时保存验证集指标最好的模型。另外,如果显存不够,可以用梯度累积来模拟大batch size。
4. DeepLab:空洞卷积与多尺度特征提取
4.1 DeepLab系列的发展脉络
DeepLab是Google提出的系列工作,从v1到v3+,每一版都在解决前版的问题。v1引入了空洞卷积(Atrous Convolution)和CRF后处理;v2提出了ASPP(Atrous Spatial Pyramid Pooling)和可分离卷积;v3改进了ASPP,去掉了CRF;v3+加了编码器-解码器结构。
这个系列的核心贡献是空洞卷积和ASPP。空洞卷积让网络在不增加参数量的情况下扩大感受野,ASPP用不同膨胀率的空洞卷积并行提取多尺度特征。这两个设计对后续的分割网络影响深远。
4.2 空洞卷积的原理与优势
空洞卷积(也叫膨胀卷积)就是在卷积核的元素之间插入空洞。比如一个3×3的卷积核,膨胀率为2的时候,实际覆盖的区域是5×5,但只有9个参数。膨胀率为r的时候,感受野是(2r+1)×(2r+1),但参数量不变。
这个设计的优势很明显:不增加计算量和参数量的情况下,有效扩大感受野。在语义分割里,感受野越大,网络能看到的上下文信息越多,对大目标的判断就越准确。但传统的做法是加深网络或者加大卷积核,这两种方式都会增加参数量。
空洞卷积的另一个好处是保持分辨率。传统的下采样会降低分辨率,丢失空间信息。空洞卷积可以在不降分辨率的情况下扩大感受野,这对分割任务特别友好。
但空洞卷积也有问题。当膨胀率接近特征图尺寸时,空洞卷积会退化成1×1卷积,因为大部分权重都落在特征图外面了。这就是为什么DeepLab v3的ASPP里,当膨胀率很大时,会用一个全局平均池化分支来补充全局信息。
4.3 ASPP模块的设计与实现
ASPP是DeepLab的核心模块。它用不同膨胀率的空洞卷积并行处理同一个特征图,然后把结果拼接起来。这样网络就能同时捕捉不同尺度的信息。
以DeepLab v3为例,ASPP包含:
- 一个1×1卷积(膨胀率=1)
- 三个3×3空洞卷积,膨胀率分别为6、12、18
- 一个全局平均池化分支(image-level features)
这五个分支的输出在通道维度上拼接,然后经过一个1×1卷积融合。膨胀率的选择跟特征图尺寸有关,特征图越小,膨胀率可以越大。在DeepLab v3中,输出特征图尺寸是输入的1/16,所以膨胀率6、12、18是合理的。
ASPP的实现有几个细节要注意。第一,每个分支后面都要加Batch Normalization和ReLU。第二,全局平均池化分支要先池化到1×1,然后上采样回原尺寸,再跟其他分支拼接。第三,拼接后的1×1卷积用来降维和融合,输出通道数通常设成256。
4.4 DeepLab v3+的编码器-解码器改进
DeepLab v3+在v3的基础上加了编码器-解码器结构。编码器是DeepLab v3的骨干网络+ASPP,解码器是一个简单的上采样模块。具体来说,解码器把ASPP的输出上采样4倍,跟骨干网络中对应分辨率的浅层特征拼接,然后经过几个3×3卷积,再上采样4倍得到最终输出。
这个改进的目的是恢复边界细节。ASPP的输出虽然语义强,但分辨率只有输入的1/16,直接上采样16倍边界会很粗糙。融合浅层特征后,边界精度有明显提升。
DeepLab v3+还支持用深度可分离卷积替代普通卷积,减少计算量。骨干网络可以用ResNet或Xception,后者在DeepLab v3+里表现更好,但计算量也更大。
4.5 DeepLab的实操配置与训练策略
DeepLab的训练配置比较讲究。以DeepLab v3+为例,骨干网络通常用ResNet-101或Xception,预训练权重用ImageNet的。学习率策略用poly衰减,初始学习率0.007到0.01,power=0.9。batch size通常8到16,用同步BN的话可以更大。
数据增强方面,常用的有随机缩放(scale range 0.5到2.0)、随机左右翻转、随机裁剪(crop size 513×513)。注意DeepLab对输入尺寸有要求,因为要保证下采样后特征图尺寸是膨胀率的整数倍。通常用513×513,因为513=16×32+1,下采样16倍后是33×33,膨胀率6、12、18都能整除。
损失函数用逐像素交叉熵,如果类别不均衡,可以给稀有类别更高的权重。评估指标用mIoU(mean Intersection over Union),这是语义分割的标准指标。
注意:DeepLab v3+的训练对显存要求比较高,尤其是用Xception骨干的时候。如果显存不够,可以减小batch size,用梯度累积,或者用混合精度训练。混合精度训练在PyTorch里用AMP(自动混合精度)就能实现,能省30%到50%的显存。
5. 三大架构的对比与选型建议
5.1 架构特性对比
| 特性 | FCN | UNet | DeepLab v3+ |
|---|---|---|---|
| 核心思想 | 全卷积+跳跃连接 | 编码器-解码器+密集跳跃连接 | 空洞卷积+ASPP+编解码 |
| 感受野 | 固定 | 逐层扩大 | 可控,通过膨胀率调节 |
| 多尺度处理 | 弱 | 中等 | 强,ASPP多分支 |
| 边界精度 | 一般 | 好 | 好 |
| 参数量 | 大(VGG骨干) | 中等 | 大(ResNet/Xception骨干) |
| 训练数据需求 | 大 | 小到中等 | 大 |
| 典型应用 | 通用分割 | 医学影像 | 通用分割、自动驾驶 |
5.2 不同场景下的选型逻辑
选哪个架构,主要看你的数据量、任务需求和计算资源。
数据量小(几百到几千张),任务对边界精度要求高,比如医学影像分割,UNet是首选。它的密集跳跃连接能在小数据上训练得很好,而且结构简单,容易改。
数据量大(几万张以上),需要处理多尺度目标,比如街景分割、遥感影像分割,DeepLab v3+更合适。ASPP能有效处理不同尺度的目标,而且可以用预训练骨干,收敛快。
如果只是想做baseline或者教学演示,FCN最简单,容易理解。但实际项目中,FCN的性能通常不如后两者,除非你做很多改进。
计算资源有限的时候,可以考虑轻量化的UNet变体(如MobileNetV2作为编码器),或者DeepLab v3+用MobileNet骨干。这些轻量模型在移动端和嵌入式设备上也能跑。
5.3 迁移学习与预训练权重
这三个架构都可以用预训练权重。FCN和DeepLab通常用ImageNet预训练的骨干网络(VGG、ResNet、Xception),UNet因为结构特殊,预训练权重少一些,但也可以用预训练的编码器。
用预训练权重的好处是收敛快、需要的标注数据少。实践中,除非你的数据量特别大(十万张以上),否则都建议用预训练权重。加载预训练权重的时候要注意,骨干网络的层名要匹配,不匹配的层要跳过或者重新初始化。
实操心得:加载预训练权重的时候,我习惯先打印模型的所有层名,然后跟预训练权重的键做对比,看看哪些层匹配上了、哪些没匹配上。没匹配上的层通常是分类头或者分割头,这些层本来就要重新训练,不用管。但如果是骨干网络的层没匹配上,那就要检查一下模型定义是不是有问题。
6. 训练中常见问题与排查技巧
6.1 Loss不下降或震荡
这是最常见的问题。可能的原因和排查思路:
- 学习率太大:loss震荡或者变成NaN。降低学习率,从1e-3降到1e-4试试。
- 学习率太小:loss下降极慢。适当增大学习率,或者用warmup策略。
- 数据标注有问题:检查标注图是不是全黑或者全白,类别ID是不是从0开始连续。
- 损失函数不适合:类别极度不均衡的时候,交叉熵会被背景类主导。换成Dice Loss或者Focal Loss试试。
- Batch Normalization的问题:小batch size下BN统计量不准,可以换成Group Normalization或者SyncBN。
6.2 过拟合
训练集loss下降但验证集loss上升,就是过拟合了。解决办法:
- 加数据增强,这是最有效的手段。随机裁剪、旋转、缩放、翻转、颜色抖动、弹性变形,能加的都加上。
- 加正则化,L2正则、Dropout、DropBlock。
- 减小模型复杂度,用更浅的网络或者更少的通道数。
- 早停,验证集loss不下降就停。
- 用预训练权重,能显著减少过拟合。
6.3 边界分割不准确
分割结果的边界粗糙、锯齿严重,可能的原因:
- 上采样方式:反卷积容易产生棋盘伪影,换成双线性插值+卷积。
- 跳跃连接不够:加深跳跃连接,或者用Attention门控。
- 损失函数:加边界损失(Boundary Loss)或者用CRF后处理。
- 输出步长:DeepLab里输出步长(output stride)设成16比32的边界精度高,但计算量更大。
6.4 类别不均衡
语义分割里类别不均衡非常常见,比如街景图里天空和路面的像素远多于行人。解决办法:
- 加权交叉熵:给稀有类别更高的权重,权重可以按类别频率的倒数来设。
- Dice Loss:直接优化重叠度,对不均衡不敏感。
- Focal Loss:降低易分类样本的权重,让网络关注难分类的样本。
- OHEM:在线难例挖掘,只对loss大的像素回传梯度。
- 数据重采样:过采样包含稀有类别的图像。
6.5 显存不足
分割任务的内存占用大,显存不足很常见。解决办法:
- 减小batch size,用梯度累积模拟大batch。
- 减小输入尺寸,或者用随机裁剪。
- 用混合精度训练(AMP)。
- 用轻量骨干网络(MobileNet、ShuffleNet)。
- 用梯度检查点(Gradient Checkpointing),用时间换空间。
避坑技巧:混合精度训练在PyTorch里用
torch.cuda.amp就能实现,但要注意loss scaling。如果loss变成NaN,可能是梯度溢出,调大loss scale的初始值或者用动态loss scaling。另外,混合精度下BN的统计量可能会不准,可以试试用FP32跑BN。
7. 从零实现一个UNet分割 pipeline
7.1 数据准备与预处理
假设你有一批图像和对应的标注mask。标注mask通常是单通道的PNG,每个像素的值是类别ID。预处理步骤:
- 把图像和mask resize到统一尺寸,比如256×256或512×512。
- 图像归一化,用ImageNet的均值和标准差,或者自己算数据集的均值和标准差。
- mask不做归一化,保持整数类别ID。
- 划分训练集、验证集、测试集,比例通常7:2:1或8:1:1。
数据增强用Albumentations库,支持图像和mask同步变换。常用的增强组合:
import albumentations as A train_transform = A.Compose([ A.RandomResizedCrop(256, 256, scale=(0.5, 1.0)), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomRotate90(p=0.5), A.ShiftScaleRotate(shift_limit=0.1, scale_limit=0.1, rotate_limit=30, p=0.5), A.ElasticTransform(alpha=120, sigma=120*0.05, p=0.3), A.RandomBrightnessContrast(p=0.3), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ])7.2 模型定义与初始化
UNet的PyTorch实现,核心是双卷积块和上采样块:
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, out_ch=2, features=[64, 128, 256, 512]): super().__init__() self.downs = nn.ModuleList() self.ups = nn.ModuleList() self.pool = nn.MaxPool2d(2) for f in features: self.downs.append(DoubleConv(in_ch, f)) in_ch = f for f in reversed(features): self.ups.append(nn.ConvTranspose2d(f*2, f, 2, stride=2)) self.ups.append(DoubleConv(f*2, f)) self.bottleneck = DoubleConv(features[-1], features[-1]*2) self.final = nn.Conv2d(features[0], out_ch, 1) def forward(self, x): skip_connections = [] for down in self.downs: x = down(x) skip_connections.append(x) x = self.pool(x) x = self.bottleneck(x) skip_connections = skip_connections[::-1] for i in range(0, len(self.ups), 2): x = self.ups[i](x) skip = skip_connections[i//2] if x.shape != skip.shape: x = nn.functional.interpolate(x, size=skip.shape[2:]) x = torch.cat([skip, x], dim=1) x = self.ups[i+1](x) return self.final(x)初始化用He初始化:
def init_weights(m): if isinstance(m, nn.Conv2d): nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') if m.bias is not None: nn.init.zeros_(m.bias) elif isinstance(m, nn.BatchNorm2d): nn.init.ones_(m.weight) nn.init.zeros_(m.bias) model = UNet(in_ch=3, out_ch=num_classes) model.apply(init_weights)7.3 损失函数与优化器配置
损失函数用交叉熵+Dice的组合:
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() criterion = nn.CrossEntropyLoss(weight=class_weights) + DiceLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50)7.4 训练循环与验证
训练循环的关键是每个epoch后在验证集上算mIoU:
def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for images, masks in loader: images, masks = images.to(device), masks.to(device) optimizer.zero_grad() outputs = model(images) loss = criterion(outputs, masks) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) def validate(model, loader, device, num_classes): model.eval() iou_sum = 0 count = 0 with torch.no_grad(): for images, masks in loader: images, masks = images.to(device), masks.to(device) outputs = model(images) preds = torch.argmax(outputs, dim=1) for cls in range(num_classes): pred_cls = (preds == cls) mask_cls = (masks == cls) intersection = (pred_cls & mask_cls).sum().item() union = (pred_cls | mask_cls).sum().item() if union > 0: iou_sum += intersection / union count += 1 return iou_sum / count if count > 0 else 07.5 推理与后处理
推理的时候,把图像resize到训练尺寸,过模型,取argmax得到预测mask,再resize回原图尺寸。后处理可以用条件随机场(CRF)或者形态学操作(开运算、闭运算)来平滑边界。
def predict(model, image, device, target_size): model.eval() with torch.no_grad(): img_tensor = preprocess(image).unsqueeze(0).to(device) output = model(img_tensor) pred = torch.argmax(output, dim=1).squeeze().cpu().numpy() pred = cv2.resize(pred.astype(np.uint8), target_size, interpolation=cv2.INTER_NEAREST) return pred实操心得:推理的时候用
torch.no_grad()能省不少显存。如果要做批量推理,注意不同图像的原始尺寸可能不一样,resize回原图的时候要逐张处理。另外,如果模型输出是logits,argmax之前不用softmax,因为argmax和softmax的单调性一致,结果一样。
8. 语义分割的评估指标与实验分析
8.1 mIoU的计算与理解
mIoU是语义分割最常用的指标。IoU是交并比,对每个类别算预测区域和真实区域的交集除以并集,然后对所有类别取平均。公式:
IoU = TP / (TP + FP + FN)
mIoU = (1/N) * Σ IoU_i
mIoU对类别不均衡比较敏感,因为每个类别权重一样。如果某个类别样本很少,它的IoU可能很低,拉低整体mIoU。所以看mIoU的时候,也要看每个类别的IoU,分析哪些类别表现差。
8.2 Pixel Accuracy与Dice系数
Pixel Accuracy是预测正确的像素占总像素的比例。这个指标在类别不均衡的时候会虚高,因为背景像素占大多数。比如一张图90%是背景,全预测背景就有90%的准确率,但实际没用。
Dice系数是2TP / (2TP + FP + FN),跟IoU类似,但对小目标更敏感。医学影像分割常用Dice,因为目标区域通常很小。
8.3 可视化分析与错误诊断
光看指标不够,还要可视化预测结果。把原图、真实mask、预测mask并排显示,看看哪里错了。常见的错误模式:
- 边界模糊:预测的边界比真实边界粗或者细。
- 小目标漏检:小物体被忽略或者跟背景混在一起。
- 类别混淆:相似类别互相误判,比如“猫”和“狗”。
- 大目标内部空洞:大目标内部出现错误的类别预测。
针对不同的错误模式,调整策略也不同。边界问题加边界损失,小目标问题加多尺度训练或者Focal Loss,类别混淆问题检查标注质量或者加类别间对比损失。
8.4 消融实验的设计
做实验的时候,消融实验能帮你理解每个模块的贡献。比如:
- 去掉ASPP,看mIoU掉多少。
- 去掉跳跃连接,看边界精度掉多少。
- 换不同的骨干网络,看性能和速度的权衡。
- 加不同的数据增强,看泛化能力的变化。
消融实验的设计要控制变量,每次只改一个因素。结果用表格记录,方便对比。
9. 语义分割的部署与优化
9.1 模型导出与推理优化
训练好的模型要部署,通常导出成ONNX或者TorchScript。导出ONNX的时候注意opset版本,不同版本支持的算子不一样。导出后用ONNX Runtime或者TensorRT做推理优化,能显著提速。
# 导出ONNX dummy_input = torch.randn(1, 3, 512, 512).to(device) torch.onnx.export(model, dummy_input, "unet.onnx", opset_version=11, input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch', 2: 'height', 3: 'width'}})9.2 量化与剪枝
量化是把FP32的权重和激活值转成INT8,能减少模型大小和加速推理。PyTorch支持动态量化和静态量化。静态量化需要校准数据,精度损失通常比动态量化小。
剪枝是去掉不重要的权重或者通道,减少计算量。结构化剪枝(去掉整个通道)对硬件更友好,非结构化剪枝(去掉单个权重)压缩率高但需要特殊硬件支持。
9.3 移动端部署的注意事项
移动端部署要考虑算力、内存和功耗。轻量骨干网络(MobileNetV3、ShuffleNetV2)是首选。输入尺寸也要控制,512×512在移动端可能太大,256×256或者更小更实际。
推理框架用NCNN、MNN或者TFLite,这些框架对移动端做了专门优化。部署前要在目标设备上测速度和精度,确保满足实时性要求。
避坑技巧:移动端部署的时候,注意算子兼容性。有些算子(如空洞卷积、可变形卷积)在移动端框架里可能不支持或者效率低。部署前先查一下框架支持的算子列表,不支持的算子要替换或者用自定义实现。
10. 语义分割的进阶方向与扩展思路
10.1 Transformer在分割中的应用
近几年Transformer在分割领域很火,比如SegFormer、Mask2Former。Transformer的自注意力机制能捕捉长距离依赖,对全局上下文的理解比CNN更强。但Transformer的计算复杂度是O(N^2),高分辨率下计算量很大,所以通常用金字塔结构或者窗口注意力来降低复杂度。
实践中,Transformer分割模型在数据量大的时候表现好,但数据量小的时候不如CNN。而且训练成本高,需要更多的显存和更长的训练时间。如果你的数据量够大、算力够,可以试试;否则CNN-based的方法更实际。
10.2 弱监督与半监督分割
标注分割mask很贵,所以弱监督和半监督分割是热门方向。弱监督用图像级标签、框标注或者涂鸦标注来训练分割模型。半监督用少量标注数据+大量未标注数据。
常用的方法有:用CAM(类激活图)生成伪标签,然后迭代训练;用一致性正则化,让模型对未标注数据的扰动保持输出一致;用对比学习,拉近同类像素的特征距离。
10.3 实时语义分割
实时分割在自动驾驶、机器人领域需求很大。核心是速度和精度的权衡。常用的方法有:轻量骨干网络、多分支架构(如BiSeNet)、早期下采样、注意力优化。
BiSeNet用两个分支,一个空间分支保留高分辨率细节,一个上下文分支提取语义,最后融合。这种设计在速度和精度之间取得了很好的平衡,适合实时应用。
10.4 3D语义分割
3D分割用于点云、体素数据,比如自动驾驶的激光雷达点云分割、医学的CT/MRI体积分割。3D UNet是最常用的架构,把2D卷积换成3D卷积。但3D卷积计算量大,显存占用高,通常需要降采样或者用稀疏卷积。
点云分割常用PointNet++、RandLA-Net这些专门处理无序点集的网络。体素分割可以用3D UNet或者稀疏卷积网络(如MinkowskiNet)。
10.5 自监督预训练在分割中的应用
自监督预训练用无标注数据学习通用特征,然后在下游分割任务上微调。常用的自监督方法有:对比学习(SimCLR、MoCo)、掩码图像建模(MAE、BEiT)、旋转预测等。
自监督预训练的好处是减少对标注数据的依赖,而且学到的特征泛化性好。但预训练成本高,需要大量数据和算力。如果下游数据量小,自监督预训练能带来明显提升。
我在实际项目里用MAE预训练的ViT做分割,在小样本场景下比ImageNet预训练的ResNet效果好不少。但MAE预训练本身要跑很久,如果只是做小项目,直接用ImageNet预训练权重更划算。
10.6 多模态语义分割
多模态分割融合RGB、深度、热红外、激光雷达等不同模态的数据。不同模态提供互补信息,比如RGB提供纹理和颜色,深度提供几何信息,热红外提供温度信息。
融合策略有早期融合(输入层拼接)、中期融合(特征层拼接或注意力融合)、晚期融合(决策层融合)。中期融合最常用,因为能兼顾不同模态的特征。融合模块的设计是关键,常用的有注意力融合、门控融合、双流网络。
实际做多模态分割的时候,最大的问题是模态对齐和缺失模态的处理。不同传感器的数据在空间和时间上可能不对齐,需要做标定和同步。如果某个模态在推理时缺失,模型要能降级运行,这需要在训练时做模态dropout。
11. 我踩过的那些坑与经验总结
11.1 数据标注的坑
标注质量直接决定模型上限。我见过太多项目因为标注问题导致模型效果差。常见问题:标注边界不一致(不同标注员对边界的理解不同)、漏标(小目标被忽略)、类别定义模糊(“道路”和“人行道”的边界不清)。
解决办法:制定详细的标注规范,做标注一致性检查(多人标注同一批数据,算IoU),用标注工具做质量审核。如果预算允许,做多轮标注和交叉验证。
11.2 学习率调参的坑
学习率是最重要的超参数。我试过用太大的学习率导致loss爆炸,也试过太小的学习率导致收敛极慢。经验是:先用一个较大的学习率跑几个epoch,看loss变化,如果震荡就降10倍,如果下降太慢就升3倍。找到合适的范围后,用Cosine Annealing或者OneCycle策略。
另外,warmup对Transformer和大batch训练很重要。前几个epoch用很小的学习率,然后线性增加到初始学习率,能避免训练初期的不稳定。
11.3 预训练权重的坑
加载预训练权重的时候,经常遇到键不匹配的问题。比如骨干网络用了ResNet,但预训练权重是ImageNet分类的,最后的全连接层键名不一样。这时候要过滤掉不匹配的键,只加载匹配的。
还有一种情况是,预训练权重的通道顺序或者归一化方式跟你的数据不一样。比如ImageNet预训练用的是RGB通道,如果你的数据是BGR,要先转换。归一化参数也要匹配,用ImageNet的均值和标准差。
11.4 多GPU训练的坑
多GPU训练用DataParallel或者DistributedDataParallel。DataParallel简单但效率低,因为主GPU的负载高。DistributedDataParallel效率高,但配置复杂,需要设置local_rank和分布式采样器。
用多GPU的时候,Batch Normalization要换成SyncBN,否则每个GPU的统计量不一致,影响性能。学习率也要按GPU数量线性缩放,比如单GPU用0.01,4GPU就用0.04。
11.5 模型保存与恢复的坑
保存模型的时候,要保存模型状态字典、优化器状态、epoch数、最佳指标。只保存模型权重的话,恢复训练的时候优化器状态丢了,学习率调度会乱。
恢复训练的时候,注意模型结构要跟保存时一致。如果改了模型结构,加载权重会报错。这时候可以用strict=False加载,跳过不匹配的层。
最后分享一个小技巧:训练分割模型的时候,我习惯在每个epoch结束后保存一个checkpoint,同时保存最佳模型。checkpoint包含所有状态,用于恢复训练;最佳模型只包含权重,用于推理。这样即使训练中断,也能从最近的checkpoint恢复,不会丢进度。另外,验证集指标用mIoU和Dice都算一下,有时候mIoU高但Dice低,说明小目标分割不好,需要针对性优化。