1. 为什么选FCN配合Cityscapes做语义分割入门:先说结论
说起语义分割,FCN(Fully Convolutional Network)是一个绕不开的名字。2015年Long等人提出FCN的时候,它第一次让端到端的像素级分类成为可能,把VGG、GoogLeNet这类分类网络最后的全连接层换成了卷积层,解决了"输入任意尺寸图像、输出对应尺寸分割图"的问题。而Cityscapes,则是自动驾驶场景下最经典的街景数据集之一,包含50个城市的道路驾驶影像,精细标注了19个类别,是检验分割模型泛化能力的标准考场。
在讨论"FCN训练Cityscapes"这个话题时,很多人容易陷入两个误区:一是觉得FCN太老,不如DeepLabV3+、PSPNet这类模型先进,不值得学;二是觉得Cityscapes数据集太大、标注太繁琐,直接用现成脚本跑一遍就完事。实际我做完整个流程的感受是——FCN+Cityscapes恰好是理解语义分割全链路的最佳组合。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, dtype=np.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的整数倍,比如stride=2时用4x4卷积核,能在很大程度上避免棋盘效应。
3.3 预训练权重:VGG16迁移学习的正确姿势
训练FCN时,VGG16那部分卷积层通常用ImageNet预训练权重初始化。这个操作让模型起步就具备较强的特征提取能力,从随机初始化开始训练的话,同等迭代次数下mIoU会低10个点左右。
但直接加载预训练权重有一个坑:VGG16最后的三层全连接层在FCN中已经被替换为1x1卷积了,加载权重时如果strict=True会报错。正确的做法是加载时忽略分类头:
import torch from torchvision.models import vgg16_bn vgg = vgg16_bn(pretrained=True) 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(), minlength=19) 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, power=0.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跳变,收敛更平滑。
优化器方面,我试过SGD(momentum=0.9, weight_decay=1e-4)和AdamW,最终选的是SGD。分割任务里Adam类优化器收敛快但容易停留在尖锐极小值,泛化性能略差;SGD配合poly学习率虽然前期Loss降得慢,但验证集mIoU最终更高。如果你用AdamW,建议把初始学习率调低到SGD的十分之一(即1e-4左右),否则前期Loss会震荡得厉害。
4.3 完整训练参数参考
下面是我在一张RTX 3090(24GB显存)上训练FCN-8s的完整配置,可以直接套用:
| 参数 | 值 |
|---|---|
| 输入尺寸 | 768x384(随机裁剪) |
| 批量大小 | 8 |
| 初始学习率 | 1e-3 |
| 优化器 | SGD(momentum=0.9, weight_decay=1e-4) |
| 学习率策略 | Poly(power=0.9) |
| 训练轮数 | 120 epoch(约40k步) |
| 类别权重 | class-balanced |
| 损失函数 | CrossEntropyLoss(ignore_index=255) |
| 数据增强 | 随机翻转、随机缩放、颜色抖动 |
这个配置下,从VGG16预训练权重开始,训练约80个epoch后验证集mIoU能到63%左右,120个epoch能到67%左右。如果从零开始训练,同等epoch数大概只有55%上下,差距非常明显。
4.4 显存管理:BatchSize和梯度累积的取舍
很多人第一次跑分割模型都会遇到OOM(out of memory)。显存不足时有三个选择:
- 减小batch size。注意同步BatchNorm的场景下batch太小会影响统计量,单卡训练时batch size不要低于4。
- 缩小输入尺寸。768x384降到640x320,显存占用降为原来的0.69倍,但精度会有约1个百分点的损失。
- 梯度累积。把batch size设成2,每4步累积一次梯度更新,等效batch size为8:
accumulation_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)+ 颜色抖动 ↓ 构建DataLoader(num_workers=6,pin_memory=True) ↓ 加载VGG16预训练权重(跳过classifier层) ↓ 前向传播 → CrossEntropyLoss(weight=类别权重,ignore_index=255) ↓ 反向传播 → SGD(momentum=0.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或者512x256,BN层的统计量会发生变化。即使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.8,sidewalk 82.5,building 91.2,person 74.6,rider 65.3,motorcycle 51.2。小类别(motorcycle、rider)的表现明显低于大类别,这是FCN这类模型的能力上限所在,如果想进一步提升,可以考虑加一个金字塔池化模块,或者把骨干网络换成ResNet50,但那是另一个话题了。
训练速度方面,单张RTX 3090上跑FCN-8s(768x384输入)大概是5.2秒/epoch,120个epoch约10小时。如果你时间紧张,有一个加速技巧:前50个epoch用512x256的输入训练(速度快一倍),后70个epoch切换到768x384微调。这样做精度损失很小(大约0.5个点),但总训练时间能压缩到7小时左右。我自己在多个模型上试过,这个"先粗后精"策略非常稳定。