Pytorch + ADE20K 语义分割与场景理解工程实践
2026/9/16 14:12:10 网站建设 项目流程

简介:一份基于Pytorch的语义分割与场景理解项目实战包,面向计算机视觉初学者及进阶开发者。资源围绕MIT-ADE20K大规模数据集,提供从数据预处理、模型构建到训练评估的完整流程,帮助读者掌握像素级分类与场景结构分析的关键方法。压缩包共101个文件,其中68个Python脚本涵盖核心网络与训练逻辑,14个YAML配置文件用于实验参数管理,另含标注文件、CSV类别信息、可视化演示及说明文档,整体大小仅2.93MB,轻量且便于快速部署。目前已有781人学习浏览,项目内含SegNet等经典分割网络实现,并附Notebook交互式演示,可直接观察分割效果。通过阅读源码并运行实验,读者能深入理解数据增强、损失函数和评估指标的实际运用,也可基于现有代码调整超参数或尝试新结构,进一步拓展场景理解方向的研究与实践。

1. 从像素分类到场景理解,为什么 ADE20K 才是绕不过去的考场

做语义分割的人常有这样一种错觉:在 Cityscapes 或 VOC 上把 mIoU 刷到 70% 以上,就觉得自己已经把“分割”这件事搞明白了。但一旦把模型丢到 MIT-ADE20K 上,很多人会得到一个非常难看的数字——不是模型代码写错了,而是 ADE20K 这个数据集的难度曲线和前面那些赛道完全不在一个量级。它包含了 150 个语义类别,从家具、交通工具到墙面的材质、天空的纹理,很多类别在单张图里只占几十个像素,长尾分布极其严重。也就是说,语义分割在这个数据集上的目标不只是“把物体轮廓描出来”,而是让模型具备真正的场景理解能力,知道一张图里“哪里是什么、彼此是什么关系、哪些小目标容易被漏掉”。

这篇内容打算顺着“Pytorch + MIT-ADE20K”这条线,把一个可落地、可复现的语义分割与场景理解工程拆开讲清楚:从数据集的加载与评估协议,到基于 Pytorch 的模型搭建与训练参数设置,再到多尺度推理、逐类指标诊断和模型导出。整个方案不依赖某个特定仓库的魔改代码,而是用 Pytorch 基础框架能力,一步步把工程里最常见的坑和最优做法过一遍。适合正在做算法落地、研究生课题或者自己动手复现论文的实验者阅读,看完可以直接在自己的显卡上跑通。

2. ADE20K 数据集与场景理解任务:先搞清楚你要预测什么

2.1 150 类的长尾分布决定了损失函数和评估方式

ADE20K 的官方训练集包含大约 2 万张图像,验证集约 2000 张,每张图像都对应一个像素级标注的 mask,覆盖面非常广。和 VOC 的 21 类、Cityscapes 的 19 类不同,ADE20K 的 150 个类别里既有“人”“车”“树”这种常见大类,也有“灯”“开关”“插座”这种极难识别的小物体。官方还额外提供场景分类标签,因此它也常被用来做场景理解和语义分割的联合研究。

做这个数据集的第一步,是理解它的评估协议。官方推荐使用 mIoU(mean Intersection over Union)作为主指标,计算时对 150 个类别的 IoU 取平均。由于类别极度不均衡,那些出现频率低的类别对最终分数的拖累非常大。

指标计算方式对长尾的敏感度
mIoU各类别 IoU 的算术平均
mAcc各类别准确率的平均
aAcc全像素准确率
Frequency Weighted IoU按像素频率加权

我一般会同时观察 mIoU 和 mAcc,前者反映边界质量与类别召回,后者能暴露模型是否把某些类彻底忽略了。如果 mAcc 比 mIoU 低很多,说明某些类预测出来但位置偏了;反之则说明小类被漏检。

2.1.1 自制 Dataset 的加载细节

torchvision 没有直接封装 ADE20K 的官方下载,所以需要自己写 Dataset。常见做法是使用官方发布的annotations目录里的trainingvalidation文件夹,图像与 mask 文件名一一对应。下面这段代码给出了一个可跑的骨架:

import os import torch from torch.utils.data import Dataset from PIL import Image import numpy as np class ADE20KDataset(Dataset): def __init__(self, image_dir, mask_dir, transform=None, mask_transform=None): self.image_dir = image_dir self.mask_dir = mask_dir self.transform = transform self.mask_transform = mask_transform self.images = sorted(os.listdir(image_dir)) def __len__(self): return len(self.images) def __getitem__(self, idx): img_name = self.images[idx] img_path = os.path.join(self.image_dir, img_name) mask_path = os.path.join(self.mask_dir, img_name.replace(".jpg", ".png")) image = Image.open(img_path).convert("RGB") mask = Image.open(mask_path) if self.transform is not None: image = self.transform(image) if self.mask_transform is not None: mask = self.mask_transform(mask) # mask 转为 LongTensor,类别索引从 0 到 149 mask = torch.as_tensor(np.array(mask), dtype=torch.long) return image, mask

这里有几个容易被忽略的细节。第一,ADE20K 的 mask 图像里,背景类和其他物体的索引是连续排列的,训练时不需要像 VOC 那样做 -1 偏移,但需要确认类别数是否为 150。第二,mask 在读取时不能做归一化,因为它是类别索引而不是概率图。第三,如果使用了随机裁剪、翻转等数据增强,必须保证 image 和 mask 使用同一组随机参数,最简单的做法是把二者拼在一起做变换,或者使用 torchvision 的Compose分别传入同一个随机种子。

2.2 数据增强与类别权重:解决长尾问题的第一步

长尾分布最直接的危害是模型倾向把稀缺类别预测成常见类别。例如“插座”和“白色墙壁”在像素层面高度相似,如果训练数据里插座出现得少,模型很容易把插座区域直接当成墙壁。应对策略有两个层面:数据层面做增强和重采样,损失函数层面做类别加权。

训练时我会保留以下增强组合:

  • 随机缩放(0.5 到 2.0),然后随机裁剪到固定尺寸,例如 512x512
  • 左右翻转,概率 0.5
  • 颜色抖动,包括亮度、对比度、饱和度调整,幅度不要太大,否则会影响材质类别的判断
  • 随机遮挡(CutOut 的一种变体),帮助模型减少对局部纹理的依赖

由于 ADE20K 图像尺寸差异大,直接 resize 会把小物体的比例压变形,多尺度训练有利于小物体分割。常见做法是把训练分辨率设置在 384 到 640 之间随机选择,验证时固定为 512。

类别权重的计算方式一般采用中位数频率平衡(median frequency balancing)或者简单的 inverse frequency。计算代码如下:

import numpy as np def compute_class_weights(mask_paths, num_classes=150): freq = np.zeros(num_classes, dtype=np.float64) for mask_path in mask_paths: mask = np.array(Image.open(mask_path)) for c in range(num_classes): freq[c] += (mask == c).sum() # 中位数频率平衡 median_freq = np.median(freq[freq > 0]) weights = median_freq / (freq + 1e-6) return torch.from_numpy(weights.astype(np.float32))

这个权重表可以直接传给 CrossEntropyLoss 的weight参数。但要注意,权重设置过大容易导致训练初期 loss 剧烈震荡,建议对权重做一次截断,比如限制在 [0.1, 10] 范围内,或者先不使用权重,等模型训练到中段再加入,这种渐进式加权策略在许多实验中比全程加权更稳。

3. 基于 Pytorch 的语义分割模型搭建:从 DeepLabV3 到 backbone 替换

3.1 为什么 DeepLabV3 是 ADE20K 上最稳妥的起点

语义分割的模型选型通常会面临一个矛盾:U-Net 类模型结构简单、易训练,但感受野有限,难以捕捉全局场景信息;而 Transformer 类模型性能强,但对数据量和训练技巧要求高。DeepLabV3 正好处于一个平衡点:它通过 ASPP(空洞空间金字塔池化)模块用不同膨胀率的空洞卷积并行采样,能够在不大幅增加计算量的前提下扩大感受野,非常适合 ADE20K 这种同时存在大物体(墙壁、天空)和小物体(开关、插座)的复杂场景。

Pytorch 官方torchvision库直接提供了 DeepLabV3 的实现,使用deeplabv3_resnet101deeplabv3_resnet50作为主干。以下是把它迁移到 ADE20K 150 类的最小代码:

import torchvision.models.segmentation as segmentation model = segmentation.deeplabv3_resnet50( weights=segmentation.DeepLabV3_ResNet50_Weights.COCO_WITH_VOC_LABELS_V1, num_classes=150 )

需要注意,num_classes参数必须和预训练权重维度不一致。如果直接加载 COCO 预训练权重然后修改输出层,Pytorch 会报 shape mismatch。常见做法是先把model.classifier[4](最后的 1x1 卷积)替换成输出 150 维的新卷积层,然后只加载 backbone 部分的权重,或者放弃预训练权重从零开始训练。从零训练 DeepLabV3 在 ADE20K 上需要较长时间收敛,因此更推荐冻结 backbone 前几层、只解冻后半部分和 ASPP 头的迁移学习方案。

3.2 用 timm 替换 backbone:当 ResNet 不够用时的灵活方案

标准 DeepLabV3 的可扩展性略差,因为 ASPP 模块的输入通道数是固定的,替换主干网络时需要同步调整空洞卷积的输入维度。社区里更工程化的做法是使用timm库加载各种 backbone,然后自己组装一个分割头。下面展示一个用timm.create_model替换主干的最小实现:

import timm import torch.nn as nn import torch class SimpleDeepLabV3(nn.Module): def __init__(self, backbone_name="resnet50", num_classes=150): super().__init__() self.backbone = timm.create_model( backbone_name, pretrained=True, features_only=True, out_indices=[4], output_stride=16 ) # 获取 backbone 输出通道数 self.aspp = nn.Sequential( nn.Conv2d(self.backbone.feature_info.channels()[-1], 256, 1), nn.BatchNorm2d(256), nn.ReLU(inplace=True), ) self.classifier = nn.Conv2d(256, num_classes, kernel_size=1) def forward(self, x): features = self.backbone(x)[0] # 取最后一个 feature map x = self.aspp(features) x = self.classifier(x) # 上采样到原图尺寸 x = nn.functional.interpolate( x, size=x.shape[-2:], mode="bilinear", align_corners=False ) return x

真正做场景理解任务时,只取最后一个特征图仍会丢失小目标的细节。更合理的做法是保留两个层级的特征图,用 FPN 结构将其融合,在 ADE20K 长尾类别检测上效果提升相当明显。out_indices参数可以返回多个尺度特征,例如out_indices=[2, 3, 4],然后分别做 1x1 卷积降维、上采样相加,再用一个简单分割头输出。

这个方法的价值在于:换 backbone 从改模型结构变成改一行字符串,想测试convnextefficientnetmobilenet或者带 Transformer 结构的maxvit,不需要改其他任何逻辑。

3.3 辅助损失与深监督:让梯度在长尾任务里流动得更充分

单纯依赖最后的分割输出计算 loss,对深层的分类层有效,但对浅层细节边缘的监督不足。受 PSPNet 和 U-Net 系列启发,我在中间特征层上额外挂一个辅助分割头,用一个较小的权重(如 0.4)把辅助损失叠加到总损失上。这个技巧在 ADE20K 上的收益比在 Cityscapes 上更明显,原因是小类别物体往往依赖中低层语义信息。

def auxiliary_loss(model, outputs, targets, aux_weight=0.4): # 假设 model 的 forward 返回 (main_out, aux_out) main_out, aux_out = outputs main_loss = nn.functional.cross_entropy(main_out, targets) aux_loss = nn.functional.cross_entropy(aux_out, targets) return main_loss + aux_weight * aux_loss

注意辅助分支的输出特征图尺寸必须和 targets 对齐,一般做法是直接把 aux 分支的特征图上采样到 targets 的分辨率再算损失。推理阶段不用修改模型结构,因为辅助分支只在训练时参与。

4. 训练策略与排错:让模型在 ADE20K 上真正收敛

4.1 超参数配置表:照着跑就能复现一份不错的结果

模型结构确定后,训练策略直接决定最终指标。下面是默认推荐的超参数,涵盖不同显存容量下 batch size 的对应设置。

超参数推荐取值说明
学习率0.01(poly 策略),batch size 16 时SGD 搭配 momentum=0.9, weight_decay=1e-4
初始分辨率512x512显存不够时先用 384,后期再调大
Batch Size按显存调,建议至少 8 张过小会导致 BatchNorm 统计不稳定
训练轮数60 到 80 epochADE20K 数据量大,少于 40 epoch 难收敛
学习率调度poly,power=0.9比 cosine 更适合分割任务的长尾收敛
混合精度开启 AMP可减少 40% 显存占用,训练速度提升明显

poly 学习率策略的核心公式是lr = base_lr * (1 - iter/total_iter)^power,它让学习率后期缓慢下降,能够更精细地收敛长尾类别的决策边界。Pytorch 实现时可以用torch.optim.lr_scheduler.LambdaLR配合如下代码:

def poly_lr(epoch, max_epoch, power=0.9): return (1 - epoch / max_epoch) ** power scheduler = torch.optim.lr_scheduler.LambdaLR( optimizer, lr_lambda=lambda epoch: poly_lr(epoch, max_epoch=80) )

4.2 一个完整的训练循环骨架

完整的训练流程需要关注几个细节:混合精度缩放、梯度裁剪、mask 上采样对齐、loss 统计。以下代码段给出核心逻辑:

scaler = torch.cuda.amp.GradScaler() criterion = nn.CrossEntropyLoss(ignore_index=255) for images, masks in train_loader: images = images.cuda() masks = masks.cuda() optimizer.zero_grad() with torch.cuda.amp.autocast(): outputs = model(images)["out"] # torchvision 模型 outputs = nn.functional.interpolate( outputs, size=masks.shape[-2:], mode="bilinear", align_corners=False ) loss = criterion(outputs, masks) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=5.0) scaler.step(optimizer) scaler.update() scheduler.step()

这段代码里最容易出错的地方是interpolate。DeepLabV3 输出的是输入图像的 1/8 或 1/16 分辨率特征图,如果直接与原始尺寸 mask 算 loss,Pytorch 会报维度错误。使用ignore_index=255可以跳过无效区域,在图像边界裁剪时避免影响 loss。梯度裁剪对分割任务不是必需的,但在混合精度环境下能避免梯度爆炸引起的 NaN loss。

4.3 训练失败时的三个排错视角

loss 不下降和 mIoU 始终在低位徘徊是最常见的两个问题,排查顺序要按数据、模型、环境三层递进。

数据层面:先确认 mask 可视化是否与图像对齐。很多人在做“语义分割数据集如何制作”的自制数据时,最容易犯的错是 mask 和原图 dimensions 不匹配。先用matplotlib把原图和 mask 叠加打印出来看,确认物体轮廓是否重合,再检查类别索引是否有空洞。ADE20K 的索引是连续分配的,但部分第三方转换脚本可能会把背景类设为 255,这时必须重映射为 0。

模型层面:观察验证集逐类准确率。训练 10 个 epoch 后,如果大多数类别的 IoU 都是零,说明学习率过大导致模型饱和;如果常见类别 IoU 正常但小众类别全零,则检查类别权重是否生效,以及是否启用了 OHEM 在线困难样本挖掘。OHEM 在长尾任务中比较有效,但实现稍复杂,可以用 top-k 像素 loss 近似替代。

环境层面:混合精度下 loss 为 NaN,优先确认 CUDA 和 cuDNN 版本是否匹配。Pytorch 2.x 搭配 CUDA 12.x 时,某些旧版本 GPU 驱动会在 AMP 反向传播阶段出现数值溢出,常见解决办法是关闭 AMP 或者在GradScaler中设置init_scale=2**10减少初始缩放。

5. 推理、可视化与逐类诊断:把分割结果变成可用的场景理解

5.1 多尺度加翻转 TTA:不增加训练成本就能提升指标

验证时如果直接把单个尺度的模型输出上采样再计算 mIoU,通常会比训练时的验证指标低 1 到 3 个点。多尺度推理(Multi-Scale Inference)是分割任务的标准提分手段,常见做法是对输入图像取 0.75、1.0、1.5 三个尺度,同时加入水平翻转,最后将各结果插值回原尺寸并取平均。

import torch.nn.functional as F def tta_inference(model, image, scales=[0.75, 1.0, 1.5], flip=True): model.eval() _, h, w = image.shape logits_sum = None with torch.no_grad(): for scale in scales: new_h, new_w = int(h * scale), int(w * scale) img_resized = F.interpolate( image.unsqueeze(0), size=(new_h, new_w), mode="bilinear", align_corners=False ) logits = model(img_resized.cuda())["out"] logits = F.interpolate(logits, size=(h, w), mode="bilinear", align_corners=False) if flip: logits_flip = model(torch.flip(img_resized.cuda(), dims=[3]))["out"] logits_flip = F.interpolate(logits_flip, size=(h, w), mode="bilinear", align_corners=False) logits += torch.flip(logits_flip, dims=[3]) if logits_sum is None: logits_sum = logits else: logits_sum += logits logits_sum /= len(scales) * (2 if flip else 1) return logits_sum.argmax(dim=1).squeeze(0)

这段代码的逻辑是:每个尺度的 logits 都先上采样回原始尺寸再累加,避免在小尺度图像上直接 argmax 导致物体边缘锯齿。翻转推理时要把 logits 翻转回原方向再累加,不能翻转预测结果。多尺度推理会让推理时间翻倍,如果是视频或在线推理场景,建议只在精度评测或离线任务里使用。

5.2 按类计算 IoU:定位模型短板的唯一正确方法

平均 mIoU 会掩盖很多问题,必须把 150 个类别的 IoU 输出成表格,按升序排列观察尾部类别。下面是一个完整的逐类评估代码:

def compute_per_class_iou(preds, targets, num_classes=150): ious = [] for c in range(num_classes): pred_c = (preds == c) target_c = (targets == c) intersection = (pred_c & target_c).sum().item() union = (pred_c | target_c).sum().item() if union == 0: ious.append(float("nan")) else: ious.append(intersection / union) return ious

把结果写入 CSV 后,你会看到模型在“天花板”“地板”“墙壁”这类大面积背景类上表现很好,而在“玻璃杯”“台灯”“画框”这类小物体上 IoU 可能只有个位数。这就是场景理解能力不足的真实体现——模型没有学会利用上下文关系来推断这些物体的位置。这时候再针对性地补充这些类别在训练集中的数量,或者调整 loss 权重,才有意义。

5.3 分割结果可视化与颜色映射的工程实现

把预测 mask 叠加到原图上,是算法工程师和 reviewer 沟通最直观的方式。每次运行时使用的颜色映射必须固定,否则不同时刻跑出的可视化结果颜色不一致,无法做对比分析。下面用固定 colormap 实现可视化:

import matplotlib.pyplot as plt import numpy as np def decode_segmap(mask, num_classes=150): # 固定生成一组 RGB 颜色 cmap = np.random.RandomState(42).rand(num_classes + 1, 3) rgb = cmap[mask] return (rgb * 255).astype(np.uint8) mask = pred.cpu().numpy() colored = decode_segmap(mask) plt.imshow(colored)

颜色映射在工程部署时往往被忽略,但在做 PPT 汇报、写技术文档或对比实验时,如果两次运行的颜色不一致,很难说服别人相信两个结果是同一个模型产出的。把 colormap 序列化保存到 json 文件里,每次运行时加载,要比每次重新生成稳妥得多。

如果你想继续深挖,可以尝试训练一个辅助场景分类头,把图像级场景标签和像素级分割结果做联合推理——例如模型预测出“沙发”和“茶几”,那么图像场景大概率是“客厅”。这种场景理解层面的推理要比单纯堆 mIoU 更能体现 ADE20K 的设计初衷,也是这个数据集区别于其他分割 benchmark 的最大价值所在。

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

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

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

立即咨询