
简介面向计算机视觉语义分割研究者的PyTorch实现基于Cityscapes数据集训练DeepLabV3解决街景场景中物体边缘模糊与多尺度特征提取问题。压缩包共18个文件约258MB其中12个py脚本覆盖模型结构定义、数据预处理、DataLoader加载、训练、验证评估与可视化4个pth文件为预训练权重可加载后继续训练或直接推理另有README说明文档与license许可文件目录结构清晰便于复现实验。这一实现引入空洞空间金字塔池化ASPP和全局上下文模块能够捕获不同感受野下的语义信息数据处理环节针对Cityscapes图像缩放、归一化与标签编码做了适配配套交叉熵损失、SGD/Adam优化器及mIoU评估流程。从配置到训练再到评估的完整链路均已打通适合初中级开发者快速跑通语义分割项目进而深入理解DeepLabV3的设计思想。目前已有2097人学习下载。1. 在 Cityscapes 数据集上训练 DeepLabV3真正花时间的是标签管线不是网络结构很多同学以为把 DeepLabV3 放到 Cityscapes 数据集上训练核心难点在网络结构上真正动过手的人才会发现PyTorch 实现的成本大头在标签管线和类别索引对齐上。这个标题处理的是一整套工程下载两份数据、把原始 labelId 映射成 19 类 trainId、写好带随机缩放的 DataLoader、用 SGD 加 poly 学习率把网络训到能迁移的程度再用 mIoU 验证效果。它解决的问题不是“把模型跑起来”而是“跑出可复现、可迁移的结果”。这篇文章适合想在自有分割任务上用 Cityscapes 预训练模型做迁移的从业者新手能照着命令走熟手可以直接跳到避坑那张表。2. 网络选型DeepLabV3 为什么适合街景分割ASPP 和输出步长怎么定2.1 高分辨率图像下空洞卷积和 ASPP 是怎么把感受野撑起来的Cityscapes 的原始图像是 2048×1024这个分辨率决定了网络不能靠单纯加深来获得足够大的感受野。普通卷积每卷一层感受野线性增长到了深层还得不停下采样最后特征图缩到 1/32对小目标极不友好。DeepLabV3 的核心思路是不急着把分辨率压低用空洞卷积在保持特征图分辨率的同时扩大感受野。空洞率 r 意味着卷积核相邻采样点之间隔 r-1 个像素同样一个 3×3 卷积r1 是普通感受野r2 就能覆盖 7×7 的区域r4 能覆盖 15×15。真正让 DeepLabV3 在街景上站住脚的是它末端的 ASPP 模块。这个模块把同一份特征并行喂给四个分支一个 1×1 卷积三个空洞率分别为 6、12、18 的 3×3 卷积另外再接一个全局平均池化分支来捕捉整张图的上下文。四个分支输出拼在一起再过 1×1 卷积融合。这样设计的好处是它不依赖单一空洞率而是让不同尺度目标各取所需小目标靠 1×1 分支和低空洞率大目标靠高空洞率和全局分支。车辆、行人、建筑在街景里尺度差异极大单一感受野很难同时兼顾。2.2 选 backbone 和输出步长时的 3 个实测依据我的经验是backbone 优先选 ResNet101而不是 ResNet50。Cityscapes 训练集只有 2975 张很多人担心 ResNet101 会过拟合实际上在这个数据集上更深的主干带来的精度提升非常稳定风险完全可以用数据增强和早停来控制。ResNet50 的 mIoU 会比 101 低 2 到 3 个点这个差距在后续迁移到自有数据时会被放大。如果显存实在紧张ResNet50 也能用但别期望它和 101 表现相当。输出步长这里要做一个取舍。DeepLabV3 论文里常用 output_stride16输入分辨率是 1024×2048 时特征图是 64×128每个像素对应的原图区域约 16×16足够密。output_stride8 特征图更密精度略高但显存和训练时间都会涨。我一般先用 16 训练最后如果需要提点再单独跑一个 output_stride8 的微调实验不要一上来就上 8。另一个相关参数是 backbone 最后一层的空洞率output_stride16 时stage4 的卷积会改成空洞率 2这样才能保证输出分辨率不下探到 1/32。2.3 Cityscapes 19 类索引表为什么不能直接训练原始 gtFine 标签Cityscapes 官方给的是 34 类的 labelId其中很多类别互相嵌套比如“人”和“骑车人”在标注时区分得很细但实际训练时并不需要那么碎。标准做法是把 34 类合并成 19 个 trainId这个映射关系在官方脚本里有但只要理解下面这张表就够了。trainId类别说明0road道路1sidewalk人行道最容易和 road 混淆2building建筑3wall墙体4fence围栏5pole杆状物6traffic light交通灯7traffic sign交通标志8vegetation植被9terrain地形比 vegetation 更矮更杂10sky天空11person行人12rider骑车人13car小汽车14truck卡车15bus公交车16train火车17motorcycle摩托车18bicycle自行车原始 gtFine 里无法标注的区域、边界线和空洞都属于 voidtrainId 是 255。训练时必须把这个值传给损失函数的 ignore_index否则它会作为第 20 类参与梯度计算背景像素直接把 loss 带偏。转换这块坑我后面会单独讲这里先记住一个原则训练用 trainId可视化用 19 类调色板两者千万别混用。3. 准备数据官方目录结构、labelId 到 trainId 的转换脚本、DataLoader3.1 下载后的目录长什么样两份数据缺一不可Cityscapes 要下载两个部分leftImg8bit 是左侧摄像头拍摄的原始图像gtFine 是高精度标注。很多人只下载了图像跑起来才发现没有标签又回下载页补一次浪费时间。gtFine 里每个样本包含多个文件我们训练只用_gtFine_labelIds.png这个文件是 8 位单通道的 PNG像素值存的就是 labelId 0-33。目录结构整理成下面这样后续所有脚本都按这个路径来# 只展示目录骨架下载好的压缩包解压后放到 data/Cityscapes/ 下 mkdir -p data/Cityscapes/leftImg8bit/train mkdir -p data/Cityscapes/leftImg8bit/val mkdir -p data/Cityscapes/gtFine/train mkdir -p data/Cityscapes/gtFine/val一定要把 train 和 val 分开放在 leftImg8bit 和 gtFine 下而且两份目录里的文件名前缀严格一致。左图文件是aachen_000000_000019_leftImg8bit.png标签是aachen_000000_000019_gtFine_labelIds.png代码里只要把后缀替换就能配对。如果目录结构不一致后面 glob 排序时图片和标签会错位训练等于白跑。3.2 用一个脚本把 labelId 批量转成 trainId官方源码里有现成转换逻辑但很多人不愿意为了一个脚本引入整个仓库依赖。我习惯直接写一个独立的小脚本几十行搞定逻辑透明出了问题也好改。这个脚本会遍历 gtFine/ 下的所有_labelIds.png生成对应的_labelTrainIds.png到另一个目录方便 DataLoader 直接读。# tools/convert_trainid.py # 将 Cityscapes 原始 labelId 转换为 19 类 trainIdvoid 区域统一为 255 import cv2 import numpy as np from pathlib import Path # labelId - trainId 映射仅列需要保留的类别 label2train { 0: 0, # road 1: 1, # sidewalk 2: 2, # building 3: 3, # wall 4: 4, # fence 5: 5, # pole 6: 6, # traffic light 7: 7, # traffic sign 8: 8, # vegetation 9: 9, # terrain 10: 10, # sky 11: 11, # person 12: 12, # rider 13: 13, # car 14: 14, # truck 15: 15, # bus 16: 16, # train 17: 17, # motorcycle 18: 18, # bicycle } def convert(src_path: Path, dst_path: Path) - None: # imread 默认按彩色图读这里必须加 0 按灰度读才能拿到原始 labelId 数值 img cv2.imread(str(src_path), cv2.IMREAD_GRAYSCALE) out np.full(img.shape, 255, dtypenp.uint8) for src_id, train_id in label2train.items(): out[img src_id] train_id dst_path.parent.mkdir(parentsTrue, exist_okTrue) cv2.imwrite(str(dst_path), out) if __name__ __main__: root Path(data/Cityscapes) for split in [train, val]: srcs sorted((root / gtFine / split).glob(*_gtFine_labelIds.png)) for src in srcs: dst root / gtFine_labelTrainId / split / (src.name.replace(labelIds, labelTrainIds)) convert(src, dst)这个脚本的核心是那个映射表原图中不是这 19 个 labelId 的像素全部置成 255也就是 void。这样在后续计算损失和 mIoU 时只需一个 ignore_index 就能处理所有无效像素。写完后先跑一张图用 matplotlib 打开_labelTrainIds.png检查应该能看到明显的区块边界背景区域是黑色而不是灰蒙蒙的一片。如果看到满屏 255说明 cv2.imread 按通道处理导致数值错乱检查读取方式。3.3 DataLoader 里的随机缩放、裁剪和归一化参数图像和标签都准备好了往下走是 Dataset。Cityscapes 原始图是 2048×1024直接把整张图送进显存一张就可能吃掉 4GB 以上。常见做法是训练时随机缩放加随机裁剪到固定尺寸。我习惯用 769×769这个尺寸是 DeepLabV3 论文里的常用设置正好覆盖在 output_stride16 下约 49×49 像素的特征区域。# dataset/cityscapes.py # 读取转换好的 labelTrainIds 图训练时做随机缩放和随机裁剪 import random import cv2 import numpy as np import torch from torch.utils.data import Dataset class CityscapesDataset(Dataset): def __init__(self, root, splittrain, crop_size769): self.images sorted((root / leftImg8bit / split).glob(*_leftImg8bit.png)) self.masks sorted((root / gtFine_labelTrainId / split).glob(*_labelTrainIds.png)) self.crop_size crop_size self.train (split train) def __len__(self): return len(self.images) def __getitem__(self, idx): img cv2.imread(str(self.images[idx]), cv2.IMREAD_COLOR) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(str(self.masks[idx]), cv2.IMREAD_GRAYSCALE) if self.train: # 先随机缩放再裁剪最后随机水平翻转 scale random.uniform(0.5, 2.0) new_h, new_w int(img.shape[0] * scale), int(img.shape[1] * scale) img cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) mask cv2.resize(mask, (new_w, new_h), interpolationcv2.INTER_NEAREST) h, w img.shape[:2] y random.randint(0, h - self.crop_size) if h self.crop_size else 0 x random.randint(0, w - self.crop_size) if w self.crop_size else 0 img img[y:y self.crop_size, x:x self.crop_size] mask mask[y:y self.crop_size, x:x self.crop_size] if random.random() 0.5: img img[:, ::-1].copy() mask mask[:, ::-1].copy() # 归一化使用 ImageNet 统计量PyTorch 生态分割模型默认沿用这套 img img.astype(np.float32) / 255.0 img (img - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img torch.from_numpy(img.transpose(2, 0, 1)).float() mask torch.from_numpy(mask.astype(np.int64)).long() return img, mask注意 mask 的 resize 插值必须用INTER_NEAREST不能用线性插值。线性插值会在类别边界上造出 0.5 这种不存在的类别训练时 CrossEntropyLoss 直接报错或学出界面糊成一团的模型。图像 resize 用线性插值没问题因为人眼可能感知到但标签是离散标号必须最近邻。随机缩放范围 0.5 到 2.0 看起来很大实战中这个范围能显著提升对不同距离目标的泛化能力代价是训练集内每张图每次迭代的形状都不同GPU 利用率会略降这个折中值得。4. 训练与验证从 deeplabv3_resnet101 到 mIoU 的一张完整流程4.1 模型初始化、损失函数和带 poly 策略的 SGD 配置PyTorch 生态里的分割模型包已经提供了 DeepLabV3 的完整结构不需要自己手写 backbone 和 ASPP。我一般这样初始化模型先加载一个在通用数据集上预训练的 backbone再把最后的分类头改成 19 类。但这里有个特别容易混淆的点开箱即用的 DeepLabV3 模型默认是在 ImageNet 或 COCO 这类通用数据上训练的它给出的“预训练权重”并不等于“Cityscapes 训练好的权重”我们这一步只是继承通用视觉特征让它从更优的起点开始 Cityscapes 训练。# train.py 模型与优化器配置节选 import torch import torch.nn as nn from torchvision.models.segmentation import deeplabv3_resnet101 base_lr 0.007 model deeplabv3_resnet101(weightsbackbone, num_classes19) # 分类头学习率放大 10 倍新初始化的层更需要大步长 optimizer torch.optim.SGD([ {params: model.backbone.parameters(), lr: base_lr}, {params: model.classifier.parameters(), lr: base_lr * 10}, ], momentum0.9, weight_decay1e-4) criterion nn.CrossEntropyLoss(ignore_index255)损失函数必须带 ignore_index255这个参数会在计算交叉熵时自动跳过所有 void 像素不参与梯度回传。学习率这里没有用固定值而是计划在训练循环里按迭代数实时更新这就是 DeepLabV3 论文里常用的 poly 策略lr base_lr * (1 - iter / total_iter) ** 0.9。它的特点是后期学习率下降得很平缓能让模型在收敛附近多磨一阵。优化器选 SGD 而不是 Adam 不是玄学分割任务里 Adam 容易在长尾类别上震荡SGD 配合 poly 学习率更稳。4.2 训练循环里我习惯盯的几个信号训练脚本本身并不复杂几个关键点写出来就是完整的骨架。数据加载用前面写的 Dataset 包成 DataLoaderbatch size 要看显存。769×769 的输入、batch 大小 4 到 8 是常见范围更大就等着 OOM。# train.py 单卡训练循环节选 from torch.utils.data import DataLoader from dataset.cityscapes import CityscapesDataset dataset CityscapesDataset(rootdata/Cityscapes, splittrain, crop_size769) loader DataLoader(dataset, batch_size4, shuffleTrue, num_workers4, drop_lastTrue) max_iter 300 * len(loader) # 300 个 epoch约等于 17.2 万步 iter_idx 0 for epoch in range(300): for images, masks in loader: # 根据迭代进度动态调整学习率 lr base_lr * (1 - iter_idx / max_iter) ** 0.9 for g in optimizer.param_groups: g[lr] lr images, masks images.cuda(), masks.cuda() out model(images)[out] # 输出形状 [B, 19, H, W]未上采样 loss criterion(out, masks) optimizer.zero_grad() loss.backward() optimizer.step() if iter_idx % 500 0: # 打印 loss 和当前学习率loss 在 0.3-0.5 区间波动是正常现象 print(fiter {iter_idx}, loss {loss.item():.4f}, lr {lr:.6f}) iter_idx 1模型输出的out是 1/8 分辨率特征而 mask 是原始尺寸的注释CrossEntropyLoss 会自动广播因为 PyTorch 的交叉熵会匹配 logits 和 target 的形状。loss 的走势要注意一点前 5000 步里损失可能从 4 猛降到 1然后又慢慢爬回 2 附近这是分类头在自适应不代表坏事。真正要看的是 loss 震荡的底部是否缓慢下移如果到了 20 万步还在 0.5 附近晃大概率是数据加载或标签映射的问题。训练时长按 2975 张训练图、batch 4 来算17 万步要跑很多天。中间检查点是刚需每 20000 步保存一次权重文件名带上迭代号这是后悔药。别只留最后一个因为最优 mIoU 通常不在最后一轮。4.3 验证阶段跳过类别 255 算 mIoU训练到中段就该跑验证了。mIoU 是 Cityscapes 官方认可的主指标计算方式是对每个类别算交并比最后对 19 类取平均。验证时要把网络输出的 logits 做 argmax得到预测类别再和 ground truth 逐类比对。这里需要忽略 255否则 void 像素会被当成错误虚标 mIoU。# evaluate.py mIoU 计算核心 def compute_miou(pred, target, num_classes19): # pred/target 形状 [B, H, W]来自模型 argmax 结果 ious [] for cls in range(num_classes): pred_mask (pred cls) target_mask (target cls) inter (pred_mask target_mask).sum().float() union (pred_mask | target_mask).sum().float() if union 0: ious.append(float(nan)) # 当前图中没有该类不参与平均 else: ious.append((inter / union).item()) # 忽略 nan 类别后再平均避免拉低分数 valid [v for v in ious if not np.isnan(v)] return np.mean(valid)验证时更推荐做多尺度推理把输入图缩放成 0.5、0.75、1.0、1.25、1.5、1.75 六种尺度每张都前向一次再把 logits 双线性插值回原图尺寸后相加最后取 argmax。这个技巧在这个数据集上能稳定带来 1.5 到 2 个点的 mIoU 提升。代价是验证速度慢 6 倍但验证集只有 500 张完全能接受。5. 踩坑与排查类别索引、权重加载、多尺度推理的三类血泪问题5.1 结果可视化全是同一颜色先查标签再查预测现象训练 loss 正常下降但把预测结果画出来整张图几乎是一种颜色或者某些区域出现明显的马赛克块。原因绝大多数情况是标签转换错了。最常见的是直接用_gtFine_labelIds.png参与训练而没有剪裁成 trainId。原始 labelId 有 34 类类别之间数值不连续模型无法学习从 20 到 29 这些空缺索引的语义另一种情况是可视化时拿 trainId 直接套调色板19 类索引对应的颜色偏差不明显人眼看起来就像全是一个类。解决先单独可视化_labelTrainIds.png用 matplotlib 的 imshow 显示灰度图确认只有 0 到 18 和 255 这些值再保存完整的 19 类调色板字典每次可视化都必须走该字典映射。如果标签图看起来是对的再检查模型的输出确认 argmax 是在类别维度上做的。我写过一个快速脚本打印预测图里出现的类别集合只需要一行np.unique(pred)立刻能看出是不是把背景 255 当成了第 20 类。5.2 加载预训练权重时 shape 不匹配现象加载某个权重文件时报unexpected key in state dict或size mismatch报错停留在classifier.4.weight附近。原因权重文件的分类头维度与当前模型不一致。DeepLabV3 默认是在 21 类数据集上训练的直接把 num_classes19 的模型和 21 类的权重对不上。还有一类坑是保存时用了torch.save(model.module.state_dict())加载时模型没有 DataParallel 包装state dict 的 key 多出一个module.前缀。解决如果报错在分类头很简单先加载只包含 backbone 的预训练权重然后让模型随机初始化分类头再开始训练。如果报错是 module 前缀处理办法是遍历 state dict 重建一个去前缀的字典# 处理旧权重里多余的 module. 前缀 new_state {k.replace(module., ): v for k, v in old_state.items() if module. in k} model.load_state_dict(new_state, strictFalse)加载时用strictFalse并打印缺失的 key能快速确认是哪些层对不上。我在这个环节花过一整晚现在养成了新项目先打印模型的 state_dict 结构和权重结构作对比的习惯这个习惯后来帮我避开很多类似坑。5.3 验证分数停在某个区间上不去现象mIoU 陷入平台期单尺度验证分数比预期的低很多大概 60 或 65 就上不去了。原因模型过拟合到训练集的裁剪分布。Cityscapes 训练图全是城市街景固定区域反复裁剪后模型对图像边缘的车辆、行人的泛化变差。另一个常见原因是验证时没有做多尺度推理单尺度 769 的输入窗口实际上看不到远处的小目标感受野不够。解决验证时切换到多尺度推理这是最大的一次涨点来源。再者检查渲染目标时有没有做随机缩放到 0.5-2.0有些人贪方便只做固定裁剪这等于人为收窄了训练分布。值得一提的是多尺度推理并不会显著增加显存占用因为每次推理是一张一张图和缩略图逐个跑的结果直接累加最后占的显存还是单张的规模。5.4 训练到一半显存爆掉现象训练一段时间后CUDA out of memory有时报错在 loss.backward() 这行。原因Cityscapes 的 769×769 裁剪加上 batch 4 在 11G 显存上已经接近上限。如果中途内存增长通常是把验证集混进训练 DataLoader 时累积了图。解决先把 batch size 降到 2确认可以稳定跑完一个小 epoch 再往上加。如果 batch size 已经很小还爆就要检查数据加载确认没有中途把验证集图也 append 到训练集里产生了数据混叠。真到了这一步就别在数据管线省事了NumPy 数组转张量前先释放中间占用的 float32 数组再走保留到 CUDA 的路径。6. 把 Cityscapes 模型迁移到自己的分割任务换分类头和解冻策略当你在 Cityscapes 上把模型训练好真正的价值才刚刚开始。你自己的数据绝不会正好是 19 类这个预训练模型在你那 3000 张小图上关键是换分类头后不要让整个网络重学一遍而是让它基于已经掌握的边缘、纹理和几何结构快速适配新类别。# finetune.py 换分类头并选择性解冻 model deeplabv3_resnet101(num_classes19) model.load_state_dict(torch.load(cityscapes_best_19.pth)) # 替换最后的 1x1 分类卷积num_classes 换成你自己的类别数 new_conv nn.Conv2d(256, num_classes, kernel_size1) model.classifier[4] new_conv这个替换逻辑里classifier 前几层是 ASPP 分支最后是 1×1 卷积所以只换索引 4 就能保留大部分迁移特征。换完后的策略我总结成一句数据少于 500 张就冻结 backbone只训练分类头数据在 500 到 5000 张之间把 backbone 最后一层解冻在架构上分离的轻量层解冻上也行。学习率不要再按 poly 从头走了固定 1e-4 或从 5e-4 线性衰减SGD 依然是首选Adam 在迁移阶段反而波动更明显。我吃过一次亏就是解冻了太多层又用大了学习率结果新任务的 loss 不降反升最后发现是早期特征被冲掉了。这个方向值不值得做我的答案很明确如果你面对的道路、园区、工地场景和 Cityscapes 有交集迁移起步至少省掉一周的收敛时间最终效果也明显好于从随机初始化硬训。现在的习惯是拿到新任务先看类别分布和 Cityscapes 的重叠度重叠高就大胆微调重叠低就只训分类头。这个判断比调任何损失函数都重要。希望帮到你。本文还有配套的精品资源点击获取