简介:面向计算机视觉方向学生的毕设与课程作业场景语义分割项目包,聚焦深度学习在像素级图像分类中的完整落地流程,涵盖模型设计、数据准备、训练优化与部署关键环节。压缩包共24个文件,以10个Python脚本为核心,覆盖模型结构定义(如SUPnet)、训练流程(train_SUPnet.py)、数据加载与预处理工具(data_utils、indoor3d_util)等;另含6个XML配置、2个TXT说明、2个PNG效果图及工程配置文件,大小约820KB,结构清晰便于按模块查阅。内容从FCN、U-Net等经典架构出发,涉及数据增强、损失函数、评估指标(像素准确率、IoU)与模型部署转换,并补充C++高性能推理思路,适合需要系统完成课程设计或毕业项目的学习者快速对照实践。已有157人学习下载,可作为深度学习语义分割项目从零搭建的参考模板。
1. 基于深度学习的场景语义分割,毕设和课程作业最常见的落地点
毕业答辩倒计时一开始,十个同学里有八个都在做图像分类,剩下两个里,一个做目标检测,另一个就是做基于深度学习的场景语义分割。这个任务很直接:把图像里的每个像素都打上语义标签,路面、天空、行人、墙面各归各。跟目标检测比,它要输出像素级掩码,更细;跟实例分割比,它不区分同一类别的不同个体,落在本科毕设和课程作业范围内,难度刚好能在一学期内憋出来,最后还能可视化出一张张彩色掩码图,答辩演示效果很好。一个典型的“基于深度学习的场景语义分割”项目压缩包里,通常装着模型训练代码、数据预处理脚本、训练好的权重、验证可视化脚本和一份报告文档。这篇笔记按我平时做分割项目的工作流展开,从网络选型讲到数据准备、训练参数、踩坑记录,让拿到包的人能跑通,也能讲明白每一个环节。
2. 场景语义分割的网络选型:从FCN到DeepLabV3,原理和参数一次说清
第一次接触场景语义分割的人,最容易把“端到端”挂在嘴边,却说不清网络为什么要做成全卷积。原因很简单:语义分割是像素级分类,输入一张 H×W×3 的图像,输出的是和输入同尺寸的 H×W×num_classes 概率图。如果沿用分类网络,最后用全连接层把所有空间位置压成一个向量,每个像素的位置信息就彻底丢了,边界自然标不出来。FCN 的核心改动就是去掉全连接层,把最后一层改成 1×1 卷积,让特征图从“图像描述”变成“逐像素的分类得分”,再用转置卷积把低分辨率得分图上采样回原图尺寸。这个改动听起来不大,但意味着模型可以接受任意尺寸输入,也是所有后续分割模型的基础。
2.1 全卷积网络为什么是语义分割的基石
FCN 里有两个细节值得盯一下。第一个是上采样方式,原版用转置卷积,也就是通常说的反卷积。转置卷积有可学习的参数,能通过训练自动调整上采样系数,但也会带来棋盘格噪声;后来的很多实现干脆改用双线性插值,比如 DeepLabV3 的最终上采样就是用双线性插值把输出恢复到输入尺寸。第二个是跳跃连接,FCN-8s 里把第三、第四个池化层的输出和最终预测融合,用来恢复小物体的细节。这个思路后来被 UNet 的编码-解码结构发扬光大,只不过 UNet 用拼接而不是相加。
现在你回头看 DeepLabV3 的网络结构,会发现它依然是“卷积特征提取 + 空间池化 + 轻量解码头”的框架,只是把编码阶段的降采样策略改了改。理解这点对你调参很有帮助:分割模型的难点不是“谁在上采样”,而是“在小物体和大物体同时出现时,怎么让特征保留足够的空间分辨率”。FCN 的粗糙在于连续池化把分辨率降到原图的 1/32,小物体基本被洗没了,VOC 上的 mIOU 也只有 60 出头。所以当下做毕设或课程作业,很少有人直接用原始 FCN,而是把它当 baseline 理解,然后用 UNet 或 DeepLabV3 做主力。
另一个容易忽略的点是输入分辨率。很多教程默认用 256×256,但如果你跑的是街景数据,路牌、行人这类小目标占的像素很少,256 的输入会让它们在特征图里只剩一两个像素。我一般建议先看数据里最小目标的尺寸,再决定输入大小。VOC 用 512 居多,Cityscapes 用 769 或 1024,UNet 这类医学分割常用 256 到 512。输入尺寸直接影响显存和感受野,改模型不如先改这个参数划算。
2.2 DeepLabV3 的空洞卷积和 ASPP 模块:选型的核心依据
DeepLab 系列最大的特点是使用空洞卷积。空洞卷积可以在不增加参数量的前提下扩大感受野,简单说就是让一个 3×3 的卷积核“看到”更大的范围。dilation rate=2 的 3×3 卷积,等效于一个 5×5 感受野;rate=4 等效于 9×9。VOC 和 Cityscapes 这类街景数据里,一辆车可能占几百像素,一个人可能只有几十像素,单一感受野很难同时照顾两者。DeepLabV3 的 ASPP 模块用四个并行的分支来解决:一个 1×1 卷积,三个 dilation rate 分别为 6、12、18 的 3×3 空洞卷积,再加一个全局平均池化后再 1×1 卷积。四个分支的特征被拼在一起,再用 1×1 卷积融合,相当于让网络自己决定在哪个尺度上看目标。
从工程选型角度看,如果你的数据是街景、遥感这类各类别尺度差异很大的场景,优先选 DeepLabV3 加 ResNet50/ResNet101 backbone;如果数据是医学影像、微小目标,UNet 的编码解码结构保留细节更稳,而且从头训练效果也不差。下表是我常用的选型参考,注意里面 mIOU 是公开数据集上的大致水平,不是绝对指标。
| 模型 | backbone | 参数体量 | VOC mIOU(约) | 单卡训练建议 |
|---|---|---|---|---|
| FCN-32s | VGG16 | 约 134M | 62 | 不推荐新项目 |
| UNet | 自编码 | 约 31M | 72 | 医学、小数据 |
| DeepLabV3 | ResNet50 | 约 42M | 81 | 街景、遥感 |
| DeepLabV3+ | ResNet101 | 约 59M | 88 | 追求精度、显存充足 |
需要注意的是,DeepLabV3 的 backbone 输出 stride 一般是 8,也就是预测图是输入尺寸的 1/8,然后靠双线性插值放大回去。1/8 分辨率虽然损失不少细节,但大部分时候够用;如果物体很小,你可以把 backbone 的输出 stride 改成 16 试试,特征更密,代价是显存更高。torchvision 的实现里,这个 stride 直接由最后一组残差块的步长控制,修改时要注意同时调整空洞卷积的 rate,否则感受野会突然变小。单纯加输入尺寸也能缓解,但显存开销更大。
2.3 预训练权重和迁移学习的实际用法
场景语义分割很少从零开始训练,因为 ImageNet 上预训练的 backbone 已经掌握了边缘、纹理、颜色等基础特征,能大幅缩短收敛时间。最常见的做法是用 torchvision 里的分割模型直接加载 COCO 预训练权重,然后把分类头换成自己的类别数。下面是从 torchvision 加载 DeepLabV3 并适配自定义类别的代码:
import torchvision num_classes = 8 # 你自己数据集的类别数,记得加上背景 model = torchvision.models.segmentation.deeplabv3_resnet50( pretrained=True, num_classes=21 ) model.classifier[-1] = torch.nn.Conv2d(256, num_classes, kernel_size=1) model.aux_classifier[-1] = torch.nn.Conv2d(256, num_classes, kernel_size=1)这段代码的逻辑是:先用 pretrained=True 构造一个在 COCO 上训练过的 DeepLabV3,COCO 有 21 类(包含背景),所以分类头输出是 21。然后用一个新的 1×1 卷积替换classifier的输出层,把通道数改成自己的 num_classes。aux_classifier是 DeepLabV3 的辅助观测分支,训练时它也会输出一个预测图用来合并 loss,所以要一起换掉。
参数说明:model.classifier是一个 Sequential,最后一个是 1×1 卷积,原实现里in_channels=256,你的新输出层要保留这个输入通道。如果你的 num_classes 超过 21,新的卷积层参数会随机初始化,后面的训练需要额外跑几轮才能跟上前面的 backbone。另外,pretrained=True 会下载权重文件,默认存到~/.cache/torch/hub/checkpoints;如果下载不顺利,就手动下载后放到该目录,文件名要跟 torchvision 要求的一致。迁移学习时,如果显存紧张,可以把 backbone 的前几层requires_grad=False冻结起来,但不要全部冻结,否则网络学不到分割特有的空间信息。另一个常见做法是开启aux_loss=True,这个辅助 loss 只在前向时多一个分支,对整体收敛有一点帮助,代价是显存多一小块,可以在环境跑通后再关掉。
提示:如果加载预训练权重后直接替换分类头,记得把新分类头的 bias 初始化为 0,weight 用正态分布初始化。torchvision 的 reset_parameters 方法已经做了,但你自定义的
nn.Conv2d需要手动nn.init.normal_(conv.weight, 0, 0.01),不然第一轮 loss 可能异常大。
3. 数据准备与标签处理:VOC格式转成训练用的Tensor
数据部分是语义分割项目里最脏最累的一步,也是最容易让模型“玄学翻车”的地方。绝大多数毕设包用的数据集是 VOC2012 或者自己标注的小数据集。不管哪种,你需要先把图片和标签整理成对齐的结构,再转换成语义分割训练真正需要的索引掩码,最后考虑数据增强。这里每一步的坑我都踩过,写出来给你省时间。
3.1 数据来源与目录结构
先介绍最常见的 VOC2012 目录结构,这也是课程作业最常用的数据集。你需要JPEGImages放原图,SegmentationClass放分割掩码,ImageSets/Segmentation里放训练/验证集的文件名列表,如下:
VOCdevkit/VOC2012/ ├── JPEGImages/ │ ├── 2008_000001.jpg │ ├── 2008_000002.jpg │ └── ... ├── SegmentationClass/ │ ├── 2008_000001.png │ ├── 2008_000002.png │ └── ... └── ImageSets/Segmentation/ ├── train.txt ├── val.txt └── trainval.txttrain.txt 里每一行是图片名去掉后缀的名称,比如 2008_000001。多数代码里会直接读这个 txt,然后拼接出图片和掩码的完整路径。这里有一个容易踩的坑:VOC 的掩码图不是普通 RGB 图片,而是调色板模式。如果你直接用Image.open(mask).convert('RGB')读它,得到的不是类别索引,而是被解释成三个通道的调色板颜色,训练起来 loss 会一直卡在不合理的数值上。正确做法是保持 'P' 模式读入,再转成 numpy 数组,取到的就是 0~20 的类别索引。
from PIL import Image import numpy as np mask = Image.open('VOCdevkit/VOC2012/SegmentationClass/2008_000001.png') print(mask.mode) # 输出 P,表示调色板模式 mask_arr = np.array(mask, dtype=np.uint8) print(np.unique(mask_arr)) # 输出所有出现的类别索引这段检查代码的作用是让你确认掩码的取值是类别索引而不是 RGB 颜色。如果np.unique里出现 255,说明 VOC 中有边缘区域被标注为 255,也就是“忽略区域”,训练时要传给损失函数的 ignore_index 跳过去。熟悉这个数据结构后,再迁移到 Cityscapes 或其他自定义数据集就顺手了,因为它们的掩码读取方式大同小异,只是掩码所在目录不同。Cityscapes 的 gtFine 子目录里还分 labelIds 和 instanceIds,用 labelIds 做语义分割就够了。
3.2 标签编码:把mask的类别值对齐到训练损失
数据准备好的下一件事是写一个 Dataset 类,把图像路径、掩码路径和对齐逻辑封装起来。很多新手以为只是读图就完事了,实际还要做三件事:把读进来的掩码从 numpy 数组转成 torch 的 LongTensor、把 255 统一标记为 ignore_index、最后再交给增强函数同步处理。下面是一个最小可用的 VOC Dataset 实现:
import os import numpy as np from PIL import Image from torch.utils.data import Dataset class VOCSegmentDataset(Dataset): def __init__(self, img_dir, mask_dir, split_file, transform=None, ignore_index=255): with open(split_file, 'r') as f: self.ids = [line.strip() for line in f.readlines()] self.img_dir = img_dir self.mask_dir = mask_dir self.transform = transform self.ignore_index = ignore_index def __len__(self): return len(self.ids) def __getitem__(self, idx): name = self.ids[idx] img = Image.open(os.path.join(self.img_dir, name + '.jpg')).convert('RGB') mask = Image.open(os.path.join(self.mask_dir, name + '.png')) img = np.array(img, dtype=np.float32) mask = np.array(mask, dtype=np.uint8) # 关键:P模式转索引数组 mask[mask == 255] = self.ignore_index if self.transform is not None: aug = self.transform(image=img, mask=mask) img = aug['image'] mask = aug['mask'] img = torch.from_numpy(img.transpose(2, 0, 1)).float() mask = torch.from_numpy(mask).long() return img, mask这里的逻辑要点在于mask = np.array(mask, dtype=np.uint8),它拿到的是像素级的类别标签,而不是三通道的彩色图。然后mask[mask == 255] = self.ignore_index把 VOC 的 255 边缘置为 ignore。torch.from_numpy(mask).long()确保掩码是 LongTensor,因为nn.CrossEntropyLoss要求目标为 LongTensor,且不能是 one-hot。
要注意img.transpose(2, 0, 1)是把 HWC 变成 CHW,因为 torch 的卷积层默认期望(B, C, H, W)。如果你用了 albumentations 的A.ToTensorV2(),它会自动做转置和换类型,这里为了保持优雅,没有在 transform 里返回 tensor,而是在 Dataset 末尾统一转换,这样掩码插值问题也更容易排查。如果你的ignore_index不是 255,一定要同时改损失函数里的参数,否则两处设置不一致,边缘区域会被当成真实类别参与梯度。
3.3 数据增强与归一化的同步更新
语义分割里的增强技巧跟分类不同:对图像做翻转、裁剪、缩放,掩码必须做完全相同的几何变换;而颜色抖动、归一化,只能作用于图像,不能作用在掩码上。albumentations 是目前最方便的工具,它自动保证这种情况同步。训练、验证两套变换如下:
import albumentations as A train_transform = A.Compose([ A.RandomResizedCrop(size=(256, 256), scale=(0.5, 2.0), ratio=(0.75, 1.33)), A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.3), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225), max_pixel_value=255.0), ]) val_transform = A.Compose([ A.Resize(256, 256), A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225), max_pixel_value=255.0), ])这段代码在训练时用了随机裁剪、水平翻转和亮度对比度调整,验证集只做等比压缩和归一化。A.RandomResizedCrop的 scale 参数控制随机裁剪面积在原图 50% 到 200% 之间,ratio 控制宽高比,这样模型能经常看到不同尺度的目标,对场景语义分割很有用。A.Normalize用的均值方差和 ImageNet 预训练模型一致,因为 backbone 在 ImageNet 上预训练过,输入分布不统一会在开头几个 epoch 拉低收敛速度。
时刻记得,A.Compose内部已经知道 image 和 mask 是同步处理的,所以你在 Dataset 里用aug = self.transform(image=img, mask=mask)就行,不要自己先对图片做 resize 再对 mask 做 resize。如果不得已要用 torchvision 的 Resize,一定要给 mask 显式设置interpolation=Image.NEAREST,防止双线性插值把类别索引插成小数,造成训练掩码出现非法标签。另外,如果原图尺寸和训练尺寸差太多,比如原图 1024,训练 256,建议先用滑动窗口抽样或在 RandomResizedCrop 的 scale 里把裁剪范围调大,否则模型看到的多是局部纹理,学不到全局结构。
4. 训练流程与关键参数:用PyTorch跑通一个最小可用的基线
网络结构和数据准备好了,接下来就是训练。我这里用一个 PyTorch 的最小训练脚本做例子,目标是让你把 DeepLabV3 在 VOC 上跑通,而不是追求最高的 mIOU。关键在四个点:DataLoader、损失函数、优化器和训练循环。每一个点都有参数要调,我给出常用的那组。
4.1 数据集类与加载器
使用上一章的 Dataset 类,直接构造训练集和验证集,然后给 DataLoader 配置参数:
from torch.utils.data import DataLoader train_dataset = VOCSegmentDataset( img_dir='VOCdevkit/VOC2012/JPEGImages', mask_dir='VOCdevkit/VOC2012/SegmentationClass', split_file='VOCdevkit/VOC2012/ImageSets/Segmentation/train.txt', transform=train_transform, ) val_dataset = VOCSegmentDataset( img_dir='VOCdevkit/VOC2012/JPEGImages', mask_dir='VOCdevkit/VOC2012/SegmentationClass', split_file='VOCdevkit/VOC2012/ImageSets/Segmentation/val.txt', transform=val_transform, ) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4, pin_memory=True, drop_last=True) val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False, num_workers=4, pin_memory=False)DataLoader 的参数里面,shuffle=True只用于训练集,验证集必须保持顺序,否则会影响评估的确定性。num_workers一般设置为 CPU 内核数的一半,Windows 上不要设太大,否则容易报多进程错误。pin_memory=True在训练时会加快显存拷贝,但如果机器内存不足反而可能拖慢。drop_last=True是避免最后一个 batch 的尺寸不齐,特别是有 BatchNorm 层时。
如果你的显存只有 4GB,batch_size=8很可能会爆。一个常见技巧是梯度累积:先跑完几个小 batch 再更新一次参数。假设你想用等效 batch size 32,显存只支持 8,就每 4 个小 batch 做一次优化器 step。代码里需要改训练循环,下面一并给出。还有一个容易忽略的设置是torch.backends.cudnn.benchmark=True,当你的输入尺寸固定时,它能帮 cudnn 选择最快的卷积算法,对训练提速有帮助;如果输入尺寸不固定,这个开关反而会浪费时间,建议让它保持 False。
4.2 损失函数:交叉熵与类别权重
语义分割最常用的损失就是逐像素交叉熵,PyTorch 的nn.CrossEntropyLoss直接支持像素级多分类。它内部先对每个像素做 softmax,再计算交叉熵,目标必须是 LongTensor,不能是 one-hot。遇到类别不平衡,直接默认加权的方式会给背景过大的权重,因为背景像素数量常常是前景的几倍甚至十几倍。常见做法是统计训练集每个类别的像素频率,然后计算中位数频率权重:
import numpy as np import torch.nn as nn def compute_class_weights(mask_dir, split_file, num_classes=21): with open(split_file, 'r') as f: ids = [line.strip() for line in f.readlines()] counts = np.zeros(num_classes, dtype=np.float64) for name in ids: mask_path = os.path.join(mask_dir, name + '.png') mask = np.array(Image.open(mask_path), dtype=np.uint8) counts += np.bincount(mask[mask < num_classes].flatten(), minlength=num_classes) median = np.median(counts[counts > 0]) weights = median / counts.astype(np.float64) weights[counts == 0] = 1.0 return torch.from_numpy(weights.astype(np.float32)) class_weights = compute_class_weights(...).cuda() criterion = nn.CrossEntropyLoss(weight=class_weights, ignore_index=255)逻辑说明:这里用中位数频率权重,而不是直接反比,是为了防止某个极高频类别把权重压得太低,导致训练不稳。ignore_index=255让 VOC 里的边缘和难例区域不参与 loss 计算。如果你的自定义数据集没有 255,把 ignore_index 设成 21 或某个不会出现的数字也行,但要保证 mask 里不会真的出现这个值。
参数说明:weight的长度必须等于类别数,顺序要跟模型输出的通道顺序一样。如果你发现模型一直在预测背景,优先检查权重是否生效,比如输出类别 mean 是否过小。另一个高阶方案是使用 OHEM,也叫在线困难样本挖掘,只回传 loss 最高的 20% 像素的梯度,这种做法在类别不均衡场景下比权重更稳定,但入门项目不必追求,先把权重调好就够了。如果你用的是 DeepLabV3 的 aux_loss,weight参数要同时传给两个损失分支,否则 aux 分支的不平衡问题会带偏早期梯度。
4.3 优化器、学习率调度与训练循环
优化器我建议用 SGD,虽然 Adam 收敛快,但语义分割在 SGD 下跑久一点,mIOU 通常更高。常用的学习率是 0.01,配合多项式衰减,而不是固定的 decay 步长。下面是一个完整的训练循环:
model.cuda() optimizer = torch.optim.SGD(model.parameters(), lr=0.01, momentum=0.9, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.PolynomialLR( optimizer, total_iters=num_epochs, power=0.9) num_epochs = 30 accumulation_steps = 4 # 等效batch_size = 8 * 4 = 32 for epoch in range(num_epochs): model.train() total_loss = 0.0 optimizer.zero_grad() for i, (imgs, masks) in enumerate(train_loader): imgs = imgs.cuda(non_blocking=True) masks = masks.cuda(non_blocking=True) outputs = model(imgs) if isinstance(outputs, dict): out = outputs['out'] else: out = outputs loss = criterion(out, masks) if isinstance(outputs, dict) and 'aux' in outputs: loss += 0.4 * criterion(outputs['aux'], masks) loss = loss / accumulation_steps loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad() total_loss += loss.item() * accumulation_steps scheduler.step() print(f"epoch {epoch}, loss {total_loss / len(train_loader):.4f}")逻辑说明:PolynomialLR会在每个 epoch 后把学习率按power=0.9逐步衰减到接近 0,比线性衰减更适合分割。outputs['out']是主分支,outputs['aux']是辅助分支;aux loss 乘以 0.4 加进总 loss,让前期的梯度信号更丰富。梯度累积的核心是loss = loss / accumulation_steps,然后达到累积步数后再optimizer.step()。注意scheduler.step()在每个 epoch 结束后调用,而不是每个 batch。
参数说明:SGD 的 momentum 固定 0.9,weight_decay 5e-4。如果你的 batch size 不是 8,学习率要做线性缩放:batch size 翻倍,lr 也翻倍,比如 batch size 32 时 lr=0.02。训练 30 个 epoch 在 VOC 上差不多能收敛;如果时间紧,可以只跑 15 个 epoch,mIOU 会有差距但能演示。训练过程中如果 loss 突然变成 NaN,大概率是学习率太大或者输入里有 NaN,先检查数据流。
训练完不要立刻保存模型,要保存权重和 optimizer 状态,方便继续训练和评估。常用:
torch.save({ 'model_state_dict': model.state_dict(), 'optimizer_state_dict': optimizer.state_dict(), }, 'checkpoint.pth')这比单独保存 model.state_dict() 更好,因为你可以在中途恢复训练,而不需要重新初始化学习率。恢复训练时,用torch.load('checkpoint.pth')读进来,再model.load_state_dict(ckpt['model_state_dict'])就继续了。如果你的实验需要对比多个模型,建议每次保存时把 mIOU 和 epoch 记进文件名,避免覆盖掉最好的权重。
5. 避坑指南:语义分割里最常翻车的5个细节
这一章把我在做毕设和课程作业时亲身踩过的坑整理了一遍。下面每一条都按“现象 → 原因 → 解决”来写,你在复现时如果撞上,可以直接对号入座。
5.1 标签错位:mask的类别序号和训练设置不一致
现象:训练 loss 能降,但在验证集上可视化预测图整个乱串,原本是汽车的像素被预测成行人,而且预测颜色整体偏色,mIOU 长时间在 0.3 以下。
原因:最典型的是把 VOC 的分割掩码当 RGB 图片读入,然后又用 0-255 的灰度值当类别号。VOC 掩码的调色板里,某些前景类别索引并不等于 RGB 值,比如类别 1 是汽车,但 RGB 存储可能不是 1。一旦你convert('RGB')后再np.array,数值变成了颜色通道值,类别就漂了。
解决:按 3.2 节的方法,保持Image.open的 'P' 模式,直接np.array(mask, dtype=np.uint8)。如果是从别人代码里继承的数据,先跑一遍np.unique(mask),确认索引范围,比如 VOC 是 0-20,Cityscapes 是 0-33。出现 255 表示 ignore 区域。养成这个检查习惯,比事后调半天参数都省时间。
5.2 图像和mask的resize不同步
现象:训练时 loss 正常,但验证集 mIOU 低得离谱,而且验证 loss 和训练 loss 差别很大。
原因:用 torchvision 的Resize单独处理图片和 mask,图片用了默认双线性插值,mask 没指定interpolation=NEAREST。双线性插值会在掩码边界上产生 1.5、2.7 这样的小数,导致掩码中出现无效的类别标签。
解决:优先用 albumentations 同步处理,它是同一个 transform 作用于 image 和 mask,不会出现插值冲突。如果只能用 torchvision,必须写成transforms.Resize((size,size), interpolation=PIL.Image.NEAREST)用于 mask。另外,在 Dataset 里拿到增广后的 mask 后,做一个mask = mask.round().long()防一手,这属于“后悔药”,能拦截非法标签。不过最好还是从根源上避免。
5.3 类别不平衡:模型只预测背景
现象:训练 loss 一直下降,但预测图里全是背景色,前景物体一个都没标出来,mIOU 在 0.1 以下。
原因:VOC 里背景像素占了绝大多数,交叉熵的梯度被背景类别主导。没有类别权重时,模型最优策略就是“全都猜背景”,因为它这样能让 loss 降到很低。很多课程作业代码不处理权重,最后就是这种结果。
解决:按 4.2 节计算中位数频率权重,加到CrossEntropyLoss的weight参数里。如果你想更快看到效果,也可以在构造 Dataset 时做类别采样,只从包含小物体的图片里抽样本。一个简单的做法是训练时对每张图片随机裁剪,裁剪窗口里如果有目标区域,保留的概率更高;这相当于用数据增强来平衡,效果也不错。至少先检查训练集每个类别的像素占比,把占最大的类别和占最小的类别对比一下。
5.4 GPU显存不足:batch size调不下来
现象:运行训练脚本时,提示CUDA out of memory,把 batch_size 降到 2 还是爆,代码作者又没有给替代方案。
原因:DeepLabV3 在num_classes=21下,每张 512×512 的输入会产生相当大的中间特征图,尤其是残差 block 的 stage4 输出通道数很大。再加上aux_loss会多一份特征,显存翻倍。很多人一上来用 512 尺寸和 num_workers 12,机器跟不上。
解决:按优先级依次处理:先用A.Resize(256,256)把输入降到 256;关掉 aux_loss,也就是训练时model = deeplabv3_resnet50(..., aux_loss=False),少一份辅助分支;再不行就把 DataLoader 的 batch_size 设成 1,用梯度累积来模拟大 batch。代码在 4.3 里已经写了累积循环,这里说下关键参数:accumulation_steps = 4表示 4 个小 batch 才更新一次,显存占用约等于 batch_size 1,但梯度和 batch_size 4 差不太远。另外把pin_memory关掉也可能省一点内存,但影响很小。
5.5 评估mIOU时漏掉ignore_index和背景
现象:验证集 mIOU 看起来很高,比如 0.9,但是可视化预测图明显有很多错误,分数和实际表现对不上。
原因:评估时直接把预测的 argmax 结果和 ground truth 做全局对比,把 255 的边缘区域也算进去了,或者没有排除背景。由于 255 区域不参与训练,模型在这些像素上通常是随机的,如果把它当普通像素计算,会额外引入很多错误或正确命中,导致指标失真。另外,背景类别在很多课题里不算 mIOU,只看前景平均,挨个计算时应该屏蔽掉。
解决:正确计算 mIOU 的做法是逐类别统计confusion matrix,对每个类别 c,计算intersection / union,最后对除背景外的所有类别取平均。计算时要设置一个valid = mask != 255,然后把预测结果和真实值都过滤到这个 valid 区域,否则 255 像素会影响统计。下面这段计算代码可以直接用:
def compute_miou(pred, mask, num_classes=21, ignore_index=255): pred = pred.argmax(dim=1).cpu().numpy().flatten() mask = mask.cpu().numpy().flatten() valid = mask != ignore_index pred = pred[valid] mask = mask[valid] ious = [] for cls in range(num_classes): if cls == 0: # 背景不算,按需调整 continue pred_inds = (pred == cls) gt_inds = (mask == cls) inter = (pred_inds & gt_inds).sum() union = (pred_inds | gt_inds).sum() if union == 0: ious.append(float('nan')) else: ious.append(inter / union) return np.nanmean(ious)注意这里跳过了背景类别,如果你的任务希望把背景也算进去,把if cls == 0去掉即可。对于某些类在验证集没有出现,union == 0时跳过,不参与平均,这是语义分割评估的惯例。另外,pred.argmax(dim=1)要求模型输出已经是(B, C, H, W)的概率图,如果你的模型头输出是(B, H, W, C),记得先转通道顺序。
6. 验证与进阶:计算mIOU,并用可视化结果判断模型好坏
除了数字指标,我强烈建议在验证时同时保存预测可视化图。因为 mIOU 是一个宏观分数,很多小区域被淹没了,比如道路边缘的细线、天空和楼房的交界。可视化能让你直接看出模型在哪些结构上失效。保存可视化结果最简单的方法是把预测的索引图转成 VOC 调色板颜色,再和原图并排保存:
def colorize_mask(mask, palette): color_mask = np.zeros((mask.shape[0], mask.shape[1], 3), dtype=np.uint8) for cls, color in enumerate(palette): color_mask[mask == cls] = color return color_mask然后对验证集跑前向,保存一份pred.png。实际项目里你会发现,loss 降到 0.2 并不等于边界清晰;边缘处的像素本来就在类别之间,这是语义分割的固有难题。想提高分数,可以试试多尺度推理:同一个输入分别用 0.75、1.0、1.5 倍缩放送进模型,把三个概率图双线性采样到原图尺寸再取均值。这个技巧在单个模型上通常能涨 1-2 个点的 mIOU,不用改训练代码。
我自己的习惯是,先跑一批 30 个 epoch 的 baseline,记录 mIOU,然后只改一个变量,比如换 backbone 或加多尺度推理,对比曲线再往下走。毕设那会儿我为了省时间跳过可视化,结果 loss 降得很好看,实际上模型把白色墙面全预测成了天空,那一幕到现在还记得。所以我的教训是:每 5 个 epoch 存一次预测图,花不了几分钟,却能在答辩前拦住最离谱的翻车。希望这些步骤能帮你把场景语义分割的 baseline 稳稳跑起来,再在这个基础上做文章。
本文还有配套的精品资源,点击获取