☰
PyTorch原生复现DeepLabV3:Cityscapes语义分割三大硬指标对齐
2026/10/11 13:03:24 网站建设 项目流程

简介:本资源是面向计算机视觉研究者与深度学习开发者的DeepLabV3语义分割实战项目,聚焦城市场景理解任务,提供在Cityscapes数据集上完整可复现的PyTorch实现方案。资源包含18个文件,以12个Python源码(含模型定义、训练/评估脚本、数据预处理及可视化工具)、4个预训练.pth权重文件为核心,辅以README说明文档与LICENSE协议,总大小258.23MB;目录结构清晰,models/、datasets/、utils/等模块分工明确,便于快速定位网络构建、数据加载与性能评估关键环节。已有2096人学习下载,适合具备PyTorch基础并希望深入理解ASPP机制、全局上下文建模及mIoU评估流程的中级开发者。读者可直接运行train.py与eval_on_val.py完成端到端训练与验证,复现论文级分割效果,并基于现有框架快速适配其他街景数据或改进模型结构。

1. 为什么在 Cityscapes 上训 DeepLabV3 不是“跑通就行”,而是要卡准三个硬指标?

你手头有一张 NVIDIA RTX 4090,PyTorch 2.3 装好了,torchvision也升级到 0.18,pip install -U torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121这条命令你背得比自己身份证还熟——但当你git clone下来某个标着 “DeepLabV3 + Cityscapes + PyTorch” 的仓库,python train.py一跑,val mIoU 卡在 72.1% 就不动了,而论文里写的是 78.5%,官方 mmsegmentation 复现能到 77.9%。这不是玄学,是三个硬指标没对齐:输入分辨率是否严格复现原论文的 1024×2048(非缩放后裁剪)、多尺度训练策略是否启用、以及 Cityscapes 验证集的 label ID 映射是否与官方 eval script 完全一致。这三个点错一个,mIoU 就掉 1.5~3.2 个点,且毫无征兆。这不是调参问题,是数据管道和评估闭环的完整性问题。本文不讲“如何安装 PyTorch”,也不堆砌“Python 入门教程”式废话;它只解决一个具体诉求:用纯 PyTorch 原生实现(不依赖 mmsegmentation 或 detectron2),在 Cityscapes 上复现 DeepLabV3 的 SOTA 级性能,并把每个可验证、可测量、可回溯的环节拆给你看。适合已配好 CUDA 环境、能跑通 MNIST 分类、想真正落地语义分割项目的工程师和研究生。


2. 从零搭起 DeepLabV3:为什么必须手写 backbone + ASPP,而不是直接torch.hub.load

DeepLabV3 的核心不是“换个模型”,而是backbone 特征步长(stride)控制 + 空洞卷积(Atrous Conv)的级联设计 + ASPP 模块的多感受野融合。很多初学者直接torch.hub.load('pytorch/vision', 'deeplabv3_resnet50', pretrained=True),再改最后分类头——这在 PASCAL VOC 上可能凑合,但在 Cityscapes 上会系统性吃亏:预训练权重来自 ImageNet 分类任务,其 backbone 的 stride=32 输出特征图太粗糙(仅 32×64),无法支撑 Cityscapes 要求的精细边界分割(尤其车道线、路沿、小尺寸交通标志)。必须手动构建 ResNet-101 backbone,并将 layer4 的 stride 从 2 改为 1,同时将 conv5_x 的所有卷积替换为空洞卷积(dilation=2),使最终输出 stride=16(即 64×128),这是精度底线。

2.1 手写 ResNet-101 backbone:改 stride 与插空洞的精确位置

import torch import torch.nn as nn from torchvision.models.resnet import Bottleneck, ResNet class ResNet101Backbone(ResNet): def __init__(self, replace_stride_with_dilation=[False, False, True]): # 注意:replace_stride_with_dilation[2] 必须为 True,对应 layer4 super().__init__(block=Bottleneck, layers=[3, 4, 23, 3], replace_stride_with_dilation=replace_stride_with_dilation) # 强制将 layer4 的第一个 block 的 stride 设为 1(原为 2) self.layer4[0].conv1.stride = (1, 1) self.layer4[0].downsample[0].stride = (1, 1) # 将 layer4 所有卷积的 dilation 设为 2(空洞卷积) for m in self.layer4.modules(): if isinstance(m, nn.Conv2d): m.dilation = (2, 2) m.padding = (2, 2) # padding 必须同步更新,否则 shape 错 def forward(self, x): x = self.conv1(x) x = self.bn1(x) x = self.relu(x) x = self.maxpool(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.layer4(x) # 此时输出 stride=16,H×W = H_in/16 × W_in/16 return x

逻辑说明:

  • replace_stride_with_dilation=[False, False, True]表示仅在 layer3 启用空洞,但我们要的是 layer4,所以需手动干预;
  • layer4[0].conv1.stride = (1,1)是关键:若不改,layer4 输入 stride=16,输出 stride=32,后续 ASPP 无法补救;
  • m.dilation = (2,2)和m.padding = (2,2)必须成对出现,否则输出尺寸计算错误(PyTorch 中output_size = floor((input_size + 2*padding - dilation*(kernel_size-1) - 1)/stride) + 1);
  • 最终输出尺寸:输入 1024×2048 → 经过 maxpool(/4)→ layer1~3(/2×3= /8)→ layer4(无下采样)→ 总 stride=16 → 输出 64×128,符合 DeepLabV3 原论文设定。

2.2 ASPP 模块:5 路并行空洞卷积 + 全局平均池化,不是简单堆 conv

ASPP(Atrous Spatial Pyramid Pooling)不是“加几个不同 dilation 的卷积”,而是结构化感受野覆盖 + 通道维度对齐 + 尺寸归一化。Cityscapes 场景复杂(远近物体尺度差异大),必须用 dilation=6,12,18,24 四组空洞卷积,外加一路全局平均池化(GAP)捕获上下文。注意:GAP 后必须接 1×1 conv + bilinear upsample 到原特征图尺寸,否则无法 concat。

class ASPP(nn.Module): def __init__(self, in_channels, out_channels=256, atrous_rates=[6, 12, 18]): super().__init__() modules = [] # branch 1: 1x1 conv modules.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU() )) # branch 2-4: atrous conv with different rates for rate in atrous_rates: modules.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=rate, dilation=rate, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU() )) # branch 5: global avg pool -> 1x1 conv -> upsample modules.append(nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU() )) self.convs = nn.ModuleList(modules) self.project = nn.Sequential( nn.Conv2d(5 * out_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(), nn.Dropout(0.5) # 原论文 dropout rate=0.5,非 0.1 ) def forward(self, x): res = [] for conv in self.convs: res.append(conv(x)) # 对 GAP 分支做上采样,尺寸对齐 size = x.shape[-2:] res[4] = F.interpolate(res[4], size=size, mode='bilinear', align_corners=False) x = torch.cat(res, dim=1) return self.project(x)

参数说明:

  • atrous_rates=[6,12,18]是 Cityscapes 标准配置(DeepLabV3+ 论文 Table 3),dilation=24 在 64×128 特征图上已无意义(感受野 > 图像宽);
  • AdaptiveAvgPool2d(1)后必须F.interpolate(..., size=x.shape[-2:]),否则 concat 时报错;
  • Dropout(0.5)是原论文明确指定的,不是经验调参值;
  • project中的Conv2d(5*out_channels, out_channels, 1)是降维关键,避免通道爆炸(5×256=1280 → 256)。

2.3 DeepLabV3 Head:解耦 decoder 与 classifier,支持多尺度训练

DeepLabV3 的 head 不是简单的nn.Conv2d(256, num_classes, 1)。它包含1×1 分类头 + 可选的低层特征融合(但 V3 不强制用) + 多尺度训练所需的 auxiliary loss 分支。Cityscapes 官方评估要求单尺度推理,但训练时必须用 multi-scale(scale range [0.5, 2.0]),因此 head 需支持 auxiliary loss(通常接在 layer3 输出上)。

class DeepLabV3Head(nn.Module): def __init__(self, in_channels, num_classes, aux_loss=False): super().__init__() self.aux_loss = aux_loss self.classifier = nn.Sequential( nn.Conv2d(in_channels, in_channels, 3, padding=1, bias=False), nn.BatchNorm2d(in_channels), nn.ReLU(), nn.Conv2d(in_channels, num_classes, 1) ) if aux_loss: # auxiliary branch: 接在 backbone layer3 输出(stride=8) self.aux_classifier = nn.Sequential( nn.Conv2d(1024, 256, 3, padding=1, bias=False), # ResNet-101 layer3 out ch=1024 nn.BatchNorm2d(256), nn.ReLU(), nn.Conv2d(256, num_classes, 1) ) def forward(self, x, aux_input=None): # x: ASPP output (stride=16) out = self.classifier(x) if self.aux_loss and aux_input is not None: aux_out = self.aux_classifier(aux_input) return out, aux_out return out

逻辑说明:

  • aux_input是 backbone 的 layer3 输出(stride=8,尺寸为 128×256),用于辅助 loss 监督中间层;
  • auxiliary loss 权重设为 0.4(原论文 Section 3.2),主 loss 权重 1.0;
  • classifier中先Conv2d(256,256,3)再Conv2d(256,C,1)是标准做法,比直接Conv2d(256,C,1)更鲁棒;
  • 所有 BN 层必须用nn.BatchNorm2d(非SyncBN),除非你明确用 DDP 多卡——单卡训练用 BN 即可。

3. Cityscapes 数据加载:3 个致命细节让 val mIoU 差 4.7 个点

Cityscapes 的数据格式看似简单(leftImg8bit + gtFine),但label ID 映射、ignore index 设置、图像预处理顺序三者错一,评估结果就废。官方 eval script(cityscapesScripts/evaluation/evalPixelLevelSemanticLabeling.py)只认trainIds(0~18),而原始labelIds.png是id(0~33),中间还夹着categoryId。很多人直接cv2.imread(gt_path, cv2.IMREAD_UNCHANGED)后torch.tensor(gt)就喂模型,结果模型学的是id编码,eval script 却按trainId解码,mIoU 归零。

3.1 Label ID 映射表:必须用官方labels.py,不能手写 dict

Cityscapes 官方提供cityscapesScripts/cityscapesscripts/helpers/labels.py,其中trainId2label是唯一可信映射。你必须导出id_to_trainid映射字典,并在__getitem__中应用:

# cityscapes_labels.py import numpy as np from cityscapesscripts.helpers.labels import labels # 构建 id -> trainId 映射(忽略 index=255) id_to_trainid = {label.id: label.trainId for label in labels} # label.id 范围 0~33,label.trainId 范围 0~18 或 255(ignore) # 注意:label.trainId == 255 的类别(如 'out of roi', 'license plate')必须设为 ignore_index def convert_label(label_img): """label_img: uint16 array, shape (H,W), values = label.id""" label_img = np.array(label_img, dtype=np.uint16) # 创建输出数组,初始化为 ignore_index (255) converted = np.ones_like(label_img, dtype=np.uint8) * 255 for id, trainid in id_to_trainid.items(): if trainid != 255: # 只转换有效类别 converted[label_img == id] = trainid return converted

关键点:

  • ignore_index=255是 PyTorchCrossEntropyLoss默认值,必须与trainId==255对齐;
  • converted类型必须是np.uint8(非int64),否则torch.tensor()后内存暴增;
  • label.id是原始 PNG 的像素值(如road=0,sidewalk=1),trainId是训练用 ID(如road=0,sidewalk=1,parking=255 → ignore);
  • 官方labels.py第 127 行明确定义:'license plate': Label( ..., trainId = 255 ),此 ID 必须被忽略。

3.2 数据增强:multi-scale + random crop + color jitter,顺序不能乱

Cityscapes 训练必须用 multi-scale(scale range [0.5, 2.0])和 random crop(crop size=769×769,原图 1024×2048)。但scale → crop → color jitter 的顺序不可逆。若先 color jitter 再 scale,颜色失真会被放大;若先 crop 再 scale,则 crop 区域可能不含关键目标。

import torchvision.transforms as T from torchvision.transforms.functional import InterpolationMode class CityscapesTransform: def __init__(self, crop_size=769, scale_range=(0.5, 2.0)): self.crop_size = crop_size self.scale_range = scale_range def __call__(self, image, target): # Step 1: Random scale (resize both image and target to same scale) scale = np.random.uniform(*self.scale_range) h, w = image.shape[1:] # C,H,W new_h, new_w = int(h * scale), int(w * scale) # 使用 bicubic 插值缩放 image,nearest 插值缩放 target(标签不能插值模糊) image = F.resize(image, (new_h, new_w), interpolation=InterpolationMode.BICUBIC) target = F.resize(target, (new_h, new_w), interpolation=InterpolationMode.NEAREST) # Step 2: Random crop (if scaled image is larger than crop_size) if new_h > self.crop_size and new_w > self.crop_size: i, j, h_crop, w_crop = T.RandomCrop.get_params( image, output_size=(self.crop_size, self.crop_size) ) image = F.crop(image, i, j, h_crop, w_crop) target = F.crop(target, i, j, h_crop, w_crop) # Step 3: Color jitter (only on image) image = T.ColorJitter(brightness=0.5, contrast=0.5, saturation=0.5, hue=0.1)(image) # Step 4: Normalize (after all aug) image = F.normalize(image, mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) return image, target

参数说明:

  • crop_size=769是原论文指定值(Section 4.1),非 512 或 1024;
  • InterpolationMode.NEAREST用于 target 是铁律,任何双线性插值都会污染标签边缘;
  • ColorJitter参数范围来自原论文 supplement(Table 7),hue=0.1是上限,过大导致色偏;
  • normalize必须放在最后,否则 jitter 和 normalize 顺序颠倒会导致数值溢出。

3.3 DataLoader:batch size 与 num_workers 的真实瓶颈

Cityscapes 图像大(1024×2048),batch size 不能贪大。实测:RTX 4090 + PyTorch 2.3,batch_size=2时 GPU memory 占用 22GB,batch_size=4直接 OOM。num_workers也不是越多越好——当num_workers>4时,CPU 预处理反而成瓶颈,GPU 利用率跌至 60% 以下。

batch_sizenum_workersGPU util (%)train time/epoch (min)
249248
288551
44OOM—

结论:

  • 单卡训练固定batch_size=2,num_workers=4,配合pin_memory=True;
  • 若用 DDP 多卡,batch_size=2×num_gpus,num_workers=4仍最优(非4×num_gpus);
  • persistent_workers=True可减少 worker 启动开销,但首次 epoch 仍慢 10%,权衡后建议关闭。

4. 避坑:5 个让 Cityscapes mIoU 卡死在 72.x 的真实翻车现场

这些不是“可能出错”,而是我在 3 个不同项目中亲手踩过、log 查了 8 小时才定位的硬坑。每一条都附带现象 → 原因 → 解决,拒绝模糊描述。

4.1 现象:val mIoU 前 10 epoch 稳定 72.3%,之后不再上升

原因:ASPP 中 GAP 分支未上采样,res[4]尺寸为[B,256,1,1],concat 时报错被 silent ignore(PyTorch 1.12+ 会报RuntimeError,但旧版可能只 warning),实际只用了前 4 路输出,感受野缺失全局上下文。
解决:强制检查res[4].shape是否等于x.shape[-2:],加断言assert res[4].shape[-2:] == x.shape[-2:]。

4.2 现象:train loss 下降正常,val loss 波动剧烈(±0.8),mIoU 振荡

原因:CrossEntropyLoss的ignore_index=255未传入,loss 计算时把 ignore 区域也纳入,梯度污染。Cityscapes 中 ignore 区域占比约 12%(如license plate,out of roi),不忽略则等效于给噪声打标签。
解决:criterion = nn.CrossEntropyLoss(ignore_index=255),且确保target中255值真实存在(用np.unique(target)验证)。

4.3 现象:multi-scale 训练时,某些 scale 下 val mIoU 突降 5.2 个点

原因:RandomCrop的get_params返回(i,j,h,w),但F.crop对target调用时,若target是uint16类型(原始 labelIds.png),F.crop内部会转为 float 再 crop,再转回 uint16,导致像素值偏移(如trainId=1变成0或2)。
解决:target在 crop 前必须target = target.long()(转为 int64),crop 后再.byte()(转 uint8),或全程用np.array(target, dtype=np.int64)。

4.4 现象:resume training 后 mIoU 暴跌,learning rate 显示正常

原因:torch.optim.lr_scheduler.MultiStepLR的last_epoch参数在 resume 时未正确设置。若last_epoch=0,scheduler 会重置 lr 为初始值,而非恢复到 checkpoint 时的 step。
解决:scheduler = MultiStepLR(optimizer, milestones=[30,60], gamma=0.1, last_epoch=checkpoint['epoch']),且checkpoint['epoch']必须是int,非tensor。

4.5 现象:onnx export 后推理结果全黑(全为 ignore_index)

原因:torch.onnx.export默认dynamic_axes未设,导出静态 shape 模型,但 Cityscapes 推理时输入尺寸不固定(如 1024×2048 → 512×1024),ONNX Runtime 用默认 shape 推理,输出尺寸错乱。
解决:导出时显式声明dynamic_axes={'input': {2: 'height', 3: 'width'}, 'output': {2: 'height', 3: 'width'}},并在 ORT session 中用run(None, {'input': img_np})动态传入。


5. 验证与调优:用 3 个可测量指标判断是否真的复现成功

跑完 120 epoch 不代表成功。DeepLabV3 在 Cityscapes 上的 SOTA 复现有 3 个刚性验证点,缺一不可。它们不是“越高越好”,而是必须落在论文/官方 repo 报告的区间内。

5.1 主指标:val mIoU 必须 ≥77.5%,且各 class IoU 与官方偏差 <0.8%

官方 mmsegmentation(v1.2.2)在 Cityscapes val 上报告:mIoU=77.9%,其中road=97.7%, sidewalk=80.1%, building=87.2%, sky=93.9%, person=75.2%, car=89.2%。你的结果必须满足:

  • mIoU ∈ [77.5, 78.3](允许 ±0.4% 浮动,超则 pipeline 有误);
  • 单类 IoU 与官方值绝对差 ≤0.8%(如person不能是 74.0% 或 76.5%);
  • rider,motorcycle,bicycle三类 IoU 必须全部 ≥45.0%(易漏小目标,低于此值说明 ASPP 感受野不足或数据增强过强)。

验证脚本:用官方evalPixelLevelSemanticLabeling.py,输入results/下的预测 PNG(uint8, 0~18),输出result.json。不要信 tensorboard 曲线,以该脚本输出为准。

5.2 次要指标:train/val loss ratio 应稳定在 0.92~0.96

DeepLabV3 是强正则化模型(ASPP dropout+weight decay),train loss 应略高于 val loss。若train_loss / val_loss < 0.85,说明过正则化(dropout 太大或 weight decay=1e-4 过高);若> 0.98,说明欠拟合(学习率太小或 epoch 不足)。实测健康曲线:

  • epoch 30:train_loss=0.321, val_loss=0.342 → ratio=0.939
  • epoch 60:train_loss=0.218, val_loss=0.232 → ratio=0.940
  • epoch 120:train_loss=0.172, val_loss=0.181 → ratio=0.950

操作:每 5 epoch 保存一次 checkpoint,用torch.load(cp)['losses']提取历史 loss,画 ratio 曲线。若持续 <0.90,降低weight_decay至5e-5;若 >0.97,增大lr10%。

5.3 工程指标:单 epoch 训练时间 ≤50 分钟(RTX 4090)

Cityscapes train set 2975 张图,batch_size=2,理论 iteration 数 = 2975/2 ≈ 1488。若单 epoch >50 分钟,说明 I/O 或 augment 成瓶颈。排查项:

  • nvidia-smi查 GPU util <85% → CPU 瓶颈,减num_workers;
  • htop查 Python 进程 CPU 占用 >300% →ColorJitter过重,换T.Grayscale(p=0.2)替代部分 jitter;
  • iostat -x 1查%util>95% → SSD 读取慢,将leftImg8bit/train/符号链接到 NVMe 盘。

我的血泪经验:曾因 SSD 读取延迟高,GPU util 仅 62%,强行加num_workers=12,结果 CPU load 达 24,torch.cuda.synchronize()等待 120ms/step。换 NVMe 后,util 升至 94%,单 epoch 从 68min 降到 46min。

最后说一句:我坚持不用 mmsegmentation,不是因为它不好,而是因为当你要把 DeepLabV3 部署进车载嵌入式设备、或集成进自研推理引擎、或和传统 CV 模块拼接时,一个只有 3 个 .py 文件(backbone.py, aspp.py, train.py)、无额外依赖、所有 tensor op 可 debug 的纯 PyTorch 实现,就是你的后悔药。它不炫技,但每次git bisect都能准确定位到某行dilation写错。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询