简介:面向医学图像分割学习者,这套基于Unet与Resnet的多尺度分割实战项目,配套腹部多脏器5类别分割数据集,解决多类别分割与多尺度训练问题。压缩包共1020个文件,总计约363MB,以990张PNG图像为主,另有Python脚本、模型权重、配置参数及训练日志等,结构清晰便于直接运行。目前已有665人学习下载,适合想从理论走向落地实战的深度学习开发者。项目训练脚本可自动完成多尺度随机缩放(0.5-1.5倍),utils中的compute_gray函数会将mask灰度值保存在txt文本中,并自动为UNET网络定义输出通道;transforms.py中重写了全部预处理函数。代码训练了50个epoch,miou约0.84,采用cos学习率衰减,run_results内保留损失与iou曲线,训练日志中可查看每个类别的iou、recall、precision以及全局像素点准确率,还保存了最佳权重。预测脚本可批量推理inference下的所有图片,README文档说明如何迁移到自定义数据,全程带注释,便于二次开发。
1. 腹部多脏器分割凭什么要用 Unet+Resnet:小器官翻车才是常态
做腹部多脏器分割这个深度学习项目时,多数人第一步就是把 Unet 跑通,拿一套 5 类别分割数据集开训。但真正上了 CT 数据才会发现,原版 Unet 能分大器官,到了胰腺和肾脏这种小目标,预测结果经常只剩一坨噪点。把编码器换成 Resnet,用预训练权重初始化,再配合多尺度训练,是这类任务里最稳的一套组合拳:Resnet 解决梯度流通和特征表达,多尺度训练解决器官尺度方差,5 类别分割数据集则把问题限定在“和背景一起分 6 个类”的范围内。这篇笔记适合想在自己数据上跑通 Unet+Resnet、被类别不平衡和多尺度调参折磨过的人,跟着步骤能复现,坑也会一个个点出来。
2. Unet+Resnet 编码器怎么搭:替换下采样路径的三个关键选择
2.1 为什么选 Resnet34 做编码器而不是从头训 VGG 块
原版 Unet 的编码器是“两次 3x3 卷积 + ReLU + MaxPool”反复堆叠,深度大概十几层,下采样四次后特征图分辨率降到输入的 1/16。这个结构在细胞分割、道路分割这类场景够用,但到腹部 CT 上,肝脏边缘和胰腺边界需要更抽象的特征,十几层的感受野不够,小器官边缘的梯度也容易在深层消失。
把编码器换成分辨率的 Resnet 之后,变化是结构性的:Resnet 是 34 层、分四个 stage,每个 stage 的输出分辨率依次减半,通道数按 64、128、256、512 递增。残差连接让梯度能跨 block 直接回传,即使网络变深,也不会出现训练不收敛的退化问题。对分割任务来说,浅层输出保边缘纹理,深层输出保语义位置,这本身就接近“粗粒度特征 + 细粒度特征”的需求。
另一个现实理由是权重初始化。分割模型很少从零训,encoder_weights="imagenet"能加载 Resnet 预训练模型,虽然 CT 是灰度图、和自然图像模态差得远,但预训练权重里的边缘、角点、纹理基元依然有效,微调起来比随机初始化快得多。我在项目里先试过 Resnet50,显存占用更高、训练更慢,换回 Resnet34 后精度只掉了不到 0.5 个点,训练时间却省了三分之一。所以这个任务选 Resnet34,不是因为参数越多越好,而是它在精度、显存、收敛速度上最均衡。
2.2 用 segmentation_models_pytorch 搭一个最小可跑的 Unet 模型
写这个项目时,我不太建议手写 Resnet 编码器再缝 Unet 解码器,segmentation_models_pytorch(简称 SMP)已经把组合封装好了,一行代码就能拿到完整的 Unet+Resnet:
import torch from segmentation_models_pytorch import Unet model = Unet( encoder_name="resnet34", # 编码器主干,取 Resnet34 encoder_weights="imagenet", # 加载 ImageNet 预训练权重 in_channels=1, # CT 是单通道灰度图,SMP 内部会扩成 3 通道 classes=6, # 0 背景 + 5 个器官 = 6 个输出通道 activation=None, # 输出 logits,不进 softmax ) print(sum(p.numel() for p in model.parameters()) / 1e6, "M params")这段代码里几个参数必须说清楚。in_channels=1是因为腹部 CT 本质是灰度图,SMP 会在编码器入口把单通道复制成三通道喂给 Resnet,所以不需要真的去转三通道图;classes=6对应“背景 + 5 类腹部多脏器”,与数据集的标签值一一对应;activation=None让模型输出未经过 softmax 的 logits,方便在 loss 函数里自己选 Dice 还是交叉熵。
这里有一处容易被文档误导的细节:加载encoder_weights="imagenet"后,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,让每个解码器分支都接一个损失,对小器官有辅助作用,但调试成本高,新手先不要开,等基础模型跑通再考虑。
如果想在 Unet+Resnet 基础上走得更远,可以试试 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 训练也能做,但每次读取都要走 SimpleITK,IO 开销大。我习惯先把每个病例转成两个 .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(), minlength=6) total = seg_arr.size for cls in range(6): print(f"class {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 或自注意力做多尺度融合,但对 Unet+Resnet 这种结构来说,训练阶段的输入尺度多样性比结构改造成本更低、收益更直接。
多尺度训练不是万能的,它主要提升小器官的召回率。如果你的模型大器官已经分得很好、胰腺却经常漏,多尺度值得试;如果所有器官都分得稀烂,那应该先回头查数据和 loss,不要指望缩放输入能解决根本问题。
4.2 在线多尺度采样的实现:随机缩放与固定裁剪
多尺度训练不需要额外造数据,只需要在数据加载时随机缩放。我推荐用 albumentations 的 RandomResizedCrop,它把“随机缩放 + 随机裁剪”合成一步,每次迭代都会产生不同尺度的样本:
import albumentations as A train_transform = A.Compose([ A.RandomResizedCrop( height=256, width=256, scale=(0.75, 1.25), # 随机缩放范围:75% 到 125% ratio=(0.9, 1.1), # 宽高比扰动,防止同一比例 p=1.0 ), A.HorizontalFlip(p=0.5), A.ShiftScaleRotate( shift_limit=0.05, scale_limit=0.15, rotate_limit=20, p=0.5 ), ]) def apply_transform(ct_slice, seg_slice): aug = train_transform(image=ct_slice, mask=seg_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. Unet+Resnet 训练避坑:从 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.shape,shape 不一致直接报错,不放过任何一条数据。可视化检查这件事很费时间,但它是医疗分割项目的第一道质检,省不掉。
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_loss:
import torch.nn.functional as F def mixed_loss(logits, target, dice_weight=0.6, focal_weight=0.4): probs = F.softmax(logits, dim=1) target_onehot = F.one_hot(target, num_classes=logits.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, reduction="none") 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 的调制因子取 2,gamma 越大越关注难样本,但过大容易让小器官训练不稳定。这套配置在我项目里比单独 Dice Loss 的胰腺 Dice 高了 0.05 左右。
6.2 一个逐类验证脚本:先打表再决定调什么
我后来养成了一个习惯:验证阶段绝不只看平均指标,每次实验都要输出每个器官的 Dice 报告。只有看到“肝脏 0.94、脾脏 0.91、肾脏 0.88、胰腺 0.42”这样的表格,才知道下一步该动采样、动 loss 还是动后处理:
def per_class_dice(pred, target, num_classes=6): 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。从那以后,每次实验结束先跑这份报告,小器官不掉点,才敢说这版模型真的进步了。做腹部多脏器分割,类别不平衡和小器官丢失是常态,单指标会骗人,逐类体检才是项目的导航仪。希望这些经验能帮你在自己的数据集上少走几步弯路。
本文还有配套的精品资源,点击获取