DeepLabv3+图像分割实战:从Pytorch环境搭建到Cityscapes训练避坑 简介面向图像分割学习者和算法工程师的DeepLabv3实战资源基于Pytorch在VOC与Cityscapes两个公开数据集上完成训练、验证与推理覆盖数据加载、数据增强、网络定义、损失函数、学习率策略、评估指标和可视化等关键环节适合快速上手的初中级开发者。资源共包含55个文件以Python脚本为主辅以预测结果示例图、训练依赖配置和说明文档脚本按功能拆分为数据处理、模型结构、训练调度、损失计算、指标统计及可视化等模块并提供预测结果与原图、标注图的对照样例便于直观理解分割效果。压缩包整体约2.25MB目录结构清晰自带VOC与Cityscapes数据集适配代码主干网络可选ResNet、Xception、MobileNetV2等能够灵活调整实验配置快速切换训练场景。目前已有139人学习使用作为课程设计、论文复现或入门工程实践的参考都比较合适。1. 图像分割项目从哪开始VOC 和 Cityscapes 上的 DeepLabv3 值得做图像分割这几年从一个「学术指标好看」的方向变成了地毯质检、广告牌识别、车道可行驶区域分割这些场景里的刚需。做这类任务DeepLabv3 是我最常推荐的开局模型结构不复杂单卡能训在 VOC 与 Cityscapes 两个基准上有大量公开 mIoU 可以对照少了很多「自己骗自己」的空间。很多同学一上来就奔着 Cityscapes 去结果第一周就卡在标签编码上——官方给的 labelIds 和训练要用的 trainIds 根本不是一套东西。这篇流程按「环境 → 数据 → 训练 → 避坑 → 验证导出」的顺序把一条可复现的 Pytorch 训练链路讲清楚适合第一次做分割项目、希望少走弯路的人。看完你至少能回答三个问题这个方案值不值得投入、自己机器能不能跑、跑挂了该看哪里。2. DeepLabv3 结构与数据集选型先搞懂网络和基准再动手训练先花 20 分钟把网络和基准摸清楚比直接 clone 一个仓库开训省下至少一个周末。DeepLabv3 不是那种黑匣子式的模型它的每个模块都能对应到一个具体的工程问题理解之后再改 backbone、调空洞率、换数据集都心里有数。2.1 backbone、ASPP、Decoder 各管一块别把整个结构当黑匣子DeepLabv3 由三块拼起来backbone 负责提取多尺度特征ASPP 负责在高层特征上扩大感受野Decoder 负责把低层细节和高层语义融合回去。默认 backbone 是 ResNet101输出 stride 为 16也就是说输入 513×513backbone 出来是约 32×32 的特征图。想跑得更快就用 ResNet50 或 MobileNetV2mIoU 会掉 24 个点但显存和耗时都友好很多。ASPP 的核心是「不同空洞率的并行卷积」。常见配置是四个 3×3 卷积dilation 分别为 1、6、12、18再接一个全局平均池化分支五路输出 concat 后再用 1×1 卷积压缩到 256 通道。空洞率为什么选这几个数因为 backbone 输出 stride 已经是 16高层特征本身分辨率低如果空洞率太小3×3 卷积的实际感受野覆盖不了大目标太大又会让靠近边缘的权重稀疏小目标细节丢失。1、6、12、18 是论文调出来的折中换数据集时我一般先不动它。Decoder 是 DeepLabv3 相对 v3 最大的改进。它把 backbone 第二个 stage 输出的低层特征stride 4ResNet101 下是 256 通道先用 1×1 卷积降到 48 通道再和经过 4 倍上采样的 ASPP 输出 concat过两个 3×3 卷积后分类最后整体 4 倍上采样回原图尺寸。为什么要拼低层特征因为高层特征经过多次池化和空洞卷积后物体边缘的位置信息已经很糊了分割任务里「边缘准不准」直接决定 mIoU 上限。低层特征能把这些边界细节拉回来。所以如果发现预测结果边缘锯齿严重第一反应不是调损失而是看 Decoder 的低层特征路径有没有被 BN 或 dropout 搞坏。2.2 VOC 与 Cityscapes两个数据集的差异决定了你的参数VOC2012 和 Cityscapes 虽然都是分割基准但训练体验完全是两个量级很多人在这上面翻车是因为拿 VOC 的参数直接去跑 Cityscapes。对比项VOC2012Cityscapes类别数20 类 背景 21训练 19 类原始 30 类含 ignore训练图像数1464 张SBD 增强后 10582 张2975 张图像分辨率约 375×500尺寸不一2048×1024 固定分辨率标注精细度较粗物体轮廓简单像素级精细远处小目标多主要难点小物体、遮挡、类别不平衡细长结构杆、路灯、类别极端不平衡VOC 只有 1464 张训练图随便一个 50 层的模型都能过拟合所以训练时数据增强和 dropout 要给足Cityscapes 每张图都是 2048×1024直接整图训练一张卡基本装不下必须随机裁剪到 512×1024 或者更小的 patch 来训。VOC 的 21 类里背景占比很高Cityscapes 的 19 类里 road、building 这类大块类别占了绝大多数像素类别不平衡更严重所以后者的 mIoU 想要过 70 难度远大于 VOC 过 70。选择建议第一次跑通流程用 VOC迭代快半小时内能看到完整的训练闭环要落地到自动驾驶或园区巡检这类真实场景再用 Cityscapes。如果直接用 Cityscapes 起步建议先用少量数据把全流程跑通再放开全量训练否则很容易出现「训练了 20 个小时最后发现验证代码算错了指标」这种事。2.3 mIoU 是唯一值得盯的指标吗损失、评估与忽略类分割任务里最常用的指标是 mIoU公式是每个类别的交并比取平均mIoU (1/C) Σ TP / (TP FP FN)。它比 pixel accuracy 严格得多因为 pixel accuracy 会被背景这种大类带偏哪怕小类全错只要背景对了也能有 90% 以上的准确率而 mIoU 会如实反映小类表现。训练损失通常直接用交叉熵但有一个关键参数ignore_index。Cityscapes 的 void 类、VOC 里没有标注的区域在标签里要设成 255损失计算时跳过这些像素。Pytorch 里就是nn.CrossEntropyLoss(ignore_index255)少设这一行loss 会被一堆「不该学的像素」污染训练起来指标飘忽不定。评估时也要过滤掉 255否则一个类别的 IoU 分母被无意义像素撑大mIoU 直接失真。很多人用 MMSegmentation 训练 Cityscapes 是因为它把数据预处理、同步 BN、评估脚本都封装好了省事。但我建议第一遍还是自己写流程分割项目里数据链路占 60% 的工作量用现成框架跑通一次之后你仍然不知道标签编码怎么来的、验证尺寸为什么不一致。自己写一遍再回头看 MMSegmentation 的配置会豁然开朗。3. 环境与数据准备从安装 Pytorch 到生成 Cityscapes trainIds数据链路是分割项目里最容易出错、又最不值得花时间 debug 的部分。按顺序做每一步做完了再往下走不要一口气把五个步骤的活一起干完再回头查。3.1 用 conda 创建环境并装好 GPU 版 Pytorch环境搭建没什么玄学核心就两步建一个干净的环境然后用官方推荐的命令装 Pytorch。我一般这样建conda create -n deeplab python3.10 conda activate deeplab python -m pip install --upgrade pip装 Pytorch 的坑在于版本和 CUDA 的对应关系。去 pytorch.org 的 install 页面选你的操作系统和 CUDA 版本复制它生成的命令。不要凭记忆敲pip install torch那样装的很可能是 CPU 版。装完一定做一次检测python -c import torch; print(torch.__version__, torch.cuda.is_available())输出2.x.xcu121 True才算成功。如果cuda.is_available()是 False先看是不是装了 CPU 版再看显卡驱动是否支持对应的 CUDA 版本。没有 NVIDIA 独显的机器比如只有 Intel 核显也能跑通 VOC 的小实验但要走 Intel 的 IPEX 分支和本文主线不一致建议直接找一台带 CUDA 显卡的机器。3.2 下载并整理 VOC2012注意标签 PNG 是调色板索引图VOC2012 的官方压缩包是VOCtrainval_11-May-2012.tar从你能访问的镜像下载后解压wget 镜像地址/VOCtrainval_11-May-2012.tar tar -xvf VOCtrainval_11-May-2012.tar解压后目录结构是这样的VOCdevkit/ └─ VOC2012/ ├─ JPEGImages/ # 原图 ├─ SegmentationClass/ # 分割标签 PNG └─ ImageSets/Segmentation/ ├─ train.txt └─ val.txttrain.txt里是训练图片的文件名不含扩展名每行一个读取时拼上路径即可。这里有一个最常见的坑SegmentationClass里的 PNG 不是普通的 RGB 图而是带调色板的索引图像素值 020 对应 21 个类别。很多人读标签时用了cv2.imread或Image.open(...).convert(RGB)把索引值映射到了完全不同的 RGB 编码上训练出来的模型跟胡说八道差不多。正确的读取方式from PIL import Image import numpy as np mask Image.open(VOCdevkit/VOC2012/SegmentationClass/2007_000032.png) label np.array(mask).astype(np.int64) # 保持索引值不要转 RGB提示凡是看到分割标签读取后出现「好几个类颜色一样」「类别数对不上」的诡异现象先怀疑是不是把调色板图转成了 RGB。3.3 Cityscapes 标签转换官方 labelIds 与训练 trainIds 的映射脚本Cityscapes 官方的gtFine里给的是labelIds编码方式和训练需要的trainIds不一致。比如labelId7是 road但训练时 road 的标签是 0labelId24是 person训练时是 11。如果你直接把官方标签拿来训练等于让模型去学一套错误的映射mIoU 会非常难看。必须写脚本转换常见做法是按 cityscapes 官方脚本里的映射关系生成 trainIdsimport os import numpy as np from PIL import Image # labelId - trainId只保留训练用得到的 19 类 mapping { 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(src_path, dst_path): table np.zeros(256, dtypenp.uint8) table.fill(255) # 未映射的类全部置为 ignore for label_id, train_id in mapping.items(): table[label_id] train_id os.makedirs(dst_path, exist_okTrue) for city in os.listdir(src_path): city_src os.path.join(src_path, city) city_dst os.path.join(dst_path, city) os.makedirs(city_dst, exist_okTrue) for name in os.listdir(city_src): img np.array(Image.open(os.path.join(city_src, name))) img table[img] out_name name.replace(labelIds, trainIds) Image.fromarray(img.astype(np.uint8)).save(os.path.join(city_dst, out_name))这段脚本的核心逻辑是构建一张 256 长度的查找表把 19 个有效类的 labelId 映射到 018其余全部映射到 255损失函数里会被忽略。这样转换出来的trainIds图和gtFine目录一一对应训练时直接读。目录组织建议仿照官方leftImg8bit/train/城市名/图片.png后续写 DataLoader 时路径拼接会简单很多。3.4 数据加载与增强随机缩放、裁剪、翻转的参数怎么定分割任务的数据增强和分类不同图像和标签必须做完全相同的变换。一个最基础、效果也最稳的增强组合是「随机缩放 随机裁剪 随机翻转」。随机缩放能让模型适应不同尺度的目标随机裁剪是为了把大图切成固定尺寸 batch随机翻转是白送的样本扩充。import random import numpy as np from PIL import Image import torch class SegDataset(torch.utils.data.Dataset): def __init__(self, img_dir, mask_dir, id_list, crop_size513, scale_range(0.5, 2.0)): self.img_dir img_dir self.mask_dir mask_dir self.ids [line.strip() for line in open(id_list)] self.crop_size crop_size self.scale_range scale_range def __getitem__(self, idx): name self.ids[idx] image Image.open(f{self.img_dir}/{name}.jpg).convert(RGB) mask Image.open(f{self.mask_dir}/{name}.png) # 随机缩放mask 用 NEAREST不能用线性插值 scale random.uniform(*self.scale_range) new_w, new_h int(image.width * scale), int(image.height * scale) image image.resize((new_w, new_h), Image.BILINEAR) mask mask.resize((new_w, new_h), Image.NEAREST) # 随机裁剪到固定大小 if image.width self.crop_size and image.height self.crop_size: x random.randint(0, image.width - self.crop_size) y random.randint(0, image.height - self.crop_size) image image.crop((x, y, x self.crop_size, y self.crop_size)) mask mask.crop((x, y, x self.crop_size, y self.crop_size)) # 随机水平翻转 if random.random() 0.5: image image.transpose(Image.FLIP_LEFT_RIGHT) mask mask.transpose(Image.FLIP_LEFT_RIGHT) img np.array(image).astype(np.float32) / 255.0 img (img - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225] img torch.from_numpy(img).permute(2, 0, 1).float() label torch.from_numpy(np.array(mask)).long() return img, label两个参数需要留意。crop_size在 VOC 上用 513 是论文惯例Cityscapes 常用 512×1024 但显存紧张的场景可以退到 512×512scale_range我习惯用 (0.5, 2.0)小目标多的数据集把下限提到 0.7防止小物体被缩得太小直接消失。标签 resize 必须用NEAREST用双线性会把类别边界插出不存在的新值。4. 训练脚本与关键参数在 Pytorch 里把 DeepLabv3 跑起来训练代码本身不难难的是每个参数背后都有代价。这一章把模型、损失、优化器、学习率调度按顺序拆开讲每个参数给推荐值抄完能直接跑。4.1 模型组装与损失ignore_index255 不设对前面白干DeepLabv3 的成熟实现网上很多项目源码里一般直接引入现成的model.py。但你要能看懂它不然改 backbone、换类别数时无从下手。ASPP 和 Decoder 的核心代码并不长import torch import torch.nn as nn import torch.nn.functional as F class ASPP(nn.Module): def __init__(self, in_channels2048, out_channels256): super().__init__() self.convs nn.ModuleList([ nn.Conv2d(in_channels, out_channels, 1, biasFalse), nn.Conv2d(in_channels, out_channels, 3, padding6, dilation6, biasFalse), nn.Conv2d(in_channels, out_channels, 3, padding12, dilation12, biasFalse), nn.Conv2d(in_channels, out_channels, 3, padding18, dilation18, biasFalse), ]) self.pool nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, biasFalse), ) self.project nn.Sequential( nn.Conv2d(out_channels * 5, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), ) def forward(self, x): res [conv(x) for conv in self.convs] res.append(F.interpolate(self.pool(x), sizex.shape[-2:], modebilinear, align_cornersTrue)) return self.project(torch.cat(res, dim1))这里padding必须等于dilation否则空洞卷积会改变特征图的尺寸拼接时直接报错这是新手最常踩的维度错误。全局池化分支要上采样回输入尺寸再 concatalign_cornersTrue和训练时的上采样保持一致。Decoder 的输入是两层ASPP 输出和 backbone 的低层特征。低层特征通道较多ResNet101 的 layer1 是 256 通道直接 concat 会让低层信息占比过大所以先 1×1 降到 48 通道class Decoder(nn.Module): def __init__(self, low_level_channels256, num_classes21): super().__init__() self.reduce nn.Sequential( nn.Conv2d(low_level_channels, 48, 1, biasFalse), nn.BatchNorm2d(48), nn.ReLU(inplaceTrue), ) self.classifier nn.Sequential( nn.Conv2d(256 48, 256, 3, padding1, biasFalse), nn.BatchNorm2d(256), nn.ReLU(inplaceTrue), nn.Dropout(0.1), nn.Conv2d(256, num_classes, 1), ) def forward(self, high_level, low_level): high_level F.interpolate(high_level, sizelow_level.shape[-2:], modebilinear, align_cornersTrue) return self.classifier(torch.cat([high_level, self.reduce(low_level)], dim1))num_classes在 VOC 是 21Cityscapes 是 19改这里就好。损失函数只有一行但ignore_index255必须写criterion nn.CrossEntropyLoss(ignore_index255)4.2 训练循环SGD、poly 学习率、混合精度一次配齐分割任务的主流配置是 SGD poly 学习率。poly 的意思是学习率按(1 - epoch/max_epoch)^power衰减power 取 0.9前期下降慢、后期下降快比固定学习率稳定很多。完整的训练循环骨架import torch model DeepLabV3Plus(num_classes21) # 或加载 ImageNet 预训练 backbone model model.cuda() optimizer torch.optim.SGD(model.parameters(), lr0.01, momentum0.9, weight_decay1e-4) scaler torch.cuda.amp.GradScaler() base_lr, epochs, power 0.01, 50, 0.9 for epoch in range(epochs): lr base_lr * (1 - epoch / epochs) ** power for g in optimizer.param_groups: g[lr] lr model.train() for images, masks in train_loader: images, masks images.cuda(), masks.cuda() with torch.cuda.amp.autocast(): logits model(images) loss criterion(logits, masks) optimizer.zero_grad() scaler.scale(loss).backward() scaler.step(optimizer) scaler.update()weight_decay1e-4就是 L2 正则化分割任务里 1e-4 是安全默认值加大到 1e-3 会明显压 mIoU。GradScaler是混合精度的核心新版推荐写成torch.amp.GradScaler(cuda)老写法也兼容。混合精度在这类任务上能把显存占用压掉近一半训练速度提升 30% 左右代价是偶尔出现 NaN loss真遇到了先把混合精度关掉跑两个 epoch 对比。4.3 训练日志与收敛判断loss 在跌不代表 mIoU 在涨训练分割模型日志至少每 50 个 iteration 打一次 loss每个 epoch 结束在验证集上算一次 mIoU。只盯 loss 会踩坑loss 在跌可能仅仅是背景类拟合得更好而 person、rider 这类小类完全没学会。这里给出两组常用参数参考VOC 和 Cityscapes 差别明显参数VOC2012Cityscapes输入尺寸513×513512×1024 或 512×512batch size16单卡 88单卡 4学习率0.01batch 160.01batch 8 时 0.007 更稳epochs50120200训练时间单卡 12 小时单卡 12 天VOC 上训练 50 个 epoch验证 mIoU 正常能到 6872Cityscapes 训练 150 epoch 左右单卡 ResNet101 能到 7276。如果训练了 30 个 epoch mIoU 还在 20 以下不是耐心问题是数据链路的 bug去查标签读取和增强。5. DeepLabv3 训练避坑记录5 条翻车现场与修复路径这章写的是我在项目里真实撞过的墙。每条按「现象 → 原因 → 解决」的顺序记录希望能帮你省下排查时间。5.1 conda activate 后提示找不到 conda现象Windows 上装完 Anaconda照着教程conda activate deeplabPowerShell 报错「无法将 conda 项识别为 cmdlet、函数、脚本文件或可运行程序的名称」。原因conda 没有被初始化到当前 shell。conda 的可执行文件装了但 PowerShell 不知道去哪找它需要先执行conda init把初始化脚本写进 shell 配置。解决在新开的终端里先跑conda init powershell然后关掉终端重新打开再执行conda activate deeplab。注意一定要重开终端同一会话里 conda 是读不到新配置的。如果你用的是 CMD则执行conda init cmd.exe。5.2 训练 loss 不下降或直接发散现象第一轮 loss 在 3.0 左右训练 10 个 epoch 后还在 2.5 附近晃或者 loss 直接冲到几百几千然后变成 NaN。原因多数情况是学习率太大或者 backbone 加载预训练权重时strictFalse导致部分层是随机初始化BN 的均值和方差还没收敛。另一个常见原因是标签里有超出类别范围的值比如 255 没有设置 ignore_index交叉熵在计算时会对不存在的类别求损失数值直接崩。解决先把学习率降到原来的 1/10 跑 3 个 epoch 看趋势检查损失函数的ignore_index255加载预训练权重后打印模型里未加载的参数名确认 backbone 的 BN 层不是从零开始。出现 NaN 时先关混合精度再用torch.nan_to_num之前先定位是哪一层产生的。5.3 CUDA out of memory现象训练到第二个 iteration 报CUDA out of memory或者显存看着够用但跑着跑着就爆了。原因分割模型的显存大头是激活值不是参数。crop_size 越大、batch size 越大中间特征图占的显存成倍增长。513×513 的输入ASPP 五路分支同时保留中间特征一个 batch 占的显存是分类模型的数倍。解决顺序是「先降 batch size 到 2再降 crop_size 到 480最后开混合精度」。VOC 上单卡 11GB 显存建议batch_size8 crop513 混合精度Cityscapes 上 11GB 建议batch_size4 crop512×512。还有一个容易忽略的点验证时记得torch.no_grad()很多人验证代码忘了写显存直接被验证阶段吃满。5.4 验证 mIoU 与训练表现对不上现象训练 loss 降得很快训练集上 mIoU 都快 80 了验证集 mIoU 只有 30看起来像是「严重过拟合」但换更强的正则化也没用。原因训练时做了随机缩放和裁剪验证时如果直接把原图缩放到某个固定尺寸GT 和预测的尺寸对齐方式不同导致 mIoU 计算时每个像素错位。更隐蔽的原因是验证时没有过滤 255 像素void 类被当成普通类别参与计算把 IoU 拉低了一大截。解决验证统一在原始分辨率上进行预测输出后argmax再和原始尺寸的 GT 对齐计算混淆矩阵前一定用mask target ! 255把 ignore 像素筛掉。先做到这两点再看 mIoU 是不是真的低。5.5 转 ONNX 后输出与 Pytorch 不一致现象Pytorch 里推理效果正常转成 ONNX 后用 onnxruntime 跑同一张图输出差异很大mIoU 掉 10 个点以上。原因F.interpolate的align_corners参数、动态输入尺寸、opset 版本这三者任何一个不匹配上采样行为就会变。DeepLabv3 里 Decoder 有两处上采样只要有一处的对齐方式变了边缘预测就会错位。解决模型内所有interpolate统一固定align_cornersTrue导出时opset_version用 12 及以上如果必须支持动态输入尺寸用dynamic_axes声明高度和宽度维度但导出前先在同一尺寸下对比 Pytorch 和 ONNX 的逐像素输出差异最大误差控制在 1e-4 量级再上线。6. 验证与导出的最后两个技巧mIoU 计算与 ONNX 导出模型训练完真正决定它能不能被用起来的是验证和导出这两步。我自己在这些地方栽过跟头最后沉淀成两个固定习惯。6.1 mIoU 计算时把 255 和边界处理干净mIoU 的计算代码看起来简单但细节决定数字是否可信。正确做法是预测argmax后只对target ! 255的像素统计混淆矩阵逐类计算 IoU最后取平均def compute_miou(pred, target, num_classes): pred pred.view(-1) target target.view(-1) mask target ! 255 pred, target pred[mask], 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(float(nan)) # 该类别在验证集里没出现 else: ious.append(intersection / union) return torch.tensor(ious).nanmean().item()类别没有出现在 GT 里时IoU 记 0 会把指标压死记 1 又虚高常见做法是跳过它再取平均用nanmean实现。边界处理指的是验证前不要对预测做任何 smoothing直接argmax取硬标签。6.2 ONNX 导出固定 align_corners 与输入尺寸导出 ONNX 时最省心的做法是固定输入尺寸因为动态尺寸会让上采样行为变得更难对齐。代码很简单model.eval() dummy torch.randn(1, 3, 513, 513).cuda() torch.onnx.export( model, dummy, deeplabv3plus.onnx, opset_version12, input_names[input], output_names[logits], dynamic_axes{input: {0: batch}}, )只把 batch 维度设为动态保持高度宽度固定能显著降低上采样行为不一致的风险。导出后做一次数值对齐把同一张图分别跑 Pytorch 和 onnxruntime对比 logits 的绝对误差。我的经验是最大误差在 1e-5 量级才说明模型行为一致。我做分割项目养成的习惯是模型里所有上采样都集中到一个工具函数里强制统一align_cornersTrue每训完一个版本导出 ONNX 后先跑数值对比再交给下游绝不在没验证的情况下直接部署。这样虽然多花十分钟但能省掉后续服务端排查「为什么效果变了」的半天希望帮到你。本文还有配套的精品资源点击获取