简介本资源面向计算机、生物医学工程等专业的毕业设计、课程设计与项目开发人群提供一套基于 Python 实现的多模态 CMR 图像心肌结构和病灶分割完整源码帮助读者快速搭建心脏影像分割实验环境理解多模态数据融合与病灶定位的实现思路。压缩包共 38 个文件约 36KB以 19 个 py 源码文件为核心辅以 10 个 pyc 编译文件、3 个 yaml 配置、2 个 sh 运行脚本、2 个 pth 模型权重及 txt、md 说明文档覆盖训练、推理与配置全流程。资源已通过严格测试可直接参考并在此基础上延伸使用目录中包含 src 源码、configs 配置、outputs 输出与推理、训练脚本等模块便于读者按需修改网络结构、调整超参数或替换数据集。目前已有 69 人学习适合作为分割算法入门与二次开发的实践参考。1. 多模态 CMR 心肌与病灶分割从数据到可复现的 Python 方案心脏磁共振CMR延迟强化成像LGE是临床上评估心肌梗死、心肌炎和肥厚型心肌病等结构性心脏病的关键手段。放射科医生需要在短轴切面上逐层勾画心肌内外膜轮廓并标注出强化病灶区域这个过程耗时且高度依赖经验。基于 Python 实现的多模态 CMR 图像心肌结构和病灶分割本质上就是把这套勾画流程自动化输入同一患者配准后的多序列 CMR 图像常见如 LGE、T1 mapping、T2 mapping 或 bSSFP 电影序列输出心肌区域和病灶区域的像素级掩膜。这个方向适合三类人做医学图像分割课题的毕业生、需要快速搭建 CMR 分割基线的算法工程师、以及想把多模态融合思路迁移到其他医学影像任务的开发者。它解决的核心问题是单模态信息不足——LGE 上病灶对比度高但心肌边界模糊电影序列上心肌运动清晰但病灶几乎不可见只有把多模态信息在特征层面融合才能同时拿到准确的心肌轮廓和病灶定位。下面从数据准备、模型搭建、训练调参到避坑排查把一条能跑通的路径拆开讲。2. 多模态 CMR 数据准备与预处理配准、裁剪与归一化2.1 多模态 CMR 数据的典型形态与配准前提CMR 多模态数据在临床采集时并不是天然对齐的。LGE 序列通常在注射对比剂后 10 到 15 分钟采集层厚 8 到 10 毫米层间距可能不连续T1/T2 mapping 序列层厚更薄但覆盖范围可能不同电影序列则是同一个心动周期内多个时相。做多模态分割的第一步是把同一患者的不同序列在空间上对齐到同一个参考坐标系。常见做法是以 LGE 序列为参考帧把其他模态通过刚性或仿射配准变换到 LGE 空间。如果数据来自公开数据集如 EMIDEC、MyoPS 挑战赛数据通常已经提供了配准后的多模态图像和对应标注可以直接使用如果是自己医院采集的原始 DICOM就需要先做层间插值、重采样到各向同性分辨率常用 1mm × 1mm × 1mm 或 1.5mm × 1.5mm × 1.5mm再做跨模态配准。配准工具方面Python 生态里常用 SimpleITK 或 ANTsPy。SimpleITK 的刚性配准对同一患者不同序列之间的全局位移和旋转已经够用计算量也可控。配准质量直接决定后续融合效果——如果 LGE 和 T1 mapping 的心肌区域错位超过 2 到 3 个像素特征融合时反而会引入噪声。我一般会在配准后叠加显示两个模态的轮廓肉眼确认心尖、心底和右室插入点这几个关键位置是否对齐。2.2 用 Python 做重采样、ROI 裁剪和强度归一化原始 CMR 图像直接送入网络有两个问题一是图像尺寸和分辨率不统一二是心脏区域只占整幅图像的一小部分大量背景像素会稀释有效梯度。标准流程是重采样到统一间距 → 根据标注或检测框裁剪心脏 ROI → 对每个模态分别做强度归一化。下面是一段可复现的预处理代码假设输入是 NIfTI 格式的多模态图像和标注import numpy as np import SimpleITK as sitk from scipy.ndimage import zoom def resample_to_spacing(image, target_spacing(1.5, 1.5, 1.5), is_labelFalse): 将 SimpleITK 图像重采样到目标间距 original_spacing image.GetSpacing() original_size image.GetSize() target_size [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetSize(target_size) resampler.SetOutputSpacing(target_spacing) resampler.SetOutputOrigin(image.GetOrigin()) resampler.SetOutputDirection(image.GetDirection()) # 标签用最近邻图像用线性插值 resampler.SetInterpolator(sitk.sitkNearestNeighbor if is_label else sitk.sitkLinear) return resampler.Execute(image) def crop_roi(image_array, label_array, margin10): 根据标签非零区域裁剪 ROI留出 margin 边界 coords np.where(label_array 0) if len(coords[0]) 0: return image_array, label_array slices [] for dim in range(3): min_idx max(0, coords[dim].min() - margin) max_idx min(image_array.shape[dim], coords[dim].max() margin 1) slices.append(slice(min_idx, max_idx)) return image_array[tuple(slices)], label_array[tuple(slices)] def normalize_intensity(image_array, maskNone): 按心脏 ROI 内的均值和标准差做 z-score 归一化 if mask is not None and mask.sum() 0: values image_array[mask 0] else: values image_array[image_array 0] mean values.mean() std values.std() 1e-8 normalized (image_array - mean) / std return np.clip(normalized, -5, 5)这段代码的逻辑是先统一空间分辨率保证不同患者、不同模态的体素物理尺寸一致再根据标注裁剪出心脏区域减少背景干扰最后按 ROI 内统计量做归一化避免不同扫描仪的强度偏移影响模型。参数方面target_spacing选 1.5mm 是一个折中——太细会增大显存占用太粗会丢失病灶边界细节。margin设为 10 个像素是为了在裁剪后保留足够的心肌周边上下文尤其是病灶可能延伸到心肌边缘外侧的情况。归一化时用np.clip截断到 ±5是为了抑制个别高亮伪影对后续卷积的干扰。注意如果标注中包含背景类标签 0裁剪时不要用label_array 0去计算坐标否则会裁出整幅图像。正确做法是只对前景标签心肌 病灶取非零坐标。2.3 多模态通道堆叠与数据增强策略预处理完成后把配准好的 LGE、T1、T2 等模态在通道维度堆叠形成一个[H, W, D, C]的数组其中 C 是模态数。如果某些患者缺少某个模态常见做法是用零填充或者用同模态的均值图像替代但更稳妥的方式是在训练时对缺失模态做随机丢弃增强让模型学会在模态不完整时也能推理。数据增强方面CMR 分割常用的有随机旋转±15 度、随机缩放0.9 到 1.1、随机弹性形变和伽马校正。注意不要做水平翻转——心脏的左右心室位置是固定的翻转会破坏解剖先验。伽马校正对强度变化鲁棒性有帮助尤其是 LGE 序列中病灶和血池的对比度差异较大时。3. 多模态融合分割网络从 nnU-Net 基线到注意力融合3.1 为什么选 nnU-Net 作为基线而不是自己搭 U-Net医学图像分割领域nnU-Net 是目前公认的强基线。它根据数据集特性自动配置网络深度、卷积核数量、归一化方式和训练策略在多个 CMR 分割挑战赛上表现稳定。对于多模态 CMR 心肌和病灶分割直接用 nnU-Net 的多通道输入模式就能跑出一个不错的基线。自己从零搭 U-Net 不是不行但需要手动调的东西太多——学习率、批大小、数据增强强度、损失函数权重每一项都会影响最终 Dice。我一般会先用 nnU-Net 跑一版基线记录下心肌和病灶的 Dice然后再针对多模态融合部分做改进。这样即使改进效果不明显至少有一个可用的兜底方案。nnU-Net 的输入格式是每个病例一个文件夹里面包含_0000.nii.gz、_0001.nii.gz等模态文件和_seg.nii.gz标注文件。模态编号对应通道顺序训练时 nnU-Net 会自动读取并堆叠。如果要做自定义融合模块可以在 nnU-Net 的nnUNetTrainer类里重写build_network方法替换掉默认的 UNet 结构。3.2 注意力融合模块的实现与插入位置多模态融合的常见策略有三种早期融合输入层堆叠、中期融合编码器各阶段特征相加或拼接、晚期融合解码器输出后融合。对于 CMR 心肌和病灶分割中期融合通常效果最好因为不同模态在编码器的不同深度提取的特征粒度不同——浅层特征包含更多纹理和边界信息深层特征包含更多语义类别信息。一个实用的注意力融合模块可以这样实现import torch import torch.nn as nn class CrossModalAttention(nn.Module): 跨模态通道注意力融合对每个模态的特征做通道加权后相加 def __init__(self, channels, num_modalities3, reduction8): super().__init__() self.num_modalities num_modalities self.avg_pool nn.AdaptiveAvgPool3d(1) # 为每个模态生成通道注意力权重 self.fc nn.Sequential( nn.Linear(channels * num_modalities, channels * num_modalities // reduction), nn.ReLU(inplaceTrue), nn.Linear(channels * num_modalities // reduction, channels * num_modalities), nn.Sigmoid() ) def forward(self, features): # features: list of [B, C, H, W, D] tensors, length num_modalities B, C features[0].shape[:2] # 拼接各模态的全局描述子 descriptors torch.cat([self.avg_pool(f).view(B, C) for f in features], dim1) weights self.fc(descriptors).view(B, self.num_modalities, C) # 对每个模态特征做通道加权 weighted [features[i] * weights[:, i, :].view(B, C, 1, 1, 1) for i in range(self.num_modalities)] return sum(weighted)这个模块的核心思路是先对每个模态的特征图做全局平均池化得到一个通道描述子把所有模态的描述子拼接后送入两层全连接学习模态间和通道间的交互关系最后输出每个模态每个通道的权重加权求和得到融合特征。插入位置一般选在编码器的每个下采样阶段之后替换掉原来的单模态特征。参数方面reduction控制全连接层的压缩比8 是一个常用值太小会增加参数量太大会损失表达能力。num_modalities根据实际使用的模态数设置如果只有 LGE 和 T1 两个模态就设为 2。注意如果某个模态在部分病例中缺失直接做全局平均池化会把零填充区域也计入统计量导致注意力权重偏移。解决办法是在池化前用模态可用性掩码把缺失模态的特征置零或者在训练时对缺失模态做随机丢弃让模型见到足够多的不完整样本。3.3 损失函数选择Dice、交叉熵与病灶不平衡处理心肌和病灶的分割难度差异很大。心肌区域通常占图像中较大比例边界相对清晰病灶区域可能只有几十个像素且在不同患者之间形态差异极大。如果只用交叉熵损失模型会倾向于把所有像素预测为背景或心肌病灶的召回率会很低。常见做法是组合 Dice 损失和交叉熵损失并对病灶类施加更高的权重。一个可用的损失函数配置如下class CombinedLoss(nn.Module): def __init__(self, num_classes3, dice_weight0.5, ce_weight0.5, class_weightsNone): super().__init__() self.dice_weight dice_weight self.ce_weight ce_weight self.ce nn.CrossEntropyLoss(weightclass_weights) self.num_classes num_classes def dice_loss(self, logits, targets): probs torch.softmax(logits, dim1) targets_onehot torch.nn.functional.one_hot( targets, self.num_classes).permute(0, 4, 1, 2, 3).float() intersection (probs * targets_onehot).sum(dim(2, 3, 4)) union probs.sum(dim(2, 3, 4)) targets_onehot.sum(dim(2, 3, 4)) dice (2 * intersection 1e-6) / (union 1e-6) return 1 - dice.mean() def forward(self, logits, targets): return (self.dice_weight * self.dice_loss(logits, targets) self.ce_weight * self.ce(logits, targets))这里class_weights可以设为[0.1, 1.0, 5.0]分别对应背景、心肌和病灶。背景权重低是因为背景像素占绝大多数降低权重可以避免梯度被背景主导病灶权重高是为了提升小目标的召回。Dice 损失和交叉熵各占一半是一个经验性的起点如果病灶 Dice 仍然偏低可以把病灶类的交叉熵权重进一步提高到 8 或 10同时观察心肌 Dice 是否下降太多。如果下降明显说明模型在心肌和病灶之间产生了混淆需要检查标注质量或者增加病灶区域的过采样。4. 训练调参与推理后处理让病灶 Dice 从 0.3 提到 0.64.1 学习率、批大小与迭代次数的实操设置CMR 多模态分割的训练对超参数比较敏感。以 nnU-Net 的默认配置为起点初始学习率 0.01多项式衰减批大小根据显存设为 2 到 43D 网络迭代次数 250 个 epoch。如果自己搭网络AdamW 优化器配 1e-4 到 3e-4 的学习率更稳。批大小不建议小于 2因为 BatchNorm 或 InstanceNorm 在批大小为 1 时统计量不稳定会引入训练震荡。如果显存不够可以用梯度累积模拟更大的批大小或者把输入裁剪到 128×128×64 这样的较小尺寸。迭代次数方面CMR 数据集通常不大几十到几百例250 到 500 个 epoch 足够收敛。判断收敛不能只看训练损失要每个 epoch 在验证集上算心肌和病灶的 Dice。如果验证 Dice 连续 30 个 epoch 不提升就可以停。我一般会保存验证集上病灶 Dice 最高的那个 checkpoint而不是最后一个 epoch 的权重——因为病灶分割的波动比较大最后一个 epoch 不一定是最好的。4.2 推理阶段的滑动窗口与测试时增强推理时如果整幅图像直接送入网络显存可能不够而且边缘区域的分割精度会下降。标准做法是滑动窗口推理把图像切成有重叠的子块逐块预测后把结果拼回去重叠区域取平均或投票。nnU-Net 默认的滑动窗口重叠率是 0.5对于 CMR 数据心肌和病灶区域相对集中重叠率可以降到 0.3 以减少推理时间但不要低于 0.25否则拼接处会出现明显接缝。测试时增强TTA是另一个提点手段。常用的 TTA 包括对输入做 8 个方向的翻转和旋转分别推理后把 softmax 概率图变换回原空间取平均。对于 CMR 数据水平翻转要慎用解剖位置会反但上下翻转和 90 度旋转是安全的。TTA 一般能带来 1 到 3 个百分点的 Dice 提升代价是推理时间翻 4 到 8 倍。如果部署环境对延迟不敏感建议开启。4.3 后处理连通域过滤与病灶小区域合并网络输出的分割结果往往包含一些孤立的假阳性小区域尤其是病灶类。后处理的第一步是连通域分析对每个预测类别保留体积最大的连通域或者保留体积超过某个阈值的所有连通域。对于心肌通常只有一个主要连通域左心室心肌可以直接取最大连通域对于病灶可能有多发梗死灶不能简单取最大而是保留体积大于 10 到 20 个像素的连通域。第二步是形态学闭运算填补病灶内部的小孔洞。第三步是如果病灶区域和心肌区域没有重叠说明预测可能有问题——病灶应该位于心肌内部或边缘完全脱离心肌的病灶预测大概率是假阳性可以过滤掉。from scipy import ndimage def postprocess_prediction(pred_mask, min_lesion_size15, num_classes3): 对分割结果做连通域过滤和形态学后处理 processed pred_mask.copy() # 心肌类保留最大连通域 myo_mask (pred_mask 1) labeled_myo, num_myo ndimage.label(myo_mask) if num_myo 1: sizes ndimage.sum(myo_mask, labeled_myo, range(1, num_myo 1)) largest np.argmax(sizes) 1 processed[(pred_mask 1) (labeled_myo ! largest)] 0 # 病灶类保留体积大于阈值的连通域并做闭运算 lesion_mask (pred_mask 2) labeled_lesion, num_lesion ndimage.label(lesion_mask) for i in range(1, num_lesion 1): if (labeled_lesion i).sum() min_lesion_size: processed[labeled_lesion i] 0 # 闭运算填补小孔 lesion_final (processed 2) lesion_final ndimage.binary_closing(lesion_final, iterations1) processed[lesion_final (processed ! 2)] 2 return processedmin_lesion_size设为 15 是一个经验值对应 1.5mm 各向同性分辨率下大约 50 立方毫米的体积。如果数据分辨率不同需要按物理体积换算。闭运算的iterations设为 1 是为了避免过度膨胀导致病灶边界外扩。后处理之后建议再算一遍 Dice确认后处理没有把真实病灶误删。注意后处理参数需要在验证集上单独调不能直接在测试集上调。我见过有人在测试集上反复试min_lesion_size直到 Dice 最高这种做法得到的提升是虚假的换一批数据就会翻车。5. 避坑与排查多模态 CMR 分割中常见的 5 个翻车现场5.1 配准误差导致模态融合后 Dice 反而下降现象单模态 LGE 训练时心肌 Dice 0.85加入 T1 mapping 做多模态融合后心肌 Dice 掉到 0.78病灶 Dice 也没提升。原因T1 mapping 和 LGE 之间的配准误差超过 3 个像素融合时两个模态的心肌区域没有对齐注意力模块学到的跨模态权重实际上是噪声。这种情况在层厚差异大的数据集上尤其常见。解决先单独检查配准质量。用 SimpleITK 把两个模态的图像和标注叠加显示重点看心尖、心底和右室插入点。如果错位明显重新做配准或者改用仿射配准代替刚性配准。如果配准后仍然对不齐考虑放弃该模态只用配准质量好的模态组合。5.2 病灶类权重过高导致心肌边界被侵蚀现象为了提高病灶召回把病灶类交叉熵权重从 5 提到 15结果病灶 Dice 从 0.45 提到 0.52但心肌 Dice 从 0.88 掉到 0.80心肌外膜边界出现明显凹陷。原因过高的病灶权重让模型倾向于把心肌边缘的模糊区域预测为病灶因为这样可以在病灶类上获得更高的梯度回报。心肌和病灶在 LGE 上的边界本身就不清晰权重失衡会放大这种混淆。解决病灶权重不要超过心肌权重的 5 倍。如果病灶 Dice 仍然低优先从数据层面解决——增加病灶区域的过采样比例或者在损失函数里对病灶边界像素单独加权而不是全局提高病灶类权重。5.3 验证集 Dice 很高但测试集一塌糊涂现象验证集上心肌 Dice 0.90病灶 Dice 0.65但换一家医院的数据测试时心肌 Dice 只有 0.70病灶 Dice 不到 0.30。原因训练集和测试集来自不同扫描仪或不同采集协议图像强度分布、分辨率和对比度差异大。模型学到了训练集的强度先验换一台机器就失效。这是医学图像分割里最典型的域偏移问题。解决在预处理阶段做更强的强度归一化比如直方图匹配或者 CycleGAN 风格的风格迁移。如果条件允许在测试集上做无监督域适应用测试图像的统计量重新校准归一化参数。另外数据增强里加入随机伽马校正和随机偏置场可以提升模型对强度变化的鲁棒性。5.4 滑动窗口拼接处出现分割断层现象推理结果在子块边界处出现明显的分割断层心肌轮廓在拼接位置突然中断或错位。原因滑动窗口重叠率设得太低比如 0.1或者拼接时直接取最大值而不是平均概率。子块边缘区域的预测精度本身低于中心区域低重叠率下边缘误差被直接带入拼接结果。解决把重叠率提高到 0.3 到 0.5拼接时对重叠区域的 softmax 概率取平均而不是对硬标签做投票。如果显存允许改用高斯权重拼接——子块中心权重高边缘权重低可以进一步平滑拼接边界。5.5 病灶标注不一致导致模型学偏现象同一批数据里不同标注者对病灶范围的勾画差异很大有的只勾强化核心有的把水肿区也勾进去。模型在训练时一会儿学核心一会儿学水肿最终病灶 Dice 卡在 0.4 上不去。原因病灶标注本身存在主观性LGE 上的强化区域和水肿区域的边界在临床上也没有绝对标准。如果训练数据里标注风格不统一模型无法收敛到一个稳定的决策边界。解决先做标注一致性检查。随机抽 10 到 20 个病例让同一个标注者间隔一周重新勾画计算两次勾画的 Dice。如果一致性低于 0.7说明标注规范需要统一。常见做法是明确病灶定义——只勾画 LGE 上信号强度超过 remote myocardium 均值加 5 个标准差的区域水肿区不纳入病灶类。如果数据已经无法重新标注考虑用软标签训练把不同标注者的结果做概率平均让模型学习标注的不确定性。6. 进阶技巧用不确定性估计筛选需要人工复核的病例6.1 蒙特卡罗 Dropout 做推理期不确定性估计模型在病灶边界模糊的病例上容易出错如果能自动识别出哪些病例的预测不可靠就可以优先把这些病例推给医生复核减少漏诊风险。蒙特卡罗 Dropout 是一种实现简单且效果稳定的不确定性估计方法在推理时保持 Dropout 层开启对同一输入做多次前向传播通常 10 到 30 次得到一组预测概率图计算每个像素的预测方差作为不确定性。方差高的区域就是模型“拿不准”的地方。def mc_dropout_predict(model, input_tensor, num_samples20): 蒙特卡罗 Dropout 推理返回均值概率和不确定性图 model.train() # 保持 Dropout 开启 preds [] with torch.no_grad(): for _ in range(num_samples): logits model(input_tensor) probs torch.softmax(logits, dim1) preds.append(probs.cpu().numpy()) preds np.stack(preds, axis0) # [N, B, C, H, W, D] mean_probs preds.mean(axis0) uncertainty preds.var(axis0) # 每个像素每个类别的方差 return mean_probs, uncertaintynum_samples设为 20 是一个平衡点——再少方差估计不稳定再多推理时间线性增长。得到不确定性图后可以计算病灶区域内平均不确定性如果超过某个阈值比如 0.05就把这个病例标记为需要复核。这个阈值需要在验证集上根据复核率和漏诊率的权衡来定。6.2 把不确定性图叠加到分割结果上做可视化不确定性图本身也可以作为质控工具。把不确定性高的区域用热力图叠加到原始 LGE 图像上医生可以快速看到模型在哪些位置犹豫。我一般会把不确定性图和分割轮廓一起显示轮廓用红色表示心肌黄色表示病灶不确定性超过阈值的像素用半透明热力图覆盖。这样医生复核时不需要逐像素检查只需要看热力图集中的区域。6.3 一个我踩过的坑不确定性高不等于预测错误需要提醒的是不确定性高和预测错误并不是一回事。模型可能在病灶边界处给出高不确定性但预测结果仍然在可接受范围内也可能在某个区域给出低不确定性但预测完全错误比如把伪影当成病灶。所以不确定性估计只能作为复核优先级的参考不能替代医生的最终判断。我在早期项目里曾经把不确定性阈值设得太低导致 80% 的病例都被标记为需要复核医生根本看不过来。后来把阈值调到只标记 15% 到 20% 的病例复核效率才提上来。这个阈值没有通用值必须根据自己数据集的分布和医生的复核能力来调。6.4 从单中心到多中心域泛化的几个实用手段如果要把模型推广到多个中心的数据除了前面提到的强度归一化和数据增强还有两个手段值得试。一是模态 dropout训练时随机丢弃某个模态的输入让模型不依赖特定模态组合。二是特征对齐在编码器末端加一个域判别器用对抗训练的方式让不同中心的特征分布尽量接近。这两个手段都会增加训练复杂度建议先在单中心数据上把基线做扎实再逐步加入。最后说一个我自己的习惯每次跑完一个新配置不管 Dice 是涨是跌我都会把验证集上病灶 Dice 最低的 5 个病例单独拿出来看。涨了要知道为什么涨跌了更要知道为什么跌。很多时候病灶 Dice 上不去不是因为模型不够复杂而是因为那几个病例的标注本身就有问题或者图像质量太差。把这些病例挑出来单独分析比盲目调参有效得多。希望帮到你。本文还有配套的精品资源点击获取