尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

PyTorch UNet肝脏MRI分割实战:数据预处理、模型训练与推理后处理全解

PyTorch UNet肝脏MRI分割实战:数据预处理、模型训练与推理后处理全解 简介一套基于PyTorch与U-Net架构的MRI肝脏图像分割完整项目方案面向计算机专业毕业设计、课程设计以及需要医学影像实战练习的初学者。项目包含可运行的Python源码、预处理后的肝脏MRI数据集与训练好的模型权重覆盖数据增强、模型训练、评估与推理全流程可帮助读者快速复现分割效果深入理解U-Net在医学图像处理中的实现思路。压缩包共1070个文件其中1065张增强后的PNG图像构成训练与验证样本4个py文件分别承担数据加载、网络定义、训练及预测等核心功能另有1份README说明文档整体体积约21.86MB目录结构清晰便于按模块查阅与二次开发。该资源曾获导师认可评审分达98分目前已有341人学习下载适合用作高分毕设参考或医学图像分割项目的起步模板。1. 基于pytorch的Unet肝脏MRI分割先想清楚任务边界解压这类“源码数据集模型”的zip包第一件事通常不是跑训练而是先确认数据形态。基于pytorch的Unet肝脏MRI分割任务定义非常简单把腹部MRI里的肝脏像素标成1其余标成0但落地细节并不简单MRI没有CT那种量化的CT值灰度受扫描序列、偏置场和呼吸伪影影响而肝脏和周围组织在T1/T2上的对比又偏弱。下面按读取数据、构建pytorch的Unet、设计损失与训练、推理后处理这条主线展开目标是把“高分项目”里真正影响Dice分数的部分讲透你可以直接照着参数和代码改自己的项目。2. 用pytorch准备肝脏MRI数据重采样、归一化与Dataset2.1 先看数据形态spacing与标注序列一个标注好的肝脏MRI数据集通常每例是一个nii.gz文件。解压后不要急着写加载代码先看三个信息spacing、origin、orientation。肝脏MRI的常规扫面层厚在3-5毫米xy平面分辨率在0.6-1.2毫米不等集合里各例的厚度差别很大。如果直接把不同spacing的卷按原始尺寸输入二维Unet网络看到的肝脏缩放比例各不相同学出来的模型在验证集上容易忽高忽低。常见做法是先重采样到各向同性或准各向同性体素然后再送模型。因为肝脏MRI允许一定的层间模糊我一般先把spacing统一到(1.0, 1.0, 2.0)层内细节不丢显存压力也小于1mm的1x1x1。重采样时图像用线性插值、标签mask用最近邻插值否则标签边界会出现不属于标注集的数值。2.1.1 用SimpleITK重采样nii.gzimport SimpleITK as sitk import numpy as np def resample_nii(image_path: str, target_spacing(1.0, 1.0, 2.0), is_label: bool False): img sitk.ReadImage(image_path) orig_spacing np.array(img.GetSpacing()) orig_size np.array(img.GetSize()) new_size (orig_size * orig_spacing / np.array(target_spacing)).round().astype(int) resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(list(target_spacing)) resampler.SetSize([int(s) for s in new_size]) resampler.SetOutputOrigin(img.GetOrigin()) resampler.SetOutputDirection(img.GetDirection()) resampler.SetInterpolator(sitk.sitkNearestNeighbor if is_label else sitk.sitkLinear) return sitk.GetArrayFromImage(resampler.Execute(img))代码逻辑new_size按“现有体素大小 × 现有spacing ÷ 目标spacing”计算体素数量与物理尺寸的对应关系保持不变。对label用最近邻插值可以防止重采样带来标注数值被抹成小数对图像用线性插值即可不需要更高阶的Bspline二倍以内的缩放线性插值足够过度插值反而会把肝脏边缘磨钝。返回numpy数组时注意维度顺序SimpleITK的数组维度是(z, y, x)后续送pytorch前要按需求转置。2.2 MRI归一化序列差异与百分位裁剪2.2.1 序列如何选MRI的“灰度值”没有物理单位不同扫描序列下的同一肝脏信号完全不同。如果你下载的项目zip里附带的是Dixon序列通常包含同相、反相、水相、脂肪相四组图标注如果画在水相上就只用那一套输入不要为了增加通道数把极端序列都喂进去。下表是常见选择序列灰度上肝脏表现对分割的影响T1WI肝脏呈中等信号肝内血管对比弱适合勾画整体轮廓T2WI肝脏信号中等胆道高信号血管和胆管干扰较大需后处理Dixon water脂肪抑制后实质均匀肝脏与脾脏对比清晰多数项目首选Dixon fat脂肪组织亮肝脏边缘噪声大很少单独用如果不是专业读片背景判断依据很简单标签在哪个序列上画出来的就用哪个序列。把同一卷的全部切片重采样后按z轴顺序保存为npy训练时的I/O压力会小很多也方便随机抽样。2.2.2 归一化的正确姿势肝脏MRI灰度范围波动大不能像CT那样套固定的窗宽窗位。推荐做法是先对整卷做0.5%到99.5%的百分位裁剪去掉远端离群点再在非背景区域上计算均值和标准差做z-score归一化。如果背景占大头直接在整卷上算均值会被背景拉偏分割效果会明显变差。下面这个预处理函数是实际项目中最常用的一套def normalize_volume(volume: np.ndarray, low0.005, high0.995) - np.ndarray: # 百分位裁剪剔除局部线圈导致的亮斑和空气噪声 vmin, vmax np.percentile(volume, (low * 100, high * 100)) volume np.clip(volume, vmin, vmax) # 用前景均值方差做z-score避免背景拉偏统计量 mask volume volume.mean() * 0.1 mean volume[mask].mean() std volume[mask].std() volume (volume - mean) / (std 1e-8) return volume裁剪阈值low和high按数据分布微调T2权重图动态范围大可以考虑把high改成0.995T1权重一般0.5和99.5正好。z-score后模型的激活值分布更稳定batch_size不足以支撑大显存时对训练收敛帮助明显。2.3 用pytorch Dataset做随机patch采样与增强MRI三维体数据不适合整个volume送进模型常见做法是随机采样一个固定尺寸的patch比如192×192。这样做既能做数据增强又相当于给网络随机提供了肝脏局部结构对小数据集更友好。把zip里的imagesTr和labelsTr解压后按路径列表传给下面的Dataset即可。import torch import numpy as np from torch.utils.data import Dataset class LiverPatchDataset(Dataset): def __init__(self, volumes, labels, patch_size192, trainTrue): self.volumes volumes # list of (D, H, W) self.labels labels self.patch_size patch_size self.train train def __len__(self): # 每个volume随机采5个patch相当于扩大样本量 return len(self.volumes) * 5 def __getitem__(self, idx): vol_idx idx // 5 vol self.volumes[vol_idx] lab self.labels[vol_idx] d, h, w vol.shape # 只从包含肝脏的切片附近采样提高正样本比例 z np.random.randint(0, d) y np.random.randint(0, max(1, h - self.patch_size)) x np.random.randint(0, max(1, w - self.patch_size)) img vol[z, y:yself.patch_size, x:xself.patch_size] msk lab[z, y:yself.patch_size, x:xself.patch_size] if self.train: # 在二维切片上做强增强旋转和翻转对肝脏形态扰动最小 if np.random.rand() 0.5: img, msk img[:, ::-1], msk[:, ::-1] k np.random.randint(0, 4) img np.rot90(img, k) msk np.rot90(msk, k) img torch.from_numpy(img.copy()).unsqueeze(0).float() msk torch.from_numpy(msk.copy()).unsqueeze(0).float() return img, msktrainTrue时最理想的做法是把肝脏标注投影到z轴可见性列表只在有肝脏的切片里随机选上面为了演示可读性用了全切片随机。实际项目建议先统计每层label的像素数过滤掉占比低于0.1%的切片。增强只对二维slice做因为旋转三维volume会引入不必要的插值。最后unsqueeze(0)把(H, W)变成(1, H, W)对应pytorch的channel-first张量。这个Dataset在DataLoader里设置num_workers4到8并开启persistent_workersTrue基本能满足单卡训练吞吐。数据总量只有几十例时每个epoch多采几个patch配合增强比过早加复杂的预处理更有效。3. 手写Unet骨架pytorch里搭编码器、跳跃连接与深监督3.1 为什么肝脏MRI分割仍然首选Unet先澄清一个误区Unet不是旧模型而是这个任务里的“稳定基线”。MRI肝脏分割的样本量通常只有几十到一两百例Transformer类模型在这个规模下很容易欠拟合或过拟合Unet相对参数少跳跃连接又把编码阶段的边缘信息直接传递到解码阶段对肝脏和脾脏、胃壁之间灰度接近的模糊边界非常有效。这个任务里“高分”项目通常不是换掉Unet而是在Unet上加残差块、注意力门和深监督。3.2 一个可运行的pytorch Unet骨架下面这版是几十行级别但结构完整的实现。DoubleConv是Unet的基本单元每次下采样前执行两层3×3卷积编码器做四次下采样特征图从64通道扩到512解码器把特征图逐步恢复回原始分辨率每一层解码器都和编码器对应层做concat这是Unet和普通自动编码器最重要的区别。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch, norm_groups8): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size3, padding1), nn.GroupNorm(norm_groups, out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, kernel_size3, padding1), nn.GroupNorm(norm_groups, out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels1, base_channels32, depth4): super().__init__() self.depth depth self.encoders nn.ModuleList() self.pools nn.ModuleList() in_ch in_channels out_ch base_channels for _ in range(depth): self.encoders.append(DoubleConv(in_ch, out_ch)) self.pools.append(nn.MaxPool2d(2)) in_ch, out_ch out_ch, out_ch * 2 self.bottleneck DoubleConv(in_ch, out_ch) self.upconvs nn.ModuleList() self.decoders nn.ModuleList() for i in range(depth): skip_ch out_ch // 2 self.upconvs.append(nn.ConvTranspose2d(out_ch, skip_ch, kernel_size2, stride2)) self.decoders.append(DoubleConv(out_ch, skip_ch)) out_ch out_ch // 2 self.out_conv nn.Conv2d(out_ch, 1, kernel_size1) def forward(self, x): skips [] for enc, pool in zip(self.encoders, self.pools): x enc(x) skips.append(x) x pool(x) x self.bottleneck(x) for i in range(self.depth - 1, -1, -1): x self.upconvs[i](x) x torch.cat([x, skips[i]], dim1) x self.decoders[i](x) return torch.sigmoid(self.out_conv(x))代码逻辑每下采样一次通道数翻倍空间尺寸减半解码阶段concat后通道数是skip up两部分的叠加DoubleConv要把通道合并。上面的实现里最后两个解码层输出通道会降到32以下实际项目可以在最后一个DoubleConv里加一个min_out64的保护防止特征过薄导致边界恢复不足。网络输出加sigmoid后可以直接配BCE或Dice损失。如果不想最后输出概率而是logits就把sigmoid去掉损失函数内部做变换。3.3 归一化层怎么选GroupNorm vs BatchNorm代码里用了GroupNorm而不是BatchNorm。原因是MRI数据batch size通常偏小比如2到8而且不同患者体素灰度范围差异大BatchNorm在小batch下统计量抖动明显。GroupNorm按通道分组做归一化与batch大小无关对这类batch4左右的医学分割训练更稳。显存比较紧张时还可以把base_channels从32降到16对肝脏这种边界相对规整的器官Dice通常只掉零点几个百分点。表里给出一个可直接抄的Unet配置组合配置项推荐值说明base_channels32显存紧张降到16depth4肝脏边界不算极精细4层足够kernel_size3padding1保持特征图尺寸不变归一化GroupNorm(8组)避免小batch下BN统计不稳上采样ConvTranspose2d stride2简单稳定不需要PixelShuffle输出层1x1 Conv Sigmoid二分类标准结构3.4 在解码器加注意力门和深监督Unet跳跃连接把所有编码特征直接拼给解码器这个操作里包含大量背景信息。注意力门Attention Gate的思想是在跳跃连接前生成一个0到1的权重把与肝脏相关的特征放大、不相关的抑制。它只增加几十万参数训练速度基本不影响但对低对比度的肝顶区域有帮助。class AttentionGate(nn.Module): def __init__(self, f_up, f_skip): super().__init__() self.w_g nn.Conv2d(f_up, f_up // 2, kernel_size1) self.w_x nn.Conv2d(f_skip, f_up // 2, kernel_size1) self.psi nn.Conv2d(f_up // 2, 1, kernel_size1) def forward(self, g, x): # g: 上采样后的gate特征, x: 编码器skip特征 tg self.w_g(g) tx self.w_x(x) alpha torch.sigmoid(self.psi(torch.relu(tg tx))) return x * alpha把编码器输出的skip特征过一遍AttentionGate用上一层的上采样特征g作为引导再送去concat。深监督则是把每个解码层都接一个1x1卷积输出预测与ground truth算辅助损失推理时只用最终输出对肝脏这种边界模糊的器官深监督能让浅层解码器更快学到轮廓信息代价是训练时长增加约10%。4. 训练Unet的损失函数、学习率与Dice评估4.1 损失函数BCE Dice是默认组合训练二分肝脏分割时Dice Loss直接优化目标但它对损失曲面的曲度不平滑BCE对每个像素独立计算梯度更稳定。两者相加可以兼顾Dice部分解决正负样本不均衡BCE部分保证梯度不消失。代码def dice_loss(pred, target, smooth1e-5): pred pred.contiguous().view(pred.size(0), -1) target target.contiguous().view(target.size(0), -1) inter (pred * target).sum(dim1) union pred.sum(dim1) target.sum(dim1) return 1 - (2 * inter smooth) / (union smooth) def bce_dice_loss(pred, target): bce nn.functional.binary_cross_entropy(pred, target) return bce dice_loss(pred, target)pred是sigmoid之后的概率target是0/1 mask。smooth取1e-5防止空集除零。如果数据集里肝脏占比不到5%BCE权重不要超过1否则大量背景像素会主导梯度。实际项目中我倾向于在300轮训练的前100轮用0.5*bce dice后面把bce权重降为0.2这样先快速收敛再精细分割。什么情况下换Tversky或Focal当验证集的假阳/假阴性比例失衡严重时。肝脏分割中背景占绝大比例如果网络把脾脏大面积误标为肝脏β大于α的Tversky能压低假阳如果漏检肝顶或尾状叶则提升α。Focal在背景极大的极端不平衡任务里更有效但超参数gamma稍高容易让训练震荡需要调。对多数项目BCEDice是第一选择不要一开始就上复杂损失。4.2 评估指标Dice、IoU、95%HD训练过程中要监控验证集的Dice但Dice不是唯一指标。Dice对大面积器官比较宽容某些边缘缺陷只掉零点几个点医生更在意边界是否整齐。95%HDHausdorff距离的第95百分位能捕捉局部最大误差对边缘类错误更敏感。计算肝脏mask距离图后对预测边界上的每个点取最小距离排序取95百分位数值越小边界误差越小。验证循环每两三个epoch跑一次。若训练集只有几十例常见做法是用5折交叉验证把这几个指标的平均值和标准差都打出来。一个规律同一个Unet配置下Dice浮动超过2个百分点大概率是训练过程不稳定而不是模型结构问题先检查学习率和增强不要急着换架构。4.3 训练参数与pytorch环境搭配下表是肝脏MRI分割场景下我常用的参数范围参数推荐范围说明patch_size160×160 ~ 240×240根据显存选patch越大边缘上下文越多batch_size4 ~ 16GroupNorm下小batch也稳定初始学习率AdamW 2e-4 ~ 1e-3换SGD可调高到1e-2动量0.9weight_decay1e-5 ~ 1e-4小数据下防止过拟合scheduleReduceLROnPlateaupatience 8-12轮factor 0.5训练轮数150 ~ 300小数据集通常200轮左右优化器AdamW 或 SGDAdamW收敛快SGD泛化略好在anaconda里建一个pytorch环境跑这套训练torch 2.x的版本与CUDA对应安装即可torchvision在这个任务里用到的不多不装也行。显存不足时优先降低patch_size而不是batch_size。模型权重用torch.save保存成pth加载时注意类结构和state_dict的key要对应zip里带着的model权重如果报尺寸不匹配多半是base_channels或depth配置与源码不一致。5. 推理阶段滑窗、后处理与Unet的落地检查清单5.1 大体积MRI的滑窗推理推理时整个volume直接forward会爆显存因为肝脏MRI卷的z轴通常有几十层。常见做法仍是滑窗按固定patch一般和训练时一致或更大遍历切片stride小于patch时重叠区域会得到多个预测把多个概率值平均作为该像素的最终概率。重叠的倍数通常设为50%即patch 192x192stride 96。边界处概率偏低是正常现象平均后即可消除。可以做简单TTATest Time Augmentation预测时把输入做水平翻转得到原图与翻转图两个概率翻转图恢复方向后与原始输出平均。这个操作一般能抬升0.3到0.8个Dice几乎零成本。要注意TTA的翻转轴必须与训练增强保持一致否则对网络来说是分布外输入。5.2 用最大连通域与形态学运算清理伪影模型输出概率图后转为0/1 mask之前通常加一个0.5阈值。但MRI里肝周脂肪、肠道壁常出现小片假阳肝脏内部的血管和胆管会形成低置信区域导致真实mask内部出现hole。所以还要一步后处理只保留最大的连通域作为肝脏然后做闭运算填孔和连接断开的边缘。from scipy import ndimage def postprocess(pred_prob, threshold0.5): binary pred_prob threshold label_image, num ndimage.label(binary) if num 0: return binary sizes ndimage.sum(binary, label_image, range(1, num 1)) if len(sizes) 0: return binary largest np.argmax(sizes) 1 mask label_image largest # 闭运算填掉肝内血管造成的细小断裂 mask ndimage.binary_closing(mask, iterations2) return maskndimage.label默认按四邻域或八邻域连通肝脏这种大器官在2D切片上取最大连通域基本不会误伤边缘。如果目标是包含肿瘤的精细分割建议先在3D空间做一次连通域分析再回贴到每一层否则2D切片间可能断裂。闭运算iteration控制在2以内iteration过大会把肝周的低信号区域也吞进来边缘反而粗糙。这一步对Dice的收益在带血管和胆管的T2序列上比T1更明显。5.3 把Unet分模块拆开检查如果项目跑完Dice低于预期按“数据→模型→损失→后处理”顺序检查。先可视化预处理后的切片归一化后肝脏和背景应肉眼可分辨再输出模型在某个batch上的sigmoid概率图如果所有值都在0.5以下说明模型没训练好回损失函数如果mask基本对但边缘碎后处理参数有问题。分割这类带血管空腔的器官时不要只看Dice要同时看预测mask的连通域数量和边界距离。本文还有配套的精品资源点击获取
返回列表