简介:一套基于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数据:重采样、归一化与Dataset
2.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.gz
import 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, low=0.005, high=0.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.995;T1权重一般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_size=192, train=True): 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:y+self.patch_size, x:x+self.patch_size] msk = lab[z, y:y+self.patch_size, x:x+self.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, msktrain=True时最理想的做法是把肝脏标注投影到z轴可见性列表,只在有肝脏的切片里随机选,上面为了演示可读性用了全切片随机。实际项目建议先统计每层label的像素数,过滤掉占比低于0.1%的切片。增强只对二维slice做,因为旋转三维volume会引入不必要的插值。最后unsqueeze(0)把(H, W)变成(1, H, W),对应pytorch的channel-first张量。
这个Dataset在DataLoader里设置num_workers=4到8并开启persistent_workers=True,基本能满足单卡训练吞吐。数据总量只有几十例时,每个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_groups=8): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.GroupNorm(norm_groups, out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1), nn.GroupNorm(norm_groups, out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_channels=1, base_channels=32, depth=4): 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_size=2, stride=2)) self.decoders.append(DoubleConv(out_ch, skip_ch)) out_ch = out_ch // 2 self.out_conv = nn.Conv2d(out_ch, 1, kernel_size=1) 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]], dim=1) x = self.decoders[i](x) return torch.sigmoid(self.out_conv(x))代码逻辑:每下采样一次,通道数翻倍,空间尺寸减半;解码阶段concat后通道数是skip + up两部分的叠加,DoubleConv要把通道合并。上面的实现里最后两个解码层输出通道会降到32以下,实际项目可以在最后一个DoubleConv里加一个min_out=64的保护,防止特征过薄导致边界恢复不足。网络输出加sigmoid后可以直接配BCE或Dice损失。如果不想最后输出概率而是logits,就把sigmoid去掉,损失函数内部做变换。
3.3 归一化层怎么选:GroupNorm vs BatchNorm
代码里用了GroupNorm而不是BatchNorm。原因是MRI数据batch size通常偏小,比如2到8,而且不同患者体素灰度范围差异大,BatchNorm在小batch下统计量抖动明显。GroupNorm按通道分组做归一化,与batch大小无关,对这类batch=4左右的医学分割训练更稳。显存比较紧张时还可以把base_channels从32降到16,对肝脏这种边界相对规整的器官,Dice通常只掉零点几个百分点。
表里给出一个可直接抄的Unet配置组合:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| base_channels | 32 | 显存紧张降到16 |
| depth | 4 | 肝脏边界不算极精细,4层足够 |
| kernel_size | 3,padding=1 | 保持特征图尺寸不变 |
| 归一化 | GroupNorm(8组) | 避免小batch下BN统计不稳 |
| 上采样 | ConvTranspose2d stride=2 | 简单稳定,不需要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_size=1) self.w_x = nn.Conv2d(f_skip, f_up // 2, kernel_size=1) self.psi = nn.Conv2d(f_up // 2, 1, kernel_size=1) 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, smooth=1e-5): pred = pred.contiguous().view(pred.size(0), -1) target = target.contiguous().view(target.size(0), -1) inter = (pred * target).sum(dim=1) union = pred.sum(dim=1) + target.sum(dim=1) 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稍高容易让训练震荡,需要调。对多数项目,BCE+Dice是第一选择,不要一开始就上复杂损失。
4.2 评估指标:Dice、IoU、95%HD
训练过程中要监控验证集的Dice,但Dice不是唯一指标。Dice对大面积器官比较宽容,某些边缘缺陷只掉零点几个点;医生更在意边界是否整齐。95%HD(Hausdorff距离的第95百分位)能捕捉局部最大误差,对边缘类错误更敏感。计算肝脏mask距离图后,对预测边界上的每个点取最小距离,排序取95百分位,数值越小边界误差越小。
验证循环每两三个epoch跑一次。若训练集只有几十例,常见做法是用5折交叉验证,把这几个指标的平均值和标准差都打出来。一个规律:同一个Unet配置下,Dice浮动超过2个百分点大概率是训练过程不稳定,而不是模型结构问题,先检查学习率和增强,不要急着换架构。
4.3 训练参数与pytorch环境搭配
下表是肝脏MRI分割场景下我常用的参数范围:
| 参数 | 推荐范围 | 说明 |
|---|---|---|
| patch_size | 160×160 ~ 240×240 | 根据显存选,patch越大边缘上下文越多 |
| batch_size | 4 ~ 16 | GroupNorm下小batch也稳定 |
| 初始学习率 | AdamW 2e-4 ~ 1e-3 | 换SGD可调高到1e-2,动量0.9 |
| weight_decay | 1e-5 ~ 1e-4 | 小数据下防止过拟合 |
| schedule | ReduceLROnPlateau | patience 8-12轮,factor 0.5 |
| 训练轮数 | 150 ~ 300 | 小数据集通常200轮左右 |
| 优化器 | AdamW 或 SGD | AdamW收敛快,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 192x192,stride 96。边界处概率偏低是正常现象,平均后即可消除。
可以做简单TTA(Test 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, threshold=0.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, iterations=2) return maskndimage.label默认按四邻域或八邻域连通,肝脏这种大器官在2D切片上取最大连通域基本不会误伤边缘。如果目标是包含肿瘤的精细分割,建议先在3D空间做一次连通域分析,再回贴到每一层,否则2D切片间可能断裂。闭运算iteration控制在2以内,iteration过大会把肝周的低信号区域也吞进来,边缘反而粗糙。这一步对Dice的收益在带血管和胆管的T2序列上比T1更明显。
5.3 把Unet分模块拆开检查
如果项目跑完Dice低于预期,按“数据→模型→损失→后处理”顺序检查。先可视化预处理后的切片:归一化后肝脏和背景应肉眼可分辨;再输出模型在某个batch上的sigmoid概率图,如果所有值都在0.5以下,说明模型没训练好,回损失函数;如果mask基本对但边缘碎,后处理参数有问题。分割这类带血管空腔的器官时,不要只看Dice,要同时看预测mask的连通域数量和边界距离。
本文还有配套的精品资源,点击获取