ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Unet+Resnet多类别分割:腹部多脏器数据集实战解析

Unet+Resnet多类别分割:腹部多脏器数据集实战解析 简介面向医学图像分割入门与进阶开发者的UnetResnet多尺度分割实战项目配套腹部多脏器5类别分割数据集。工程将Unet骨干替换为Resnet并实现将数据随机缩放至设定尺寸0.51.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人学习下载适合想快速上手多类别分割项目、理解UnetResnet多尺度训练细节的研究者或工程开发者。1. 深度学习 UnetResnet 多类别分割这份腹部多脏器数据集项目到底能解决什么医学图像分割里UnetResnet 是最常见的组合但常见不等于容易跑通。我最近拆完一份腹部多脏器 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, backboneresnet34, in_channels3, pretrainedTrue): super().__init__() resnet getattr(models, backbone)(pretrainedpretrained) # 第一层换成 3x3 stride1避免一进入网络就丢细节 self.conv1 nn.Conv2d(in_channels, 64, kernel_size3, stride1, padding1, biasFalse) 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_channels3是因为我把单通道 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_size2, stride2) self.conv nn.Conv2d(in_ch // 2 skip_ch, out_ch, kernel_size3, padding1) def forward(self, x, skip): x self.up(x) # 尺寸不整除时先对齐再拼接避免 shape 报错 if x.shape[-2:] ! skip.shape[-2:]: x nn.functional.interpolate( x, sizeskip.shape[-2:], modebilinear, align_cornersFalse) return self.conv(torch.cat([x, skip], dim1))拼接之前的interpolate对齐是我强烈建议保留的。Unet 对输入尺寸要求不高但下采样次数不同会让特征图在边界处差半个像素不加对齐会经常遇到 shape mismatch 的报错。加上这个保护之后换输入尺寸、换 backbone 都不需要反复查维度。2.3 预训练权重加载第一次最容易在这里翻车用 torchvision 的 resnet34 时pretrainedTrue会自动下载 ImageNet 权重。但很多教程会让你手工改 fc 层、换 decoder最后 state_dict 的 key 对不上加载时直接报错。最稳妥的加载方式是使用strictFalse先打印缺失和多余的 keycheckpoint torch.load(pretrained/resnet34.pth) missing, unexpected model.encoder.load_state_dict(checkpoint, strictFalse) print(missing:, missing) print(unexpected:, unexpected)这里missing是预训练权重里有、但当前模型里没有的层unexpected是当前模型有、但预训练权重里没有的层。理想情况下missing里只有conv1.weight和fcunexpected里也只有conv1.weight和fc因为我把 7x7 卷积换成了 3x3且没有任何全连接层。如果还有其他层出现在列表里先检查是不是 backbone 名字写错了或者模型定义里多加了一个模块。这一步做对了后面训练才会真正吃到预训练红利。3. 多尺度训练patch 怎么取、尺度抖动怎么设3.1 腹部 CT 序列的尺寸与 patch 选取腹部多脏器分割数据集的原始切片常见尺寸在 512x512 左右五个器官里肝脏占比大脾脏和胰腺占比小。直接整图训练模型会把大部分参数浪费在背景上小器官的像素占比太低网络很难学到有效特征。常见做法是随机裁剪固定 patch我在这份数据上试出来的有效区间是 192x192 到 320x320 之间。patch 尺寸显存需求batch2效果备注128x1282~4 GB小器官容易没裁全脾脏召回率低192x1924~6 GB边角偶尔丢掉整体稳定256x2566~8 GB本次实战主要使用的尺寸320x32012 GB 以上多尺度收益明显但显存压力大表格里的显存需求默认是 resnet34 encoder 加中等深度 decoder 的配置开启混合精度后还可以再省 20% 左右。如果显存只有 8 GB建议直接从 256x256 起步。注意 patch 不是越大越好patch 太大一个 batch 里实际能放进的小器官数量反而变少因为每个 patch 都被大器官和背景占据。3.2 两种多尺度实现尺度抖动与双尺度并行多尺度训练的本质是让模型同时看到不同分辨率的器官既掌握粗粒度特征又保留细粒度特征。两种常见做法里尺度抖动实现最简单每个 iteration 随机把输入 resize 到 0.75x 到 1.5x再裁剪固定 patchscale 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), modebilinear, align_cornersFalse) mask F.interpolate(mask.unsqueeze(0).float(), size(new_h, new_w), modenearest).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_factors, modebilinear, align_cornersFalse)) out F.interpolate(out, size(H, W), modebilinear, align_cornersFalse) probs.append(torch.softmax(out, dim1)) pred torch.stack(probs).mean(dim0).argmax(dim1)注意这里的顺序多个尺度的概率图必须在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。eps1.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.25batch 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 永远用modenearest图像用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 三行代码算 Dicepred model(img).argmax(dim1).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最后做边界目测和体积估算。这套流程帮我把很多“以为要改网络”的问题变成了“其实是数据或参数设置不对”的问题。希望帮到你。本文还有配套的精品资源点击获取
返回列表