ARTICLE DETAIL

资讯详情

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

基于Unet的心脏分割实战:从数据准备到推理部署的Python源码与模型

基于Unet的心脏分割实战:从数据准备到推理部署的Python源码与模型 简介这份资源面向计算机相关专业学生与深度学习入门者提供一套基于U-Net实现心脏图像分割的完整课程设计项目可直接用于期末大作业、课程设计或项目实战练习。压缩包共620个文件约53.53MB其中597个png为心脏影像与分割结果图12个py脚本覆盖数据加载、模型搭建、训练与评估流程另有2个h5权重文件、readme.md说明文档及miou-pa-cpa指标记录便于复现与对照。项目经导师指导并获98分认可包含训练日志与评估指标读者可据此理解U-Net编码器-解码器结构、跳跃连接与医学图像分割的完整实现路径并在此基础上替换数据集或调整网络完成二次开发。目前已有405人学习下载适合需要快速获取可运行源码与预训练模型的学习者参考。1. 心脏分割任务里Unet 为什么成了那根定海神针拿到「基于Unet实现的心脏分割任务python源码模型」这个标题多数人第一反应是去找一份能跑的代码但真正卡住人的往往不是代码本身而是不知道这套东西在医学影像里到底解决什么问题。心脏分割要做的是从心脏 MRI 或 CT 的体数据里把左心室、右心室、心肌这些结构逐像素标出来输出一张和原图同尺寸的掩膜。这件事在临床上直接关系到射血分数、心室容积这些指标的测量精度手工勾画一个病例动辄半小时以上而且不同医生之间差异不小。Unet 之所以在这个任务里被反复提起是因为它的编码器-解码器加跳跃连接结构天生适合处理「输入输出尺寸一致、目标边界精细」的分割问题小样本医学数据上也能训出可用的结果。这篇内容面向的是手里有心脏影像数据、想用 Python 把 Unet 跑起来并拿到可用模型的从业者从数据准备、网络搭建、训练调参到推理验证把每一步能复现的细节讲清楚顺带把那些跑不通、训不动的坑提前标出来。2. 心脏分割的数据准备与 Unet 输入构造2.1 心脏影像数据的常见格式与读取方式心脏分割公开数据集里常见的是 NIfTI 格式的 3D 体数据扩展名.nii或.nii.gz配套的标签也是同格式。也有部分数据以 DICOM 序列形式给出一个病例一个文件夹里面几十到几百张切片。Python 里读 NIfTI 用nibabel读 DICOM 用pydicom或SimpleITK这两个库在医学影像圈子里基本是标配。我一般会先把所有病例统一转成 NIfTI因为后续做重采样、裁剪、增强都方便DICOM 的元信息在分割任务里用得不多。import nibabel as nib import numpy as np # 读取一个病例的影像和标签 img nib.load(patient001.nii.gz) label nib.load(patient001_label.nii.gz) # 拿到 numpy 数组注意方向 img_data img.get_fdata() # shape: (H, W, D) label_data label.get_fdata() # 标签通常是整数 print(img_data.shape, img_data.dtype) print(np.unique(label_data)) # 看标签里有哪些类别这段代码做的是最基础的读取。get_fdata()返回 float64 数组如果数据量大后面要转成 float32 省内存。np.unique看标签类别心脏分割常见的是 0 背景、1 左心室、2 心肌、3 右心室具体看数据集定义。注意 NIfTI 有方向信息img.affine里存着体素到世界坐标的映射做重采样时不能丢。2.2 从 3D 体数据到 2D 切片的转换策略Unet 原版是 2D 的直接吃 3D 体数据需要改成 3D Unet显存吃得厉害。多数人起步阶段会把 3D 体数据沿轴向切成 2D 切片一片一片训推理时再拼回去。这个策略的代价是丢失了层间上下文但对心脏分割来说短轴切面本身层间变化平缓2D 切片训练已经能拿到不错的结果。切的时候要注意不是所有切片都有标签心脏区域只占一部分空白切片要过滤掉否则正负样本极度不平衡。def extract_slices(img_data, label_data, axis2): slices [] for i in range(img_data.shape[axis]): img_slice np.take(img_data, i, axisaxis) label_slice np.take(label_data, i, axisaxis) # 过滤掉没有前景的切片 if label_slice.sum() 0: slices.append((img_slice, label_slice)) return slices slices extract_slices(img_data, label_data) print(f有效切片数: {len(slices)})axis2对应短轴方向具体取哪个轴要看数据采集方向。label_slice.sum() 0这个条件把纯背景切片扔掉能显著减少训练时间。如果某些病例前景切片很少可以考虑过采样但别把背景切片全扔了模型需要一定比例的负样本学会不误报。2.3 归一化、重采样与数据增强的参数设置医学影像的灰度范围因设备而异直接送进网络会训崩。常见做法是先做 z-score 归一化用前景区域的均值和标准差而不是全图因为背景大片零值会把统计量拉偏。重采样到统一 spacing 也很关键不同病例体素大小不一样不统一的话模型学到的尺度是乱的。数据增强方面心脏分割常用随机旋转、缩放、弹性形变弹性形变对模拟心脏形变很有效但参数别开太大。from scipy.ndimage import zoom def normalize_foreground(img, label): foreground img[label 0] if foreground.size 0: return img mean foreground.mean() std foreground.std() 1e-8 return (img - mean) / std def resample_to_spacing(img, label, current_spacing, target_spacing(1.0, 1.0, 1.0)): factors [c / t for c, t in zip(current_spacing, target_spacing)] img_resampled zoom(img, factors, order1) label_resampled zoom(label, factors, order0) return img_resampled, label_resamplednormalize_foreground只用前景像素算统计量避免背景干扰。resample_to_spacing里影像用一阶插值order1标签用最近邻order0这个区别很重要标签插值成小数就没法做交叉熵了。目标 spacing 设成 1mm 是常见选择具体看你的数据分辨率别把低分辨率数据硬拉到高分辨率插值出来的细节是假的。3. Unet 网络搭建与心脏分割的输出层设计3.1 编码器-解码器结构与跳跃连接的作用Unet 的核心是下采样提特征、上采样恢复分辨率中间用跳跃连接把编码器的高分辨率特征直接拼到解码器对应层。心脏分割里左心室和心肌的边界很细下采样几次之后边界信息就模糊了跳跃连接相当于给解码器递了一份「原始细节备份」这是 Unet 在边界精度上比普通全卷积网络强的主要原因。编码器每层通常是两次 3x3 卷积加 ReLU然后 2x2 最大池化解码器是上采样后与对应编码器特征拼接再两次卷积。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x)DoubleConv是 Unet 的基本砖块padding1保证卷积后尺寸不变BatchNorm2d在医学影像上能加速收敛但 batch size 很小时统计量不稳可以换InstanceNorm2d或GroupNorm。这个类不包含池化和上采样那些在主干网络里单独写。3.2 针对多类别心脏结构修改输出通道与损失函数心脏分割通常不止一类左心室、心肌、右心室要分开输出通道数等于类别数含背景。最后一层用 1x1 卷积把特征图压到类别数不加 softmax因为损失函数里会用CrossEntropyLoss它内部带 softmax。如果类别不平衡严重比如心肌像素远少于背景可以给损失加权重或者用 Dice Loss 和交叉熵的组合。class UNet(nn.Module): def __init__(self, in_ch1, n_classes4): super().__init__() self.enc1 DoubleConv(in_ch, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.pool nn.MaxPool2d(2) self.up2 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec2 DoubleConv(256, 128) self.up1 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec1 DoubleConv(128, 64) self.out nn.Conv2d(64, n_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) d2 self.dec2(torch.cat([self.up2(e3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)这里为了简洁只写了两层下采样实际用的时候一般四到五层。torch.cat的dim1是通道维拼接这是跳跃连接的关键操作。n_classes4对应背景加三类心脏结构如果你的数据集只有左心室和背景改成 2。输出层不加激活训练时直接送CrossEntropyLoss推理时再argmax。3.3 训练循环、学习率与 batch size 的实操取值训练循环里优化器我一般用 Adam初始学习率 1e-3 到 1e-4 之间配合ReduceLROnPlateau在验证损失不降时减半。batch size 受显存限制2D 切片 256x256 输入8GB 显存大概能跑 batch 16不够就降到 8 或 4同时把学习率按比例调小。验证指标用 Dice 系数比像素准确率更能反映分割质量因为背景占大头准确率容易虚高。from torch.optim import Adam from torch.optim.lr_scheduler import ReduceLROnPlateau model UNet(in_ch1, n_classes4).cuda() optimizer Adam(model.parameters(), lr1e-3) scheduler ReduceLROnPlateau(optimizer, modemin, patience5, factor0.5) criterion nn.CrossEntropyLoss() for epoch in range(100): model.train() for img, label in train_loader: img, label img.cuda(), label.cuda().long() optimizer.zero_grad() pred model(img) loss criterion(pred, label) loss.backward() optimizer.step() # 验证阶段算 Dicescheduler.step(val_loss)label.long()是必须的CrossEntropyLoss要求标签是 int64。patience5表示验证损失连续 5 个 epoch 不降才减学习率别设太小否则学习率掉太快。Dice 的计算放在验证循环里用argmax拿到预测类别后逐类算背景类通常不算进平均 Dice。4. 训练完的模型怎么验证与推理部署4.1 Dice、IoU 与 Hausdorff 距离在心脏分割里的取舍Dice 和 IoU 衡量的是区域重叠度对心脏分割的整体形状评价够用但它们对边界偏移不敏感。Hausdorff 距离衡量的是预测边界和真实边界的最远距离能抓出「某处突出去一块」这种错误但容易受单个离群点影响所以常用 95% Hausdorff。实际项目里我一般三个都算Dice 看整体95% Hausdorff 看边界质量如果 Dice 高但 Hausdorff 也高说明大体对但局部有毛刺可能需要后处理。def dice_coeff(pred, target, num_classes4): dice_list [] for c in range(1, num_classes): # 跳过背景 pred_c (pred c) target_c (target c) intersection (pred_c target_c).sum() union pred_c.sum() target_c.sum() dice (2. * intersection) / (union 1e-8) dice_list.append(dice) return sum(dice_list) / len(dice_list)pred和target都是整数类别图pred c得到布尔掩膜。跳过背景是因为背景 Dice 通常接近 1算进去会把平均值拉高掩盖前景类的问题。1e-8防止除零某个类别在切片里完全不存在时 union 为 0。4.2 滑窗推理与 3D 体数据拼接的注意事项2D 切片训出来的模型推理时如果直接逐片预测再堆叠层间会出现抖动因为相邻切片独立预测没有一致性约束。常见做法是滑窗推理沿轴向取连续几片作为一个输入比如把 2D 模型改成吃 3 通道或者推理后做 3D 连通域后处理把小的孤立区域去掉。拼接时注意记录每片的原始位置别把顺序搞乱。def sliding_window_inference(model, volume, window_size3, stride1): model.eval() preds [] with torch.no_grad(): for i in range(0, volume.shape[0] - window_size 1, stride): window volume[i:iwindow_size] window_tensor torch.from_numpy(window).float().unsqueeze(0).cuda() pred model(window_tensor) preds.append(pred.argmax(dim1).cpu().numpy()) return np.concatenate(preds, axis0)window_size3表示每次取 3 个连续切片模型输入通道要相应改成 3。stride1是逐片滑重叠多但慢可以设成 2 加速。argmax(dim1)把类别概率转成类别图cpu().numpy()转回 numpy 方便后续处理。拼接后建议做一次 3D 形态学开运算去掉零星误检。4.3 把模型导出成可复用的推理脚本训练脚本和推理脚本分开写推理脚本只依赖模型权重和配置文件不依赖训练时的数据集类。导出时把模型结构定义单独放一个文件推理时 import 进来权重用torch.save(model.state_dict())存加载时先实例化模型再load_state_dict。输入预处理要和训练时完全一致归一化的均值标准差最好存进配置文件别硬编码。# inference.py import torch import nibabel as nib from model import UNet def load_model(weight_path, n_classes4): model UNet(in_ch1, n_classesn_classes) model.load_state_dict(torch.load(weight_path, map_locationcpu)) model.eval() return model def predict_single_case(model, nii_path): img nib.load(nii_path).get_fdata().astype(np.float32) # 这里要复现训练时的归一化 img (img - img.mean()) / (img.std() 1e-8) tensor torch.from_numpy(img).unsqueeze(0).unsqueeze(0) with torch.no_grad(): pred model(tensor).argmax(dim1).squeeze().numpy() return predmap_locationcpu让权重能在没有 GPU 的机器上加载方便部署。unsqueeze(0)两次分别加 batch 维和通道维输入变成(1, 1, H, W)。归一化这里用了全图统计量如果训练时用的是前景统计量这里必须改成一样的否则推理结果会偏。5. 心脏分割 Unet 训练与推理的避坑排查5.1 损失不下降Dice 卡在 0.3 上不去现象是训练几个 epoch 后 loss 震荡不降验证 Dice 一直在 0.3 附近。原因通常是学习率太大或者归一化没做对。医学影像灰度范围差异大不做归一化直接送网络第一层卷积就饱和了。解决方法是先检查输入数据的均值和方差确认归一化生效然后把学习率降到 1e-4 再试。如果还不行看看标签是不是有多个类别但输出通道设成了 2这种维度不匹配有时不会报错但结果全错。5.2 预测结果全是背景或全是某一类现象是推理出来的掩膜要么全 0要么全是一个类别。原因多半是类别极度不平衡背景像素占 95% 以上模型学会全预测背景就能拿到很低的 loss。解决办法是在损失函数里给前景类加权权重可以按类别频率的倒数来设或者换成 Dice Loss 为主。另一个可能是推理时的归一化参数和训练时不一致模型看到的输入分布变了输出自然乱。5.3 显存不够batch size 降到 1 还是 OOM现象是训练时 CUDA out of memory降到 batch 1 依然报错。原因是输入尺寸太大或者模型层数太深。心脏短轴切片常见 256x256 或 512x512512 的输入显存占用是 256 的四倍。解决办法是把输入裁剪到心脏区域再送网络或者把模型下采样层数减少一层。也可以用混合精度训练torch.cuda.amp能省将近一半显存对分割精度影响很小。5.4 验证集 Dice 很高但实际推理效果差现象是训练时验证 Dice 到 0.9拿新病例推理却一塌糊涂。原因是验证集和训练集来自同一批数据分布太像模型过拟合了。解决办法是划分数据时按病例分别按切片分同一个病例的切片不能同时出现在训练和验证里。另外看看新病例的 spacing 和训练数据差多少差太多要先重采样到统一 spacing 再推理。5.5 训练到一半 loss 突然变成 NaN现象是前几十个 epoch 正常突然 loss 变 NaN。原因通常是学习率偏大导致梯度爆炸或者某批数据里有异常值。解决办法是加梯度裁剪torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0)把梯度范数限制住。同时检查数据里有没有全零切片或者标签越界全零切片归一化时除零会产生 inf送进网络就 NaN 了。6. 把 Unet 心脏分割推到可用精度的一个关键技巧模型能跑通之后真正拉开精度差距的往往不是换更深的网络而是推理阶段的后处理。我试过在相同权重下只加一步 3D 连通域分析把体积小于 50 体素的孤立区域去掉Dice 能涨两到三个点。心脏结构在空间上是连续的模型偶尔在某个切片上误检出一小块单独看每片 Dice 掉得不多但 3D 整体指标会被拉低。后处理代码不长但效果立竿见影。from scipy import ndimage def remove_small_components(pred, min_size50): # pred: 3D 类别图 cleaned np.zeros_like(pred) for c in range(1, pred.max() 1): mask (pred c) labeled, num ndimage.label(mask) for i in range(1, num 1): component (labeled i) if component.sum() min_size: continue cleaned[component] c return cleanedndimage.label给每个连通域编号component.sum()是体素数小于阈值的直接丢弃。min_size50是个经验值你的数据体素 spacing 大的话可以调大spacing 小就调小。这个操作对每一类分别做别把不同类别的连通域混在一起。另一个值得养成的习惯是每次训完模型别只看平均 Dice把每个病例的 Dice 列出来找出最差的那几个单独看它们的影像和预测。十有八九是某个病例的成像质量差或者标注边界有争议这种病例在临床数据里很常见。我一般会把这类病例单独存一份下次调参时重点看它们有没有改善。心脏分割这个方向公开数据集上刷到 0.9 不难难的是在自己的数据上稳定到 0.85 以上靠的就是这种一个病例一个病例抠的笨功夫。希望帮到你。本文还有配套的精品资源点击获取
返回列表