简介面向医学图像分割学习者这套基于Unet与Resnet的多尺度分割实战项目配套腹部多脏器5类别分割数据集解决多类别分割与多尺度训练问题。压缩包共1020个文件总计约363MB以990张PNG图像为主另有Python脚本、模型权重、配置参数及训练日志等结构清晰便于直接运行。目前已有665人学习下载适合想从理论走向落地实战的深度学习开发者。项目训练脚本可自动完成多尺度随机缩放0.5-1.5倍utils中的compute_gray函数会将mask灰度值保存在txt文本中并自动为UNET网络定义输出通道transforms.py中重写了全部预处理函数。代码训练了50个epochmiou约0.84采用cos学习率衰减run_results内保留损失与iou曲线训练日志中可查看每个类别的iou、recall、precision以及全局像素点准确率还保存了最佳权重。预测脚本可批量推理inference下的所有图片README文档说明如何迁移到自定义数据全程带注释便于二次开发。1. 腹部多脏器分割凭什么要用 UnetResnet小器官翻车才是常态做腹部多脏器分割这个深度学习项目时多数人第一步就是把 Unet 跑通拿一套 5 类别分割数据集开训。但真正上了 CT 数据才会发现原版 Unet 能分大器官到了胰腺和肾脏这种小目标预测结果经常只剩一坨噪点。把编码器换成 Resnet用预训练权重初始化再配合多尺度训练是这类任务里最稳的一套组合拳Resnet 解决梯度流通和特征表达多尺度训练解决器官尺度方差5 类别分割数据集则把问题限定在“和背景一起分 6 个类”的范围内。这篇笔记适合想在自己数据上跑通 UnetResnet、被类别不平衡和多尺度调参折磨过的人跟着步骤能复现坑也会一个个点出来。2. UnetResnet 编码器怎么搭替换下采样路径的三个关键选择2.1 为什么选 Resnet34 做编码器而不是从头训 VGG 块原版 Unet 的编码器是“两次 3x3 卷积 ReLU MaxPool”反复堆叠深度大概十几层下采样四次后特征图分辨率降到输入的 1/16。这个结构在细胞分割、道路分割这类场景够用但到腹部 CT 上肝脏边缘和胰腺边界需要更抽象的特征十几层的感受野不够小器官边缘的梯度也容易在深层消失。把编码器换成分辨率的 Resnet 之后变化是结构性的Resnet 是 34 层、分四个 stage每个 stage 的输出分辨率依次减半通道数按 64、128、256、512 递增。残差连接让梯度能跨 block 直接回传即使网络变深也不会出现训练不收敛的退化问题。对分割任务来说浅层输出保边缘纹理深层输出保语义位置这本身就接近“粗粒度特征 细粒度特征”的需求。另一个现实理由是权重初始化。分割模型很少从零训encoder_weightsimagenet能加载 Resnet 预训练模型虽然 CT 是灰度图、和自然图像模态差得远但预训练权重里的边缘、角点、纹理基元依然有效微调起来比随机初始化快得多。我在项目里先试过 Resnet50显存占用更高、训练更慢换回 Resnet34 后精度只掉了不到 0.5 个点训练时间却省了三分之一。所以这个任务选 Resnet34不是因为参数越多越好而是它在精度、显存、收敛速度上最均衡。2.2 用 segmentation_models_pytorch 搭一个最小可跑的 Unet 模型写这个项目时我不太建议手写 Resnet 编码器再缝 Unet 解码器segmentation_models_pytorch简称 SMP已经把组合封装好了一行代码就能拿到完整的 UnetResnetimport torch from segmentation_models_pytorch import Unet model Unet( encoder_nameresnet34, # 编码器主干取 Resnet34 encoder_weightsimagenet, # 加载 ImageNet 预训练权重 in_channels1, # CT 是单通道灰度图SMP 内部会扩成 3 通道 classes6, # 0 背景 5 个器官 6 个输出通道 activationNone, # 输出 logits不进 softmax ) print(sum(p.numel() for p in model.parameters()) / 1e6, M params)这段代码里几个参数必须说清楚。in_channels1是因为腹部 CT 本质是灰度图SMP 会在编码器入口把单通道复制成三通道喂给 Resnet所以不需要真的去转三通道图classes6对应“背景 5 类腹部多脏器”与数据集的标签值一一对应activationNone让模型输出未经过 softmax 的 logits方便在 loss 函数里自己选 Dice 还是交叉熵。这里有一处容易被文档误导的细节加载encoder_weightsimagenet后SMP 在前向传播时会按 ImageNet 的均值方差做归一化所以输入张量必须是 float32。常见翻车是把 CT 数组直接以 uint8 类型喂进去模型跑出来的 loss 是 NaN。我一般会在 Dataset 里统一转成 float32 并除以 255 或按窗宽窗位归一化后面第 3 章会给出具体做法。跑完这段代码后forward 一个(1, 1, 256, 256)的输入输出 shape 是(1, 6, 256, 256)第 1 维就是这个像素属于 6 个类的 logits。2.3 解码器侧的两个关键设计跳跃连接与上采样方式编码器换成 Resnet 后解码器不用大改但有两个点值得花心思。第一点是跳跃连接的数量和位置。Resnet34 的每个 stage 输出分辨率不同stage1 输出 1/2、stage2 输出 1/4、stage3 输出 1/8、stage4 输出 1/16。SMP 的 Unet 实现默认把后面三个 stage 的特征图接到解码器对应层浅层的细粒度特征保边缘深层的粗粒度特征保语义。如果训练时发现肝脏边缘细碎、胰腺轮廓模糊可以先检查跳跃连接有没有被改动过而不是急着换 loss。第二点是上采样的方式。解码器里每级上采样可以用转置卷积也可以用双线性插值 3x3 卷积。转置卷积能学参数但容易产生棋盘格伪影双线性插值 卷积更稳对腹部器官这种边界清晰的目标足够。SMP 默认采用后者我建议保持默认。有些改进版 Unet 会加 deep supervision让每个解码器分支都接一个损失对小器官有辅助作用但调试成本高新手先不要开等基础模型跑通再考虑。如果想在 UnetResnet 基础上走得更远可以试试 SMP 里的 FPN 结构它对“粗粒度特征和细粒度特征”的融合方式更激进适合器官尺度差异极大的场景。不过这个项目先以 Unet 为主太多变量一起上出了问题很难定位。3. 5类腹部多脏器数据集标签编码、nii 转 npy 与训练集划分3.1 5类别分割的本质类别定义与标签数值约定标题里的“腹部多脏器 5 类别分割”是指数据集中除了背景外有 5 个器官类别。常见的医学分割数据集里标签可能包含肝脏、右肾、左肾、脾脏、胰腺、胆囊等具体是哪些器官以 .nii 文件里的原始标注为准。如果数据集自带 6 类或 7 类就要先做类别筛选把不需要的器官标签合并进背景确保最终只有 5 个前景类别。类别数值约定是整个项目的地基。我一般这样定义标签为 0 的像素是背景标签 1 到 5 依次对应 5 个器官。这个“0 背景”的约定直接决定 loss 计算和 argmax 后处理。有人习惯把背景标成 255或者把第一个器官从 0 开始编号模型照样能跑但预测时 argmax 得到的类别索引和可视化脚本对不上Dice 全乱。训练前必须打印一次标签数组的唯一值确认是 {0,1,2,3,4,5} 而不是别的集合。一个更隐蔽的问题是 CT 和标签的空间对齐。公开数据集一般已经配准好但自己标注的数据集经常出现 CT 和 mask 尺寸一致、spacing 不一致的情况切片上看是错位的。项目一开始就应该用 SimpleITK 读取这两个文件的 spacing 字段做校验不匹配就先做重采样否则后面所有训练都是在拟合错位数据验证指标再高也是虚的。3.2 从原始 nii 到 numpy 数组可复制的转换流程腹部 CT 原始格式通常是 .nii.gz标签文件也是 .nii.gz。直接拿 nii 训练也能做但每次读取都要走 SimpleITKIO 开销大。我习惯先把每个病例转成两个 .npy 文件训练时按 slice 索引加载。下面是项目里实际用的转换脚本import SimpleITK as sitk import numpy as np ct sitk.ReadImage(case_001_ct.nii.gz) seg sitk.ReadImage(case_001_seg.nii.gz) ct_arr sitk.GetArrayFromImage(ct).astype(np.float32) # shape: (D, H, W)单位 HU seg_arr sitk.GetArrayFromImage(seg).astype(np.uint8) # shape: (D, H, W)取值 0~5 # 腹部软组织窗宽窗位裁剪去掉骨骼和空气的干扰 min_hu, max_hu -75, 175 ct_arr np.clip(ct_arr, min_hu, max_hu) ct_arr (ct_arr - min_hu) / (max_hu - min_hu) # 归一化到 [0, 1] # 如果原数据集包含其他器官类别这里统一置为背景 seg_arr[seg_arr 5] 0 np.save(case_001_ct.npy, ct_arr.astype(np.float32)) np.save(case_001_seg.npy, seg_arr.astype(np.uint8))GetArrayFromImage返回的维度顺序是 (z, y, x)也就是 (层数, 高, 宽)。很多从 nii 转 npy 的脚本挂在这种地方有人按 (H, W, D) 的顺序存等训练时切 slice 才发现方向全反了。窗宽窗位的范围 [-75, 175] 是我做腹部软组织分割时常用的区间肝脏和肾脏的 HU 值大约在 40 到 120 之间胰腺略低这个范围能把腹部主要器官的灰度差异保留下来同时把骨头的高亮和空气的低值压掉。如果你用的数据集是已经预处理的 PNG 切片就不需要这步直接读图即可。转换完成后训练时按 z 轴切片取二维图一个病例通常能获得几十到上百张 2D slice。要注意不是每层 slice 都有前景很多层面只含背景这类样本对模型学习没有帮助还会拖慢收敛常见做法是在 Dataset 里过滤掉“mask 全为 0 的 slice”只保留包含至少一个器官的层。3.3 训练验证集划分与类别频率统计划分训练集和验证集时有一个血泪经验必须按病人划分不能按 slice 随机划分。同一个病人的相邻 slice 高度相似如果一部分 slice 进训练集、另一部分进验证集模型在验证集上会“偷看”到几乎一样的图像mIoU 虚高好几个点等部署到新病人上立刻现原形。我一般按病人 ID 排序后前 80% 的病人做训练、后 20% 做验证保证验证集里的人一个都没见过。划分完毕后先统计一遍类别频率这是后面调 loss 权重的依据volumes np.bincount(seg_arr.ravel(), minlength6) total seg_arr.size for cls in range(6): print(fclass {cls}: {volumes[cls] / total:.4f})我在项目里看到的统计数据大致是肝脏能占到 10% 以上脾脏和肾脏各占 1% 到 3%胰腺往往不足 1%。这个差距意味着如果只用普通交叉熵模型只要把胰腺像素全预测成背景损失也不会太大。所以类别频率统计不是可有可无的分析它直接决定你后面要不要做类别加权、要不要用 Focal Loss以及多尺度训练的裁剪策略是否需要格外照顾小器官。4. 多尺度训练让一个小胰腺在 1/32 分辨率下还能被看见4.1 多尺度训练到底在解决什么问题尺度方差与感受野腹部 CT 里器官尺度方差大得离谱肝脏横截面能占 200x150 像素胰腺可能只有 30x20 像素。模型输入固定为 256x256 时经过 4 次下采样特征图缩到 16x16肝脏还剩 12x9 个像素胰腺可能只剩 1x1这个分辨率下别说分割能检测到它存在都难。多尺度训练的思路是每个 epoch 随机改变输入图像的缩放比例让模型交替看到放大的胰腺切片和缩小的肝脏切片。放大后的胰腺能在特征图里保留足够的像素模型有机会学到它的边界缩小后的肝脏则迫使模型关注整体结构而不是局部纹理。这在语义分割算法里是一个常规操作等于是隐式数据增强代价只有一点点训练时间。这里要和“粗粒度特征与细粒度特征”联系起来多尺度训练本质上是在教编码器同时提取两种特征——从放大图像里提取小器官的细粒度边缘从缩小图像里提取大器官的粗粒度上下文。有些模型会在后面接 FPN 或自注意力做多尺度融合但对 UnetResnet 这种结构来说训练阶段的输入尺度多样性比结构改造成本更低、收益更直接。多尺度训练不是万能的它主要提升小器官的召回率。如果你的模型大器官已经分得很好、胰腺却经常漏多尺度值得试如果所有器官都分得稀烂那应该先回头查数据和 loss不要指望缩放输入能解决根本问题。4.2 在线多尺度采样的实现随机缩放与固定裁剪多尺度训练不需要额外造数据只需要在数据加载时随机缩放。我推荐用 albumentations 的 RandomResizedCrop它把“随机缩放 随机裁剪”合成一步每次迭代都会产生不同尺度的样本import albumentations as A train_transform A.Compose([ A.RandomResizedCrop( height256, width256, scale(0.75, 1.25), # 随机缩放范围75% 到 125% ratio(0.9, 1.1), # 宽高比扰动防止同一比例 p1.0 ), A.HorizontalFlip(p0.5), A.ShiftScaleRotate( shift_limit0.05, scale_limit0.15, rotate_limit20, p0.5 ), ]) def apply_transform(ct_slice, seg_slice): aug train_transform(imagect_slice, maskseg_slice) return aug[image], aug[mask]scale(0.75, 1.25)表示每次采样会先从 0.75 到 1.25 之间随机取一个缩放因子把原图缩放到该比例后裁剪到 256x256。这个范围是我项目里调过的结果0.75 让小器官缩小压力测试1.25 让胰腺放大到足够分辨率再激进到 1.5 会导致肝脏被裁掉一大半反而引入噪声。ratio(0.9, 1.1)只做轻微宽高比扰动腹部器官是实心结构太大的各向异性拉伸会让形态失真。有一点要多说一句每个 batch 内的图像尺度是不一样的这会导致 BatchNorm 的统计量波动更大但实际训练中影响不大。如果你发现 loss 震荡明显可以把随机缩放改成固定三档 scale{0.75, 1.0, 1.25} 轮换稳定性会好一些代价是训练时间变长。验证时不要用多尺度变换固定中心裁剪或直接 resize 到 256x256 即可保证评估指标可复现。4.3 多尺度训练的三个必调参数缩放范围、裁剪尺寸、batch size参数一缩放范围。scale(0.75, 1.25)是起步值胰腺占比太低时可以把下限调低到 0.7让胰腺更大面积地出现在输入里如果大器官边缘已经开始模糊说明上限 1.25 导致肝脏被裁太多把上限降到 1.15。调这个参数没有固定公式我一般用验证集上小器官的类别 Dice 做反馈胰腺 Dice 低就放大尺度下限肝脏边界差就收窄尺度上限。参数二裁剪尺寸。256x256 是显存和精度的折中Resnet34 编码器在这尺寸下12G 显存 batch size 开到 8 没问题。如果显存有富余我会把裁剪尺寸提到 384x384小器官的绝对像素数增加分割边界会更细但训练速度下降约一倍且对没有前景的裁剪样本要更严格过滤。低于 224 则不要用小器官在 1/16 分辨率下会退化到不可用。参数三batch size。多尺度训练下建议先从 batch size 4 跑通一遍确认 loss 在下降再逐步加大到 8 或 16。batch size 太小BatchNorm 统计量不稳定多尺度带来的输入扰动会被放大batch size 太大单 epoch 迭代次数变少随机缩放覆盖的尺度组合不够多样。8 是我在这个项目里的常用值精度和速度都比较平衡。多尺度训练通常能在验证 mIoU 上带来 1 到 2 个点的提升主要收益在小器官别期待大器官指标突飞猛进。5. UnetResnet 训练避坑从 loss 不降到 mask 全黑5.1 现象loss 在 0.6 附近震荡胰腺在预测结果里完全消失训练日志里 loss 一直下不去验证集上肝脏脾脏轮廓都在唯独胰腺一整片缺失预测 mask 里那个位置是纯黑。这种翻车几乎都是类别极端不平衡造成的胰腺像素占比不到 1%模型把所有像素都预测成背景整体损失也不会涨多少。普通交叉熵在大前景类别上梯度主导小目标的梯度信号被湮没。解决办法分两步走。第一把损失函数换成 Dice Loss 或 Dice Focal 的混合损失后面第 6 章会给出具体配置第二在数据加载时做针对性过采样每个 epoch 强制包含若干张含胰腺的 slice保证模型每个 epoch 都见过这个小器官。我试过只换 loss 不加采样胰腺 Dice 从 0.3 涨到 0.55两只都上能到 0.7 左右。如果你做了类别加权交叉熵权重建议按类别频率的倒数归一化但要注意权重过大会导致大器官的收敛变慢需要同时调学习率。5.2 现象训练正常、验证 mIoU 不错但可视化时预测 mask 和原图完全错位这是我第一次在 nii 转 npy 时踩过的坑CT 数组存成了 (D,H,W)标签数组存成了 (H,W,D)训练时按第一个维度切 slice某几个病例的 mask 和输入图方位完全对不上。loss 能正常下降是因为模型把“每张图都对应同一个错位模式”当成了一种偏置去拟合验证集因为分布相似所以指标不差但换到新病人立刻失效。这个坑要在数据转换脚本里就拦住。第一转换完成后随机抽 5 个 slice把 CT 和 mask 叠在一张图上人工检查第二打印sitk.ReadImage返回的 spacing 和 direction确认 CT 和 mask 两个文件的空间元信息一致第三在 Dataset 的__getitem__里用 assert 确保ct.shape seg.shapeshape 不一致直接报错不放过任何一条数据。可视化检查这件事很费时间但它是医疗分割项目的第一道质检省不掉。5.3 现象多尺度训练开启后小器官的类别 Dice 反而下降多尺度训练本意是照顾小器官但开启后胰腺 Dice 从 0.68 掉到 0.5检查代码发现是 RandomResizedCrop 在随机裁剪时把胰腺裁掉了。当原图里胰腺只占很小一块区域随机裁剪有较大概率落不到它身上导致这个 batch 的样本里根本没出现前景模型学到的是“这张图全是背景”。解决方式是给裁剪变换加约束缩小 RandomResizedCrop 的 scale 下限或者在裁剪后判断 mask 中前景像素数是否少于阈值低于阈值就重新采样。我一般要求裁剪后的 mask 至少包含 50 个前景像素否则重新裁。如果数据集里小器官切片本身就不多更稳妥的做法是不用全局随机裁剪而是先把包含胰腺的 slice 单独抽出来在这些 slice 上做小范围随机扰动再和普通 slice 按比例混合进每个 epoch。5.4 现象验证集 mIoU 有 0.85但医生看完图说边界全是毛刺mIoU 是按像素统计的边界上 1 到 2 个像素的偏差对 IoU 的影响往往不到 0.01但对临床勾画来说整圈边缘都毛糙就是不能用。项目里出现过验证指标好看、实际效果没法交付的情况根因是 loss 只关注区域重叠不关心边界平滑度。解决分三层。第一评估指标从单一 mIoU 扩展到每个类别的 Dice并且对边界敏感类胰腺、左肾单独盯。第二训练时在混合 loss 里加一项边界损失比如对 mask 做拉普拉斯滤波后计算预测边界的 Dice但实现成本稍高先不展开。第三推理后处理用形态学开闭运算去掉孤立小连通域再保留最大连通域作为器官 mask。腹部多脏器中每个器官本身是单连通区域这个先验在多数情况下成立可以显著清理边界毛刺。6. 把评估从单指标变成逐类体检混合损失与五类 Dice 报告6.1 用 Dice Focal 混合损失替代单一交叉熵单一 Dice Loss 在小器官上比交叉熵好但如果器官边界模糊它收敛很慢单一 Focal Loss 能关注难样本但前景尺度差异大的时候依然会被大器官主导。我在项目里的最终配置是0.6 * dice_loss 0.4 * focal_lossimport torch.nn.functional as F def mixed_loss(logits, target, dice_weight0.6, focal_weight0.4): probs F.softmax(logits, dim1) target_onehot F.one_hot(target, num_classeslogits.shape[1]).permute(0, 3, 1, 2).float() # Dice Loss按类别逐类算再加权平均 smooth 1e-6 intersection (probs * target_onehot).sum(dim(0, 2, 3)) union probs.sum(dim(0, 2, 3)) target_onehot.sum(dim(0, 2, 3)) dice (2 * intersection smooth) / (union smooth) dice_loss 1 - dice.mean() # Focal Loss只取正样本通道的交叉熵加调制因子 ce F.cross_entropy(logits, target, reductionnone) pt probs.gather(1, target.unsqueeze(1)).squeeze(1) focal_loss ((1 - pt) ** 2 * ce).mean() return dice_weight * dice_loss focal_weight * focal_lossfocal 的调制因子取 2gamma 越大越关注难样本但过大容易让小器官训练不稳定。这套配置在我项目里比单独 Dice Loss 的胰腺 Dice 高了 0.05 左右。6.2 一个逐类验证脚本先打表再决定调什么我后来养成了一个习惯验证阶段绝不只看平均指标每次实验都要输出每个器官的 Dice 报告。只有看到“肝脏 0.94、脾脏 0.91、肾脏 0.88、胰腺 0.42”这样的表格才知道下一步该动采样、动 loss 还是动后处理def per_class_dice(pred, target, num_classes6): dice_list [] for cls in range(1, num_classes): # 跳过背景 p pred cls t target cls inter (p t).sum() union (p | t).sum() dice_list.append(2 * inter / (inter union 1e-6)) return dice_list # 示例pred 是模型 argmax 后的结果target 是标签 dice per_class_dice(pred, target) print(肝脏 右肾 左肾 脾脏 胰腺) print([round(d, 3) for d in dice])这个脚本比只看 mIoU 值可靠得多。我最早的训练只看平均指标调了好几轮都以为模型已经可用直到逐类打印才发现胰腺的 Dice 一直没超过 0.4。从那以后每次实验结束先跑这份报告小器官不掉点才敢说这版模型真的进步了。做腹部多脏器分割类别不平衡和小器官丢失是常态单指标会骗人逐类体检才是项目的导航仪。希望这些经验能帮你在自己的数据集上少走几步弯路。本文还有配套的精品资源点击获取
