自己动手训一个语义分割模型,最绕不开的坎就是数据集和结构实现之间的那道缝。市面上的教程不少,但多数只给你一段能跑的代码,换到自己的数据上立刻露馅:标签不对、尺寸对不上、显存爆了、迭代几轮后发现loss是负的。这篇记录是我个人从零开始用PyTorch搭DeepLabv3+,并完整训练自定义数据集的全程复盘,覆盖数据准备、网络搭建、训练配置和常见坑排查,每个环节都给出可复现的细节。照着走一遍,你应该能少走掉我当初踩的大半弯路。
1. 整体设计与思路拆解
1.1 为什么用DeepLabv3+作为分割模型基线
做语义分割,模型的选型会直接决定你在数据处理、训练时间、调优策略上投入多少精力。早期尝试过FCN和U-Net的一个直接感受是:FCN的上下文信息不足,对多尺度目标容易出现破碎预测;U-Net在小数据集上表现不错,但一旦图像语义复杂度上升,它的编码器主干显得不够强。相比之下,DeepLabv3+在编码器-解码器框架上做了很好的折中:编码器用空洞卷积扩大感受野,配合ASPP模块并行采样多尺度特征;解码器把浅层空间信息与深层语义信息融合,修整分割边界。
这个结构对大目标和小目标都能照顾到,而且训练难度相对可控。当时也考虑过PSPNet,金字塔池化模块对场景级分割很有效,但要处理大量自定义类别的细节纹理时,ASPP的扩张卷积采样比池化更灵活。DeepLabv3+另一个优势是PyTorch社区里先例很多,遇到问题容易找到参考,自己从零写也更有把握。如果你手里的是一个几百张到几千张的中小型自定义数据集,DeepLabv3+是一个投入产出比非常高的选择。
1.2 从零搭建的总体路线
整个流程我拆成四件事:数据集准备与增强、骨干网络与ASPP/Decoder实现、训练策略与评估脚本、推理与调优。顺序不能乱,因为每一步的坑会直接影响下一步。先准备好干净有效的标签,再搭建网络,然后用合适的损失与学习率调度去训练,最后用mIoU指标衡量效果并回炉调整。
工具链方面,PyTorch版本用的1.9以上,CUDA环境自行匹配,单卡训练就够了。图像处理用OpenCV和Pillow搭配,数据加载用PyTorch原生Dataset和DataLoader。如果你之前只做过分类任务,这里需要建立起一个转变:分割模型的输出不是单标签,而是一个与输入尺寸相关的类别索引图,所以后续所有数据操作都得围绕“像素级对应关系”展开。
2. 数据集准备:从原始图像到训练加载器
2.1 标注与目录组织
我这次用的是一套自采集的道路场景数据,内容包含五个类别:背景、路面、车辆、行人和标志物。标注工具用的是某开源图像标注软件,导出时选择VOC格式的PNG索引图,每个像素值直接对应类别编号。注意,如果你导出的是带调色板的彩色PNG,训练前必须转成灰度索引图读取,不然模型会把RGB三通道当作三个独立分割目标。
目录结构我做得和PASCAL VOC保持一致,这样后面很多现成脚本可以直接复用:
dataset/ ├── JPEGImages/ ├── SegmentationClass/ └── ImageSets/ └── Segmentation/ ├── train.txt └── val.txtJPEGImages放原图,SegmentationClass放标注图,train.txt和val.txt分别记录不带扩展名的文件名。文件划分上我用了一个随机种子做8:2切分,保证每次运行实验的可重复性。有个细节:原图和标注图的文件名必须严格一致,否则加载时配对会失败,我一开始就是吃了命名不统一的亏。
2.2 Dataset类的关键实现
数据加载这一步,核心是保证原图和标签图在经历同样的预处理后仍然对齐坐标。这里不能简单地把标签图当成普通彩色图片来做归一化,而是要先读取成像素索引格式,再在之后转成long tensor作为交叉熵损失的目标。
一个相对稳妥的写法是:
import cv2 import torch from torch.utils.data import Dataset from PIL import Image import numpy as np class SegDataset(Dataset): def __init__(self, file_list, root, crop_size=513, mode='train'): self.file_list = [line.strip() for line in open(file_list)] self.root = root self.crop_size = crop_size self.mode = mode def __len__(self): return len(self.file_list) def __getitem__(self, idx): name = self.file_list[idx] img = cv2.imread(f'{self.root}/JPEGImages/{name}.jpg') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask = cv2.imread(f'{self.root}/SegmentationClass/{name}.png', cv2.IMREAD_GRAYSCALE) if self.mode == 'train': img, mask = self.random_crop(img, mask) if np.random.rand() > 0.5: img = img[:, ::-1].copy() mask = mask[:, ::-1].copy() else: img = cv2.resize(img, (self.crop_size, self.crop_size)) mask = cv2.resize(mask, (self.crop_size, self.crop_size), interpolation=cv2.INTER_NEAREST) img = img.astype(np.float32) / 255.0 img = (img - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img = torch.from_numpy(img.transpose(2, 0, 1)).float() mask = torch.from_numpy(mask.astype(np.int64)).long() return img, mask def random_crop(self, img, mask): h, w = img.shape[:2] ch = min(self.crop_size, h) cw = min(self.crop_size, w) top = np.random.randint(0, h - ch + 1) if h > ch else 0 left = np.random.randint(0, w - cw + 1) if w > cw else 0 img = img[top:top+ch, left:left+cw] mask = mask[top:top+ch, left:left+cw] if h < self.crop_size or w < self.crop_size: img = cv2.resize(img, (self.crop_size, self.crop_size)) mask = cv2.resize(mask, (self.crop_size, self.crop_size), interpolation=cv2.INTER_NEAREST) return img, mask这里最容易被忽略的一点是mask的resize插值方式。任何时候缩放标签图,都必须用INTER_NEAREST,绝对不能用线性插值或立方插值,否则边界会产生不属于任何类别的中间值。随机裁剪我设置了如果图像比裁剪尺寸小就先resize放大的兜底逻辑,防止因为个别小尺寸图片导致程序中断。
2.3 数据增强策略怎么选才不翻车
数据增强的目标是让模型对光照、视角、遮挡更鲁棒,但分割任务里增强方式必须谨慎。水平翻转、随机缩放、随机旋转小角度、色彩抖动都是常用手段。我实际测试下来,随机缩放配合固定crop_size效果最好,因为多尺度训练能帮助模型适应不同大小的目标。
还有一个很容易踩的坑:颜色归一化的mean和std,必须使用骨干网络预训练时的统计值。如果用自己的数据重新计算mean和std,网络加载预训练权重后会出现严重的分布偏移,收敛速度会慢一大截。ResNet系列的统计值是[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225],直接沿用即可。
3. 模型搭建:骨架、ASPP与Decoder的PyTorch实现
3.1 骨干网络与空洞卷积的配合
DeepLabv3+的骨干我用的是ResNet101。这里的关键在于把标准ResNet的最后两个stage(即stage3和stage4)的stride改成1,然后引入空洞卷积来补偿下采样带来的分辨率损失,而不是直接把最后的全局平均池化接上输出。核心逻辑是:模型输出特征图尺寸为输入尺寸的1/8,然后经过ASPP采样和Decoder恢复空间细节。
具体来说,ResNet101的输出步长默认是32,做语义分割时通常要降到8或16。常见的做法是将stage3的stride从2改成1,同时用dilation=2,stage4的stride从2改成1,dilation=4。这个过程在torchvision提供的现成ResNet101中需要手动调整replace_stride_with_dilation参数,或者在自定义ResNet layer时传入不同的dilation配置。import torchvision.models as models时可以直接用models.resnet101(pretrained=True, replace_stride_with_dilation=[False, True, True]),这样stage3和stage4的stride会被替换掉。
这里需要留意的是,预训练权重是基于ImageNet的图像分类训练的,它的stage层结构和空洞卷积并不冲突,因为空洞卷积只改变了采样间隔,不改变卷积核参数个数。所以加载官方预训练参数是安全的,文件大小大约170MB,如果下载速度不理想可以先离线下载后再加载。
3.2 ASPP模块的完整代码与设计思路
ASPP(Atrous Spatial Pyramid Pooling)的作用是在同一个特征图上并行用不同采样率的空洞卷积,捕捉不同尺度的上下文。编码器输出的特征图经过这几个分支后拼接在一起,最后用一个1x1卷积压缩通道数。
各分支的设计如下:
import torch import torch.nn as nn import torch.nn.functional as F class ASPP(nn.Module): def __init__(self, in_channels=2048, out_channels=256): super(ASPP, self).__init__() self.conv1 = nn.Conv2d(in_channels, out_channels, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_channels) self.conv2 = nn.Conv2d(in_channels, out_channels, 3, dilation=6, padding=6, bias=False) self.bn2 = nn.BatchNorm2d(out_channels) self.conv3 = nn.Conv2d(in_channels, out_channels, 3, dilation=12, padding=12, bias=False) self.bn3 = nn.BatchNorm2d(out_channels) self.conv4 = nn.Conv2d(in_channels, out_channels, 3, dilation=18, padding=18, bias=False) self.bn4 = nn.BatchNorm2d(out_channels) self.global_avg = nn.AdaptiveAvgPool2d(1) self.conv5 = nn.Conv2d(in_channels, out_channels, 1, bias=False) self.bn5 = nn.BatchNorm2d(out_channels) self.project = nn.Sequential( nn.Conv2d(out_channels * 5, out_channels, 1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, x): b, c, h, w = x.size() x1 = F.relu(self.bn1(self.conv1(x))) x2 = F.relu(self.bn2(self.conv2(x))) x3 = F.relu(self.bn3(self.conv3(x))) x4 = F.relu(self.bn4(self.conv4(x))) x5 = self.global_avg(x) x5 = F.relu(self.bn5(self.conv5(x5))) x5 = F.interpolate(x5, size=(h, w), mode='bilinear', align_corners=True) x = torch.cat([x1, x2, x3, x4, x5], dim=1) return self.project(x)dilation取值6、12、18是我测试后觉得比较稳定的组合。如果输入图像分辨率比较大,dilation可以适当上调,例如8、16、24,这取决于目标尺度分布。global_avg分支相当于一个全局上下文池化,它能把整幅图像的语义信息压缩成一小块,再上采样回原尺寸与局部分支融合。首次写这块时容易漏掉padding参数,结果就是输出特征图尺寸比输入小,拼起来维度对不上。
3.3 Decoder:让边界回来
编码器的输出分辨率是输入的1/8,如果直接上采样回原尺寸,边界会非常糊。DeepLabv3+的做法是引入一个轻量级Decoder:取骨干网络stage2输出的低层特征(分辨率是输入的1/4),经过1x1卷积压缩到48通道,再与编码器输出上采样4倍后的特征在通道维度上拼接,随后经过3x3卷积和两次上采样恢复分辨率。
这里为什么低层特征要压到48通道而不是保留原始256或512通道?原因是低层特征虽然含有丰富的空间细节,但语义信息弱,通道数过多会让模型训练时低层信息占据主导,同时增加显存消耗。48通道是一个折中值,原论文实验也验证了在48附近效果最好。
class Decoder(nn.Module): def __init__(self, low_level_channels=256, num_classes=5): super(Decoder, self).__init__() self.low_level_conv = nn.Sequential( nn.Conv2d(low_level_channels, 48, 1, bias=False), nn.BatchNorm2d(48), nn.ReLU(inplace=True) ) self.conv3x3 = nn.Sequential( nn.Conv2d(256 + 48, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(inplace=True), nn.Conv2d(256, 256, 3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(inplace=True) ) self.classifier = nn.Conv2d(256, num_classes, 1) def forward(self, encoder_feat, low_level_feat): low_level_feat = self.low_level_conv(low_level_feat) encoder_feat = F.interpolate(encoder_feat, size=low_level_feat.size()[2:], mode='bilinear', align_corners=True) x = torch.cat([encoder_feat, low_level_feat], dim=1) x = self.conv3x3(x) x = self.classifier(x) return F.interpolate(x, scale_factor=4, mode='bilinear', align_corners=True)有个细节值得强调:Decoder中的上采样建议使用align_corners=True,这能保证与标注图的对齐方式一致,虽然从数值上不会产生巨大差异,但在严格评估mIoU时会避免少数像素偏移导致的精度波动。
4. 训练配置与优化策略
4.1 训练主循环的搭建细节
训练脚本的整体结构其实不复杂,但每个环节的细节都需要对齐。我采用的是VOC标准训练方式,一个epoch内对每张图随机裁剪与增强,然后批量送入模型。下面是我用来稳定收敛的训练循环骨架:
dataloader = DataLoader(train_set, batch_size=8, shuffle=True, num_workers=4, drop_last=True) model = DeepLabV3Plus(num_classes=5, backbone='resnet101') model.cuda() model.train() criterion = nn.CrossEntropyLoss(ignore_index=255) optimizer = torch.optim.SGD(model.parameters(), lr=0.007, momentum=0.9, weight_decay=0.0001) total_iters = len(dataloader) * epochs current_iter = 0 for epoch in range(epochs): for batch_idx, (images, labels) in enumerate(dataloader): images, labels = images.cuda(), labels.cuda() outputs = model(images) loss = criterion(outputs, labels) optimizer.zero_grad() loss.backward() optimizer.step() lr = base_lr * (1 - current_iter / total_iters) ** 0.9 for param_group in optimizer.param_groups: param_group['lr'] = lr current_iter += 1batch_size这里写的是8,如果你的是16或32,请按比例调低初始学习率,否则容易在训练前几个step就发散。我在单卡16GB显存下,crop_size=513,batch_size=8,ResNet101骨干刚好能跑。
4.2 损失函数与类别不均衡的处理
分割任务的损失函数首选还是交叉熵。但自定义数据集里类别分布经常非常不均衡。在我这份道路数据里,背景像素数量远超车辆和行人,如果不做任何处理,模型会把所有区域都预测为背景,最终mIoU却显得不算太差,这是因为背景占了大多数像素。
解决思路有两层。第一层是ignore_index,只在标签里把不确定区域设为255并忽略它们。第二层是给每个类别的交叉熵加权,权重可以按类别像素占比的倒数来算,也可以用中值频率平衡。我这里使用的方案是先统计每个类别的像素比例,然后构造权重向量:
import torch.nn.functional as F def compute_class_weights(mask_paths, num_classes): counts = [1] * num_classes for p in mask_paths: m = cv2.imread(p, cv2.IMREAD_GRAYSCALE) for c in range(num_classes): counts[c] += int((m == c).sum()) total = sum(counts) weights = [total / (num_classes * c) for c in counts] return torch.tensor(weights, dtype=torch.float32)这类权重能让少数类别在损失中的贡献变大,但前提是标注数据足够可靠。如果某些类别标签噪声很大,过高的权重反而会放大错误监督信号,这时需要结合人工抽检结果来平衡。
4.3 mIoU评估脚本:不能只看loss曲线
训练过程中loss下降未必代表分割质量好,尤其是类别不平衡严重时。最好每个epoch结束时在验证集上算一次mIoU。IoU的定义是某一类别的交集和并集之比:IoU = TP / (TP + FP + FN),mIoU是所有类别IoU的算术平均。
具体实现时,我维护一个混淆矩阵,每批次累加预测结果和真实标签的统计关系:
def compute_miou(confusion_matrix): iou_list = [] for i in range(confusion_matrix.shape[0]): tp = confusion_matrix[i, i] fp = confusion_matrix[i, :].sum() - tp fn = confusion_matrix[:, i].sum() - tp union = tp + fp + fn if union == 0: iou_list.append(float('nan')) else: iou_list.append(tp / union) return np.mean([x if not np.isnan(x) else 0 for x in iou_list]) def update_confusion_matrix(pred, label, num_classes): mask = (label >= 0) & (label < num_classes) hist = np.zeros((num_classes, num_classes)) p = pred[mask] t = label[mask] hist += np.bincount(t * num_classes + p, minlength=num_classes ** 2).reshape(num_classes, num_classes) return hist这里很容易犯的错是把预测结果直接传成概率图,必须用torch.argmax(output, dim=1)先把logits转成类别索引,再移到CPU转NumPy计算。另一个细节是评估时的图像尺寸,验证集一般也需要先缩放到固定尺寸再送入模型,不能直接使用原图尺寸,否则batch内形状不一致。
4.4 学习率调度和训练轮数经验值
我用的学习率调度是poly策略,即lr = base_lr * (1 - iter / total_iter) ** 0.9,比固定学习率或StepLR收敛得更平滑,因为分割模型一般需要尽量充分地探索后期的小学习率区间。base_lr这个值的选取和batch_size直接相关,原论文在batch=16的情况下用0.007,但那是基于多卡和较深骨干。单卡batch=8时,base_lr设成0.001到0.005之间比较稳,我最终用0.003。
训练轮数常规设置是60到80个epoch左右。如果你的数据集规模只有几百张,更早的监控频率会更有用,例如每三到五个epoch验证一次即可。太频繁的验证会打断训练节奏,而且验证集样本变化带来的波动会误导调参判断。
5. 常见问题与排查技巧实录
5.1 训练后全黑或全同一色输出
这个情况出现在我第一次跑完20个epoch去可视化预测图的时候,模型几乎把整幅图都预测成背景。排查思路从几个方向入手:先看单batch的loss是否正常下降,再看验证集的mIoU曲线。如果loss正常但mIoU几乎为零,多半是标签映射出错了,比如标注图的像素值范围是0-4,但normalize时不小心除以了255,导致所有标签都变成了0或1。
还有可能是背景类别在数据集中占比太高,模型陷入局部最优。解决办法是给背景类降权,或者对背景类做随机下采样,让训练时每次batch中出现的前景像素比例相对提高。另外,模型最后一层默认输出形状是[N, num_classes, H, W],如果num_classes和实际类别数不一致,可视化时自然对不上。
5.2 loss变成NaN的典型原因
NaN大多出现在网络刚初始化以后,也可能出现在训练中途。一种常见触发点是学习率过大,尤其是SGD配合poly策略在初期迭代还没衰减时,如果设置的base_lr超过0.01,很容易爆炸。另一种可能是数据里面有异常标注图,比如某个文件是灰度模式但像素值是负值,或者读入时channel顺序不对,导致输入包含异常值。
我的排查办法是训练脚本里加一个小开关,每隔一定迭代数打印输入图像的mean/std和当前loss值。如果输入图片出现全零或全255的数据,直接跳过该样本。另外,如果使用混合精度训练,也需要检查梯度缩放器是否正常工作,不过我这次用的是单精度训练,稳定性和可复现性都更好些。
5.3 如何判断是欠拟合还是数据噪声大
训练到中后期,经常出现验证集mIoU波动很大,停在某个数值上不去。这时候我会先减少数据增强强度,只保留翻转和随机缩放,看看mIoU是否反而上升。如果是,说明增强带来的样本分布扰动过大,模型还没学到足够稳健的特征。
再一个常用手段是把某些置信度极低的样本挑出来,在训练结束后用模型预测一遍训练集,把预测错误且IoU低的单张图拿出来看标签和原图。很多时候会发现标签根本没对齐,比如标注时把车辆和行人画进了同一个轮廓里,这时单纯调参没有意义,得回标注端修改。这类脏数据对分割模型的影响远大于分类模型。
6. 推理可视化与后续扩展建议
6.1 推理脚本要点
训练完成后的推理阶段,把模型设为eval模式,关闭梯度计算,输入图像同样做resize和归一化,然后取argmax得到类别索引图。这里要注意的是推理时的resize必须和训练时保持一致的目标尺寸,不能随意改变,否则模型性能会下降。
推理脚本里我还会做一步简单的后处理:去除过大过小的独立区域,使用连通域分析过滤掉面积小于某个阈值的噪声预测块。这个对道路场景尤其有效,因为单个像素被误判为车辆或行人的碎片会被清掉。阈值选多少要看具体类别,我这边设成20像素,既能滤掉噪声又不影响真实小目标。
6.2 模型导出与部署方向
如果后续要部署到服务端或者移动端,可以把训练好的权重转成ONNX。转换时需要注意PyTorch中F.interpolate的align_corners参数和输入动态尺寸,ONNX导出时要固定输入分辨率,或者设置动态轴。我实际试过用动态轴导出,推理速度和易用性整体都不错,但某些版本的ONNXRuntime对AdaptiveAvgPool2d的导出支持有限,需要先调用一次model.eval()并用假数据跑一次前向来固化图结构。
如果想要进一步提升分割效果,可以尝试把骨干从ResNet101换成MobileNetV3-Large,精度降低不多,但推理速度会快很多,适合边缘设备。另一个成本更低的方向是接入数据增强中的多尺度预测,推理时对同一张图使用三个不同尺度输入,把概率图平均后再做argmax,通常能带来1到2个mIoU点的提升。
动手实操中的几点真实体会
反复调试这套流程之后,我最大的感受是分割模型的代码量并不大,真正的工程量在数据与训练细节。网上很多现成项目仓库虽然能跑通VOC,但换到自定义数据上总会出现各种兼容问题。写这套实现的时候,我尽量把网络结构写清楚了,Dataset部分也保留了足够的可修改空间,方便你替换成自己的标注格式。
如果你准备动手,先把整个流程跑通一次,哪怕类别只有两三类,也比一开始就挑战多类别要稳。确认训练曲线和mIoU正常后,再逐步增加类别数量和数据增强强度。这套DeepLabv3+的样板之后还能继续迁移到遥感分割、医疗影像等多个方向,希望你也能在迁移中把参数调出自己的手感。