ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Cityscapes复现DeepLabV3全链路解析:数据映射、训练与避坑

Cityscapes复现DeepLabV3全链路解析:数据映射、训练与避坑 简介这是一份面向计算机视觉学习者和研究者的PyTorch语义分割项目资源基于Cityscapes数据集实现DeepLabV3模型的训练与评估适合希望掌握语义分割实战流程、理解ASPP与全局上下文模块用法的中高级开发者。压缩包共18个文件约258MB包含12个Python脚本如train.py、evaluate.py、datasets.py、aspp.py等覆盖模型定义、数据预处理、训练与验证流程另含4个pth权重文件、1个README说明和License文档。已有2097人学习下载。资源提供完整可运行的DeepLabV3训练代码、预训练权重、Cityscapes数据预处理工具及评估脚本并配有模型文件结构说明可帮助读者快速搭建训练环境、进行mIoU指标评估和模型调优。目录按models、utils、evaluation等模块整理便于追踪代码逻辑与二次开发。1. 在 Cityscapes 上复现 DeepLabV3模型不难链路才是分水岭很多人第一次在 Cityscapes 数据集上复现 DeepLabV3 的 PyTorch 实现预期是“搭好模型、调一下学习率等指标往上走就行”。真实情况往往是模型结构没出问题时间却全耗在类别映射、显存规划和评估协议上验证分数和可视化怎么都对不上。标题看起来在讲一个模型实际上是一整条链路——数据准备、骨干初始化、ASPP 结构、损失函数、训练策略、评估口径任何一环偏了复现结果都可能差出一大截。这篇笔记把我惯用的落地路径拆开讲适合已经会 PyTorch 分类训练、第一次转向语义分割的开发者你能得到一份可训练的 DeepLabV3-Cityscapes 基线代码以及一套避坑顺序。2. 先把地基打对DeepLabV3 的 ASPP 设计与 Cityscapes 的数据约定2.1 结构上的三个关键点空洞卷积、ASPP 与低层特征融合语义分割和分类任务最大的区别在于分类网络可以容忍多次池化把分辨率降到很低分割却必须保留空间位置。DeepLabV3 的骨干网络依然来自 ResNet但它通过空洞卷积控制特征图的输出步幅避免在最后一个 block 里继续下采样。输出步幅output stride是一个必须明确的术语输入分辨率是特征图分辨率的多少倍。Cityscapes 类别多、物体尺度跨度大我一般保留 output stride16 作为默认也就是输入 1024x1024 时最后特征图是 64x64再配合 ASPP 弥补下采样带来的细节损失。ASPPAtrous Spatial Pyramid Pooling是 DeepLabV3 的核心模块思路很直白同一个特征图用四个不同视野的并行分支去提取上下文再把结果拼起来。四个分支分别是 1x1 卷积、三个不同空洞率的 3x3 卷积以及一个全局平均池化分支。空洞率常见取 6、12、18这个组合几乎是经验值改动后往往得不偿失。全局池化分支让模型获得整张图的全局上下文这在 Cityscapes 这种街景场景里非常重要——天空、道路和大楼彼此依赖只看局部像素很容易猜错类别。第三个关键点是低层特征融合。严格来说带辅助分支的 DeepLabV3 在 ResNet 的 layer3 输出处会接一个额外的分类头用于加速收敛如果你用的是 torchvision 自带实现这个分支由aux_lossTrue控制。辅助分支不会进入最终推理但训练时它会提供更早期的梯度信号让骨干网络的前几层不至于训练太慢。2.2 Cityscapes 的标注映射从原始像素值到 19 类训练目标Cityscapes 的高清标注图不是像 VOC 那样直接用类别号做像素值它自带一套细粒度的类别体系和多种 ID。常见做法是拿gtFine目录里的labelIds单通道 PNG 作为训练输入但要先把原始像素值映射到 0~18 的 trainId映射不到的值统一改成 255 交给损失函数忽略。这是新手最容易翻车的点直接把像素值当类别号去算交叉熵模型会反复震荡。训练时真正参与监督的类别是下面这 19 类列表里的数字就是训练时使用的标签值trainId类别说明0road道路1sidewalk人行道2building建筑3wall墙体4fence围栏5pole杆状物6traffic light红绿灯7traffic sign交通标志8vegetation植被9terrain地面10sky天空11person行人12rider骑行者13car小汽车14truck卡车15bus公交车16train火车17motorcycle摩托车18bicycle自行车批量处理时不要每张图都现场循环做映射那个速度没法看。我惯用的做法是预先构造一个长度为 256 的查找表把原始像素值作为下标查表得到 trainId。Cityscapes 的原始标注里还有很多细分类比如 road 会细分成多种路面训练时全部归并到 road找不到归属的像素在查找表里直接写成 255作为 ignore 区域。2.3 数据加载器写法随机缩放、裁剪与归一化的完整 PipelineCityscapes 原图是 1024x2048直接整图送进 ResNet101 会非常吃显存。我一般把训练侧数据处理分成四步随机缩放、随机裁剪、随机水平翻转、归一化。随机缩放的尺度范围常用 0.5 到 2.0让网络适应不同距离下的物体尺度裁剪尺寸我习惯用 512x512 或 769x769前者省显存、后者在 baseline 上普遍高一点。下面是一个能直接落地的 PyTorch Datasetimport random import numpy as np from PIL import Image from torch.utils.data import Dataset import torchvision.transforms.functional as TF class CityscapesDataset(Dataset): def __init__(self, img_dir, label_dir, crop_size512, ignore_index255): self.img_paths sorted(glob.glob(img_dir /*.png)) self.label_paths sorted(glob.glob(label_dir /*.png)) self.crop_size crop_size self.ignore_index ignore_index # 查表法将 labelIds 映射成 trainId0~18其余一律 255 self.mapping self._build_mapping() def _build_mapping(self): # 只列需要合并的关键项实际使用时应根据 Cityscapes 官方类别表展开 mapping np.zeros(256, dtypenp.uint8) 255 for train_id in range(19): mapping[train_id] train_id # 占位正式代码用官方映射表填充 return mapping def __len__(self): return len(self.img_paths) def __getitem__(self, idx): img Image.open(self.img_paths[idx]).convert(RGB) label Image.open(self.label_paths[idx]) # 随机缩放同一尺度同时作用于图和标注 scale random.uniform(0.5, 2.0) w, h img.size img img.resize((int(w * scale), int(h * scale)), Image.BILINEAR) label label.resize((int(w * scale), int(h * scale)), Image.NEAREST) # 随机裁剪保证标签不越界 w, h img.size ch, cw self.crop_size, self.crop_size if w cw and h ch: top random.randint(0, h - ch) left random.randint(0, w - cw) img img.crop((left, top, left cw, top ch)) label label.crop((left, top, left cw, top ch)) else: img img.resize((cw, ch), Image.BILINEAR) label label.resize((cw, ch), Image.NEAREST) # 随机水平翻转 if random.random() 0.5: img TF.hflip(img) label TF.hflip(label) img np.array(img).astype(np.float32) / 255.0 label np.array(label).astype(np.int64) # 归一化使用 ImageNet 的统计量权重迁移时需要保持一致 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) img (img - mean) / std # 把原始标注值换算成 trainId label self.mapping[label % 256] img torch.from_numpy(img.transpose(2, 0, 1)).float() label torch.from_numpy(label).long() return img, label代码里有两个关键细节。第一个是标签缩放必须用Image.NEAREST不能用双线性插值否则类别边界会被插出不存在的小数类别交叉熵会把这些噪声当真。第二个是random.uniform(0.5, 2.0)的缩放上限Cityscapes 训练集车辆和行人占比不低过高的缩放会让小物体被裁出图外反而降低小类别指标。如果你的显存允许把裁剪尺寸调到 769 会显著改善 pole、traffic light 这类细长物体的召回率代价是训练轮数可能需要加长。3. 从骨干到损失在 PyTorch 里组装一个可训练的 DeepLabV33.1 模型构建torchvision 的 deeplabv3_resnet101 与分类头替换用 PyTorch 复现 DeepLabV3 并不建议从零手写整个网络。torchvision 提供了现成的实现骨干是 ResNet101ASPP 和分类头都已经接好而且还带可选的辅助分类器。直接使用它的好处是结构可靠且能加载在 COCO 上预训练过的权重。这个预训练权重对 Cityscapes 复现非常关键——语义分割的训练成本很高从零初始化 ResNet101 加 ASPP往往要跑更长的轮数才能达到同样的指标。模型构建的核心代码很短但有一个必须改的地方分类头输出通道数。torchvision 默认权重是在 COCO 数据集上训练出来的分类头输出是 21 类Cityscapes 只有 19 类所以要把最后一层卷积替换掉import torch import torch.nn as nn import torchvision.models.segmentation as seg model seg.deeplabv3_resnet101( weightsseg.DeepLabV3_ResNet101_Weights.DEFAULT, aux_lossTrue ) # 替换主分类头最后一层输入 256 通道输出改为 19 类 model.classifier[4] nn.Conv2d(256, 19, kernel_size1) # 替换辅助分类头同样改输出为 19 类 if model.aux_classifier is not None: model.aux_classifier[4] nn.Conv2d(256, 19, kernel_size1) # 训练时开启辅助损失权重一般给 0.4 model.aux_loss Trueweightsseg.DeepLabV3_ResNet101_Weights.DEFAULT在旧版本 torchvision 里可能写作pretrainedTrue如果你用的是新版本以你本地环境实际支持的枚举名为准。替换分类头后预训练权重里原有的 21 类输出参数会被丢弃但这部分参数只占整个模型很小比例不会明显影响迁移效果。3.2 损失函数与忽略策略cross-entropy 和 ignore_index255Cityscapes 官方的目标就是在每个像素上做 19 类分类因此主损失几乎固定采用交叉熵。真正要设置的参数是ignore_index。前面已经把映射之外的像素值改成 255损失函数必须同步忽略这些位置否则无意义的类别噪声会干扰训练criterion nn.CrossEntropyLoss(ignore_index255)ignore_index255意味着预测结果里这个像素不管给出什么概率都不参与梯度计算。Cityscapes 图像里边界附近的标注往往有大量不完美像素训练时不忽略它们模型会被边界上的错误标注反复拉扯导致 mIoU 掉下来。这个细节比调学习率还重要。辅助损失和主损失需要分开算。torchvision 的模型在aux_lossTrue时前向会返回out和aux两个输出。常见做法是主损失权重取 1.0辅助损失权重取 0.4然后把两个 loss 加起来再反传outputs model(images) loss criterion(outputs[out], labels) if aux in outputs: loss 0.4 * criterion(outputs[aux], labels)辅助损失可以理解成给骨干网络中段加了一个“提前判断”的任务让浅层特征更早学习到语义信息。它不影响推理时的模型结构只在训练阶段提供额外的梯度路径因此验证时可以直接只看out。3.3 一个最小可运行的训练循环骨架训练主体是标准的 PyTorch 循环但有一些分割任务特有的细节。Cityscapes 单张图大即使裁剪成 512x512ResNet101 加 ASPP 的前向后向依然占显存所以加载器、优化器、混合精度这三者要一起配合。下面是我常用的训练循环骨架import torch from torch.cuda.amp import GradScaler, autocast device torch.device(cuda) model model.to(device) scaler GradScaler() optimizer torch.optim.SGD( model.parameters(), lr0.007, momentum0.9, weight_decay1e-4 ) for epoch in range(total_epochs): model.train() for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() with autocast(): outputs model(images) loss criterion(outputs[out], labels) if aux in outputs: loss 0.4 * criterion(outputs[aux], labels) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()建议把base_lr0.007看作一个相对安全的起点。显存有限被迫把 batch size 降到 2 或 4 时这个学习率仍然能工作batch size 如果提到 16 以上学习率可以按比例上调。GradScaler是混合精度训练的标准组件Cityscapes 这种高分辨率任务上收益很大显存占用能下降 30% 左右训练速度也有明显提升而且对 mIoU 的影响通常很小。4. 训练参数与硬件下限学习率策略、batch size 和显存的三个平衡点4.1 Poly 学习率衰减与 warmup什么时候调 power什么时候开 warmupCityscapes 这类精标注数据集训练轮数不会太长常用轮数在 50 到 100 之间。固定学习率会让后期训练变成拉锯战所以语义分割里最主流的学习率策略是 Poly 衰减学习率随着训练进度从初始值慢慢降到接近 0。PyTorch 自带调度器里没有直接叫 Poly 的需要自己写一个很小的回调def poly_lr(epoch, base_lr0.007, total_epochs60, power0.9): lr base_lr * (1.0 - epoch / total_epochs) ** power return lr for epoch in range(total_epochs): lr poly_lr(epoch) for param_group in optimizer.param_groups: param_group[lr] lrpower0.9是常见默认值含义是学习率曲线衰减速度的快慢power 越大前期下降越慢、后期衰减越猛。我一般先不动 power优先确认模型能收敛如果发现验证集指标在最后 10 个 epoch 还在上升就把 total_epochs 加长而不是去调 power。warmup 则更像一个可选项当 batch size 较大或学习率设置偏高时前几个 epoch 用很小的学习率热身可以防止模型在初期权重剧烈震荡。batch size 在 8 以下时warmup 带来的收益很有限不值得为此多写代码。4.2 batch size 与显存裁剪尺寸、混合精度和梯度累积的组合很多人以为显存不够就缩小 batch size但单独这么做往往触发另一个问题batch size 过小时BatchNorm 的统计量不稳定验证 mIoU 会跟着抖动。Cityscapes 上我习惯给一个组合策略先固定裁剪尺寸再决定 batch size最后用梯度累积补足等效批大小。下面是一个常见搭配参考显存容量裁剪尺寸Batch size梯度累积等效批大小11 GB512x51242824 GB512x51281824 GB769x76942832 GB 以上769x769818梯度累积的实现并不复杂把多个小 batch 的梯度累加攒够一定步数再更新参数。需要注意的是BatchNorm 的统计量是按实际送入的 batch 计算的梯度累积只影响优化器更新频率不会让 BatchNorm 看到更大批的数据因此如果显存允许尽量让单次 batch size 不低于 4。这个环节最容易掉进去的陷阱是裁剪尺寸从 512 提到 769mIoU 确实会涨但训练时间也可能涨 1.5 倍以上。显存不充裕时我先建议保住 512 尺寸把训练跑通拿到一个稳定基线后续再逐步加大裁剪尺寸对比收益而不是一上来就追求最高指标。5. Cityscapes 训练避坑清单5 次翻车现场与解决路径5.1 第一个坑mIoU 卡在 50 上下不去类别映射在作祟现象模型结构、训练设置看着都正常但验证集 mIoU 始终在 50 左右徘徊怎么调学习率都上不去。原因数据集加载时直接用了labelIds的原始像素值做监督没有把像素映射到 trainId。Cityscapes 的原始像素值分成多组比如地面被拆成多种类型直接拿它们当 19 类学习模型把大量本应合并的类别当成了不同类预测自然混乱。解决严格走查表法把原始像素值映射到 0~18映射不上的值改成 255并在交叉熵里传ignore_index255。修正后同样配置下 mIoU 通常能回归正常区间。5.2 第二个坑训练中途显存溢出问题不在 batch size现象前几个 iteration 正常跑十几步后突然 OOM单看 batch size 也不大。原因Cityscapes 标签图和输入图都是单通道 PNG 大图在随机缩放后部分样本可能仍接近 1000x2000。如果 dataset 里__getitem__返回的图没有统一裁剪到固定尺寸batch 内会出现尺寸不齐的样本后续 padding 后送入网络的特征图尺寸波动峰值显存被撑爆。解决不要依赖 collate 的自动 padding在__getitem__里显式对图做 resize 或 crop保证每个样本输出尺寸一致。同时把混合精度打开再辅以梯度累积。这三个手段一起做大多数情况下都能把显存压住。5.3 第三个坑验证 mIoU 涨了可视化却一塌糊涂现象训练日志里 mIoU 稳步上升但把预测结果画出来看细长物体如电线杆、红绿灯经常断成几截车辆边缘也对不齐。原因单尺度推理对 Cityscapes 这种尺寸跨度大的场景不友好。训练时随机缩放让网络学到了多尺度表达能力验证时却只送入一个固定尺寸小物体特征被过度下采样自然容易断裂。解决验证时做多尺度推理把输入图分别缩放到 0.75、1.0、1.25 倍分别预测后把预测概率插值回原图尺寸取平均。这个技巧带来的 mIoU 增益通常有 1~2 个点而且不需要重新训练。5.4 第四个坑训练缓慢瓶颈不在 GPU 而在 IO现象GPU 利用率只有 40% 上下nvidia-smi 显示算力没吃满一个 epoch 要跑很久。原因Cityscapes 图像分辨率高PNG 解码开销比 VOC 这类低分辨率数据集大得多。如果数据放在机械硬盘或者网络文件系统上CPU 解图速度会成为硬瓶颈GPU 只能等数据。解决把数据集放到 SSD 上DataLoader 的num_workers调到 4 到 8打开persistent_workersTrue避免每个 epoch 重建进程。如果还想进一步压榨性能可以在离线阶段把训练图统一转成 JPEG 或预裁剪小块再入队。5.5 第五个坑复现分数和论文对不上评估协议不一致现象自己跑出来的 mIoU 和参考结果差 3~5 个点模型和训练参数几乎一样。原因大概率不是模型问题而是评估口径不同。Cityscapes 官方评估要求在原始分辨率上计算 IoU并且对边界区域的粗糙标注做特殊忽略如果评估时用了裁剪后的输入尺寸、没把预测结果上采样回 1024x2048或没忽略边界像素分数就会系统性偏低。解决评估流程固定为预测输出上采样到原始分辨率再与原始标注逐类计算 IoU。统一口径之前先不用纠结绝对分数统一之后你才能判断余下的差距来自训练技巧还是模型容量。6. 把 mIoU 算对并留住可视化一个让每个训练轮次都变透明的习惯训练到最后判断模型好坏不能只盯一个平均 mIoU还需要逐类查看哪类得分异常。Cityscapes 上很容易出现个别类别被整体压分的情况。我用基于混淆矩阵的逐类 IoU 统计代码不长但很稳定import numpy as np import torch def per_class_iou(pred, target, num_classes19, ignore_index255): pred pred.view(-1) target target.view(-1) mask target ! ignore_index pred pred[mask] target target[mask] ious [] for cls in range(num_classes): p (pred cls) t (target cls) intersection (p t).sum().item() union (p | t).sum().item() if union 0: ious.append(intersection / union) return np.mean(ious), np.array(ious)这段代码需要注意两个地方count 时只统计有该类别出现的图避免某个类别在验证集中完全没有样本时导致除零返回的逐类数组要记录到训练日志里和整体 mIoU 一起观察。比如 pole 和 bicycle 这类小物体单看平均 mIoU 很容易被 car 的高分掩盖逐类指标才能暴露问题。我最后还有一个坚持了很久的习惯每个 epoch 验证结束后从验证集里固定取几张代表性场景把预测结果、ground truth 和原图拼成一张对比图保存到日志目录。只盯数字容易漏掉模型在个别类别上的崩溃式退化而可视化能第一时间看出边界错位、类别粘连这类数字反映不出的病灶。训练结束选模型的准则很简单先看逐类 mIoU 是否有类别崩盘再看可视化的边缘质量最后才看平均分。这套流程跑通以后你会发现在 Cityscapes 上复现 DeepLabV3 真正花时间的不是模型代码而是那些数据与评估的细节。把第 5 章的几个坑提前避开你能比预期更快拿到一个稳定可用的 baseline。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进