
简介面向图像分割初学者与深度学习开发者这是一套基于PyTorch在VOC和Cityscapes数据集上训练DeepLabv3模型的完整项目。内含详细流程教程与可运行源码覆盖数据预处理、模型构建、训练调优、评估部署等关键环节帮助读者快速掌握语义分割的实现路径。压缩包共43个文件其中23个Python源码文件构成核心训练与预测模块17张效果图直观展示分割结果另有2个文本说明和1个Markdown文档辅助环境配置与项目解读整体大小2.13MB。项目代码按datasets、network、metrics、utils等目录组织提供voc/cityscapes数据加载、DeepLabv3网络定义、流式指标计算等功能并配有预测脚本。目前已有492人学习下载适合希望结合VOC与Cityscapes多类别场景进行实战、深入理解ASPP模块与解码器设计的读者。1. 从「能跑通」到「能落地」DeepLabv3 在 VOC 与 Cityscapes 上的训练闭环图像分割比目标检测细一档检测给你一个框分割给你像素级掩膜。DeepLabv3 是这一领域工程化最成熟的算法之一很多广告牌分割、医学图像分割的落地项目底座就是它。这篇笔记围绕「基于 Pytorch 在 VOC 和 Cityscapes 上训练 DeepLabv3」把环境搭建、数据准备、训练参数、踩坑记录和验证方法一条线讲完走的是这套方向最常见、也最不容易翻车的落地路径。标题里提到「项目源码 详细流程教程」意味着你不需要从零写模型而是要搞懂每个环节怎么改、为什么这么改。适合三类人准备入门图像分割的学生、要把分割模型接到业务里的工程师、以及想用这两个经典数据集做对比实验的研究者。读完你会得到一套能自己复现的训练闭环而不是一份停在纸面的原理说明。下面所有命令和脚本结构都以 PyTorch 生态为准我尽量把每个参数背后「为什么」也讲清楚这样换数据集、换骨干网络时你才知道改哪里。2. DeepLabv3 的架构拆解空洞卷积、ASPP 和解码器为什么这么组合2.1 空洞卷积不降分辨率把感受野撑大开始写代码之前得先把 DeepLabv3 的几个核心机制讲透。很多人直接拿预训练权重去微调mIoU 看得过去但一换数据集、一换类别数就翻车本质是没搞懂模型的感受野和输出步长是怎么控制的。普通卷积靠堆叠层数扩大感受野代价是中间要不断池化或降采样。图像分割恰恰不能随便降分辨率——需要像素级输出如果像分类网络那样把特征图缩到 7×7再大的解码器也补不回来细节。空洞卷积Dilated Convolution解决了这个问题卷积核里插入空洞同样的 3×3 核在 dilation rate2 时等效于 5×5 视野不增加参数量也不降低特征图分辨率。DeepLabv3 的骨干网络把最后两个 stage 的下采样去掉改用空洞卷积维持输出步长为 16 或 8这就是模型能输出精细掩膜的基础。输出步长output stride这个概念值得多说两句。output stride16 意味着输入 513×513骨干网络输出的特征图是 32×32 左右。你决定用 8 还是 16本质是在计算量和细节保留之间做取舍。常见做法是训练时用 16、推理时用 8或者在 Cityscapes 这类细节要求高的数据集上直接用 8。改起来也简单就是控制 backbone 最后几个残差块的 stride 和 dilation 设置。你从网上下载的各种 pytorch 复现工程里一般都能找到一个叫_make_layer的函数里面replace_stride_with_dilation这个参数就是干这个的。还有一个容易忽略的点空洞卷积的网格效应。当 dilation rate 较大且叠加多层时感受野内部会有未采样区域对细小物体可能造成响应断裂。ASPP 之所以同时用多个不同 rate 的分支再融合某种程度上也是在互相补齐各自的空间盲区。这也解释了为什么不要只留一个 dilation18 的卷积去代替整个 ASPP——效果会明显下降。2.2 ASPP 与编码器多尺度信息怎么「焊」进特征图DeepLabv3 的核心贡献是 ASPPAtrous Spatial Pyramid Pooling。它把最后一张特征图并行接上多个不同 dilation rate 的空洞卷积——常见配置是 6、12、18外加一个全局平均池化分支最后拼接起来过 1×1 卷积融合。目的很直接图片里目标大小差异极大Cityscapes 上一辆车可能占半个屏幕一个人可能只有几十个像素单一感受野的卷积不可能同时照顾到两者。ASPP 相当于用一组不同视野的滤波器并行扫描把多尺度上下文焊进同一份特征里。下面是我常用的 ASPP 实现结构清晰方便改 rate# model/aspp.py import torch import torch.nn as nn import torch.nn.functional as F class ASPP(nn.Module): def __init__(self, in_channels2048, out_channels256, rates(6, 12, 18)): super().__init__() self.branches nn.ModuleList() # 1x1 卷积分支等价于 dilation1 的普通卷积 self.branches.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue))) # 三个不同空洞率的 3x3 卷积分支 for rate in rates: self.branches.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, paddingrate, dilationrate, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue))) # 全局平均池化分支先池化到 1x1再在 forward 里上采样 self.gap nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue)) # 拼接后投影回 out_channels self.project nn.Conv2d(out_channels * (len(rates) 2), out_channels, 1, biasFalse) def forward(self, x): h, w x.shape[2:] branch_outs [branch(x) for branch in self.branches] # 关键全局池化分支必须插值回当前特征图尺寸才能 concat gap_out F.interpolate(self.gap(x), size(h, w), modebilinear, align_cornersTrue) return self.project(torch.cat(branch_outs [gap_out], dim1))逻辑说明branch_outs是四路并列输出1×1 卷积 三个空洞卷积gap_out是全局池化后插值回原尺寸的结果最后按通道维 concat再过project投影降低通道数。整个模块的输出尺寸和输入一致通道数变成 256方便后续解码器处理。参数说明in_channels必须和骨干网络输出通道匹配ResNet-101 是 2048ResNet-50 是 2048MobileNet 要改成 320rates三元组可以按数据集调小目标多的场景适当把 6 改成 4。需要特别注意的是align_cornersTrue这个参数——PyTorch 里双线性插值默认 FalseDeepLabv3 系列代码统一用 True如果你在主干网络里用了F.interpolate务必保持一致否则推理时会出现像素级偏移虽然不大但逐类 IoU 会掉 0.5 左右。这是一处非常隐蔽的坑属于典型的「黑匣子改了也不知道」的问题。Cityscapes 上 val mIoU 就差这零点几个点排查起来极其耗时。2.3 解码器为什么 DeepLabv3 比 DeepLabv3 多一条分支DeepLabv3 的输出直接是 ASPP 特征上采样到原图细节恢复靠的是骨干网络最后一层特征。问题是最后一层特征经过几十层卷积边缘信息早就被磨平了。DeepLabv3 的「」就加在解码器上把骨干网络中间层一般是 ResNet 的第 2 个 stage的低层特征引出来做 1×1 卷积降通道再和上采样后的高层特征 concat最后过几个 3×3 卷积融合输出。这样分割结果的边缘清晰度比 DeepLabv3 明显提升尤其在小目标和细长物体行人、杆状物上差距很大。解码器有两个参数值得调。第一是低层特征的 stage 选择ResNet 一般取 layer2 或 layer3取太浅的层特征太原始取太深的层又和高层特征语义重复融合收益小。第二是解码器内部 3×3 卷积的通道数常见设置是 256显存吃紧时可以降到 128。源码包里模型定义的改动基本就在这一块如果你想把 backbone 换成 MobileNet 或 HRNet需要同步修改的就是低层特征的下采样倍数和通道数。骨干网络的选型上ResNet-101 是性价比最高的默认项VOC 上配合 ImageNet 预训练权重能达到 78%82% 的 mIoUXception-65 是原论文的最终版本效果再好 12 个点但显存占用和训练时间都上去了。我的建议是先用 ResNet-101 把流程跑通确认数据、损失、评估都没问题后再换 Xception 或 HRNet 提点。很多人在一开始就上大模型结果 OOM 一整晚连数据对不对都没验证过这是最亏的。换个角度说DeepLabv3 这套「编码器 多尺度池化 浅层特征融合」的组合几乎成了现代分割模型的通用范式。你去看 mmsegmentation 里训练 Cityscapes 的很多配置本质上都是在这个骨架上换 backbone、换 head 而已。理解了这一节的结构后面读任何复现源码都不会卡壳。3. 环境搭建与最小可跑通流程从 PyTorch 安装到第一次迭代3.1 PyTorch 环境搭建Anaconda 建环境并匹配 CUDA训练分割模型的第一步是 PyTorch 环境搭建这也是新手问得最多的一环。我的建议是永远用 Anaconda 创建虚拟环境隔离项目不要直接在 base 环境里 pip install否则几个月后你一定会遇到依赖冲突的玄学问题。创建环境时指定 Python 版本然后按显卡驱动支持的 CUDA 版本安装 PyTorch。conda create -n deeplab python3.8 -y conda activate deeplab nvidia-smi # 右上角 CUDA Version 是驱动支持的最高版本PyTorch 运行时版本不超过它即可 pip install torch1.13.1cu117 torchvision0.14.1cu117 \ --extra-index-url https://download.pytorch.org/whl/cu117 pip install matplotlib pillow tqdm tensorboard这里有最常见的一个理解偏差nvidia-smi 显示的 CUDA 版本是驱动支持的上限不是 PyTorch 实际需要的运行时。两者只需满足「驱动版本 运行时版本」。比如驱动显示 CUDA 12.1你装 cu117 的 PyTorch 完全没问题反过来驱动是 11.x 却装了 cu121 的包torch.cuda.is_available()就会返回 False。装完用下面这段代码验证这一分钟能帮你省掉后面十小时排错import torch print(torch.__version__) # 期望如 1.13.1cu117 print(torch.cuda.is_available()) # 必须为 True print(torch.cuda.get_device_name(0)) print(torch.backends.cudnn.is_available())如果第 2 行输出 False优先检查 pip 安装的包是否带cu后缀再查是不是装了 CPU 版。conda 默认源有时会把 torch 解析成 CPU 版本这也是我推荐直接用 pip 加官方 extra-index-url 的原因。装完之后立刻做一次 512×512 的随机张量前向确认显存显式分配和释放正常再进入数据准备。3.2 数据集准备VOC 2012 与 Cityscapes 的目录结构和标签含义数据准备是分割项目里最容易出岔子、又最不值得你发明创造的部分。VOC 2012 的标准目录结构如下源码包里凡是按这个结构组织的基本可以直接用VOCdevkit/ └── VOC2012/ ├── JPEGImages/ # 原图jpg ├── SegmentationClass/ # 标签图pngP 模式值 0~20 └── ImageSets/ └── Segmentation/ # train.txt / val.txt每行一个文件名VOC 的标签是 21 类0 背景 20 前景类标签图用 PIL 默认模式打开是单通道值域 020边缘或未标注区域值为 255训练时作为 ignore_index。很多人犯的错误是读标签时加了convert(RGB)把单通道标签变成 3 通道损失函数直接算出一个离谱的大 loss。记住一句话原图用 RGB标签永远用单通道别加 convert 或只加convert(L)。下面这段 Dataset 是 VOC 的标准写法同时也是 unet 图像分割、医学图像分割等方向通用的加载范式# datasets/voc.py import os from PIL import Image from torch.utils.data import Dataset class VOCSegmentation(Dataset): def __init__(self, root, splittrain, transformNone): self.root root self.split split self.transform transform # VOC 的 train.txt / val.txt 每行是一个不带扩展名的文件名 split_file os.path.join(root, ImageSets, Segmentation, f{split}.txt) with open(split_file, r) as f: self.ids [line.strip() for line in f.readlines()] def __len__(self): return len(self.ids) def __getitem__(self, idx): img_id self.ids[idx] # 原图用 RGB标签保持单通道 P 模式 img Image.open(os.path.join(self.root, JPEGImages, f{img_id}.jpg)).convert(RGB) mask Image.open(os.path.join(self.root, SegmentationClass, f{img_id}.png)) if self.transform: img, mask self.transform(img, mask) return img, maskCityscapes 的目录结构比 VOC 复杂因为它按城市分子目录且每个样本有多个标签文件cityscapes/ ├── leftImg8bit/ │ ├── train/ # 按城市分子目录如 aachen/ 下是 png 原图 │ └── val/ └── gtFine/ ├── train/ └── val/ # 每个样本 5 个文件训练只用以下两个 # *_labelIds.png 原始标签值域 0~33 # *_labelTrainIds.png 映射后的训练标签0~18 255Cityscapes 原始 34 类训练时几乎所有人只用 19 类需要把 labelIds 映射成 trainIds。这个映射在框架里通常叫trainId2label逐类对应错一个类 mIoU 直接掉 10 个点。实际训练时我一般不会把 2975 张训练图全量读进内存而是按需读取# datasets/cityscapes.py import os from PIL import Image from torch.utils.data import Dataset class CityscapesDataset(Dataset): def __init__(self, root, splittrain, transformNone): self.img_dir os.path.join(root, leftImg8bit, split) self.gt_dir os.path.join(root, gtFine, split) self.images [] # Cityscapes 按城市分子目录需要两层遍历收集 for city in sorted(os.listdir(self.img_dir)): city_dir os.path.join(self.img_dir, city) for fname in sorted(os.listdir(city_dir)): if fname.endswith(.png): self.images.append((city, fname)) self.transform transform def __getitem__(self, idx): city, fname self.images[idx] img Image.open(os.path.join(self.img_dir, city, fname)).convert(RGB) # 关键文件名替换leftImg8bit - gtFine_labelTrainIds gt_name fname.replace(leftImg8bit, gtFine_labelTrainIds) mask Image.open(os.path.join(self.gt_dir, city, gt_name)) if self.transform: img, mask self.transform(img, mask) return img, mask这段代码的关键在于文件名的 Replace 替换Cityscapes 图片名形如aachen_000000_000019_leftImg8bit.png对应标签是aachen_000000_000019_gtFine_labelTrainIds.png。读文件列表时可以先打印前三个元素核对路径存在性再进训练循环这比训练到一半报 FileNotFoundError 好受得多。3.3 最小训练脚本从模型初始化到第一个迭代模型实例、数据加载都就位后写一个最小训练循环。下面这段代码省略验证部分专注展示训练循环的骨架# train_minimal.py import torch import torch.nn as nn from torch.utils.data import DataLoader from datasets.voc import VOCSegmentation from model.deeplabv3plus import DeepLabV3Plus # 21 VOC 背景 1 类 前景 20 类Cityscapes 换成 19 model DeepLabV3Plus(backboneresnet101, num_classes21).cuda() criterion nn.CrossEntropyLoss(ignore_index255) # 分割任务里 SGD momentum 比 Adam 稳这是原论文延续下来的经验 optimizer torch.optim.SGD(model.parameters(), lr0.007, momentum0.9, weight_decay1e-4) train_loader DataLoader( VOCSegmentation(rootdata/VOCdevkit/VOC2012, splittrain), batch_size8, shuffleTrue, num_workers4, drop_lastTrue ) total_epochs 50 for epoch in range(total_epochs): # poly 学习率每个 epoch 按公式缩小power 取 0.9 factor (1 - epoch / total_epochs) ** 0.9 for group in optimizer.param_groups: group[lr] 0.007 * factor model.train() for i, (imgs, masks) in enumerate(train_loader): imgs, masks imgs.cuda(), masks.cuda() out model(imgs) # out: [B, 21, H/8, W/8] loss criterion(out, masks) # mask 的 255 像素被自动忽略 optimizer.zero_grad() loss.backward() optimizer.step() if i % 50 0: print(fepoch {epoch} iter {i} loss {loss.item():.4f})两个容易忽略的点。第一模型输出是输入尺寸的 1/8output stride8 时而标签是原图尺寸CrossEntropyLoss不会自动对齐分辨率所以要么在数据加载时把标签缩放到 [H/8, W/8]要么在损失前加一步F.interpolate(out, sizemasks.shape[-2:], modebilinear)常见做法是后者因为标签缩放用最近邻会丢边缘精度。第二drop_lastTrue丢弃最后一个不完整 batch避免 BN 统计量在小 batch 上抖动。torchvision 里如果有预训练权重backbone 一定要加载否则报错会出现在「loss 能下降但验证 mIoU 一直上不去」这种更隐蔽的场景。4. 训练参数与调优路径学习率、损失函数和数据增强怎么配4.1 poly 学习率与优化器为什么分割任务不流行用 StepLR分类任务里 StepLR 每固定轮次降一次学习率分割任务则几乎清一色用 poly 策略——学习率随训练进度按(1 - iter/total_iter)^power平滑衰减power 通常取 0.9。原因是分割模型更吃后期精细拟合前中期要快速收敛到大致正确的区域后期用很小的学习率慢慢抠边缘。StepLR 的阶梯式下降容易在换挡瞬间造成 loss 抖动poly 是连续的更稳。学习率初始值的参考ResNet-101 batch size 8VOC 上用 0.007 或 0.01 都常见Cityscapes 因为图像更大、类别更多一般从 0.01 起步配上前几个 iteration 的 warmup从 0 线性升到初始值。warmup 在有 ImageNet 预训练权重时不是必须的但如果是从头随机初始化没有 warmup 大概率前期就炸。优化器上原论文用的是 SGD momentum 0.9 weight_decay 1e-4这也是分割项目的主流选择。Adam 收敛快但最终 mIoU 通常略低而且 weight_decay 对 Adam 要单独调直接照搬分类任务的参数容易出问题。我的习惯是默认 SGD只有损失迟迟降不动、排查不出原因时才临时换 Adam 做对照实验。分割任务里「训练曲线平稳收敛」比「前 100 步降得飞快」重要得多这也是判断超参是否健康的第一直觉。4.2 损失函数与类别不平衡CrossEntropy 之外的三个补充手段DeepLabv3 的默认损失就是带 ignore_index 的 CrossEntropyLoss但在 Cityscapes 上直接裸跑小类别摩托车、公交、行人的表现会明显偏弱。原因很简单像素级类别分布极不平衡路面和建筑占了六成以上像素CrossEntropy 对多数类天然友好小类别梯度被淹没。三种常见补充手段按性价比排序。第一是类别权重按像素占比的倒数给每个类配权重Cityscapes 官方训练就是这么做的第二是 OhemCrossEntropy 在线困难样本挖掘只对 loss 排在前 20% 的像素回传梯度轮廓和难例被强制学习第三是辅助深度监督损失在骨干网络低层接一个浅分类头与主损失按 0.4:1 加权这招在 mmsegmentation 训练 Cityscapes 的很多配置里都有。加权 CE 的示范代码如下# 以 Cityscapes 的官方类别频率为例演示权重结构 # 19 类的像素占比按官方 statistics 填 class_freq torch.tensor([ 0.394, 0.197, 0.112, 0.081, 0.052, 0.041, 0.032, 0.026, 0.021, 0.018, 0.015, 0.013, 0.011, 0.009, 0.008, 0.007, 0.006, 0.005, 0.004 ]) # 取倒数再开根号防止权重差异过大 class_weight (1.0 / (class_freq 1e-8)) ** 0.5 # 归一化保持 loss 量级与不加重时一致 class_weight class_weight / class_weight.mean() criterion nn.CrossEntropyLoss(ignore_index255, weightclass_weight.cuda())这里有个细节不能直接用 1/freq 当权重否则 loss 量级会膨胀学习率全得重调。开根号是个折中既压平极端权重又不至于把多数类压得太低。加了权重后效果反而下降的情况十有八九是这个归一化没做好。4.3 从 VOC 迁移到 Cityscapes类别数、裁剪尺寸与训练顺序先跑 VOC 再迁 Cityscapes是这套标题里最常见的进阶路径。两个数据集差异很大VOC 图像平均 500×375Cityscapes 是 2048×1024VOC 是通用 20 前景类Cityscapes 是街景专属 19 类。直接把 VOC 训好的权重拿过来微调最后一层分类头维度对不上必须丢弃重建# 迁移脚本片段加载 VOC 权重跳过不匹配的分类头 pretrained torch.load(voc_deeplab.pth, map_locationcuda) state_dict pretrained[model] if model in pretrained else pretrained new_state {} for k, v in state_dict.items(): if k.startswith(classifier.): continue # 21 类分类头直接丢弃随机初始化成新的 19 类 new_state[k] v model DeepLabV3Plus(backboneresnet101, num_classes19) # strictFalse 允许缺失分类头的键 model.load_state_dict(new_state, strictFalse)新分类头是随机初始化的训练时最好给它单独配一个 10 倍于 backbone 的初始学习率否则前几个 epoch 分类头学得慢会拖累整体收敛速度。写法上可以给模型参数分组decoder_params list(model.classifier.parameters()) backbone_params [p for p in model.parameters() if p not in decoder_params] optimizer torch.optim.SGD([ {params: backbone_params, lr: 0.007}, {params: decoder_params, lr: 0.07} # 10 倍学习率 ], momentum0.9, weight_decay1e-4)Cityscapes 原图 2048×1024 直接进 ResNet-101batch 8 显存直接爆。常规做法是随机裁剪成 769×769 或 1024×512 再训练既控制显存又顺带做了数据增强。下面这张表是我在两套数据上来回折腾后沉淀下来的一套默认配置可以直接抄配置项VOC 2012Cityscapes类别数2120 前景 背景19官方映射后典型图像尺寸500×375 左右2048×1024训练 crop 尺寸513×513769×769 或 1024×512初始学习率0.007 ~ 0.010.01骨干网络ResNet-101 足够ResNet-101 / Xception-65输出步长168 或 16训练轮数30 ~ 5080 ~ 160损失函数CrossEntropy 即可Weighted CE / Ohem数据增强方面随机缩放 0.5~2.0、随机翻转、随机裁剪是必配三件套颜色抖动在 Cityscapes 上作用不大街景光照相对可控过度颜色扰动反而引入伪影建议只保留亮度对比度的小幅扰动。Cityscapes 还有个常用 trick 是官方提供的粗糙标注 gtCoarse训练时混入一部分粗糙标注数据能稳定提 1~2 个 mIoU代价是训练时间变长适合做最终模型时再用。5. 避坑指南DeepLabv3 训练中最高频的 5 个翻车现场5.1 显存不够OOM 偏偏在第 3 个 epoch 才出现现象训练前几个 epoch 正常到第 3、4 个 epoch 突然 RuntimeError: CUDA out of memory重启后在同一位置再炸。原因多数情况是某个 batch 里混进了一张特别大的图随机 crop 偶尔没有覆盖到加上模型在 output stride8 时中间特征图巨大8 batch × 769×769 的 ResNet-101 在 11G 显存上本就接近临界。另外 num_workers 开太高时数据预取队列也会占额外显存。解决先把 batch 降到 4 或 2 确认能跑crop 尺寸从 769 降到 513num_workers 降到 2。还不够就改用梯度累积模拟大 batchaccum_steps 4 optimizer.zero_grad() for i, (imgs, masks) in enumerate(train_loader): out model(imgs.cuda()) # 除以累积步数等价于把 4 个小 batch 拼成一个大 batch loss criterion(out, masks.cuda()) / accum_steps loss.backward() if (i 1) % accum_steps 0: optimizer.step() optimizer.zero_grad()注意 loss 必须除以 accum_steps否则等效学习率变成 4 倍模型会表现出诡异的发散。梯度累积本质是用时间换显存BN 统计量仍按小 batch 算效果略逊于真大 batch但比反复 OOM 强太多。5.2 训练 loss 变成 NaN 或直接发散现象loss 在前几十个 iteration 冲到几百然后变成 nan训练进展停摆。原因最常见是学习率过大。0.007~0.01 的初始学习率配合随机初始化的分类头前向输出 logits 数值极大交叉熵梯度爆炸。其次是输入图像没有归一化或数据集中有坏图全黑、全白、损坏文件产生 inf 一路传播。解决先用 0.001 学习率跑 10 个 iteration 验证不炸再加回 0.007在 DataLoader 里打印一个 batch 的 tensor执行torch.isnan(imgs).any()检查输入是否有 nan新初始化的分类头可以单独乘一个 0.1 的小系数或者用 4.3 节的独立学习率分组处理。这一条里最常见的误操作是loss 一发散就盲改网络结构改半天发现只是学习率太大白折腾。5.3 分割结果整片黑或全是背景现象训练完推理输出图九成以上是背景目标物体轮廓糊成一团或者反过来全是前景类。原因十有八九是标签读取问题。VOC 的 SegmentationClass 是调色板模式 PNG代码如果用了convert(RGB)每个像素变成 3 通道映射值模型学到的对应关系完全错乱。另一个可能是 ignore_index 设错把背景 0 也设成 ignore导致背景类从不被监督模型就往前景类乱飘。解决标签一律用Image.open(path)默认模式或convert(L)训练前的检查脚本里打印 mask 张量的torch.unique()值确认在 0~20 且包含 255ignore_index 只设 255。Cityscapes 同理确认读到的是 labelTrainIds 而不是 labelIds——labelIds 有 34 个值会让分类头彻底混乱且训练时不报任何错等推理出来一张花图才发现。这类问题属于数据层面的黑匣子护城河做法就是「训练前先花两分钟打印标签分布」。5.4 Cityscapes 上 mIoU 比论文低 15 个点现象同样的模型和训练配置VOC 上 mIoU 正常换 Cityscapes 直接腰斩。原因类别映射表错了。很多人把 labelIds 的 34 类直接当标签训练或者映射时把多个原始类映射到同一个 trainId 后忘了检查顺序。另一个常见问题是评估时用了原图分辨率而训练时用 769×769 crop输入尺寸分布不一致导致验证失真。解决进入训练前先写一个小脚本统计数据集里真实出现的类别索引集合与 19 类比对评估统一走模型eval()模式验证集预处理与训练完全一致只是关掉随机部分。强烈建议第一次跑 Cityscapes 时先用官方提供的预训练权重做一次完整的训练验证闭环确认评估管线输出的 mIoU 在合理区间再回来训自己的模型。这样万一指标异常你能判断是数据问题、代码问题还是模型问题而不是对着一个 60% 的 mIoU 猜三天。这个经验是我踩了两次一样的坑之后总结出来的——没有任何后悔药能替代「先验证基准线」这个习惯。5.5 训练和验证 mIoU 差距巨大现象训练集 mIoU 85%验证集 55%差距明显不合理。原因训练时用了随机翻转、缩放、crop验证时却用了另一套预处理组合或者验证时忘了关闭随机增强另一个常见原因是验证时模型仍处在model.train()状态BN 用了 batch 统计量而不是全局统计量。解决验证代码固定三段式——model.eval()、with torch.no_grad():、关闭所有随机增强验证预处理模板与训练模板逐行比对尤其是归一化的 mean/std 是否一致如果训练用了多尺度验证时也要用同一组尺度做 TTA 后平均再评估。还有一个细节验证集的图像分辨率不要为了省显存随意 resize 成正方形DeepLabv3 对长宽比敏感粗暴挤压会丢失大量小目标把验证 mIoU 压得很低误导你判断模型真实水平。6. 验证与进阶mIoU 计算、推理可视化和部署前检查6.1 mIoU 的正确打开方式分类任务的 accuracy 在分割里没有意义——背景像素占大头全预测成背景就能拿 80% 的分数。分割的黄金指标是 mIoU每个类别的交并比取平均。计算时两个关键点忽略 ignore 像素、跳过不存在的类别。下面这是我一直沿用的实现import numpy as np def compute_miou(pred, label, num_classes): pred pred.view(-1).cpu() label label.view(-1).cpu() valid label ! 255 # 去掉 ignore 像素 pred, label pred[valid], label[valid] ious [] for cls in range(num_classes): p (pred cls) l (label cls) inter (p l).sum().item() union (p | l).sum().item() if union 0: # 当前图里不存在的类跳过 ious.append(inter / union) return float(np.mean(ious)), ious我建议每次训练完不仅看平均 mIoU还要把逐类 IoU 列成表逐项检查。VOC 上如果 person 和 bicycle 这类小目标明显偏低说明小目标处理有问题Cityscapes 上如果 rider 和 motorcycle 混淆往往是两个类别在特征空间太接近可以考虑加类别权重或增大 crop。平均分高但类别方差大的结果不可信逐类看才是真诊断。6.2 推理可视化调色板与叠加输出模型输出 logitsargmax 后得到类别索引显示成彩色图需要调色板。VOC 官方定义了一套 21 色调色板Cityscapes 的 19 类颜色与 trainIds 一一对应源码包里一般都会带上。推理脚本的核心逻辑如下palette voc_palette # 21 个 RGB 三元组列表 color np.zeros((pred.shape[0], pred.shape[1], 3), dtypenp.uint8) for cls, rgb in enumerate(palette): color[pred cls] rgb overlay Image.blend(img.convert(RGB), Image.fromarray(color).convert(RGB), alpha0.5)resize 回去时注意用 PIL 的 NEAREST 或 BILINEAR不要用默认的 LANZCOS 插值后者会在类别边界产生伪色。验证完单张图再放进视频帧循环里重点看连续帧之间的边缘抖动。训练效果再好部署到实拍场景也会暴露运动模糊和小目标闪烁这些是图像分割算法落地的经典痛点提前在验证阶段看视频能省去后期大量返工。6.3 进阶多尺度 TTA 与导出部署如果想刷分多尺度 TTA 是必加的推理时把输入缩放成 0.75、1.0、1.25 三档softmax 后取平均再 argmaxVOC 上一般稳定提 1~2 个 mIoU代价是推理时间乘以 3。加到 5 档收益递减不建议。如果目标是部署则要导出成 TorchScript 或 ONNX。导出前记得model.eval()把 ASPP 里所有依赖 Python 分支的逻辑都用静态张量操作重写否则带控制流的脚本在导出时会报错或导出错误结构同时固定输入尺寸、避免动态维度这是嵌入式部署最关心的点。我这几年养成的一个习惯每个实验跑完把「数据集 预处理配置 训练超参 最终 mIoU」记在一个 Markdown 文件里标注当次改了什么、结果比起上一版差在哪。因为分割实验周期长、变量多等你想复现两周前那个 81.2% 时只靠命令行历史根本拼不出当时的配置。数据、代码、配置三位一体留档远比多跑两个 epoch 有价值。希望这个习惯和这篇流程笔记能帮到你——从把 DeepLabv3 跑通到真正让它变成你手上一套可复现、可调优、可交付的分割方案。本文还有配套的精品资源点击获取