简介:这是一份面向计算机视觉学习者和研究者的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 stride=16 作为默认,也就是输入 1024x1024 时,最后特征图是 64x64,再配合 ASPP 弥补下采样带来的细节损失。
ASPP(Atrous Spatial Pyramid Pooling)是 DeepLabV3 的核心模块,思路很直白:同一个特征图,用四个不同视野的并行分支去提取上下文,再把结果拼起来。四个分支分别是 1x1 卷积、三个不同空洞率的 3x3 卷积,以及一个全局平均池化分支。空洞率常见取 6、12、18,这个组合几乎是经验值,改动后往往得不偿失。全局池化分支让模型获得整张图的全局上下文,这在 Cityscapes 这种街景场景里非常重要——天空、道路和大楼彼此依赖,只看局部像素很容易猜错类别。
第三个关键点是低层特征融合。严格来说,带辅助分支的 DeepLabV3 在 ResNet 的 layer3 输出处会接一个额外的分类头,用于加速收敛;如果你用的是 torchvision 自带实现,这个分支由aux_loss=True控制。辅助分支不会进入最终推理,但训练时它会提供更早期的梯度信号,让骨干网络的前几层不至于训练太慢。
2.2 Cityscapes 的标注映射:从原始像素值到 19 类训练目标
Cityscapes 的高清标注图不是像 VOC 那样直接用类别号做像素值,它自带一套细粒度的类别体系和多种 ID。常见做法是拿gtFine目录里的labelIds单通道 PNG 作为训练输入,但要先把原始像素值映射到 0~18 的 trainId,映射不到的值统一改成 255 交给损失函数忽略。这是新手最容易翻车的点:直接把像素值当类别号去算交叉熵,模型会反复震荡。
训练时真正参与监督的类别是下面这 19 类,列表里的数字就是训练时使用的标签值:
| trainId | 类别 | 说明 |
|---|---|---|
| 0 | road | 道路 |
| 1 | sidewalk | 人行道 |
| 2 | building | 建筑 |
| 3 | wall | 墙体 |
| 4 | fence | 围栏 |
| 5 | pole | 杆状物 |
| 6 | traffic light | 红绿灯 |
| 7 | traffic sign | 交通标志 |
| 8 | vegetation | 植被 |
| 9 | terrain | 地面 |
| 10 | sky | 天空 |
| 11 | person | 行人 |
| 12 | rider | 骑行者 |
| 13 | car | 小汽车 |
| 14 | truck | 卡车 |
| 15 | bus | 公交车 |
| 16 | train | 火车 |
| 17 | motorcycle | 摩托车 |
| 18 | bicycle | 自行车 |
批量处理时不要每张图都现场循环做映射,那个速度没法看。我惯用的做法是预先构造一个长度为 256 的查找表,把原始像素值作为下标,查表得到 trainId。Cityscapes 的原始标注里还有很多细分类,比如 road 会细分成多种路面,训练时全部归并到 road;找不到归属的像素在查找表里直接写成 255,作为 ignore 区域。
2.3 数据加载器写法:随机缩放、裁剪与归一化的完整 Pipeline
Cityscapes 原图是 1024x2048,直接整图送进 ResNet101 会非常吃显存。我一般把训练侧数据处理分成四步:随机缩放、随机裁剪、随机水平翻转、归一化。随机缩放的尺度范围常用 0.5 到 2.0,让网络适应不同距离下的物体尺度;裁剪尺寸我习惯用 512x512 或 769x769,前者省显存、后者在 baseline 上普遍高一点。下面是一个能直接落地的 PyTorch Dataset:
import 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_size=512, ignore_index=255): 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 映射成 trainId(0~18),其余一律 255 self.mapping = self._build_mapping() def _build_mapping(self): # 只列需要合并的关键项,实际使用时应根据 Cityscapes 官方类别表展开 mapping = np.zeros(256, dtype=np.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], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.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 里组装一个可训练的 DeepLabV3
3.1 模型构建:torchvision 的 deeplabv3_resnet101 与分类头替换
用 PyTorch 复现 DeepLabV3 并不建议从零手写整个网络。torchvision 提供了现成的实现,骨干是 ResNet101,ASPP 和分类头都已经接好,而且还带可选的辅助分类器。直接使用它的好处是:结构可靠,且能加载在 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( weights=seg.DeepLabV3_ResNet101_Weights.DEFAULT, aux_loss=True ) # 替换主分类头:最后一层输入 256 通道,输出改为 19 类 model.classifier[4] = nn.Conv2d(256, 19, kernel_size=1) # 替换辅助分类头,同样改输出为 19 类 if model.aux_classifier is not None: model.aux_classifier[4] = nn.Conv2d(256, 19, kernel_size=1) # 训练时开启辅助损失,权重一般给 0.4 model.aux_loss = Trueweights=seg.DeepLabV3_ResNet101_Weights.DEFAULT在旧版本 torchvision 里可能写作pretrained=True,如果你用的是新版本,以你本地环境实际支持的枚举名为准。替换分类头后,预训练权重里原有的 21 类输出参数会被丢弃,但这部分参数只占整个模型很小比例,不会明显影响迁移效果。
3.2 损失函数与忽略策略:cross-entropy 和 ignore_index=255
Cityscapes 官方的目标就是在每个像素上做 19 类分类,因此主损失几乎固定采用交叉熵。真正要设置的参数是ignore_index。前面已经把映射之外的像素值改成 255,损失函数必须同步忽略这些位置,否则无意义的类别噪声会干扰训练:
criterion = nn.CrossEntropyLoss(ignore_index=255)ignore_index=255意味着:预测结果里这个像素不管给出什么概率,都不参与梯度计算。Cityscapes 图像里,边界附近的标注往往有大量不完美像素,训练时不忽略它们,模型会被边界上的错误标注反复拉扯,导致 mIoU 掉下来。这个细节比调学习率还重要。
辅助损失和主损失需要分开算。torchvision 的模型在aux_loss=True时,前向会返回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 单张图大,即使裁剪成 512x512,ResNet101 加 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(), lr=0.007, momentum=0.9, weight_decay=1e-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_lr=0.007看作一个相对安全的起点。显存有限被迫把 batch size 降到 2 或 4 时,这个学习率仍然能工作;batch size 如果提到 16 以上,学习率可以按比例上调。GradScaler是混合精度训练的标准组件,Cityscapes 这种高分辨率任务上收益很大:显存占用能下降 30% 左右,训练速度也有明显提升,而且对 mIoU 的影响通常很小。
4. 训练参数与硬件下限:学习率策略、batch size 和显存的三个平衡点
4.1 Poly 学习率衰减与 warmup:什么时候调 power,什么时候开 warmup
Cityscapes 这类精标注数据集训练轮数不会太长,常用轮数在 50 到 100 之间。固定学习率会让后期训练变成拉锯战,所以语义分割里最主流的学习率策略是 Poly 衰减:学习率随着训练进度从初始值慢慢降到接近 0。PyTorch 自带调度器里没有直接叫 Poly 的,需要自己写一个很小的回调:
def poly_lr(epoch, base_lr=0.007, total_epochs=60, power=0.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"] = lrpower=0.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 GB | 512x512 | 4 | 2 | 8 |
| 24 GB | 512x512 | 8 | 1 | 8 |
| 24 GB | 769x769 | 4 | 2 | 8 |
| 32 GB 以上 | 769x769 | 8 | 1 | 8 |
梯度累积的实现并不复杂:把多个小 batch 的梯度累加,攒够一定步数再更新参数。需要注意的是,BatchNorm 的统计量是按实际送入的 batch 计算的,梯度累积只影响优化器更新频率,不会让 BatchNorm 看到更大批的数据;因此如果显存允许,尽量让单次 batch size 不低于 4。
这个环节最容易掉进去的陷阱是:裁剪尺寸从 512 提到 769,mIoU 确实会涨,但训练时间也可能涨 1.5 倍以上。显存不充裕时,我先建议保住 512 尺寸,把训练跑通拿到一个稳定基线;后续再逐步加大裁剪尺寸对比收益,而不是一上来就追求最高指标。
5. Cityscapes 训练避坑清单:5 次翻车现场与解决路径
5.1 第一个坑:mIoU 卡在 50 上下不去,类别映射在作祟
现象:模型结构、训练设置看着都正常,但验证集 mIoU 始终在 50 左右徘徊,怎么调学习率都上不去。
原因:数据集加载时直接用了labelIds的原始像素值做监督,没有把像素映射到 trainId。Cityscapes 的原始像素值分成多组,比如地面被拆成多种类型,直接拿它们当 19 类学习,模型把大量本应合并的类别当成了不同类,预测自然混乱。
解决:严格走查表法把原始像素值映射到 0~18,映射不上的值改成 255,并在交叉熵里传ignore_index=255。修正后同样配置下 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_workers=True避免每个 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_classes=19, ignore_index=255): 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。希望帮到你。
本文还有配套的精品资源,点击获取