1. 语义分割任务的核心逻辑与方案选型
语义分割这件事,说白了就是让机器对图像里的每一个像素做分类。输入一张图,输出一张同样尺寸的标签图,每个像素归属到某个类别——路面、车辆、行人、天空、建筑,诸如此类。和图像分类只输出一个标签不同,语义分割要求的是像素级理解,这对模型的空间感知能力提出了完全不同的要求。
我最早接触这个方向的时候,踩过一个很典型的坑:拿分类网络的思路直接套分割任务,把 backbone 换掉就开始训,结果 mIoU 死活上不去。后来才想明白,分类网络经过多次下采样之后,空间分辨率已经丢得差不多了,而分割恰恰需要精细的空间信息。这个认知转变,是理解整个语义分割训练与优化技巧的起点。
1.1 编码器-解码器架构为什么成为主流
目前绝大多数语义分割方案都采用编码器-解码器结构。编码器负责逐级提取语义特征,空间尺寸不断缩小、通道数不断增加;解码器则负责逐级恢复空间分辨率,把深层语义信息映射回原图尺寸。这个设计的核心逻辑在于:语义分割既需要“这是什么”的语义判断,也需要“在哪里”的空间定位,两者缺一不可。
编码器通常用在 ImageNet 上预训练过的分类网络来初始化,比如 ResNet、EfficientNet 这类。为什么一定要用预训练权重?因为分割数据集的标注成本极高,一张精细标注的分割图可能需要几十分钟甚至更久,导致公开数据集规模远小于分类数据集。预训练权重提供了良好的特征提取起点,能让模型在较小数据集上更快收敛。我实测过,从零训练和用预训练权重初始化,在同样的迭代次数下 mIoU 差距可以到 15 个点以上。
解码器的设计则有多种流派。FCN 式的直接上采样最简单,但恢复出来的边界很粗糙;U-Net 式的跳跃连接把编码器各阶段的特征拼接到解码器对应层,能显著改善边界质量;还有 ASPP(空洞空间金字塔池化)这类结构,通过不同膨胀率的空洞卷积捕获多尺度上下文。选哪种,取决于你的任务对边界精度的要求和对推理速度的容忍度。
1.2 损失函数的选择不是拍脑袋决定的
语义分割最常用的损失是交叉熵损失,逐像素计算分类误差。但直接用交叉熵有个问题:类别不平衡。比如街景分割里,路面和天空可能占了大半张图,而交通标志、行人这类小目标只占几个像素。交叉熵会被大类别主导,小类别学不好。
解决方案有几条路。一是加权交叉熵,给小类别更高的权重,权重可以按类别频率的倒数来设,也可以按中位数频率归一化。二是 Dice Loss,它直接优化预测区域和真实区域的重叠度,对类别不平衡天然更鲁棒。三是 Focal Loss,降低易分类样本的权重,让模型聚焦在难样本上。
我自己的经验是:如果类别极度不平衡(比如小目标占比不到 1%),Dice Loss 或者 Dice + CE 的组合效果最稳;如果类别相对均衡,纯 CE 就够了,加太多花哨的损失反而可能拖慢收敛。另外,OHEM(在线难例挖掘)也值得一试,每批数据里挑损失最大的那些像素回传梯度,能加速难样本的学习。
1.3 评价指标:mIoU 之外你还需要关注什么
mIoU(平均交并比)是语义分割的标配指标,计算每个类别的 IoU 再取平均。但只看 mIoU 容易忽略一些问题。比如小类别的 IoU 可能很低,但因为类别多,平均下来被大类别的高分掩盖了。所以我会同时看 per-class IoU,确认没有哪个类别被彻底放弃。
另外,边界精度也值得单独关注。有些场景下 mIoU 看起来不错,但分割边界很毛糙,实际可用性差。可以用 Boundary IoU 这类指标来评估边界质量,或者在验证集上可视化几个典型样本,肉眼确认边界贴合程度。像素准确率(Pixel Accuracy)虽然直观,但在类别不平衡时参考价值有限,不建议作为主要指标。
2. 数据准备与增强策略的实操细节
数据这块,很多人把精力全花在模型结构上,结果被数据问题卡住。我见过不少案例,模型没问题,就是标注质量差或者增强策略不合理,导致训练效果上不去。语义分割对标注质量的要求比分类和检测都高,因为每个像素都要标对。
2.1 标注质量检查不能省
拿到标注数据后,第一件事是做质量抽检。具体做法是随机抽 5% 到 10% 的样本,把原图和标注图叠加显示,逐张检查。重点看几个地方:类别边界是否贴合、有没有漏标的小目标、有没有把相似类别标混(比如把“轿车”标成“卡车”)、标注是否覆盖了整张图。
我踩过的一个坑是:某个数据集的标注里,图像边缘有一圈未标注区域,训练时这些像素的损失被算进去了,但标签是无效的。后来在数据加载阶段加了一个 mask,把无效区域排除在损失计算之外,mIoU 直接涨了 3 个点。所以,检查标注的完整性、确认是否有 ignore 区域,是数据准备阶段必须做的事。
2.2 数据增强:几何变换与颜色变换的取舍
语义分割的数据增强必须同时作用于图像和标注图,而且几何变换要保持两者对齐。常用的增强手段分两类:
几何变换包括随机缩放、随机裁剪、随机水平翻转、随机旋转。缩放和裁剪是最有效的,因为它们直接改变了目标的尺度和位置分布。水平翻转在大多数自然场景下都合理,但要注意有些类别翻转后语义会变(比如文字、交通标志的方向),这类场景要慎用。旋转的话,小角度(±10 度以内)通常安全,大角度旋转可能引入不合理的场景布局。
颜色变换包括亮度、对比度、饱和度、色调的随机扰动,以及添加噪声、模糊等。颜色变换只作用于图像,不改变标注。这类增强能提升模型对光照和成像条件变化的鲁棒性。我通常会把颜色扰动的幅度控制在适中范围,太强的话可能让图像失真到影响语义判断。
还有一个进阶技巧是 CutMix 和 Copy-Paste。CutMix 是把一张图的某个区域裁剪出来贴到另一张图上,标签也相应混合。Copy-Paste 则是把某个类别的实例抠出来贴到其他图上。这两种方法对小目标分割特别有效,因为它们人为增加了小目标的出现频率。不过要注意粘贴时的上下文合理性,别把“船”贴到“天空”上。
2.3 数据加载与预处理中的性能陷阱
数据加载往往是训练速度的瓶颈。我见过不少情况,GPU 利用率只有 30% 到 40%,瓶颈全在数据加载上。几个优化点:
- 使用多进程数据加载,num_workers 设成 CPU 核心数的 2 到 4 倍,但不要超过 16,否则进程调度开销反而拖慢速度。
- 把预处理做在 GPU 上。比如归一化、颜色变换这些操作,可以在数据加载到 GPU 之后再用 GPU 算,比在 CPU 上做快得多。
- 预先把数据转成更高效的格式。比如把大量小图打包成 LMDB 或 HDF5,减少文件 I/O 开销。
- 如果用了随机裁剪,确保裁剪区域里至少包含一个有效类别的像素,否则这张裁剪图对训练没有贡献。
注意:多进程加载时,如果数据集对象里持有文件句柄或数据库连接,可能因为进程 fork 导致问题。建议在 Dataset 的init里只存路径和元信息,实际读取放在getitem里。
3. 训练策略与优化技巧的深度拆解
训练策略这块,学习率调度、优化器选择、批量大小设置、正则化手段,每一项都会影响最终效果。我下面按实际训练流程的顺序来讲,从初始化到收敛,把每个环节的关键决策点说清楚。
3.1 学习率调度:warmup 与衰减的配合
学习率是最重要的超参数之一。语义分割训练通常采用“warmup + 衰减”的策略。warmup 是在训练初期用很小的学习率逐步升到初始学习率,目的是避免预训练权重被早期的大梯度破坏。我一般设 warmup 迭代数为总迭代数的 5% 到 10%,初始 warmup 学习率设为初始学习率的 1/1000 到 1/100。
衰减策略常用 poly 衰减或余弦退火。poly 衰减的公式是 lr = init_lr * (1 - iter/max_iter)^power,power 通常取 0.9。余弦退火则是 lr = min_lr + 0.5 * (init_lr - min_lr) * (1 + cos(pi * iter / max_iter))。两种我都用过,poly 衰减在分割任务上更常见,收敛更稳;余弦退火在训练后期学习率降得更慢,有时能挖出更好的解。
初始学习率怎么定?如果 backbone 用了预训练权重,初始学习率可以设大一点,比如 0.01 到 0.02(SGD)或者 1e-4 到 3e-4(Adam)。如果是从零训练,学习率要小一个量级。我通常先跑一个短周期的学习率扫描,比如用 1/10 的数据跑 20 个 epoch,试三到五个学习率,看哪个收敛最好。
3.2 优化器选择:SGD 还是 Adam
SGD 加动量是分割任务的传统选择,泛化性能通常更好,但收敛慢,对学习率敏感。Adam 收敛快,对学习率不那么敏感,但有时泛化不如 SGD。我的经验是:如果训练周期充足(比如 100 个 epoch 以上),用 SGD + momentum 0.9 + weight decay 1e-4;如果训练周期短或者数据量小,用 Adam 或 AdamW 更省心。
Weight decay 的设置也有讲究。用 SGD 时,weight decay 通常设 1e-4 到 5e-4;用 Adam 时,建议用 AdamW,它把 weight decay 和梯度更新解耦,效果更稳定。另外,对 BN 层的参数和 bias 通常不施加 weight decay,这个细节在实现时要注意。
3.3 批量大小与同步 BN
批量大小受显存限制,分割任务因为特征图大,批量通常比分类小很多。单卡可能只能放 2 到 4 张图。批量小会导致 BN 层的统计量估计不准,影响训练稳定性。解决方案有两个:一是用同步 BN,多卡之间同步 BN 的均值和方差;二是用 Group Norm 或 Layer Norm 替代 BN。
同步 BN 在 PyTorch 里可以通过 SyncBatchNorm 转换实现。我实测过,4 卡同步 BN 相比单卡 BN,mIoU 能涨 1 到 2 个点,尤其是批量小于 8 的时候效果明显。如果不想用同步 BN,也可以冻结 BN 层的统计量,用预训练时的均值和方差,但这样会损失一些适应性。
3.4 正则化与防过拟合
分割模型参数量大,过拟合是常见问题。除了 weight decay,还有几个手段:
- Dropout:在解码器部分加 Dropout,比例 0.1 到 0.5。注意不要在编码器加太多,否则会破坏预训练特征。
- DropBlock:比 Dropout 更适合卷积网络,它随机丢弃连续的区域而不是单个像素,迫使网络学习更鲁棒的特征。
- 早停:监控验证集 mIoU,如果连续 10 到 15 个 epoch 没有提升,就停止训练。这是最省事的防过拟合手段。
- 模型集成:训练多个模型取平均,或者用不同随机种子训练同一个模型取平均,通常能涨 1 到 3 个点。
提示:验证集 mIoU 的波动可能比较大,建议用滑动平均或者每 5 个 epoch 评估一次,避免被单次波动误导。
4. 常见问题排查与调优经验实录
训练过程中遇到的问题五花八门,我整理了几个最典型的,附上排查思路和解决方法。
4.1 损失不下降或下降很慢
这是最常见的问题。排查顺序如下:
第一,检查数据管道。把数据加载后的图像和标注可视化出来,确认图像归一化是否正确、标注是否对齐、类别映射是否对得上。我遇到过标注图的像素值是 0 到 255 的灰度图,但代码里按 0 到 N 的类别索引去读,结果全错位了。
第二,检查学习率。学习率太大可能导致损失震荡不下降,太小则下降极慢。可以跑一个学习率扫描,或者先用一个很小的学习率跑几个 epoch,确认模型能过拟合一小批数据。如果连 10 张图都过拟合不了,那肯定是代码有 bug。
第三,检查损失函数。确认 ignore 区域的像素是否被正确排除,类别权重是否设置合理。如果用了 Dice Loss,确认分母有没有加平滑项,否则小目标区域可能除零。
第四,检查预训练权重加载。有时候权重加载了但 key 对不上,实际上还是随机初始化。打印一下加载前后的参数对比,确认权重真的加载进去了。
4.2 验证集 mIoU 远低于训练集
这是过拟合的典型表现。解决方法按优先级排:
- 增加数据增强的强度和多样性。
- 增大 weight decay。
- 加 Dropout 或 DropBlock。
- 减少模型参数量,或者用更轻量的 backbone。
- 早停,取验证集最好的 checkpoint。
- 如果训练集和验证集分布差异大,考虑用域适应方法,或者在验证集分布上做微调。
还有一种可能是验证集的评估代码有 bug。比如验证时忘了把模型设为 eval 模式,BN 层还在用批量统计量;或者验证时的输入尺寸和训练时不一致,导致性能下降。这些细节都要逐一确认。
4.3 小目标分割效果差
小目标是分割的难点。除了前面提到的 Copy-Paste 增强和 Dice Loss,还有几个技巧:
- 提高输出特征图的分辨率。比如把解码器的最后上采样倍数从 8 倍改成 4 倍,或者用空洞卷积保持更高的空间分辨率。
- 用多尺度训练和测试。训练时随机缩放图像,测试时对多个尺度取平均。
- 在损失函数里给小目标类别更高的权重。
- 后处理时对小目标区域做形态学操作,比如闭运算填补空洞。
我试过一个案例,街景分割里交通标志的 IoU 只有 0.3 左右,用了 Copy-Paste 增强加上多尺度测试之后,涨到了 0.55 以上。
4.4 边界分割不精细
边界问题通常和感受野、特征分辨率有关。解决方法:
- 用 U-Net 式的跳跃连接,把浅层的高分辨率特征引入解码器。
- 在损失函数里加边界感知的项,比如 Boundary Loss,它计算预测边界和真实边界的距离。
- 后处理用 CRF(条件随机场)或者导向滤波来细化边界。CRF 效果更好但速度慢,导向滤波速度快但效果稍逊。
- 训练时对边界区域给更高的损失权重。
4.5 训练速度慢,GPU 利用率低
先确认瓶颈在哪。用 nvidia-smi 看 GPU 利用率,如果低于 70%,大概率是数据加载或预处理拖后腿。用 PyTorch Profiler 或者简单的计时,看数据加载、前向、反向各占多少时间。
如果是数据加载慢,增加 num_workers、用更快的存储、预先把数据转成二进制格式。如果是前向反向慢,检查有没有不必要的同步操作,比如频繁的 .item() 调用或者 CPU-GPU 数据传输。混合精度训练(AMP)也能显著加速,通常能快 30% 到 50%,显存占用还更少。
| 问题现象 | 可能原因 | 排查方法 | 解决方向 |
|---|---|---|---|
| 损失不下降 | 数据管道错误 | 可视化数据 | 修正归一化/标注读取 |
| 损失不下降 | 学习率不当 | 学习率扫描 | 调整学习率 |
| 验证 mIoU 低 | 过拟合 | 对比训练/验证曲线 | 增强正则化 |
| 小目标效果差 | 分辨率不足 | 检查输出步长 | 提高分辨率/多尺度 |
| 边界粗糙 | 浅层特征丢失 | 检查跳跃连接 | 加跳跃连接/边界损失 |
| 训练慢 | 数据加载瓶颈 | Profiler 计时 | 多进程/预转格式 |
5. 推理部署与后处理优化
训练完之后,推理部署这块也有不少可以优化的地方。模型在验证集上表现好,不代表部署到实际场景就没问题。
5.1 推理速度优化
推理速度直接影响用户体验。几个常用手段:
- 模型剪枝:去掉冗余的通道或层,参数量可以减少 30% 到 50%,精度损失通常在 1 到 2 个点以内。
- 量化:把 FP32 转成 FP16 或 INT8,速度能提升 2 到 4 倍,显存占用大幅降低。INT8 量化需要校准数据,精度损失要仔细评估。
- TensorRT 或 ONNX Runtime:把模型转成推理引擎,做图层融合和内核优化,速度提升明显。
- 输入分辨率调整:如果场景允许,降低推理分辨率能显著提速,但小目标精度会下降,需要权衡。
我一般会先导出 ONNX,确认输出和 PyTorch 一致,再用 TensorRT 做进一步优化。导出时注意动态轴设置,如果推理时输入尺寸会变,要把高度和宽度设为动态维度。
5.2 后处理:CRF 与形态学操作
CRF 后处理能显著改善边界质量,尤其是物体边界模糊的情况。做法是把 CNN 输出的概率图作为 CRF 的一元势,图像的颜色和位置作为二元势,优化后得到更精细的边界。缺点是速度慢,一张图可能要几百毫秒。如果对速度有要求,可以用导向滤波替代,速度快一个量级,效果也不错。
形态学操作主要用于去除小噪点和填补空洞。开运算(先腐蚀后膨胀)去噪点,闭运算(先膨胀后腐蚀)填空洞。结构元素的大小根据目标尺度来定,通常 3x3 或 5x5 就够了。注意不要过度操作,否则会破坏小目标。
5.3 多尺度测试与模型集成
多尺度测试是把输入图像缩放到多个尺度分别推理,再把结果融合。融合方式可以是平均概率图,或者取最大概率。多尺度测试通常能涨 1 到 2 个点,但推理时间成倍增加。如果延迟允许,建议至少用三个尺度(0.75x、1.0x、1.25x)。
模型集成则是训练多个模型,推理时取平均。不同 backbone、不同随机种子、不同数据增强策略训练出来的模型,集成效果最好。我试过用 ResNet-50 和 EfficientNet 两个 backbone 分别训练,集成后 mIoU 比单模型涨了 2.5 个点。
提示:多尺度测试和模型集成的收益会随基线提升而递减。如果单模型已经很强了,集成带来的提升可能只有零点几个点,这时候要权衡推理成本和收益。
6. 我踩过的坑与实战心得
最后分享几个我在实际项目中踩过的坑,都是文档里不会写的。
第一个坑:忽略标注的 ignore 区域。前面提过,但值得再强调。很多数据集有 void 或 unlabeled 区域,如果不排除,损失会被这些无效像素干扰。检查方法是统计标注图的像素值分布,确认有没有超出类别数的值。
第二个坑:验证集评估时忘了同步 BN 的统计量。如果用同步 BN 训练,验证时也要用同步 BN,否则统计量不一致,mIoU 会偏低。这个坑很隐蔽,因为训练时看起来一切正常。
第三个坑:学习率 warmup 太短。warmup 太短的话,预训练权重在早期就被大梯度破坏了,后面很难恢复。我一般设 warmup 迭代数不少于 500 次,数据量大的话设 1000 到 2000 次。
第四个坑:数据增强的随机性没有固定种子。调试时每次跑的结果不一样,很难定位问题。建议在调试阶段固定随机种子,确认流程没问题后再放开。
第五个坑:推理时的预处理和训练时不一致。比如训练时用了归一化,推理时忘了,结果输出完全不对。建议把预处理逻辑封装成一个函数,训练和推理共用,避免不一致。
第六个坑:模型保存时只存了参数,没存优化器状态。如果要恢复训练,优化器状态丢了会导致学习率调度和动量信息丢失,恢复后的训练可能不稳定。建议保存完整的 checkpoint,包括模型参数、优化器状态、当前 epoch 和最佳 mIoU。
这些经验都是实际项目中积累的,希望能帮你少走些弯路。语义分割这个方向,模型结构固然重要,但数据质量、训练策略、后处理这些环节同样关键,任何一个环节出问题都可能让最终效果大打折扣。