简介:一套基于 PyTorch 在 VOC 与 Cityscapes 数据集上训练 DeepLabv3+ 图像分割算法的完整实战项目,面向深度学习、计算机视觉方向的学习者与开发者,可帮助解决从数据准备、模型训练到预测推理的全流程落地问题。压缩包共 55 个文件,体积约 2.25MB,以 23 个 Python 脚本为核心,配套 17 张结果可视化图片、2 个文本说明文件,以及 README 文档、LICENSE 与 Git 配置等,另有 9 个 .zbak 备份文件可见工程迭代痕迹;同时附带独立附赠压缩包,便于扩展学习。目录按 datasets、metrics、network、utils 等模块划分,涵盖 VOC 与 Cityscapes 数据预处理流程、DeepLabv3+ 网络主体(内置 ResNet、MobileNetV2、Xception、HRNetV2 多种骨干选择)、损失函数、学习率调度、可视化工具、训练与预测脚本;samples 目录另附原图、目标掩码、预测结果及叠加效果对比图,便于直观评估模型表现。目前已有 139 人学习下载,适合希望系统跑通图像分割实验、理解模型结构并在此基础上二次开发的读者。
1. 拿同一套 DeepLabv3+ 从 VOC 换到 Cityscapes,先改的不是网络,是这三处
同样的 DeepLabv3+ 结构,在 VOC 上能跑到 78 以上的 mIoU,换到 Cityscapes 直接掉到 68 以下,很多人第一反应是调 backnone,实际问题大多出在数据加载和统计口径上。这个项目把main.py、datasets/voc.py、datasets/cityscapes.py、network/_deeplab.py完整串起来了,VOC 和 Cityscapes 两个数据集的训练都能跑。对刚接触图像分割的读者来说,它是一份能直接对照源码看懂 ASPP、空洞卷积、mIoU 计算的完整流程;对有几年经验的工程来说,值得关注的是 ignore_index=255 如何影响 loss 和评估指标、Cityscapes 忽略 void 像素后为何 mIoU 波动变大,以及 poly 学习率和训练总步数的配合。以下几章按网络建模、数据集、训练、推理的顺序逐层拆,并给出可直接复用的代码。
2. 空洞卷积替换 stride:DeepLabv3+ 的 ASPP 与 Decoder 建模思路
2.1 用空洞卷积保住 1/16 分辨率,而不是继续下采样
图像分割和分类一个核心差异是输出是逐像素的,特征图不能一路池化到 7×7。ResNet 默认做 32 倍下采样,对于分割任务细节丢失太多。DeepLabv3+ 的做法是保留 backbone 前两层的正常下采样,从 layer3、layer4 开始把 stride 改成 1,同时用空洞卷积补偿感受野。network/backbone/resnet.py里的replace_stride_with_dilation参数就是干这个的,常见实现如下:
def _make_layer(self, block, planes, blocks, stride=1, dilate=False): norm_layer = self._norm_layer downsample = None previous_dilation = self.dilation if dilate: self.dilation *= stride stride = 1 if stride != 1 or self.inplanes != planes * block.expansion: downsample = nn.Sequential( nn.Conv2d(self.inplanes, planes * block.expansion, 1, stride=stride, bias=False), norm_layer(planes * block.expansion)) layers = [] layers.append(block(self.inplanes, planes, stride, downsample, self.groups, self.base_width, previous_dilation, norm_layer)) self.inplanes = planes * block.expansion for _ in range(1, blocks): layers.append(block(self.inplanes, planes, groups=self.groups, base_width=self.base_width, dilation=self.dilation, norm_layer=norm_layer)) return nn.Sequential(*layers)注意看previous_dilation = self.dilation这行,扩容前先把当前膨胀率存下来,传给这一层的第一个 block。layer3 设置dilate=True时 dilation 从 1 变成 2,layer4 再从 2 变成 4,最终输出保持原图的 1/16。如果配置output_stride=8,就在 layer2 也做同样的替换,但显存占用会显著上升,Cityscapes 这类大分辨率数据集我一般不用 8。
2.2 ASPP:用四种采样率做多尺度上下文聚合
拿到 1/16 特征图后,DeepLabv3+ 用 ASPP 模块并行提取不同感受野的信息:一个 1×1 卷积、三个膨胀率分别为 6、12、18 的 3×3 空洞卷积,再加一个全局平均池化分支。五个分支的结果在通道维拼接,再用 1×1 卷积压缩回 256 通道。这种设计让网络同时看到小目标细节和大物体轮廓:
class ASPP(nn.Module): def __init__(self, in_channels, out_channels=256, rates=(6, 12, 18)): super().__init__() self.convs = nn.ModuleList() self.convs.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True))) for rate in rates: self.convs.append(nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=rate, dilation=rate, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True))) self.pool = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True)) self.project = nn.Sequential( nn.Conv2d(5 * out_channels, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True)) def forward(self, x): res = [conv(x) for conv in self.convs] res.append(F.interpolate(self.pool(x), size=x.shape[-2:], mode='bilinear', align_corners=True)) return self.project(torch.cat(res, dim=1))rates=(6, 12, 18)是针对 1/16 特征图调的,如果 backbone 输出变成 1/8,这些 rate 要相应减半,否则等效感受野变大,小目标反而容易漏。全局池化分支先压缩到 1×1 再上采样,是为了把整图级别的上下文塞进每个位置,对 Cityscapes 里大面积的道路、天空区域很有帮助。project把五路拼接后的 1280 通道压到 256,后面的 Decoder 才接得住。
2.3 Decoder:低层特征和 ASPP 输出拼接
DeepLabv3+ 的 Decoder 部分很多人会忽略,实际上它决定了边缘精细度。ASPP 输出先上采样 4 倍,和 backbone layer1 的低层特征做通道拼接,再接两个 3×3 卷积,最后上采样 4 倍回到原图。低层特征要先经过一个 1×1 卷积把通道降到 48,否则边缘纹理会把语义信息淹没。
| backbone 文件 | 特点 | 在 Cityscapes 上的常见表现 |
|---|---|---|
| resnet.py | 参数适中,收敛稳,易加载预训练 | 性价比最高,mIoU 中上 |
| xception.py | 原版 DeepLabv3+ 标配,感受野大 | 上限高,显存占用大 |
| mobilenetv2.py | 轻量,适合实时推理 | 精度略低,速度最快 |
| hrnetv2.py | 高分辨率特征保持好 | 小目标好,训练成本高 |
训练时model(x)直接返回原图尺寸的 logits,loss 就在这个输出上计算。而predict.py推理时同样走这条通路,所以网络结构代码里不需要额外接分类头。如果从modeling.py里换 backbone,最常踩的坑是输出通道对不上,比如 hrnetv2 的 last_channel 和 resnet 不同,_deeplab.py里in_channels要同步改。
3. 数据集的坑:voc.py 与 cityscapes.py 为何不能共用一套读取逻辑
3.1 train_aug.txt 与 trainId 编码的差异
VOC 和 Cityscapes 的标注存储方式完全不同。VOC 的SegmentationClassAug是每个像素存类别索引的 P 模式 PNG,0 是背景,1 到 20 是物体类;Cityscapes 的gtFine里存的是 trainId,0 到 18 是训练类别,255 是 void。用 PIL 读的时候都要用mode="P",但千万不能convert("RGB"),一转换索引就对不上了。项目里datasets/voc.py的数据读取核心逻辑大概是:
class VOCSegmentation(data.Dataset): def __init__(self, root, image_set='train', transform=None): super().__init__() self.root = root self.transform = transform with open(os.path.join(root, "train_aug.txt")) as f: self.images = [line.strip() for line in f] def __getitem__(self, index): name = self.images[index] img_path = os.path.join(self.root, "JPEGImages", name + ".jpg") lb_path = os.path.join(self.root, "SegmentationClassAug", name + ".png") image = Image.open(img_path).convert("RGB") label = Image.open(lb_path) return self.transform(image, label)注意 VOC 这侧是把文件名行直接读进来,而不是在__init__里扫描整个文件夹,训练集和验证集的可复现性完全由 txt 决定。train_aug.txt是增强后的训练清单,数量比原始的 1464 张多很多,这是 mIoU 能上去的关键。Cityscapes 侧则要自己把路径拼出来,并且读取后把非 0 到 18 的像素统一成 255:
label = torch.from_numpy(np.array(label, dtype=np.uint8)) label[label >= 19] = 255 # 只保留 0-18,其余全部视为 void这行处理是 Cityscapes 训练最重要的预处理之一。原图里有很多未标注或标注为 ignore 的区域,不统一成 255 的话,loss 里会把这些区域当成有效类别反向传播,模型会在栅栏、车辆边缘这些地方产生大量错误预测。
3.2 ext_transforms 的随机缩放裁剪
项目里的utils/ext_transforms.py提供了一套扩展版 transforms,和 torchvision 自带的差异在于:随机缩放、随机裁剪、水平翻转这些操作要同时作用于 image 和 label,并且 label 的插值方式不能是 BILINEAR,必须用 NEAREST,否则会产生原本不存在的类别值。训练时的标准增强流程如下:
train_transform = ext_transforms.ExtCompose([ ext_transforms.ExtRandomScale((0.5, 2.0)), ext_transforms.ExtRandomCrop(size=(513, 513), pad_if_needed=True), ext_transforms.ExtRandomHorizontalFlip(), ext_transforms.ExtToTensor(), ext_transforms.ExtNormalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])这里ExtRandomScale先把整图随机制成 0.5 到 2.0 倍,再裁剪出固定大小的 patch,比直接 resize 更接近分割竞赛里的多尺度训练,能缓解 Cityscapes 这类数据集里物体尺度差异大的问题。pad_if_needed=True很重要,因为随机缩放后图片可能小于裁剪尺寸,不 pad 就崩了。Cityscapes 原始分辨率是 1024×2048,如果显存不足,常见的做法是ExtRandomCrop到 768×768 而不是直接 resize 到 512×1024,前者保留更多细节。
3.3 ignore_index 如何同时影响 loss 和评估指标
ignore_index不是一个只在 CrossEntropyLoss 里出现一次的参数,它同时进入两个逻辑。训练时nn.CrossEntropyLoss(ignore_index=255)会跳过 label 为 255 的像素,不参与梯度计算;评估时 confusion matrix 也需要排除这些像素。如果忽略了后者,mIoU 会被 void 区域的大面积真实标签干扰,尤其 Cityscapes 里很多图像上下边缘都有未标注区域。正确做法是判断像素是否大于等于 19,这些位置直接不累积到混淆矩阵中。
| 项目 | VOC 2012 | Cityscapes |
|---|---|---|
| 类别数 | 20 类 + 1 背景 | 19 类 + 255 void |
| 训练集规模 | 约 10582(含增强) | 2975 |
| 验证集规模 | 1449 | 500 |
| ignore 值 | 255 | 255 |
| 典型 crop 尺寸 | 513×513 | 768×768 或更大 |
从数据规模看,Cityscapes 训练集只有 VOC 增强后的四分之一不到,但训练难度反而更大,因为每张图都是高分辨率场景,类别像素分布极不均衡,道路面积可能占三分之一,而摩托车可能只有几十个像素。这个不均衡直接反映在 mIoU 波动上,我一般会多留几个 checkpoint,用验证集 mIoU 而不是最后一个 epoch 的结果来选模型。
4. main.py 训练流程:从命令行参数到 poly 学习率与 mIoU 计算
4.1 训练入口与关键参数
main.py把网络构建、数据加载、优化器调度和评估串成一条流水线。运行前需要确认三件事:backbone 预训练权重路径、数据集根目录、device 数量。常见启动命令如下:
python main.py \ --model deeplabv3plus_resnet101 \ --dataset voc --year 2012_aug \ --gpu_id 0 \ --batch_size 8 \ --lr 0.01 \ --lr_scheduler poly \ --total_itrs 30000 \ --ckpt ./checkpoints/best_deeplabv3plus_voc.pth--model deeplabv3plus_resnet101决定_deeplab.py里加载哪个 backbone 和对应输出通道;--dataset决定走voc.py还是cityscapes.py;--total_itrs不是 epoch 数,而是总迭代步数,Cityscapes 一般需要 60000 到 90000 步,VOC 30000 步左右就能收敛。Batch size 受显存限制,Cityscapes 大图通常只能开到 4 到 8,这时 BatchNorm 统计量会很不稳定,多卡训练要开 SyncBN。
下面的参数表是我认为在这个项目里最值得优先调的部分,其它参数保持默认即可:
| 参数 | 建议值 | 说明 |
|---|---|---|
--lr | 0.01(单卡) | 大模型或大 batch 调低到 0.007 |
--momentum | 0.9 | SGD 默认 |
--weight_decay | 5e-4 | 过大的 wd 会让 Cityscapes 掉点 |
--crop_size | 513 / 768 | 由数据集决定,需与 transform 一致 |
--total_itrs | 30000 / 60000 | 配合 poly 学习率衰减 |
--val_interval | 500 | 每 500 步跑一次验证 |
4.2 Poly 学习率与 WarmUp 的实际作用
项目里有utils/scheduler.py,它实现的不是 torchvision 里常见的 StepLR,而是分割任务最常用的 poly 策略:学习率随迭代次数按指数衰减到接近 0。配合total_itrs而不是 epoch 来算,是因为每个 epoch 的步数会随 batch size 变化,而迭代次数是固定的,这样无论怎么改 batch size,学习率曲线形状都不变。
def poly_lr(base_lr, current_step, max_steps, power=0.9): return base_lr * (1.0 - current_step / max_steps) ** powerpower=0.9是 DeepLab 系列的标准配置。前 10% 的迭代步里,很多工程会叠加一个 warmup,让 lr 从 0 线性上升到基学习率,避免加载 ImageNet 预训练权重后前几步 loss 直接爆掉。如果发现训练到一半 loss 还在震荡,先检查是不是 warmup 没生效,再看 batch size 和 BN 统计量。
4.3 CrossEntropyLoss 与 mIoU 的计算口径
训练时 loss 直接用交叉熵,但评估时如果对每个 batch 单独算 mIoU 再平均,小类别会被大类别稀释产生偏差。正确做法是用utils/metrics/stream_metrics.py里的StreamSegMetrics,把整个验证集的混淆矩阵累积起来,最后统一计算:
metric = StreamSegMetrics(num_classes, ignore_index=255) for images, targets in val_loader: with torch.no_grad(): logits = model(images) pred = logits.argmax(dim=1).cpu().numpy() metric.update(targets.cpu().numpy(), pred) score = metric.get_results() print("mIoU:", score["Mean IoU"])StreamSegMetrics.update内部是按像素累积混淆矩阵,get_results时才对各类别逐行算 IoU,再对 19 个类别取平均。使用这个类的时候要确认创建时传入的num_classes和ignore_index和数据集一致,否则混淆矩阵维度对不上或者把 void 也算进去,最终数值完全不可信。
5. predict.py 推理与可视化:从模型输出到带调色板的 PNG
5.1 推理时的预处理必须和训练严格对齐
推理阶段最常见的错误是把训练时的随机裁剪原样搬过来,结果每张图的输出尺寸都不一样。predict.py的流程是先读 checkpoint,取出model_state_dict或state_dict字段加载模型,再对单张图做和验证一致的 resize 与归一化,最后输出和原图一样大的预测。关键点是归一化的 mean、std 必须和训练相同,通道顺序 BGR 和 RGB 也要确认,否则 logits 分布完全错乱。
5.2 用调色板保存 P 模式预测图
分割结果不能直接存成 RGB,因为每类颜色需要固定映射,这样后续与原图、GT 叠加比较才有意义。VOC 和 Cityscapes 都有各自的调色板,把预测类别索引存入 P 模式 PNG,再挂上调色板:
import numpy as np from PIL import Image def save_pred(pred, palette, out_path): # pred: (H, W) int 数组,每个像素是类别索引 out = Image.fromarray(pred.astype(np.uint8), mode="P") out.putpalette(palette) out.save(out_path) palette = cityscapes_palette() # 长度为 256*3 的列表,idx -> (r, g, b) save_pred(pred, palette, "city_1_pred.png")putpalette接收一个长度 768 的列表,每三个元素对应一个索引的 RGB 颜色。Cityscapes 的调色板在很多开源工具里叫trainId2color,VOC 的调色板则是固定的 21 类加一个黑色背景。保存后打开图片,如果发现某个类整体颜色异常,先检查调色板顺序是不是和类别索引对齐,而不是去怀疑模型。
5.3 从示例图判断模型状态
项目里samples目录存放了1_image.png、1_target.png、1_pred.png、1_overlay.png和 city 系列样例。overlay是把预测结果半透明叠加在原图上,用来观察边缘贴合度。看到prod和target的差异时,先分清是整体误差还是局部误差:整体 mIoU 低通常是类别不平衡或训得不够;局部边缘锯齿大多是低层特征融合问题,可以回看 2.3 的 Decoder;大块区域被错分成同类,则大概率是 ASPP 的 rate 没适配分辨率。这张对比图也是调参时最直接的反馈,比盯着 loss 曲线有效得多。
6. 训练后必须检查的三个细节:mIoU 口径、忽略像素与权重加载
6.1 多次验证的 mIoU 不能简单平均
验证集很大时,常见做法是分几个 batch 推理,最后把混淆矩阵相加再统一算 IoU。如果图省事在每个 batch 算一次 mIoU 然后取平均,大类别(比如 road、terrain)占比越大偏差越明显。这个偏差不是线性的,小类别的 IoU 波动大,平均法会把这些波动放大。正确实现是StreamSegMetrics.get_results()之前只update,不reset,把所有验证样本累积完再出指标。
6.2 手动核对 Cityscapes 的 void 像素是否被真正忽略
城市道路实拍图里,物品的边缘会有大量未标注像素,读取 GT 后这些区域的 label 可能是 255,也可能是 0 到 18 之外的其它 trainId。训练脚本里的label[label >= 19] = 255是一次性清洗,如果验证时忘了做这一步,混淆矩阵里会出现非法类别,mIoU 直接崩掉。这里可以写一个小脚本验证:
python -c " from PIL import Image import numpy as np lb = np.array(Image.open('city_1_gtFine_labelTrainIds.png')) print('max label:', lb.max()) print('void ratio:', (lb >= 19).mean()) "max label应该是 18 或 255,void ratio一般在 5% 到 20% 之间。如果 ratio 为 0,说明这张图边缘全部标注了,不太符合 Cityscapes 的真实情况;ratio 超过 30%,则要考虑是不是 trainId 映射写错了,把 33 个原始类别直接当成了索引。
6.3 加载预训练权重时的 key 前缀问题
从 torchvision 下载的 resnet 权重 key 是layer1.0.conv1.weight,而_deeplab.py里的模型会用backbone.conv1.weight包裹这两层结构,直接load_state_dict会报 missing key。常见做法是先按前缀剥离再加载,保留 backbone 之外的模块随机初始化:
state = torch.load("resnet101.pth") new_state = {} for k, v in state.items(): new_state["backbone." + k] = v missing, unexpected = model.load_state_dict(new_state, strict=False) print("missing:", missing.keys()[:5])打印的 missing 应该是 classifier 这类分割头参数,unexpected 为空。这部分对应network/backbone/resnet.py顶层接口,如果是 xception 或 hrnetv2 预训练,前缀又会不同。建议把 loading 逻辑统一封装在modeling.py的init_weights里,这样切换 backbone 时不用改训练主流程。
本文还有配套的精品资源,点击获取