简介:针对腹部多脏器五类别分割任务,这是一套完整的Unet多尺度实战项目,包含数据集、训练与预测代码,面向深度学习初学者及医学图像处理研究者。项目实现了随机缩放零点五至一点五倍的多尺度训练,自动依据掩膜灰度值生成类别通道,所有预处理函数均重新实现。网络训练五十轮,平均交并比约零点七二,学习率采用余弦衰减,结果目录中附有训练日志、最佳权重以及损失和交并比曲线图,日志内可查看每个类别的交并比、召回率、精确率及全局像素准确率。资源共一千零二十个文件,以九百九十张PNG图像为主,另有Python脚本、配置文本、权重文件及说明文档,压缩包约一百二十八兆。已有近两千人学习,预测脚本可一键推理全部图片,代码注释完整,参照README即可训练自定义数据。
1. 深度学习 Unet 实战分割项目:这套腹部多脏器五类别分割是怎么落地跑通的
去年我接了个腹部 CT 影像辅助标注的活儿,甲方给的要求很直接:不是要一篇原理讲得天花乱坠的论文,而是要一个能跑、能改、能出分割结果的 Unet 工程。我翻了不少开源仓库,绝大多数要么是单类别分割演示,要么数据集太小训完过拟合得没法看。直到我拿到这套「Unet 实战分割 + 多尺度训练 + 腹部多脏器五类别分割数据集」,才算是把「从数据到模型再到推理」这条链路完整打通了。这套资源解决的核心问题很具体:如何用 Unet 在腹部 CT 上同时分割肝脏、左右肾、脾脏和背景一共五个类别,并且通过多尺度输入缓解小器官(比如肾脏)在深层网络中特征丢失的问题。适合刚做完 Unet 基础复现、正愁没有像样医学数据集练手的研究生,也适合想把分割模型快速迁移到自有 CT 数据集的算法工程师。
2. 先看数据再做模型:腹部五类别分割数据集的构成与预处理
2.1 这份数据集里到底有什么
拿到资源后我第一件事就是清点目录结构。数据不是那种打包成一个超大 npy 就扔给你的黑匣子,而是按标准医学分割任务的习惯组织的。训练集和验证集分开存放,每例样本包含原始 CT 影像和对应的五类别掩膜标注,掩膜中像素值分别为 0(背景)、1(肝脏)、2(右肾)、3(左肾)、4(脾脏)。这种像素标签的设定非常关键,它直接决定了模型输出通道数和损失函数的写法。
dataset/ ├── images/ │ ├── train/ │ │ ├── case_001.npy │ │ ├── case_002.npy │ │ └── ... │ └── val/ │ ├── case_101.npy │ └── ... ├── masks/ │ ├── train/ │ │ ├── case_001.npy │ │ └── ... │ └── val/ │ ├── case_101.npy │ └── ... └── train_val_split.txt这里我把文件格式特意列出来,是因为很多新手拿到资源第一步就走偏:不看数据格式就直接写 DataLoader,结果读进来维度对不上。这套数据集的影像和掩膜都是单通道二维切片,形状为(H, W),H 和 W 一般在 256 或 512 附近,具体看原始切片尺寸。掩膜里每个像素存的是类别索引,不是 one-hot 编码,这一点在做可视化调试时特别容易翻车——直接用 plt.imshow 显示是灰度图,得映射成彩色才能看出五类分布。
2.2 预处理三件套:窗口化、归一化与重采样
CT 影像和自然图像最大的区别在于像素值代表的是组织对 X 射线的衰减系数,单位是亨氏单位(HU)。腹部 CT 里肝脏大概在 40~60 HU,肾脏在 20~40 HU,脾脏稍高一些。如果不做窗口化,直接扔给 Unet 训练,网络会被空气、骨骼这些极值干扰得厉害。我一般先把 CT 值裁剪到 [-125, 275] HU 区间,这个范围能覆盖腹部主要软组织的灰度分布。
import numpy as np def preprocess_ct(volume, window_min=-125, window_max=275): # 窗口化:把区间外的像素截断,抑制骨骼与空气干扰 volume = np.clip(volume, window_min, window_max) # 线性归一化到 [0, 1],保证不同病例的灰度分布可比 volume = (volume - window_min) / (window_max - window_min) return volume.astype(np.float32) def load_case(case_path, mask_path): image = np.load(case_path).astype(np.float32) mask = np.load(mask_path).astype(np.int64) image = preprocess_ct(image) return image, mask这段代码里np.clip是窗口化的核心操作,参数window_min和window_max不是随便拍的。我试过把上限放宽到 500,结果肾脏和肝脏在灰度上几乎黏在一起;把下限调到 -200 后背景噪声又变多。最终 [-125, 275] 这个区间是在验证集 dice 分数上对比出来的,比默认的 [-1024, 3071] 整体高了大概两个点。astype(np.float32)是为了后续在 GPU 上训练时避免数据类型转换的开销,掩膜保持np.int64是因为 PyTorch 的交叉熵损失要求标签为长整型。
2.3 DataLoader 里的边界处理:别把批次维度搞混
写 DataLoader 时最容易踩的坑是把切片方向搞错。腹部 CT 是三维体数据,但这份数据集已经按轴向切片拆好了,每张图是一个二维切片。如果在加载时不小心把(H, W)reshape 成(W, H),模型照样能训练,但分割结果里肝脏和肾脏的位置会整体旋转 90 度,属于典型的「训练指标正常、推理结果诡异」问题。
from torch.utils.data import Dataset, DataLoader class AbdomenDataset(Dataset): def __init__(self, image_paths, mask_paths, transform=None): self.image_paths = image_paths self.mask_paths = mask_paths self.transform = transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = np.load(self.image_paths[idx]).astype(np.float32) mask = np.load(self.mask_paths[idx]).astype(np.int64) image = preprocess_ct(image) # 给通道维:Unet 输入要求 (C, H, W) image = image[np.newaxis, ...] if self.transform: image, mask = self.transform(image, mask) return image, maskimage[np.newaxis, ...]这一行的作用是加通道维,把(H, W)变成(1, H, W)。这里有个隐藏细节:PyTorch 的 Conv2d 默认输入格式是(N, C, H, W),其中 N 是批次大小,C 是通道数。如果忘了加通道维,模型会直接把 H 当成 C,报错信息会提示通道数与权重不匹配。验证集建议单独写一个 DataLoader,并且shuffle=False,这样在保存预测结果时可以按索引对应回原始文件名。
3. 多尺度训练的选型逻辑:为什么普通 Unet 在肾脏上不够用
3.1 小器官分割失效的根因
纯粹的 Unet 在肝脏这类大器官上表现不错,因为肝脏占整张切片的面积比例高,深层特征经过四次下采样后依然保有足够多的像素响应。但肾脏在腹部切片里往往只占几十到上百个像素,经过四次池化后分辨率降到原来的 1/16,在特征图里就剩几个点的响应了,解码器根本没法恢复精细边界。这就是医学分割里常说的小器官漏检问题。多尺度训练的思路不是修改网络结构,而是从输入侧和损失侧同时入手:让网络在不同分辨率下观察同一张图。
3.2 多尺度输入的具体实现方式
这套资源里采用的做法是尺度抖动采样。每个训练轮次中,以 50% 的概率把输入切片缩放到 0.75 倍,以 30% 的概率缩放到 1.25 倍,剩余 20% 保持原尺寸。这样同一个器官在不同尺度下的像素占比差异很大,网络被迫学习尺度不变的特征。缩放操作要在归一化之后做,否则缩放会改变像素值的插值结果。
import random import cv2 class RandomScale: def __init__(self, scales=(0.75, 1.0, 1.25), probs=(0.5, 0.2, 0.3)): self.scales = scales self.probs = probs def __call__(self, image, mask): scale = random.choices(self.scales, weights=self.probs)[0] h, w = image.shape[1], image.shape[2] new_h, new_w = int(h * scale), int(w * scale) # 双线性插值缩放图像,最近邻插值缩放掩膜 image = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, (new_w, new_h), interpolation=cv2.INTER_NEAREST) return image, mask注意掩膜的缩放必须用INTER_NEAREST,这是因为最近邻插值不会生成新的类别值。如果用了INTER_LINEAR,肝脏和肾脏交界处的掩膜会出现 2.5 这类小数插值结果,标签就毁了。缩放后的尺寸不统一也没关系,DataLoader 里可以设置collate_fn做 padding,或者干脆每张图单独 forward。我在实际训练里发现,0.75 倍尺度下肾脏的完整轮廓更容易被网络捕捉到,而 1.25 倍尺度能提升边界精细度。
3.3 损失函数层面的多尺度辅助
除了输入尺度抖动,这套工程的验证脚本里还写了多尺度损失计算:同一张图分别以原尺寸、0.8 倍、1.2 倍输入网络,三个尺度的损失按权重 0.5、0.3、0.2 相加。这种做法相当于在梯度回传时,让小目标的特征在多个感受野下都被约束。我复现时发现这个策略对验证集 dice 的提升幅度在 1.5~2 个点左右,比单纯调学习率划算得多。
def multi_scale_loss(model, image, mask, criterion, scales=[1.0, 0.8, 1.2]): total_loss = 0.0 weights = [0.5, 0.3, 0.2] for scale, weight in zip(scales, weights): if scale != 1.0: h, w = image.shape[2], image.shape[3] new_h, new_w = int(h * scale), int(w * scale) img = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR) msk = cv2.resize(mask, (new_w, new_h), interpolation=cv2.INTER_NEAREST) img = torch.from_numpy(img).float().cuda() msk = torch.from_numpy(msk).long().cuda() else: img, msk = image, mask pred = model(img) total_loss += weight * criterion(pred, msk) return total_loss这段代码有两个注意点。第一,cv2.resize对 numpy 数组操作后返回的是(H, W, C)格式,而模型的输入是(C, H, W),中间必须做一次transpose或直接用torch.Tensor.permute调整维度,我在第一次跑的时候在这个地方崩了。第二,多尺度损失的 batch size 要相应调小,因为每个 step 实际进行了三次 forward,显存占用直接乘三。
4. Unet 模型构建与训练调参:从结构选择到收敛判断
4.1 编解码器结构细节
资源的模型文件实现的是标准 Unet 结构:编码器四层,每层两个卷积加一次下采样,通道数从 64 翻倍到 512;解码器对称上采样,最后接一个 1x1 卷积把通道数映射到类别数 5。这里有个容易被忽略的参数:最后一层卷积的 bias。因为后面接的是 CrossEntropyLoss,而该损失内部集成了 Softmax 和 log 操作,所以最后一层输出的是未归一化的 logits,不需要手动做 Softmax。
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class Unet(nn.Module): def __init__(self, in_channels=1, num_classes=5): super().__init__() self.enc1 = DoubleConv(in_channels, 64) self.enc2 = DoubleConv(64, 128) self.enc3 = DoubleConv(128, 256) self.enc4 = DoubleConv(256, 512) self.pool = nn.MaxPool2d(2) self.up3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec3 = DoubleConv(512, 256) self.up2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = DoubleConv(256, 128) self.up1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = DoubleConv(128, 64) self.out = nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) d3 = self.dec3(torch.cat([self.up3(e4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)我在这个结构里做了两处改动,都在资源原版基础之上。第一是 BatchNorm 加在每个卷积之后、激活之前,原版没有 BN 层时训练到第 30 个 epoch 验证集 dice 还在 0.82 附近震荡,加了之后稳定在 0.88。第二是torch.cat的拼接顺序,必须是[上采样结果, 编码器特征],如果把顺序反了,通道数虽然没错,但特征对齐方式不同,收敛速度会明显变慢。
4.2 训练参数推荐与显存估算
我在这套资源上试过三组配置,最终选定的是一组「中间偏保守」的参数:batch size 8,初始学习率 1e-4,Adam 优化器配合 CosineAnnealingLR 调度器。显存占用大约 6.5 GB,用 11 GB 的显卡刚好能跑,如果显存只有 8 GB 建议 batch size 降到 4。输入尺寸统一为 224x224 而不是直接上 512,主要原因是多尺度缩放后 512 尺寸的显存峰值会突破 12 GB。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6) criterion = nn.CrossEntropyLoss(ignore_index=0)ignore_index=0这一行是我特别推荐的设置。它让模型在计算损失时完全不看背景类,专注优化肝脏、肾脏、脾脏三个前景类别的分割精度。背景占整张图的比例经常超过 70%,如果不忽略背景,模型会倾向于把所有像素都预测为背景来压低损失,而前景器官全漏掉。我最早用默认设置训的时候,验证集 dice 只有 0.65,改了之后一跃到 0.87,效果立竿见影。
4.3 收敛判断的两个硬指标
训练过程中不能只看 loss 曲线下降就收手。我一般同时盯两个指标:验证集上的平均 dice 系数,以及每个类别的单独 dice。平均 dice 高于 0.85 可以说明整体不错,但必须拆开看肾脏。肾脏的 dice 如果低于 0.7,说明小器官漏检问题还没解决,需要加重多尺度损失的权重或者把输入分辨率进一步提升。
def compute_dice(pred, mask, num_classes=5): dice_scores = [] for cls in range(1, num_classes): pred_cls = (pred == cls) mask_cls = (mask == cls) intersection = (pred_cls & mask_cls).sum() union = pred_cls.sum() + mask_cls.sum() dice = (2 * intersection) / (union + 1e-8) dice_scores.append(dice.item()) return dice_scoresunion项没有减intersection,是因为 dice 系数的标准定义里分母是预测与标签的像素数之和,而不是并集。这里加1e-8是为了防止除零,但在肾脏类别几乎不出现在某张切片时,两个零相加等于零,输出会变成 1.0,这是假阳性。正确的做法是跳过该类别,或者累计整个验证集的像素后再算 dice,而不是逐图平均。
5. 避坑与排查:训练和推理阶段最常见的六个翻车点
5.1 现象:训练 loss 降到很低但验证集 dice 几乎为零
原因几乎都是标签错位。我排查过一个案例:训练集里case_001.npy的影像和掩膜来自不同病例,原因是数据切分脚本里用了glob.glob后没排序,文件名错位配对。解决方法是按文件名 key 字典对齐,加载前先打印一组影像和掩膜的唯一值数组,肉眼确认掩膜里类别分布是否符合预期。
# 加载后先断言类别集合是 {0,1,2,3,4} 的子集 mask = np.load(mask_path) assert set(np.unique(mask)).issubset({0, 1, 2, 3, 4}), f"掩膜类别异常: {np.unique(mask)}"这条断言能拦截掉绝大多数的标注错位。同时我习惯在每个 epoch 的第一个 batch 训练完,把模型的预测结果保存一张可视化图,叠在输入影像上输出。如果第一张图就出现预测和 GT 轮廓完全不在同一位置,十有八九是数据配对问题,而不是模型问题。
5.2 现象:推理时输出只有背景类
原因是我在验证代码里用了model.eval()之后,忘记包在torch.no_grad()里面,导致 BatchNorm 使用了训练时统计量的近似值累积误差。但更常见的原因是多尺度测试时用了cv2.resize把掩膜也做了插值,最近邻插值没问题,一旦误用线性插值就会产生 float 类型的预测标签,取整后全部落到 0 类。解决方法是推理时只对输入做预处理,模型输出通过argmax转为类别索引,不做任何插值操作。
model.eval() with torch.no_grad(): pred = model(img_tensor) pred = torch.argmax(pred, dim=1).squeeze(0).cpu().numpy()torch.argmax沿通道维取最大值索引,通道数 5 对应五个类别。推理前必须确认模型输入尺寸和训练时一致,因为ConvTranspose2d对非整除尺寸很敏感,输入是 224x224 输出正好还原,输入是 220x220 时最后一层上采样会多出 1 像素的错位。
5.3 现象:显存爆掉但代码看起来没问题
多尺度训练时最容易触发这个坑。我在 4.3 节提过三次 forward 累加梯度,但很多人没意识到multi_scale_loss里torch.from_numpy创建的张量默认不共享 GPU 内存,每次循环都会重新分配显存。解决方法是提前预分配或者把缩放操作放到 DataLoader 里做,或者显式调用torch.cuda.empty_cache()在每个 epoch 结束后释放碎片。
5.4 现象:训练集 dice 高、验证集低,差距超过 10 个点
过拟合的典型信号。我这套资源里数据量本身不算大,如果不做数据增强,第二个 epoch 就开始偏差拉大。解决方法是在 RandomScale 之外再加随机翻转和随机旋转。注意旋转角度不能太大,腹部 CT 的解剖方位是固定的,旋转超过 15 度会让肝脏和脾脏的相对位置失真,反而干扰学习。
class RandomRotate: def __init__(self, angle_range=10): self.angle_range = angle_range def __call__(self, image, mask): angle = random.uniform(-self.angle_range, self.angle_range) h, w = image.shape[1], image.shape[2] matrix = cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) image = cv2.warpAffine(image, matrix, (w, h), flags=cv2.INTER_LINEAR) mask = cv2.warpAffine(mask, matrix, (w, h), flags=cv2.INTER_NEAREST) return image, mask5.5 现象:验证集每个 epoch 的 dice 波动幅度超过 5 个点
原因是验证集本身包含大量不含任何前景器官的纯背景切片。这些切片在计算平均 dice 时会有两种极端:全部预测背景则 dice 为 1.0,预测错一个像素则 dice 为 0。解决方法是验证时过滤掉前景面积小于阈值(比如 50 像素)的切片,或者对每个类别分别统计后做宏平均,后者更贴近临床评估习惯。
5.6 现象:换了自有数据集后训练不收敛
如果你们的 CT 数据不是腹部而是其他部位,窗口化参数必须重新标定。我接手过一套头部 CT 数据,直接沿用腹部窗口 [-125, 275] 训练,模型在颅骨和脑组织之间完全分不开。解决方法是先统计数据集的像素分布直方图,找到组织峰值所在的 HU 范围,将窗口中心对准峰值,宽度取峰值的 2~3 倍标准差。
6. 模型推理可视化与保存:用一行命令跑通预测并输出叠加图像
这套资源里预制了一个predict.py脚本,测试时可以单张图跑推理。我把它改造成了批量模式,可以在验证集上自动输出五类别彩色叠加图,不需要额外装复杂的医学影像库,OpenCV 加 matplotlib 就够。具体做法是读取 npy 切片后,给每个类别分配一个 RGB 颜色,把掩膜叠加到原图上保存。
import cv2 import numpy as np import torch COLOR_MAP = { 1: (0, 255, 0), # 肝脏 - 绿色 2: (255, 0, 0), # 右肾 - 红色 3: (0, 0, 255), # 左肾 - 蓝色 4: (0, 255, 255), # 脾脏 - 黄色 } def infer_and_visualize(model, image_path, save_path): image = np.load(image_path).astype(np.float32) image = preprocess_ct(image) img_tensor = torch.from_numpy(image[np.newaxis, np.newaxis, ...]).float().cuda() model.eval() with torch.no_grad(): pred = model(img_tensor) pred = torch.argmax(pred, dim=1).squeeze(0).cpu().numpy() overlay = np.zeros((*pred.shape, 3), dtype=np.uint8) gray = (image * 255).astype(np.uint8) for cls, color in COLOR_MAP.items(): overlay[pred == cls] = color # 原图转三通道 base = cv2.cvtColor(gray, cv2.COLOR_GRAY2BGR) # 半透明叠加 result = cv2.addWeighted(base, 0.6, overlay, 0.4, 0) cv2.imwrite(save_path, result)addWeighted的两个权重需要微调。0.6/0.4 是我试下来比较均衡的搭配,原图信息保留多一眼,掩膜颜色又能看清楚边界;如果设成 0.3/0.7,小器官会被颜色完全盖住,看不清和周围组织的空间关系。保存图命名建议带上 dice 分数,这样在批量翻图时能一眼找出分割差的案例:
dice_scores = compute_dice(pred, mask) save_path = f"result_{idx}_dice_{np.mean(dice_scores):.3f}.png"验证多尺度训练是否真正生效,我还有一个习惯:用训练好的模型分别以单尺度输入和多尺度输入跑同一个验证集,对比两者肾脏类别 dice。如果多尺度推理没有显著优势甚至更差,说明训练时的多尺度损失没有真正学进去,此时要检查训练代码里是否把多尺度 loss 的梯度正确累积了,而不是多个 loss 相加后没反向传播。资源里自带的训练日志会在每个 epoch 记录每个类别的 dice,我每次跑完都会拉出第 10、第 30、第 50 个 epoch 的数据做折线,观察肾脏 dice 的上升趋势是否持续。如果第 50 个 epoch 后肾脏 dice 还在涨,说明 100 个 epoch 不必要缩水,直接翻倍继续训,这套数据集的潜力比我最初以为的要大不少。从那以后,我在任何分割任务里都强制把「按类别拆指标」和「多尺度推理对比」走一遍,不然心里不踏实。希望帮到你。
本文还有配套的精品资源,点击获取