PyTorch UNet多类别语义分割实战:数据管道、训练评估与滑窗推理
简介:面向具备一定深度学习基础、希望在 PyTorch 中完成多类别语义分割实践的开发者,这份工程代码围绕 U-Net 的编码器—解码器结构、跳跃连接与多通道输出组织,可用于医学影像、遥感图像等像素级分类任务的学习、复现与二次开发。压缩包共 46 个文件,约 69KB,以 19 个 py 源码和 24 个 pyc 缓存文件为主,另含 2 个 txt 与 1 个 json 说明;模块覆盖数据加载与自定义变换、U-Net 模型定义、损失函数、评价指标、学习率调度、训练与演示脚本、日志曲线及结果保存,工程结构较完整。已有 15287 人学习下载,适合通过现成代码理解多类别分割从数据预处理、模型搭建到训练评估的完整链路,并在此基础上替换自有数据集、调整类别数与训练参数,理解各模块调用关系,积累实验对比和排错经验。
1. 自建多类别数据集跑 UNet:先看清标签长什么样
很多人第一次把 PyTorch + UNet 语义分割这套组合搬到自己标注的数据集上,卡住的往往不是网络结构,而是标签:loss 明明在降,推理出来却只有一片背景色。多类别语义分割和二分类的分水岭就在这一步——网络输出从 1 通道变成 C 通道,标签从 0/1 浮点图变成 0..C-1 的索引图,损失函数从 BCE 换成带 ignore_index 的交叉熵,任何一环按二分类习惯写,都是静默出错,不报错但结果不对。
这套工程解决的就是把整条链路打通:标注格式约定、图像与掩码同步增强、UNet 多类别输出头、mIoU 与 Dice 评估、推理可视化,外加显存吃紧时怎么砍。适合手里已经有几百到几千张标注图、想先跑通一版可信基线、再谈调参和换主干的从业者;也适合把别人的二分类 UNet 代码改到多类别、改完发现指标不动的人。
2. 数据管道:把彩色标注图变成可训练的掩码张量
多类别分割的成败有一大半在数据管道上。图像是 RGB 三通道,掩码在硬盘上却通常是三通道彩色 PNG(标注工具按调色板存),送进网络前必须变成单通道的类别索引图。这一步如果偷懒,后面所有指标都不可信。
2.1 调色板映射与单通道索引图
主流标注工具导出的掩码是“伪彩色图”,每个类别对应一个 RGB 值,像素值本身没有大小含义。转成索引图的常见做法是建一张 RGB 到类别 id 的查找表:
import numpy as np from PIL import Image # 键是标注工具里的 RGB,值是训练用的类别索引,必须和类别表逐字对应 PALETTE = { (0, 0, 0): 0, # 背景 (128, 0, 0): 1, # 类别 A (0, 128, 0): 2, # 类别 B (128, 128, 0): 3, # 类别 C (0, 0, 128): 4, # 类别 D } IGNORE = 255 # 未标注/边界像素,不参与 loss def rgb_mask_to_index(mask_path, palette=PALETTE, ignore=IGNORE): rgb = np.array(Image.open(mask_path).convert("RGB")) index = np.full(rgb.shape[:2], ignore, dtype=np.uint8) for color, cls_id in palette.items(): hit = np.all(rgb == np.array(color, dtype=np.uint8), axis=-1) index[hit] = cls_id return index逻辑上先整图填 255,再逐色覆盖命中的像素,没匹配上的颜色自动留在 ignore 区,不会被当成背景硬塞进 loss。参数说明两点:PALETTE必须和标注工程的类别表完全一致,差一个 RGB 值就整类丢失;IGNORE选 255 是因为它超出所有合法类别 id,交叉熵用ignore_index=255能直接跳过,换成 0 会把未标注像素算成背景,模型学会“拿不准就预测背景”。
这里有个血泪经验的坑:不要图省事写Image.open(mask).convert("L")。转灰度是按加权公式算的,(128,0,0)和(0,128,0)转完可能是同一个灰度值,两类直接合并,而且不报错。如果标注工具本身支持导出单通道索引 PNG,那就跳过这段,直接读成uint8即可,但要在可视化时用同一张调色板还原,否则肉眼看图会以为标错了。
2.2 Dataset 与同步增强
增强必须让图像和掩码走同一组随机参数,否则模型学的是错位对应关系。用 albumentations 这类支持双输入的库最省事,关键是掩码要以uint8单通道传进去:
import cv2 import torch from torch.utils.data import Dataset import albumentations as A from albumentations.pytorch import ToTensorV2 train_tf = A.Compose([ A.RandomResizedCrop(size=(512, 512), scale=(0.5, 1.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)), ToTensorV2(), ]) class SegDataset(Dataset): def __init__(self, pairs, tf): self.pairs = pairs # [(img_path, mask_path), ...] self.tf = tf def __len__(self): return len(self.pairs) def __getitem__(self, i): img_path, mask_path = self.pairs[i] image = cv2.cvtColor(cv2.imread(img_path), cv2.COLOR_BGR2RGB) mask = rgb_mask_to_index(mask_path) # uint8, HxW out = self.tf(image=image, mask=mask) return out["image"], out["mask"].long() # 交叉熵要求 int64逻辑说明:RandomResizedCrop同时裁图和掩码,HorizontalFlip同步翻转,归一化只作用在图像上(掩码不是浮点数据,不参与)。参数说明:size=(512,512)要和显存放得下的 batch 匹配,512 配 8 张大约占 8~10 GB;scale=(0.5,1.0)控制裁剪比例,小目标多的数据集别开太小,否则一轮里目标被裁没的概率很高;mask传进去必须是uint8的二维数组,库会按最近邻处理,如果误传成float32,它按双线性插值算,掩码里会冒出 1.5、2.3 这种不存在的类别,loss 不报错但语义已经错乱。
验证集只保留Resize+Normalize,不要加翻转和色彩扰动。有人为了“验证更充分”在 val 上开翻转,结果 mIoU 来回抖,找不到模型到底好在哪。
2.3 DataLoader 参数与吞吐权衡
数据管道的第二个瓶颈是喂数据速度。多类别分割的掩码解码比分类任务重,num_workers给不够时 GPU 利用率长期在 30% 以下,加 batch 也没用。
| 参数 | 建议值 | 说明 |
|---|---|---|
batch_size | 4~16 | 按 512×512、显存 12 GB 起步试 8 |
num_workers | CPU 核数的 1/4~1/2 | 每个 worker 会复制一份数据集索引,别超过 16 |
pin_memory | True | 配合non_blocking=True做异步拷贝 |
persistent_workers | True | 避免每个 epoch 重建进程,小数据集上差别明显 |
drop_last | 训练 True | 避免最后一批只有 1 张,BN 统计不稳 |
persistent_workers=True要和num_workers>0一起用,否则无效。如果在 Windows 或某些容器里起多进程报BrokenPipeError,先把num_workers降到 0 定位问题,再逐级加回来;这类问题多半是 Dataset 里放了不可序列化的对象(比如打开的文件句柄、CUDA tensor),把路径和索引留在 Dataset、把重活放在__getitem__里是更稳的写法。
3. UNet 结构落地:编码器、解码器与多类别输出头
UNet 的结构本身不复杂,翻车点集中在三处:上采样后的尺寸对齐、跳连的通道拼接、以及输出头到底给几个通道。多类别场景下第三点尤其容易错,写成num_classes-1配 sigmoid 的人不在少数。
3.1 双卷积块与下采样路径
编码器用两组 3×3 卷积加 BN 加 ReLU,每级之间 2×2 最大池化。选最大池化而不是步长卷积,是因为分割任务更看重保留显著响应位置,池化把局部最强响应往下传,对小目标召回更友好;代价是位置精度损失,靠后面的跳连补回来。
import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x)bias=False是因为后面紧跟 BN,偏置会被减掉,纯属浪费参数;inplace=True的 ReLU 省一点显存,但如果后面要做特征可视化就别开,原地操作会改掉输入。
3.2 上采样与跳连的尺寸对齐
解码器每级先上采样 2 倍,再和编码器同层特征在通道维拼接。标准 UNet 用转置卷积做上采样,好处是有可学习参数,坏处是当输入尺寸不是 32 的整数倍时,下采样再上采样会差 1 个像素,torch.cat直接抛尺寸不匹配。与其去改输入尺寸,不如在Up里显式对齐:
class Up(nn.Module): def __init__(self, in_ch, skip_ch, out_ch): super().__init__() self.up = nn.ConvTranspose2d(in_ch, in_ch // 2, kernel_size=2, stride=2) self.conv = DoubleConv(in_ch // 2 + skip_ch, out_ch) def forward(self, x, skip): x = self.up(x) # 奇数尺寸时上下采样会差 1 像素,强制对齐到跳连特征的空间尺寸 if x.shape[-2:] != skip.shape[-2:]: x = F.interpolate(x, size=skip.shape[-2:], mode="bilinear", align_corners=False) return self.conv(torch.cat([skip, x], dim=1))参数说明:in_ch // 2让上采样后的通道数和同级编码器对齐,skip_ch是跳连过来的通道数,两者相加才是DoubleConv的输入。用bilinear兜底而不是再插一层转置卷积,是因为这个分支只在尺寸差 1 时触发,用固定插值不会引入额外参数,也不会让 BN 统计在小 batch 上飘。我一般会固定用 512×512 的输入避开这个问题,但推理时遇到长条图、原图不规整,这段兜底就是后悔药。
主干拼起来:
class UNet(nn.Module): def __init__(self, in_ch=3, num_classes=5, base=64): super().__init__() c = [base, base * 2, base * 4, base * 8, base * 16] self.inc = DoubleConv(in_ch, c[0]) self.down1 = nn.MaxPool2d(2); self.e1 = DoubleConv(c[0], c[1]) self.down2 = nn.MaxPool2d(2); self.e2 = DoubleConv(c[1], c[2]) self.down3 = nn.MaxPool2d(2); self.e3 = DoubleConv(c[2], c[3]) self.down4 = nn.MaxPool2d(2); self.e4 = DoubleConv(c[3], c[4]) self.up1 = Up(c[4], c[3], c[3]) self.up2 = Up(c[3], c[2], c[2]) self.up3 = Up(c[2], c[1], c[1]) self.up4 = Up(c[1], c[0], c[0]) self.head = nn.Conv2d(c[0], num_classes, kernel_size=1) def forward(self, x): x1 = self.inc(x) x2 = self.e1(self.down1(x1)) x3 = self.e2(self.down2(x2)) x4 = self.e3(self.down3(x3)) x5 = self.e4(self.down4(x4)) x = self.up1(x5, x4) x = self.up2(x, x3) x = self.up3(x, x2) x = self.up4(x, x1) return self.head(x) # 返回 logits,不接 softmaxbase=64是最小宽度,显存紧张时降到 32,参数量降到约 1/4,mIoU 一般掉 1~3 个点,先跑通再往上加是更务实的顺序。
3.3 输出头通道数与损失函数选型
输出头给num_classes个通道,返回 logits,不要在模型里接 softmax——nn.CrossEntropyLoss内部自带 log_softmax,重复做一次会让梯度变小,训练慢得像没在学。
| 方案 | 适用场景 | 问题 |
|---|---|---|
num_classes通道 + CrossEntropyLoss | 多类别互斥,标准做法 | 类别极不平衡时需要加权 |
| 1 通道 + BCEWithLogitsLoss | 多标签重叠,或纯二分类 | 多类别互斥场景下会互相打架 |
num_classes-1通道 + sigmoid | 只在特定多标签设定下合理 | 大多数分割任务直接错 |
类别不平衡几乎必然遇到,背景占 80% 以上时模型很快学会全预测背景。两种补法,一是统计像素频率算权重,二是换 Dice 或 Tversky 类损失:
def compute_class_weight(loader, num_classes, ignore_index=255, max_ratio=20.0): counts = np.zeros(num_classes, dtype=np.int64) for _, mask in loader: m = mask.numpy() for c in range(num_classes): counts[c] += int((m == c).sum()) freq = counts / np.maximum(counts.sum(), 1) w = 1.0 / np.log(1.01 + freq) # 对数平滑,避免权重爆炸 w = np.clip(w / w.mean(), 1.0 / max_ratio, max_ratio) return torch.tensor(w, dtype=torch.float32) criterion = nn.CrossEntropyLoss(weight=class_weight.cuda(), ignore_index=255)1/log(1.01+freq)比直接用1/freq温和得多,后者在极稀有类上会算出上千的权重,一两个噪声像素就能把训练拉崩。max_ratio限制最大最小权重比在 20 倍以内,是我踩过“权重过猛导致小类过分割”之后加的硬约束。ignore_index=255必须和标签生成时的忽略值一致,这是最容易前后不一致的参数。
4. 训练循环与 mIoU:让指标能读出问题
多类别分割的训练循环本身和分类差不多,差别在评估。用 accuracy 看分割基本没意义——全预测背景也能到 80%。必须上混淆矩阵算 mIoU,才能看出到底是哪一类在拖后腿。
4.1 训练骨架与混合精度
def train_one_epoch(model, loader, optimizer, scaler, criterion, device, max_norm=1.0): model.train() total = 0.0 for img, mask in loader: img = img.to(device, non_blocking=True) mask = mask.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with torch.autocast(device_type="cuda", dtype=torch.float16): logits = model(img) loss = criterion(logits, mask) scaler.scale(loss).backward() scaler.unscale_(optimizer) # 先反缩放再裁剪,否则裁剪阈值失效 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=max_norm) scaler.step(optimizer) scaler.update() total += loss.item() * img.size(0) return total / len(loader.dataset)三个顺序不能乱:scaler.scale之后梯度被放大了,必须先unscale_再clip_grad_norm_,否则裁剪的是放大后的梯度,等于没裁;scaler.step内部会在检测到 inf/nan 时跳过这一步,所以不需要自己判;scaler.update动态调整缩放系数。max_norm=1.0对 UNet 够用,遇到 loss 尖刺可以降到 0.5。注意torch.autocast里的前向是 fp16,但 BN 和 loss 会自动保持 fp32,别手动把输入转半精度,那会让 BN 统计出问题。
4.2 混淆矩阵与 mIoU / Dice
import numpy as np class ConfusionMatrix: def __init__(self, num_classes, ignore_index=255): self.n = num_classes self.ignore = ignore_index self.mat = np.zeros((num_classes, num_classes), dtype=np.int64) def update(self, logits, target): pred = logits.argmax(dim=1).detach().cpu().numpy().astype(np.int64) target = target.detach().cpu().numpy().astype(np.int64) valid = (target != self.ignore) & (target >= 0) & (target < self.n) idx = target[valid] * self.n + pred[valid] self.mat += np.bincount(idx, minlength=self.n ** 2).reshape(self.n, self.n) def miou(self): inter = np.diag(self.mat) union = self.mat.sum(0) + self.mat.sum(1) - inter iou = inter / np.maximum(union, 1) return float(iou.mean()), iou def dice(self): inter = np.diag(self.mat) denom = self.mat.sum(0) + self.mat.sum(1) return float((2 * inter / np.maximum(denom, 1)).mean())valid掩码同时过滤 ignore 和越界值,前者来自未标注像素,后者来自数据里混进的脏值。np.bincount比双重循环快一个数量级,几百张验证图秒级出结果。返回的逐类 IoU 一定要打出来看,只报平均值等于把问题藏起来——平均 mIoU 0.55 可能是 4 类 0.68 加 1 类 0.02,那类的数据量或权重才是要动的地方。注意分母用np.maximum(union, 1)防止某类在验证集里完全没出现时除零,那种情况下该类的 IoU 应该被排除在均值外,而不是当 0 拉低整体。
4.3 调度、早停与检查点策略
学习率调度用ReduceLROnPlateau配mode="max"盯验证 mIoU,或者CosineAnnealingLR走完整周期。前者适合不确定要训多久的场景,后者适合能跑满固定 epoch 的场景。
检查点按验证 mIoU 存,不要按 loss 存。loss 更低但 mIoU 更差的模型很常见,尤其是加了类别权重之后——权重把 loss 拉向小类,平均 IoU 未必同步上涨。早停的 patience 给 10~15 个 epoch,分割任务的指标本来就有波动,patience 太小学到的是噪声。
日志里至少记四项:train loss、val loss、val mIoU、逐类 IoU。前两项看收敛,后两项看泛化和小类表现。如果 val loss 降但 val mIoU 也降,检查是不是 ignore 区的预测变多了。
5. 排查清单:多类别 UNet 最容易翻车的五处
下面五条是这套代码在自建数据集上最常遇到的,按“现象 → 原因 → 解决”写,对照自己的日志能快速定位。
5.1 标签与增强上的三处静默错误
第一条:loss 稳定下降,但推理图几乎全是一个颜色,mIoU 卡在 0.2 左右。原因通常是标签映射错了——调色板的 RGB 和类别表不对应,或者convert("L")把不同类别压成同一个灰度值,模型实际在学一个二分类任务。解决:从训练集随机抽 10 张,把索引图按调色板还原成彩色,和原始标注图逐像素比对,确认损失为 0;再统计每个类别的像素总数,某一类为 0 就说明映射漏了。
第二条:训练中途报Target x is out of bounds,或者 loss 突然变 nan。原因是增强对掩码做了插值,产生了 1.5、2.3 这类非整数值,转long之后变成越界或错误类别。解决:确认掩码以uint8单通道传入增强库,验证集只用Resize;在 Dataset 里加一句断言assert mask.max() < num_classes or mask.max() == 255,让问题在数据层就暴露,而不是几十轮之后炸在 loss 里。
第三条:训练集 mIoU 0.9,验证集 0.4,gap 大得离谱。除了过拟合,更常见的是训练和验证的预处理不一致——训练用了RandomResizedCrop加归一化,验证忘了归一化,或者验证的输入尺寸和训练差太多。解决:把预处理配置写成一个函数,训练和验证共用同一份归一化参数,只切换几何增强部分;然后拿一张训练图直接走验证管道推理,看结果是否和训练时一致。
5.2 显存与训练稳定性上的两处翻车
第四条:nvidia-smi显示显存还有富余,但 batch_size 加大就 OOM。峰值显存出现在反向前后,而不是前向结束那一刻;另外评估阶段忘了torch.no_grad(),验证集的激活值全留着,显存直接翻倍。解决:验证函数加@torch.no_grad(),训练时用optimizer.zero_grad(set_to_none=True)释放旧梯度,开 AMP 后显存一般能再省 30% 左右。
第五条:训到二三十轮,loss 突然变成 nan,之后再也不恢复。三个高频原因:学习率偏大(1e-3 配 Adam 在 UNet 上经常过冲)、没有梯度裁剪、类别权重设得太极端导致某一类梯度爆炸。解决:Adam 起步用 1e-4,加clip_grad_norm_(max_norm=1.0),类别权重限制在 20 倍以内;如果已经出 nan,从最近一个正常检查点恢复,别指望它自己爬回来。
6. 推理与验证:滑窗预测和一张图的完整走查
线上图往往比训练尺寸大得多,直接整图送进网络要么 OOM,要么缩放后小目标糊掉。我一般用滑窗推理:按固定窗口切块、重叠区取平均、再拼回原图。
@torch.no_grad() def sliding_window_predict(model, image, num_classes, window=512, stride=384, device="cuda"): model.eval() h, w = image.shape[:2] prob = np.zeros((num_classes, h, w), dtype=np.float32) count = np.zeros((h, w), dtype=np.float32) for y in range(0, max(h - window, 0) + stride, stride): for x in range(0, max(w - window, 0) + stride, stride): y0, x0 = min(y, max(h - window, 0)), min(x, max(w - window, 0)) patch = image[y0:y0 + window, x0:x0 + window] tensor = torch.from_numpy(patch).permute(2, 0, 1).float().unsqueeze(0).to(device) logits = model(tensor) p = torch.softmax(logits, dim=1)[0].cpu().numpy() prob[:, y0:y0 + window, x0:x0 + window] += p count[y0:y0 + window, x0:x0 + window] += 1 return prob / np.maximum(count, 1) # 重叠区概率平均window=512和训练尺寸保持一致,别在推理时换尺寸,BN 统计对不上;stride=384是窗口的 3/4,重叠 25%,接缝处的跳变基本消失,再小就纯浪费算力。边界处理用min(y, max(h-window, 0))把最后一块贴边,避免补零引入假特征。拿到概率图后argmax(axis=0)得索引图,再按调色板还原成彩色,叠到原图上做目视检查——这一步比看 mIoU 更能发现问题,比如某类边缘系统性外扩,往往是训练时该类的边界像素被大量标成 ignore。
验证顺序我固定成三步:先在一张训练图上推理,结果必须接近完美,否则是管道 bug 不是模型问题;再在验证集上跑混淆矩阵,确认逐类 IoU 没有 0;最后挑 5 张最差样本看图,判断是标注错、目标太小、还是类别混淆。这三步走完再决定要不要换主干或加损失。从那以后我每次改数据管道,都强制先用一张图跑通“读图 → 推理 → 上色 → 叠加”全流程再开训,省下来的 GPU 时间远比这几分钟多。这份工程的目录按数据、模型、训练、推理分开,对着自己的数据集改掉PALETTE和路径就能跑,希望帮到你。
本文还有配套的精品资源,点击获取