ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

FCN实战Cityscapes语义分割:数据预处理、训练调参与性能优化

FCN实战Cityscapes语义分割:数据预处理、训练调参与性能优化 1. 为什么选FCN配合Cityscapes做语义分割入门先说结论说起语义分割FCNFully Convolutional Network是一个绕不开的名字。2015年Long等人提出FCN的时候它第一次让端到端的像素级分类成为可能把VGG、GoogLeNet这类分类网络最后的全连接层换成了卷积层解决了输入任意尺寸图像、输出对应尺寸分割图的问题。而Cityscapes则是自动驾驶场景下最经典的街景数据集之一包含50个城市的道路驾驶影像精细标注了19个类别是检验分割模型泛化能力的标准考场。在讨论FCN训练Cityscapes这个话题时很多人容易陷入两个误区一是觉得FCN太老不如DeepLabV3、PSPNet这类模型先进不值得学二是觉得Cityscapes数据集太大、标注太繁琐直接用现成脚本跑一遍就完事。实际我做完整个流程的感受是——FCNCityscapes恰好是理解语义分割全链路的最佳组合。FCN结构足够简单你能把反卷积、跳跃连接、感受野这些概念真正搞透Cityscapes的标注质量在公开数据集里数一数二但它的原始格式处理起来又足够折腾能帮你把数据管线的基本功练扎实。这篇文章不打算贴一段训练脚本然后说照着跑就行重点讲三件事第一FCN的每个设计点到底解决什么问题遇到分割效果不好时从哪里下手第二Cityscapes的gtFine标注不是拿来就能用的格式转换、类别映射、ignore_index这些细节怎么处理第三训练过程中实际会踩到的坑——显存溢出、类别不平衡、上采样棋盘效应、预训练权重加载失败等等我把排查思路完整写出来。无论你是刚接触语义分割的研究生还是想评估FCN在业务场景里性价比的工程人员这套内容都能直接用。2. Cityscapes数据集落地实操从下载到标签转换再到类别映射2.1 下载与目录结构左右Img和gtFine要分清Cityscapes官方数据分两部分leftImg8bit是原始街景图gtFine是精细标注图。数据按城市划分训练集、验证集、测试集各自包含不同城市的序列帧。下载的时候要注册账号这个流程本身不复杂但有一个点很容易忽略——测试集没有公开的标注官方只提供原图标注要提交预测结果后由评测服务器打分。所以你本地训练和验证主要用train和val两个子集就够了。下载完的目录结构应该是这样的Cityscapes/ ├── leftImg8bit/ │ ├── train/ │ │ ├── aachen/ │ │ └── ...其他城市 │ └── val/ └── gtFine/ ├── train/ │ ├── aachen/ │ └── ... └── val/注意gtFine里每个城市文件夹下每张图对应4个文件_gtFine_color.png彩色可视化标注图人眼看的不用于训练_gtFine_labelIds.png这个才是真正训练时要用的每个像素存的是类别ID_gtFine_polygons.json多边形的矢量标注用于重新生成标注或做数据增强_gtFine_instanceIds.png实例ID标注做实例分割才用得到我见过不少新手拿着color图去训练出来的结果自然是错的。labelIds.png是灰度图像素值直接对应0~33的类别ID但里面包含34个训练ID、19个评估ID、以及忽略区域ignore region的255编号直接用会出问题需要做类别映射。2.2 类别映射34类压缩到19类的规则Cityscapes原始标注有34个类别ID但官方评测标准只用其中19类。多出来的那些类如建筑物外立面、交通标志的细节变体等在评测时被合并或忽略。训练时需要把34类映射到19类同时把road、sidewalk这19个类以外的像素设为ignore_index通常是255。映射规则有个现成的工具在Cityscapes官方脚本仓库里有labels.py里面定义了一个trainIdToLabelId数组记录了每个trainId对应的原始labelId。实际使用时只需要读取labelIds.png然后逐个像素替换import numpy as np from PIL import Image # 这是19类ignore的trainId定义 train_id_map { 0: 7, # road - road 1: 8, # sidewalk - sidewalk # ... 完整映射参考官方labels.py } def convert_label(img_path): label_img np.array(Image.open(img_path).convert(L)) output np.ones(label_img.shape, dtypenp.uint8) * 255 for train_id, label_id in train_id_map.items(): output[label_img label_id] train_id return output为什么要把忽略区域设为255而不是19因为PyTorch的CrossEntropyLoss有一个ignore_index参数设置为255后这些像素在计算loss时完全不参与梯度回传相当于告诉网络这些区域我不关心你随便预测。这个细节直接决定模型能不能收敛。如果你不处理ignore_index网络会努力把那些红绿灯杆、车辆内部这些语义模糊的像素也分对不仅拖慢收敛还会拉低真正关心的19类准确率。2.3 数据增强与加载随机裁剪比resize更适合分割任务Cityscapes原图是2048x1024这个尺寸直接喂给FCN显卡显存直接爆炸。通常做法有两种一是resize到512x256或1024x512再训练二是随机裁剪到固定大小。我在实践中更推荐后一种因为resize会改变物体的长宽比例车辆、行人特别是远处的物体会变形导致分割边界不准确。推荐的数据增强组合随机水平翻转概率0.5随机缩放0.5~2.0倍缩放后随机裁剪到768x384颜色抖动亮度、对比度、饱和度模拟不同光照没有使用旋转和错切因为街景语义有方向性旋转会让道路和天空颠倒数据加载用PyTorch的Dataset和DataLoader即可注意num_workers要根据机器配置调整Cityscapes单张图解码后比较大worker数太少会导致GPU利用率不足。我测试过一个8核CPU的机器上num_workers设为4~6比较合适超过8反而因为进程切换开销下降。3. FCN网络结构拆解从VGG骨架上理解反卷积和跳跃连接3.1 FCN-32s、FCN-16s、FCN-8s的设计逻辑FCN的核心贡献在于把传统CNN末端的全连接层替换为卷积层使得网络可以接受任意尺寸输入并输出密集预测。基于VGG16的FCN通常有三个变体区别在于上采样路径的融合程度FCN-32s的做法最直接VGG16的pool3、pool4、pool5逐级降采样到pool5时特征图尺寸已经缩小为输入的1/32。此时把最后一个卷积层的输出直接上采样32倍恢复到原图尺寸。优点是实现简单缺点是丢失了大量细节因为pool4和pool3层保存的边缘、纹理信息完全没有被利用输出的分割图很粗糙边缘像马赛克一样。FCN-16s做了一个改进把pool5上采样2倍和pool4的输出逐元素相加再整体上采样16倍。FCN-8s则更进一步把pool4的输出也上采样2倍与pool3融合再上采样8倍。融合的层数越多网络能同时利用高层语义信息和低层细节信息分割边界越锐利。我在实际训练FCN-8s时观察到一个现象上采样倍率越小Loss下降越快最终mIoU也更高这是因为梯度可以通过更短的路径回传到浅层特征图训练更充分。所以在显存允许的情况下优先选FCN-8s。3.2 上采样方式反卷积和双线性插值的选择FCN原文里用的是转置卷积也叫反卷积本质上是卷积操作的逆向过程。转置卷积有一个臭名昭著的问题——棋盘效应checkerboard artifacts也就是生成图里出现格子状的纹理伪影。这是因为卷积核的尺寸和stride不是整数倍关系时上采样图不同区域的贡献不均匀。针对这个问题实际工程里常用双线性插值上采样替代转置卷积或者先用双线性插值放大到目标尺寸再用普通卷积精修。两种做法各有取舍上采样方式优点缺点转置卷积参数可学习理论上能自适应上采样容易产生棋盘伪影参数量大双线性插值卷积无棋盘效应计算量小上采样模式固定无法学习我的经验是分割效果上两者最终差距不大但双线性插值在训练初期收敛更快且不容易出现明显伪影对工程落地更友好。如果你一定要用转置卷积kernel size最好取stride的整数倍比如stride2时用4x4卷积核能在很大程度上避免棋盘效应。3.3 预训练权重VGG16迁移学习的正确姿势训练FCN时VGG16那部分卷积层通常用ImageNet预训练权重初始化。这个操作让模型起步就具备较强的特征提取能力从随机初始化开始训练的话同等迭代次数下mIoU会低10个点左右。但直接加载预训练权重有一个坑VGG16最后的三层全连接层在FCN中已经被替换为1x1卷积了加载权重时如果strictTrue会报错。正确的做法是加载时忽略分类头import torch from torchvision.models import vgg16_bn vgg vgg16_bn(pretrainedTrue) model_dict model.state_dict() # model是你的FCN模型 pretrained_dict {k: v for k, v in vgg.state_dict().items() if k in model_dict and classifier not in k} model_dict.update(pretrained_dict) model.load_state_dict(model_dict)另外要注意官方VGG16预训练时输入图像是224x224且做了RGB通道的mean/std归一化mean[0.485, 0.456, 0.406]std[0.229, 0.224, 0.225]。你训练Cityscapes时输入尺寸是1024x1024甚至更大迁移过来的BatchNorm层统计量可能不完全匹配前几个epoch会有一个适应过程这时的Loss可能会出现小幅波动属于正常现象不要慌。4. 训练配置与超参数调优的完整记录4.1 损失函数与类别权重Cityscapes的类别不平衡问题Cityscapes里road、building这类大物体占据了画面的大部分像素而motorcycle、rider这类小物体像素占比极低。如果不做任何处理模型会严重偏向大类别小车和行人的分割效果会很差。处理办法是给损失函数加类别权重权重的计算公式一般是# 先在训练集上统计每个类别的像素占比 class_freq np.bincount(all_labels.flatten(), minlength19) class_weights 1.0 / np.log(1.02 class_freq / class_freq.sum())这个公式来自《Class-Balanced Loss Based on Effective Number of Samples》一文比简单的1.0 / freq更平滑不会让低频类别的权重过大导致训练震荡。算出来之后喂给CrossEntropyLoss的weight参数即可。实际测试下来加类别权重后mIoU能提升2~4个百分点特别是person和rider这两个类别的IoU提升非常明显。但注意权重不要过大否则模型会把很多背景误分为小物体导致precision下降。4.2 优化器和学习率Poly策略是分割任务的首选分类任务常用的StepLR每30个epoch降10倍在分割任务上效果一般FCN原文采用的是多项式衰减Poly策略def poly_lr_scheduler(optimizer, init_lr, iter, max_iter, power0.9): lr init_lr * (1 - iter / max_iter) ** power for param_group in optimizer.param_groups: param_group[lr] lr return lr这个策略的核心思想是训练初期学习率大快速探索参数空间后期学习率极小精细收敛。相比StepLR的阶梯式下降poly策略不会在衰减瞬间造成Loss跳变收敛更平滑。优化器方面我试过SGDmomentum0.9, weight_decay1e-4和AdamW最终选的是SGD。分割任务里Adam类优化器收敛快但容易停留在尖锐极小值泛化性能略差SGD配合poly学习率虽然前期Loss降得慢但验证集mIoU最终更高。如果你用AdamW建议把初始学习率调低到SGD的十分之一即1e-4左右否则前期Loss会震荡得厉害。4.3 完整训练参数参考下面是我在一张RTX 309024GB显存上训练FCN-8s的完整配置可以直接套用参数值输入尺寸768x384随机裁剪批量大小8初始学习率1e-3优化器SGDmomentum0.9, weight_decay1e-4学习率策略Polypower0.9训练轮数120 epoch约40k步类别权重class-balanced损失函数CrossEntropyLossignore_index255数据增强随机翻转、随机缩放、颜色抖动这个配置下从VGG16预训练权重开始训练约80个epoch后验证集mIoU能到63%左右120个epoch能到67%左右。如果从零开始训练同等epoch数大概只有55%上下差距非常明显。4.4 显存管理BatchSize和梯度累积的取舍很多人第一次跑分割模型都会遇到OOMout of memory。显存不足时有三个选择减小batch size。注意同步BatchNorm的场景下batch太小会影响统计量单卡训练时batch size不要低于4。缩小输入尺寸。768x384降到640x320显存占用降为原来的0.69倍但精度会有约1个百分点的损失。梯度累积。把batch size设成2每4步累积一次梯度更新等效batch size为8accumulation_steps 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): outputs model(images) loss criterion(outputs, labels) loss loss / accumulation_steps # 归一化 loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()注意梯度累积要手动把loss除以累积步数否则等效学习率变大需要同步调低初始learning rate。这个细节是我实践里踩过最多次的坑少除一步模型就会发散。5. 从验证集结果找问题mIoU曲线的解读与失效案例排查5.1 mIoU的计算逻辑和混淆矩阵分析训练到中后期光看Total Loss下降是不够的我习惯每2个epoch跑一次验证集记录mIoU和每个类别的IoU。mIoU的计算公式是mIoU (1/N) * Σ (TP_i / (TP_i FP_i FN_i))其中N是类别数TP、FP、FN分别是第i类的真正例、假正例、假负例。注意它和accuracy的本质区别——accuracy看的是所有像素里预测对的比例mIoU则对每类单独算并取平均。在Cityscapes这种类别像素占比极不均衡的数据集上accuracy可能高达90%但mIoU只有60%因为占像素最多的road类把accuracy拉高了而小类别的错误被掩盖了。验证时我会额外输出每类的IoU柱状图重点关注person、rider、motorcycle这几个小类的表现。如果发现某个类别IoU明显偏低最优策略不是加数据而是回到数据增强环节检查——比如rider类别IoU低通常是因为训练集里骑手和摩托车/自行车的重叠区域标注不清晰模型学到的是模糊边界此时加大random scaling的幅度比加训练数据更有效。5.2 分割边界毛糙的排查思路如果你发现验证集mIoU还算能看但可视化分割图边缘狗啃一样、不贴合物体轮廓问题大概率出在上采样路径的细节融合不足。常见的排查思路按优先级排列第一确认模型输出的是logits还是softmax过后的概率图。不少人可视化时直接对logits求argmax得到的边界会显得很硬但这种视觉问题不影响mIoU不需要改模型。第二检查FCN-8s的浅层融合是否真的生效。我调试过一个模型发现上采样跳过连接的通道数不匹配当时想当然用了1x1卷积强行对齐通道结果浅层特征被过度压缩边界细节信息大量丢失。正确做法是让融合层保持较高通道数比如256上采样后再用3x3卷积精修。第三检查CRF后处理。传统的DenseCRF可以进一步锐化边界但它的计算开销比较大一张图要跑几十秒。在实时性要求不高的场景下可以用否则不如训练时把random scaling的幅度调大让模型多看不同尺度的目标。5.3 一个典型失败案例验证集Loss下降但mIoU不涨这里分享一个让我印象很深的排查过程。有一轮实验训练集Loss从0.8降到了0.35验证集CrossEntropy Loss也在同步下降但mIoU卡在58%上不去了。当时我一度以为模型容量不够准备换DeepLabV3后来冷静下来逐项排查发现是数据加载环节出了问题。具体来说我的验证集在随机裁剪时没有固定种子导致每次验证看到的图像区域都不一样——同一张图这次裁剪到左边下次裁剪到右边模型预测的像素尺度分布不稳定。而Cityscapes里某些类别比如traffic light只在固定位置出现裁剪位置的随机性直接导致验证集估计不准确。解决方法是验证阶段完全不裁剪改用整图resize到固定尺寸推断并且设置torch.manual_seed(42)保证评测可复现。修正之后mIoU直接从58%跳到了64%。这个案例说明一个朴素的道理mIoU不涨的时候别急着怀疑模型先确认评测流程本身是稳定的。6. 一张图看清FCN训练Cityscapes的完整流程与工程要点以下是我完整走通一遍之后的流程图式总结文字版本每一步对应前面的详细操作原始数据leftImg8bit gtFine ↓ 类别映射34类 → 19类ignore区域置255 ↓ 数据增强随机翻转 随机缩放 随机裁剪768x384 颜色抖动 ↓ 构建DataLoadernum_workers6pin_memoryTrue ↓ 加载VGG16预训练权重跳过classifier层 ↓ 前向传播 → CrossEntropyLossweight类别权重ignore_index255 ↓ 反向传播 → SGDmomentum0.9 poly学习率衰减 ↓ 周期性验证每个epoch结束后计算mIoU和每类IoU ↓ 保存最佳模型根据验证集mIoU而非训练Loss ↓ 推理可视化原图 预测图 标签图三图并排对比工程上还有几个容易被忽略但影响很大的细节保存最佳模型时务必同时保存optimizer和scheduler状态。训练中断后要能无缝恢复我从第60个epoch开始每20个epoch备份一次这个习惯帮我省了至少3次从头训练的时间。验证集的batch size可以设大一点比如16推理时不需要梯度占用的显存更少。混合精度训练AMP在FCN这种大模型上收益明显。RTX 30系及以上显卡用torch.cuda.amp可以将训练速度提升约40%代价是mIoU可能降低0.5~1个百分点。实测下来在精度要求不极端苛刻的场景下完全值得开。7. 训练完成后的可视化与模型导出最后的临门一脚7.1 分割结果可视化的三个关键细节训练完模型自然要可视化看看效果。我的做法是把原图、预测图、真值标签三张图横向拼接保存成一张大图方便肉眼对比。这里有三个细节第一预测结果是19个类别的logits需要先argmax得到每个像素的类别索引再用Cityscapes官方的trainIdToColor颜色映射表转成彩色图。直接对灰度索引图做可视化视觉区分度很差。第二可视化图的分辨率如果和原图不一致建议用最近邻插值resize而不要用双线性插值。双线性插值会在类别边界处产生混合颜色看起来像边缘模糊其实是插值方式导致的伪影。第三把ignore区域像素值为255的地方在可视化时统一涂成黑色。否则这些区域会被映射到类别19的颜色看起来像是预测了一个不存在的类别容易误导判断。7.2 模型导出与部署时的输入尺寸坑训练时用的输入尺寸是768x384部署时如果改成1024x512或者512x256BN层的统计量会发生变化。即使FCN骨干是全卷积的BN层的running_mean和running_var仍然和训练尺寸紧密相关换了尺寸后推理结果可能有2~3个百分点的mIoU损失。解决办法是导出模型时把BN层fold进卷积层即把BN的参数合并到前面的卷积核里或者干脆用torch.jit.trace在固定尺寸的输入上trace一遍模型model.eval() dummy_input torch.randn(1, 3, 384, 768).cuda() traced_model torch.jit.trace(model, dummy_input) traced_model.save(fcn_cityscapes.pt)trace之后的模型既加速了推理又消除了输入尺寸变化带来的BN干扰。之后在C部署、TensorRT转换时也会省很多事。7.3 我的最终mIoU训练结果与提速建议用上面这套配置我在训练120个epoch之后验证集mIoU最终为67.3%。每类IoU的表现分布大概是road 97.8sidewalk 82.5building 91.2person 74.6rider 65.3motorcycle 51.2。小类别motorcycle、rider的表现明显低于大类别这是FCN这类模型的能力上限所在如果想进一步提升可以考虑加一个金字塔池化模块或者把骨干网络换成ResNet50但那是另一个话题了。训练速度方面单张RTX 3090上跑FCN-8s768x384输入大概是5.2秒/epoch120个epoch约10小时。如果你时间紧张有一个加速技巧前50个epoch用512x256的输入训练速度快一倍后70个epoch切换到768x384微调。这样做精度损失很小大约0.5个点但总训练时间能压缩到7小时左右。我自己在多个模型上试过这个先粗后精策略非常稳定。
返回列表