最近抽空把FCN在Cityscapes上从头到尾跑了一遍,从数据准备、标签映射,到模型搭建、训练调参,再到结果分析和问题排查,整个过程踩了不少坑。先说个题外话,很多人搜这个数据集时习惯拼成cityspaces,官网和论文里的正式名称其实是Cityscapes,差了一个字母,搜索的时候很容易跑偏。这篇就把我跑通FCN训练Cityscapes的完整过程整理出来,重点放在数据加载、标签处理、模型实现和训练配置这些实操环节,适合刚入坑语义分割、想亲手把第一个分割模型完整跑起来的朋友参考。
FCN是语义分割的奠基性工作,Cityscapes又是自动驾驶方向最常用的街景分割数据集,这两个组合基本是入门语义分割绕不开的第一关。模型结构本身不复杂,真正麻烦的是数据怎么处理好、标签怎么映射对、评估指标怎么写准。下面按我实际操作的顺序,把整条链路拆开讲清楚。
1. 整体设计思路:FCN和Cityscapes这对组合为什么值得跑
1.1 FCN解决的核心问题
FCN全称Fully Convolutional Network,是2015年CVPR的经典工作。在它之前,图像分类网络(比如VGG、AlexNet)最后都接全连接层,输出一个固定长度的类别概率向量。FCN的核心改动非常直接:把最后几层全连接全部换成卷积层,让网络输出从“一个概率向量”变成“一张空间概率图”。
具体来说,VGG-16的原始分类头是7×7×4096 → 1×1×4096 → 1×1×1000这样的结构,FCN把它替换成7×7×4096 → 1×1×4096 → 1×1×类别数,关键区别是最后保留空间分辨率。这样网络对每个像素都会输出一个类别分布,天然就是密集预测。输出空间分辨率比原图小,再通过上采样恢复到原图大小,就得到逐像素的分割结果。梯度可以从每个像素的预测误差回传,实现端到端训练。
当时这篇论文还提出了跳级结构,也就是把浅层的细节特征和高层的语义特征融合起来。池化层越深,感受野越大,语义信息越丰富,但空间位置信息损失越严重。FCN-32s只从最后1/32分辨率直接上采样,边缘细节非常粗糙;FCN-16s融合了pool4的信息,FCN-8s再融合pool3,分割边缘明显更精细。这三个变体的对比,能够非常直观地理解“语义-位置”这对矛盾。
1.2 Cityscapes数据集的特点与训练难点
Cityscapes是一个面向城市场景理解的语义分割数据集,图片全部来自德国和邻近国家的50个城市街道,分辨率统一为2048×1024。官方划分是训练集2975张、验证集500张、测试集1525张。标注体系比较复杂,原始类别有34类,但官方推荐的是19类训练类别加1个忽略类别,另外提供颜色映射表用于可视化。
这个数据集有几个训练时立刻能感受到的特点。一是图片真的很大,2048×1024直接塞进显卡,单张图就要占大量显存,所以基本都要用随机裁剪或者大幅缩放来训练。二是类别像素比例极不均衡,road、building、sky这类背景类占的面积非常大,traffic light、person、rider这种小目标占比很小,导致模型天然倾向预测那些大类。三是小目标特别多,远处的人、交通标志在图上可能只有几十个像素,FCN这种纯卷积模型对它们的召回率普遍不高。这些都决定了训练策略必须围绕“分辨率”和“数据均衡”这两件事来做。
1.3 为什么把FCN作为第一个训练目标
我建议入门语义分割的人,第一个完整跑通的项目就选FCN + Cityscapes,原因有两个。第一是FCN代码量少、结构透明,没有ASPP、注意力、Transformer这些复杂模块,VGG骨干加几个上采样层,几十行就能写完,出问题容易排查。第二是它恰好能覆盖语义分割项目里的全部核心环节:预训练骨干迁移、特征融合、上采样、逐像素损失、mIoU评估。后面再切换DeepLab、PSPNet、UNet这些模型,其实都是改模型定义文件而已,数据加载和评估流程完全不用动。
2. 数据准备与预处理:最容易翻车的环节
2.1 数据下载与目录组织
Cityscapes需要在官网注册申请下载权限,审核通过后下载leftImg8bit_trainvaltest.zip和gtFine_trainvaltest.zip。如果不想等审核,也可以从Kaggle上的镜像直接下载,文件内容是一样的。下载完成后,目录结构这样组织:
Cityscapes/ ├── leftImg8bit/ │ ├── train/ │ │ └── aachen/ │ │ └── aachen_000000_000019_leftImg8bit.png │ └── val/ └── gtFine/ ├── train/ │ └── aachen/ │ └── aachen_000000_000019_gtFine_labelIds.png └── val/一个特别容易踩的坑是:gtFine目录下除了labelIds文件,还有labelTrainIds文件。很多教程会让你直接用labelTrainIds.png,因为这个文件里的像素值已经映射到trainId了,省去自己转换。但我个人建议还是用labelIds.png,自己写清楚ID到trainId的映射,因为很多预训练模型和开源代码用的是不同版本的类别映射,一旦后续要改类别数或调试,能控制底层映射会顺手很多。labelTrainIds可以作为验证转换结果是否正确的手段。
2.2 标签映射:从ID到trainId
Cityscapes原始标注的34类中,参与训练的19类,剩余的类别统一归为ignore。官方给出一个ID到trainId的映射关系,我需要把它写进数据加载代码。核心代码如下:
import numpy as np from PIL import Image id_to_trainid = { 7: 0, 8: 1, 11: 2, 12: 3, 13: 4, 17: 5, 19: 6, 20: 7, 21: 8, 22: 9, 23: 10, 24: 11, 25: 12, 26: 13, 27: 14, 28: 15, 31: 16, 32: 17, 33: 18 } def convert_label(label): mapping = np.zeros(256, dtype=np.uint8) + 255 for k, v in id_to_trainid.items(): mapping[k] = v return mapping[label]这段代码的关键在于mapping数组初始化为255,然后只把19个有效类别的ID位置映射到0到18。256全数组的值全部初始化为255,这保证了不在映射表中的像素(比如未标注区域、道路边缘的粗线、其他物体)最终统一变成255,也就是训练时用ignore_index忽略掉。
读取标签文件时要用PIL而不是OpenCV,非常关键。Cityscapes的标签PNG是单通道调色板格式,OpenCV的imread会把它读取成三通道BGR图像,导致后面的索引映射完全失效。正确做法是:
label = np.array(Image.open(label_path).convert('L'), dtype=np.uint8)convert('L')强制转成灰度单通道,然后直接用上面的映射函数。不要用np.array(img)直接转,某些版本的PIL会返回一个四通道数组,维度对不上。
2.3 Dataset类与同步数据增强
数据加载类要做三件事:读取图片、读取标签并映射、同步做几何变换。图片和标签的随机翻转、缩放、裁剪必须用同一个随机种子,否则图像和标签会错位,训练出来的模型完全不可用。我写的Dataset核心逻辑如下:
import random from torch.utils.data import Dataset class CityscapesDataset(Dataset): def __init__(self, img_dir, label_dir, crop_size=(512, 512), train=True): self.img_paths = sorted(glob.glob(os.path.join(img_dir, '*', '*.png'))) self.label_paths = [ p.replace('leftImg8bit', 'gtFine').replace('_leftImg8bit.png', '_gtFine_labelIds.png') for p in self.img_paths ] self.crop_size = crop_size self.train = train def __getitem__(self, idx): img = Image.open(self.img_paths[idx]).convert('RGB') label = np.array(Image.open(self.label_paths[idx]).convert('L'), dtype=np.uint8) label = convert_label(label) if self.train: # 随机水平翻转 if random.random() > 0.5: img = img.transpose(Image.FLIP_LEFT_RIGHT) label = label[:, ::-1].copy() # 随机裁剪 w, h = img.size ch, cw = self.crop_size top = random.randint(0, h - ch) left = random.randint(0, w - cw) img = img.crop((left, top, left + cw, top + ch)) label = label[top:top + ch, left:left + cw] else: img = img.resize(self.crop_size, Image.BILINEAR) label = np.array(Image.fromarray(label).resize(self.crop_size, Image.NEAREST)) img = np.array(img).transpose(2, 0, 1).astype(np.float32) / 255.0 img = (img - np.array([0.485, 0.456, 0.406]).reshape(3, 1, 1)) / np.array([0.229, 0.224, 0.225]).reshape(3, 1, 1) return torch.from_numpy(img).float(), torch.from_numpy(label).long()验证集resize标签时一定要用NEAREST最近邻插值,绝对不能用双线性插值。双线性插值会产生介于两个类别之间的浮点数值,比如0.4、0.7,这些值在后续评估时全部会被当成错误类别,mIoU直接崩掉。训练集我目前用固定裁剪,如果显存够大,建议加入随机缩放(scale范围0.5到2.0)再裁剪,对mIoU有非常明显的提升,这部分后面的实测结果会说明。
2.4 训练前必须做可视化验证
数据加载写完后,强制建议先做一步可视化,不要直接开训。把训练集里几张图的标签映射成彩色图,和原始输入叠在一起看,确认道路、建筑、行人这些区域的颜色和形状对得上位置。Cityscapes提供官方的颜色映射,我把常用的19类颜色表放进可视化脚本:
color_map = { 0: (128, 64, 128), # road 1: (244, 35, 232), # sidewalk 2: (70, 70, 70), # building 3: (102, 102, 156), # wall 4: (190, 153, 153), # fence 5: (153, 153, 153), # pole 6: (250, 170, 30), # traffic light 7: (220, 220, 0), # traffic sign 8: (107, 142, 35), # vegetation 9: (152, 251, 152), # terrain 10: (70, 130, 180), # sky 11: (220, 20, 60), # person 12: (255, 0, 0), # rider 13: (0, 0, 142), # car 14: (0, 0, 70), # truck 15: (0, 60, 100), # bus 16: (0, 80, 100), # train 17: (0, 0, 230), # motorcycle 18: (119, 11, 32) # bicycle }可视化这一步成本很低,但能省下大量排查时间。我见过很多人在数轮训练后loss一直不降,最后发现是标签读取时通道错位,所有像素都变成了255。这种问题在训练前一张可视化就能揪出来。
3. FCN模型实现:跳级结构是灵魂
3.1 把VGG-16改成全卷积骨干
FCN的骨干网络我用的是VGG-16,带BN的vgg16_bn版本。torchvision里有预训练权重可以直接用,迁移学习效果远好于从零训练。改造方式是把VGG的classifier部分去掉,只保留features主干网络,然后额外接上fc6和fc7对应的卷积层。
vgg16_bn的features一共有44层,最后一个是MaxPool2d。完整前向时,输入经过5次下采样,分辨率变成输入的1/32。我需要保留pool3、pool4之后的特征图,因为后面跳级融合要用。实现时可以给features封装成一个Sequential,通过索引切片取出不同阶段的输出。需要注意的是VGG原始的fc6是一个7×7的卷积层,对应分类网络的输入尺寸224×224下采样32倍后正好7×7。如果训练时输入尺寸不是224的倍数,这个7×7卷积会报错或者输出尺寸对不上。我用两个1×1卷积替代fc6和fc7,既能保留感受野,又不受输入尺寸限制,实际效果差别很小,参数量还少了很多。
3.2 FCN-8s的具体实现
FCN-8s是三个变体里效果最好的,我在项目中直接用它作为最终模型。核心实现逻辑如下:
import torch import torch.nn as nn from torchvision.models import vgg16_bn class FCN8s(nn.Module): def __init__(self, num_classes=19): super().__init__() vgg = vgg16_bn(pretrained=True) features = list(vgg.features.children()) self.stage1 = nn.Sequential(*features[:7]) # 到pool2之后 self.stage2 = nn.Sequential(*features[7:17]) # 到pool3之后,1/8 self.stage3 = nn.Sequential(*features[17:27]) # 到pool4之后,1/16 self.stage4 = nn.Sequential(*features[27:44]) # 到pool5之后,1/32 self.conv6 = nn.Conv2d(512, 4096, 1) self.relu6 = nn.ReLU(inplace=True) self.conv7 = nn.Conv2d(4096, 512, 1) self.relu7 = nn.ReLU(inplace=True) self.score_final = nn.Conv2d(512, num_classes, 1) self.score_pool4 = nn.Conv2d(512, num_classes, 1) self.score_pool3 = nn.Conv2d(256, num_classes, 1) self.upsample_2x = nn.ConvTranspose2d(num_classes, num_classes, 4, stride=2, padding=1) self.upsample_8x = nn.ConvTranspose2d(num_classes, num_classes, 16, stride=8, padding=4) self._init_upsample() def _init_upsample(self): for m in self.modules(): if isinstance(m, nn.ConvTranspose2d): nn.init.constant_(m.weight, 0) nn.init.constant_(m.bias, 0) # 双线性插值初始化 ... def forward(self, x): h = self.stage1(x) h = self.stage2(h) pool3 = h h = self.stage3(h) pool4 = h h = self.stage4(h) h = self.relu6(self.conv6(h)) h = self.relu7(self.conv7(h)) h = self.score_final(h) # 1/32 h = self.upsample_2x(h) pool4_score = self.score_pool4(pool4) h = h[:, :, :pool4_score.size(2), :pool4_score.size(3)] + pool4_score h = self.upsample_2x(h) pool3_score = self.score_pool3(pool3) h = h[:, :, :pool3_score.size(2), :pool3_score.size(3)] + pool3_score logits = self.upsample_8x(h) return logits这里有两个细节非常关键。第一个是跳级相加时,上采样后的特征图尺寸要和pool3、pool4的尺寸严格对齐,如果因为stride边界的奇偶问题差了1像素,直接做截断或填充。第二个是上采样层的初始化,直接用双线性插值核初始化转置卷积,可以让初始阶段的上采样结果不是一片噪声,训练更稳定。对转置卷积不熟的读者,记住一个结论即可:转置卷积不是“恢复信息”,而是“学习如何上采样”,所以初始化为双线性核是常见做法。
如果目前显存较小,可以把upsample_2x替换成双线性插值加上1×1卷积,这样参数量更小。转置卷积如果卷积核不是双线性初始化容易出现棋盘格伪影,我实测双线性初始化后就没这个问题了。FCN-32s和FCN-16s只需要删掉对应的跳级分支,实现起来很简单,我把FCN-8s跑通后,顺手对比了32s的效果,边缘确实差了一截。
3.3 损失函数与mIoU评估
损失函数用的是nn.CrossEntropyLoss,这里有一个非常容易被忽略但严重影响的参数:ignore_index=255。如果不设置这个参数,模型会被强迫对未标注区域输出类别,而这些区域包含大量边界线和远处模糊物体,等于强行让模型学习噪声,mIoU会掉好几个点。我最初就是漏了这个参数,训练了20多个epoch loss下降缓慢,查了很久才发现。
评估指标是mIoU,全称mean Intersection over Union,也就是每个类别分别计算预测和真实标签的交集除以并集,然后对19个类别取平均。计算时同样需要把255的区域排除掉:
def compute_miou(pred, target, num_classes=19): ious = [] pred = pred.view(-1) target = target.view(-1) mask = target != 255 pred = pred[mask] target = target[mask] for cls in range(num_classes): p = (pred == cls) t = (target == cls) inter = (p & t).sum().float() union = (p | t).sum().float() ious.append(inter / (union + 1e-6)) return torch.tensor(ious).mean().item()验证时需要先对logits做argmax得到预测类别,然后传入compute_miou。
4. 训练配置与完整流程
4.1 超参配置与学习率策略
直接给出我最终使用的配置:
- 输入尺寸:训练随机裁剪512×512,验证缩放至1024×512并按原比例推理
- batch_size:8(单张GTX 3090可跑,显存不足时减到4)
- 优化器:SGD,momentum=0.9,weight_decay=5e-4
- 初始学习率:0.01
- 学习率策略:poly,lr *= (1 - iter / total_iter) ** 0.9
- 训练轮数:60个epoch
- 损失函数:CrossEntropyLoss(ignore_index=255)
- 数据增强:随机水平翻转、随机缩放(0.5到2.0)、随机裁剪
重点说两个配置背后的考虑。第一个是weight_decay取5e-4,这个值是语义分割任务里比较常见的经验值,比分类任务常用的1e-4稍大,因为分割模型参数量更大,正则化稍微强一点能防止过拟合。Cityscapes训练集只有2975张,对于这么大参数的模型来说很容易过拟合,所以正则化不能弱。第二个是poly学习率,语义分割里广泛使用,原理是让学习率在整个训练过程中平滑衰减,前期快速探索、后期精细收敛。step衰减在分割任务上往往需要调多次衰减节点,poly基本不用调,训练曲线更平滑。
4.2 训练循环与保存策略
训练主循环结构不复杂,完整代码如下:
model = FCN8s(num_classes=19).cuda() criterion = nn.CrossEntropyLoss(ignore_index=255) optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) total_iter = len(train_loader) * epochs cur_iter = 0 best_miou = 0 for epoch in range(epochs): model.train() for imgs, labels in train_loader: imgs, labels = imgs.cuda(), labels.cuda() cur_iter += 1 lr = 0.01 * (1 - cur_iter / total_iter) ** 0.9 for g in optimizer.param_groups: g['lr'] = lr optimizer.zero_grad() logits = model(imgs) loss = criterion(logits, labels) loss.backward() optimizer.step() if epoch % 5 == 0: miou = validate(model, val_loader) if miou > best_miou: best_miou = miou torch.save(model.state_dict(), 'fcn8s_best.pth') print(f'Epoch {epoch}, loss: {loss.item():.4f}, val_miou: {miou:.4f}')每个epoch约2975张图,batch_size为8,就是372个step,60个epoch一共22320次迭代。在单张3090上每个step耗时约0.4秒,一整个训练跑下来约2.5小时。如果你的显卡比较旧,建议把batch_size调到4,同时训练尺寸降到448,时间大约翻倍。
4.3 实测结果分析
我在验证集上跑出来的最终mIoU大约在0.61到0.65之间,具体数值取决于是否开启了随机缩放增强。只做固定512×512裁剪时mIoU约0.58,加入随机缩放后提升到0.62左右,再把验证侧改成缩放至1024×512全图推理,最终达到0.64。FCN-32s在同样配置下只有0.55左右,FCN-8s优势明显。
逐类IoU的分布也很有规律。road、building、vegetation、sky这几个大类IoU普遍在0.85以上,因为它们在图像中占比最大、语义明确、边缘也相对规整。person、rider、motorcycle、traffic light这些小类别IoU往往只有0.2到0.4,主要原因是像素占比太低,加上被遮挡或远处目标太小,FCN本身对小目标不敏感。这也说明如果实际项目对小目标有要求,光靠FCN是不够的,需要结合多尺度特征或更高分辨率的输入。在我这个项目里,最终结果用于实验对比已经够用了。
5. 常见问题与避坑指南
5.1 高频问题速查表
我把训练过程中遇到的高频问题整理成了一张表,方便直接对照排查。
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| Loss为NaN | 学习率过大;标签含有非法值 | 降低初始学习率到0.001;检查标签映射后的唯一值是否都在0到255之间 |
| Loss一直在0附近 | ignore_index设置错误,所有像素都被忽略 | 打印一个batch中labels的min和max,确认标签不是全255 |
| 训练loss下降缓慢 | 学习率过低;预训练权重没有正确加载 | 打印backbone第一个卷积的权重均值,确认是从预训练开始 |
| 分割结果全部是同一类 | 类别不均衡,模型倾向预测占多数的类别 | 打印逐类IoU,确认哪些类别完全没学到;尝试加权损失 |
| 验证mIoU为0 | 验证时忘记对logits做argmax;标签resize用了双线性插值 | 检查验证集pred的唯一值;标签resize改为NEAREST |
| 显存不足(OOM) | 输入尺寸过大;batch_size过大 | 降低batch_size到2或4;改成512×256缩放训练;开启AMP混合精度 |
| 高分辨率图验证时内存溢出 | 2048×1024整图直接forward | 按滑动窗口切块推理,再拼接结果 |
5.2 几个值得单独说的坑
第一个坑是BN层的问题。vgg16_bn自带BatchNorm,当batch_size太小(比如2或4)时BN的统计量不稳定,训练和验证的表现差异会很大。我的做法是训练时用8的batch_size,如果显存不够,就等梯度累积到8张图再更新一次,而不是硬把batch_size降到2。另外一种更省事的方案是用不带BN的vgg16作为骨干,但精度会略低。
第二个坑是验证时的高分辨率推理。Cityscapes原图2048×1024,如果直接全图forward,显存占用会突然飙升,很容易OOM。我的经验是验证时缩放到1024×512,这一步损失一些精度但换来稳定。如果一定要在原始分辨率上验证,可以按512×512的窗口滑动推理,相邻窗口重叠64像素再平均,结果会更平滑,但推理时间会成倍增加。实测缩放验证的mIoU大概比原分辨率低0.5到1个百分点,作为实验对比完全够用。
第三个坑是Poly学习率下如何同步Scheduler。很多初学者用的是PyTorch内置的StepLR,到某个epoch突然降一次学习率,分割任务里效果不够好。我推荐手写poly策略,每个iter都更新学习率,代码就三行,但收益很明显。需要注意训练前期学习率如果从0.01起步,前500个iter loss下降会比较猛,这是正常的,不用急着调低初始学习率。
5.3 验证集逐类分析的价值
训练结束后不要只看一个mIoU数值就收工,建议把19个类别的IoU分别打印出来,根据结果决定下一步优化方向。如果某几个类别的IoU接近0,先确认是不是标签映射错了,比如ID和trainId对应关系配错,某个类别永远没有监督信号,模型自然学不到。如果类别映射正确但小类IoU就是低,优化方向一般是换更高分辨率的输入、加多尺度推理,或者换更强的骨干网络。我见过不少人在mIoU稳定在0.6之后盲目换模型,结果提升有限,实际瓶颈可能只是训练尺寸太小,把crop从512×512换到768×768,mIoU立刻提升2到3个点。
6. 最后分享一点个人体会
跑完这个项目,我最大的感受是:FCN训练Cityscapes真正难的从来不是模型代码,而是数据链路。标签映射、ignore_index、resize插值方式这三个环节任何一个出错,训练结果都不可用,而且错误非常隐蔽,模型照样能训能存,只是mIoU上不去。建议所有准备跑这个项目的人,先花半小时把可视化脚本写好、把标签图颜色和原图对齐确认,再开始训练,省下来的排查时间远大于这半小时。
如果后续想扩展,可以沿着三个方向继续。一是把VGG骨干换成ResNet-50或ResNet-101,用torchvision自带的FCN变体结构做迁移学习,mIoU一般能到0.7以上;二是在FCN基础上加入ASPP模块,也就是DeepLab的思路,多尺度特征对小目标有明显改善;三是用RAdam或AdamW替代SGD,训练初期学习率选择更省心,收敛速度也更快。这个项目跑通之后,后续不管换哪个模型,数据加载和评估代码都不用改,改模型文件就行。