简介:面向医学图像分割入门与进阶开发者的Unet+Resnet多尺度分割实战项目,配套腹部多脏器5类别分割数据集。工程将Unet骨干替换为Resnet,并实现将数据随机缩放至设定尺寸0.5~1.5倍的多尺度训练;mask灰度值自动写入txt并据此定义网络输出通道,支持多类别分割扩展。预处理函数在transforms.py中全部重新实现,代码含注释,便于二次开发与学习。资源包共1020个文件,约363MB,以990张png图像、8个py脚本、训练日志、权重pth及readme等组成,附带完整训练流程。训练50个epochs后miou约0.84,采用cos学习率衰减;run_results中保存损失与iou曲线、每个类别的iou、recall、precision及全局像素准确率等日志,可直接查看模型效果。预测脚本可自动推理inference下所有图片,按readme操作即可用自有数据训练。已有665人学习下载,适合想快速上手多类别分割项目、理解Unet+Resnet多尺度训练细节的研究者或工程开发者。
1. 深度学习 Unet+Resnet 多类别分割:这份腹部多脏器数据集项目到底能解决什么
医学图像分割里,Unet+Resnet 是最常见的组合,但常见不等于容易跑通。我最近拆完一份腹部多脏器 5 类别分割项目——五个脏器、多尺度训练、Unet 和 Resnet 两套结构拼在一起,配套一个可复现的数据集。这个项目能解决的不只是“跑一个 Unet 网络”,而是让一个模型同时输出多张器官 mask,省掉为每个器官单独训练一个模型的重复工作。它用 Resnet34 做 encoder,靠预训练权重补足医学数据量小的短板,用多尺度训练提升小器官的分割表现。如果你正在做深度学习课程设计、毕设项目,或者想快速验证医学图像分割算法,这份资源的价值在于:从数据加载到模型训练再到评估,每一步都能直接改起来用。
2. 把 Unet 和 Resnet 接起来:5 类分割的骨干设计
2.1 为什么选 Unet 骨架而不是纯 Resnet
很多新手拿到分割任务,第一反应是“Resnet 很强,直接用 Resnet 不就行了”。但 Resnet 本质是分类网络,输出层是全局池化后的概率向量,不是逐像素的 mask。分割任务要的是“每个像素属于哪一类”,这要求网络既能在深层特征上看到器官的大致位置,又能在浅层特征上恢复细节边界。纯 Resnet 缺少把高维特征图上采样回原分辨率的结构。
Unet 天然是编码器-解码器对称结构,解码器把特征图逐步上采样回原图尺寸,通过 skip connection 把浅层细节和深层语义拼在一起。对于腹部多脏器分割,脾脏、胰腺这类小器官边界模糊,Unet 的跨层连接能直接把边缘纹理传递给解码器,恢复精度明显优于普通 FCN 类结构。
但 Unet 有个短板:如果从零训练,医学数据量往往不够,收敛慢还容易过拟合。把 Resnet 接在 Unet 前面当 encoder,就能直接加载 ImageNet 预训练权重,等于把分类任务学到的底层纹理、边缘能力迁移过来。我自己的实验里,加载预训练权重的 Unet 在验证 Dice 上通常比随机初始化高 3 到 5 个点,在几千张切片这种量级的数据集上尤其明显。这也是这个项目把 Unet 和 Resnet 拼在一起的核心动机:Unet 负责定位和恢复分辨率,Resnet 负责提供强壮的特征提取能力。
2.2 Encoder 改造:把 resnet34 的 layer 输出接到 Unet 解码器
具体实现时,我把 Unet 原始的 encoder 替换成 resnet34,保留它的 4 个残差层,每层输出的特征图作为 skip connection 传给 Unet 解码器。需要改动的地方有三个:去掉 resnet 最后的全局池化和全连接层、把第一层 7x7 大卷积换成 3x3 小卷积、把输出通道改成背景加 5 类共 6 通道。
import torch import torch.nn as nn from torchvision import models class ResnetEncoder(nn.Module): """用 resnet34 做 encoder,输出五个尺度的特征图""" def __init__(self, backbone='resnet34', in_channels=3, pretrained=True): super().__init__() resnet = getattr(models, backbone)(pretrained=pretrained) # 第一层换成 3x3 stride=1,避免一进入网络就丢细节 self.conv1 = nn.Conv2d(in_channels, 64, kernel_size=3, stride=1, padding=1, bias=False) self.bn1 = resnet.bn1 self.relu = resnet.relu self.maxpool = resnet.maxpool self.layer1 = resnet.layer1 self.layer2 = resnet.layer2 self.layer3 = resnet.layer3 self.layer4 = resnet.layer4 def forward(self, x): c1 = self.relu(self.bn1(self.conv1(x))) c1 = self.maxpool(c1) # 1/2 分辨率 c2 = self.layer1(c1) # 1/4 分辨率 c3 = self.layer2(c2) # 1/8 分辨率 c4 = self.layer3(c3) # 1/16 分辨率 c5 = self.layer4(c4) # 1/32 分辨率 return [c1, c2, c3, c4, c5]这段代码的关键在于保留了 resnet34 四个残差层的全部输出。c1到c5对应五个不同感受野的特征图,分辨率从输入的 1/2 一路降到 1/32。后面的 Unet 解码器会从c5开始逐层上采样,每一层都与对应尺度的 skip 拼接。in_channels=3是因为我把单通道 CT 复制成三通道再喂进去,这样能直接复用 ImageNet 预训练权重的第一层卷积。
解码器部分可以复用标准 Unet 的解码块,核心是上采样之后的尺寸对齐:
class DecoderBlock(nn.Module): """Unet 解码块:上采样 + 拼接 skip + 卷积""" 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 = nn.Conv2d(in_ch // 2 + skip_ch, out_ch, kernel_size=3, padding=1) def forward(self, x, skip): x = self.up(x) # 尺寸不整除时先对齐再拼接,避免 shape 报错 if x.shape[-2:] != skip.shape[-2:]: x = nn.functional.interpolate( x, size=skip.shape[-2:], mode='bilinear', align_corners=False) return self.conv(torch.cat([x, skip], dim=1))拼接之前的interpolate对齐是我强烈建议保留的。Unet 对输入尺寸要求不高,但下采样次数不同会让特征图在边界处差半个像素,不加对齐会经常遇到 shape mismatch 的报错。加上这个保护之后,换输入尺寸、换 backbone 都不需要反复查维度。
2.3 预训练权重加载:第一次最容易在这里翻车
用 torchvision 的 resnet34 时,pretrained=True会自动下载 ImageNet 权重。但很多教程会让你手工改 fc 层、换 decoder,最后 state_dict 的 key 对不上,加载时直接报错。最稳妥的加载方式是使用strict=False,先打印缺失和多余的 key:
checkpoint = torch.load('pretrained/resnet34.pth') missing, unexpected = model.encoder.load_state_dict(checkpoint, strict=False) print('missing:', missing) print('unexpected:', unexpected)这里missing是预训练权重里有、但当前模型里没有的层,unexpected是当前模型有、但预训练权重里没有的层。理想情况下,missing里只有conv1.weight和fc,unexpected里也只有conv1.weight和fc,因为我把 7x7 卷积换成了 3x3,且没有任何全连接层。如果还有其他层出现在列表里,先检查是不是 backbone 名字写错了,或者模型定义里多加了一个模块。这一步做对了,后面训练才会真正吃到预训练红利。
3. 多尺度训练:patch 怎么取、尺度抖动怎么设
3.1 腹部 CT 序列的尺寸与 patch 选取
腹部多脏器分割数据集的原始切片常见尺寸在 512x512 左右,五个器官里肝脏占比大,脾脏和胰腺占比小。直接整图训练,模型会把大部分参数浪费在背景上,小器官的像素占比太低,网络很难学到有效特征。常见做法是随机裁剪固定 patch,我在这份数据上试出来的有效区间是 192x192 到 320x320 之间。
| patch 尺寸 | 显存需求(batch=2) | 效果备注 |
|---|---|---|
| 128x128 | 2~4 GB | 小器官容易没裁全,脾脏召回率低 |
| 192x192 | 4~6 GB | 边角偶尔丢掉,整体稳定 |
| 256x256 | 6~8 GB | 本次实战主要使用的尺寸 |
| 320x320 | 12 GB 以上 | 多尺度收益明显,但显存压力大 |
表格里的显存需求默认是 resnet34 encoder 加中等深度 decoder 的配置,开启混合精度后还可以再省 20% 左右。如果显存只有 8 GB,建议直接从 256x256 起步。注意 patch 不是越大越好:patch 太大,一个 batch 里实际能放进的小器官数量反而变少,因为每个 patch 都被大器官和背景占据。
3.2 两种多尺度实现:尺度抖动与双尺度并行
多尺度训练的本质是让模型同时看到不同分辨率的器官,既掌握粗粒度特征又保留细粒度特征。两种常见做法里,尺度抖动实现最简单,每个 iteration 随机把输入 resize 到 0.75x 到 1.5x,再裁剪固定 patch:
scale = random.uniform(0.75, 1.5) new_h = int(input_h * scale) new_w = int(input_w * scale) img = F.interpolate(img, size=(new_h, new_w), mode='bilinear', align_corners=False) mask = F.interpolate(mask.unsqueeze(0).float(), size=(new_h, new_w), mode='nearest').squeeze(0).long()这段代码里,img和mask必须用不同的插值方式:图像用bilinear保留平滑梯度,mask 必须用nearest,否则类别边界会被插值成非整数标签,计算损失时就乱了。尺度抖动要放在随机裁剪之前,先 resize 再 crop,这样每个 iteration 看到的目标大小都不一样,相当于隐式做了数据增强。
第二种做法是固定双尺度并行:每张图像同时以原尺度和 0.8 尺度喂进模型,两个输出的 loss 加在一起反向传播。这种方式对提升精度更稳定,但显存占用直接翻倍,训练速度也明显变慢。我一般在显存充足、追求最终指标时才会用。
3.3 多尺度推理:softmax 之后再平均
多尺度训练还有一个配套技巧:多尺度推理。测试时把同一张图像 resize 到 0.8x、1.0x、1.2x 三个尺度分别预测,把概率图对齐回原尺寸后取平均,再 argmax 得到最终 mask。对小器官分割通常能挽回 1 到 2 个 Dice 点:
with torch.no_grad(): probs = [] for s in [0.8, 1.0, 1.2]: out = model(F.interpolate(x, scale_factor=s, mode='bilinear', align_corners=False)) out = F.interpolate(out, size=(H, W), mode='bilinear', align_corners=False) probs.append(torch.softmax(out, dim=1)) pred = torch.stack(probs).mean(dim=0).argmax(dim=1)注意这里的顺序:多个尺度的概率图必须在softmax之后平均,不能在 logits 上直接平均。不同尺度的 logits 分布不同,直接平均会让最终结果偏向某些尺度。另外边缘部分在 resize 时会有插值误差,如果项目对边界要求严格,可以只对中心区域做多尺度融合,边缘保持原尺度结果。
4. 5 类别分割的训练细节:类别不平衡与损失参数
4.1 标签划分与数据加载
腹部多脏器 5 类别分割的标签通常按固定通道顺序排列,常见划分是:0 背景、1 肝脏、2 右肾、3 左肾、4 脾脏、5 胰腺。不过每个数据集的 mask 通道顺序不一定一致,我拿到任何新数据集都会先跑一遍遍历检查:
import numpy as np mask = np.load('label.npy') # 以实际格式为准 print(np.unique(mask))这一步决定了后面所有评估指标是否有效。如果标签是从 1 开始编的,而你的损失函数把 1 当成背景,整个训练都是错的。数据加载时还有一个关键问题:一个患者 CT 有 200 层切片,含肝脏的层可能占 80%,含胰腺的层可能只占 15%。随机采样会让每个 epoch 里小器官的出现次数太少,模型被大器官主导。
4.2 加权 Dice loss
五类分割最突出的问题是类别不平衡。肝脏的面积可能是脾脏的 5 到 10 倍,普通 CrossEntropy 会让模型偏向预测大类。这个项目里我用加权 Dice loss,背景类不参与计算,每个类单独算 Dice 再按频率倒数加权:
def weighted_dice_loss(pred, target, weights): # pred: [B, C, H, W] softmax 概率 # target: [B, H, W] 类别 id eps = 1.0 loss = 0.0 for c in range(1, pred.shape[1]): p = pred[:, c] t = (target == c).float() inter = (p * t).sum(dim=(1, 2)) union = p.sum(dim=(1, 2)) + t.sum(dim=(1, 2)) dice = (2 * inter + eps) / (union + eps) loss += (1 - dice).mean() * weights[c] return loss / sum(weights[1:])weights按类别面积占比的倒数设置,比如肝脏 0.8、双肾 1.0、脾脏 1.5、胰腺 2.0。eps=1.0是平滑项,防止某个类别在当前 patch 里完全没有出现时分母为零。这种加权方式比直接对每个像素加权更稳,因为 Dice 本身已经对区域大小做了一定归一化。
4.3 阈值与后处理
预测阶段的默认阈值 0.5 并不适合所有类别。我观察到脾脏和胰腺的预测概率通常在 0.6 到 0.8 之间,直接按 0.5 切容易把边缘像素归到相邻器官。所以我一般会在推理阶段保存 softmax 概率图,而不是直接保存 argmax 结果,留到后处理里按类别调阈值。
后处理里最实用的一步是小连通域删除。腹部 CT 中胰腺附近经常出现小面积误检,用 scipy 的连通域标记去掉面积小于 50 像素的区域,肉眼观感提升非常明显:
from scipy import ndimage labeled, num = ndimage.label(mask == 4) # 假设类别 4 是脾脏 for i in range(1, num + 1): if np.sum(labeled == i) < 50: mask[labeled == i] = 0这个阈值不是固定的,我一般先打印一次误检区域的面积分布再定。如果阈值设大了,会把真正的小器官切掉;设小了,后处理基本没效果。50 像素对 512x512 的 CT 切片来说是一个比较安全的起点。
5. 避坑:多脏器分割训练中的常见问题
5.1 显存溢出(CUDA out of memory)
现象:训练到某个 iteration 突然报 CUDA out of memory,前面一切正常,重启之后跑一会儿又炸。
原因:显存溢出是逐步累积的,前面的 iteration 稳定不代表后面稳定。最容易踩的是尺度抖动里random.uniform(0.75, 1.5),某个 iteration 随机到 1.5 倍大尺寸,再裁 320 patch,特征图立刻超限。另一个情况是验证阶段把整张 512x512 原图直接喂进去,没有分 patch 推理。
解决:把尺度抖动的上限从 1.5 改成 1.25,batch size 降到 2,开启torch.cuda.amp.autocast()混合精度。混合精度在 8 GB 显存下能多撑约 20% 的显存占用,精度通常不会明显下降。推理阶段用滑动窗口或直接多尺度推理,不要一整张图塞进模型。
5.2 验证集整体 Dice 不错但小器官 Dice 只有 0.3
现象:训练 loss 正常下降,平均 Dice 到了 0.75,但拆开看每一类的 Dice,脾脏和胰腺都在 0.3 左右。
原因:patch 的随机裁剪对小器官太不友好。patch 尺寸是 256 且完全随机裁剪时,每个 patch 里有脾脏的概率可能只有 20%,模型大部分 iteration 见不到脾脏,自然学不好。
解决:统计每个器官在切片中的覆盖率,如果某个器官覆盖率低于 30%,就在采样时做器官感知采样:包含小器官的切片被抽到的概率提高 2 到 3 倍。这个数据采样策略对分割效果的影响经常被低估,比改网络结构更有效。
5.3 加了多尺度训练反而掉点
现象:本来单尺度训练 Dice 还不错,加上尺度抖动之后,Dice 反而降了 1 个点,而且训练 loss 波动变大。
原因:mask 在 resize 时用了 bilinear 插值,结果类别标签之间出现非整数中间值。计算加权 Dice 时把 0.5 这种值当成独立类别,造成类别混淆。这是一个经典的翻车点:图像用了 bilinear,顺手给 mask 也用了同样的插值。
解决:mask 的 resize 永远用mode='nearest',图像用bilinear。代码里专门把这两行分开写,不要合并成一个函数。从那以后我每次写数据增强,都会先检查 mask 路径有没有混用插值方式。
5.4 推理结果全黑或类别错乱
现象:训练正常,loss 降得很好,但推理出来的 mask 全零,或者所有像素都被预测成同一个类别。
原因:标签范围检查没做,或者推理脚本里的num_classes和训练时不一致。常见的场景是训练时背景加 5 类共 6 类,模型输出通道是 6,但推理脚本里写成了 5;或者标签文件里索引从 1 开始,脚本里默认从 0 开始算。
解决:推理前固定打印两行调试信息:print(np.unique(mask))和print(model(img).shape)。把这两行写进推理脚本里,换数据集、换模型时不需要再去翻配置文件,能直接看出问题出在数据侧还是模型侧。
6. 验证方法:从 Dice 到边界目测再到体积估算
6.1 三行代码算 Dice
pred = model(img).argmax(dim=1).cpu().numpy() gt = mask.cpu().numpy() dice = [2 * np.sum((pred == c) & (gt == c)) / (np.sum(pred == c) + np.sum(gt == c) + 1e-6) for c in range(1, 6)]这段代码按类别输出五个 Dice 值。我一般只看两个数:平均值和最小值。平均 Dice 好不代表分割好——如果胰腺只有 0.2,其余四类都在 0.9 以上,平均 Dice 依然能到 0.75 左右。最小值才是这个项目能否使用的底线,如果最小 Dice 低于 0.5,先不要调网络,回去检查数据和采样策略。
6.2 边界叠加目测法
多类别分割里背景占比大,只看 Dice 数字不够直观。我的习惯是把预测边界和真实边界分别叠加在原图上,红色标出预测边界,绿色标出真实边界。这样能直接看到边界偏移方向:是系统性偏小,还是某个脏器边缘总被相邻器官侵占。医学分割里小器官错 1 到 2 像素对 Dice 影响很大,但对整体体积影响不大,目测能快速判断误差是否可接受。
6.3 器官体积估算
分割完成后,通常还需要报告器官体积。CT 数据的 header 里有 spacing 信息,每个像素对应物理尺寸,用 mask 像素数乘以层厚和像素间距换算成毫升。这一步也是验证手段:如果算出来的肝脏体积偏离临床上常见范围,说明某一步处理大概率有系统性错误,比如标签顺序错了或者横断位方向搞反了。
从那以后我每次换数据集、改网络结构,都会强制走一遍:先看标签值,再训练时打印每个类的 Dice,最后做边界目测和体积估算。这套流程帮我把很多“以为要改网络”的问题,变成了“其实是数据或参数设置不对”的问题。希望帮到你。
本文还有配套的精品资源,点击获取