
简介面向医学图像分割研究者与深度学习初学者的脊椎MRI数据集资源。数据来源为MRI背景下的脊柱分割任务标签体系完整覆盖0-19类包括椎体L5-T9、脊髓区域Spinal Canal以及椎间盘空间L5/S1至T8/T9并基于nnUNet完成预处理与增强构成约4K多张图像及对应掩膜模板已预先划分好训练集与验证集可大幅节省数据准备时间。整个资源包共2000个文件以1998张PNG图像为主另含1个Python预处理脚本与1个JSON配置说明压缩包总大小仅111.12MB便于快速下载与迁移环境。已有530人学习过该资源适合作为医学图像分割入门实践或模型对比实验的数据基础。1. 脊椎MRI分割的难点不在模型在数据本身跑通一个医学图像分割模型并不难难的是一开始就让模型看到“正确的数据”。很多做算法的同学拿到脊椎MRI数据集第一步就是转成nii.gz然后丢给3D U-Net训练结果发现Dice始终在0.6上下徘徊。问题往往不是网络结构而是MRI本身的特性不同设备采集的T1/T2加权像灰度分布完全不同同一序列在不同扫描参数下体素间距差异极大椎骨标签标准在不同数据集里甚至编码都不一致——有的从T1开始标有的从C1开始标还有的把椎间盘也标进去。更隐蔽的是MRI的层厚通常在1mm到5mm之间如果直接在原始分辨率上做分割各向同性假设会被直接打破。本文的目标是讲清楚从原始脊椎MRI数据到能稳定产出分割结果的最小闭环数据怎么准备、标签怎么对齐、3D U-Net的输入怎么设计、训练管线怎么跑通、以及最后怎么验证结果不是“假好”。这中间每一步都有坑而且这些坑和模型结构无关属于数据工程的范畴。2. 数据预处理与标签规范化从DICOM到能喂给3D U-Net的体数据2.1 DICOM元数据里藏着分割前必须解决的三个问题MRI扫描输出的是DICOM序列这个格式本身适合影像科存储和阅片但对深度学习管线来说非常不友好。DICOM里每个患者一个文件夹里面是几十到几百张二维切片每张切片文件都带完整的元数据头。处理脊椎数据时首先遇到的就是体素间距Spacing不一致。矢状位T2加权像的in-plane分辨率可能是0.7mm×0.7mm但层厚可能是3mm或4mm这意味着z轴方向的体素尺寸远大于x/y方向。如果忽略这个信息直接把数据stack成数组模型的空间感受野在各方向是不等价的分割边界在层间就会出现阶梯状伪影。另外一个常见问题是切片顺序和方向。不同厂家的设备对DICOM ImageOrientationPatient字段的约定不同有的先扫从右到左有的从前往后。直接把数组读进来不检查方向轻则左右翻转重则把矢状位当成冠状位。这里建议统一以NIfTI格式作为中间存储转换时用dcm2niix这类的工具它会自动读取方向矩阵并把数据转到LPS或RAS坐标。最后一个问题是灰度值的物理含义。MRI的Intensity是相对值不同扫描间没有可比性。T1加权和T2加权同一解剖结构的灰度是完全相反的椎体在T1上亮在T2上灰暗。因此预处理阶段做全局归一化是不够的需要考虑序列类型带来的统计差异。代码层面至少要做两件事记录每个样本的序列类型T1/T2/STIR以及在训练时对每个样本独立做Z-Score归一化。2.2 标签空间的统一把不同数据集的mask映射到同一套编号脊椎分割数据集最让人头疼的还不是图像而是标签。常用的公开数据里有的是分椎体有的是分椎体和椎间盘有的连终板都标出来了。这是跨数据集训练时最容易被忽略的一步。假设你用IVD数据集和CTSpine1k联合训练两边标签字典不一致网络输出的通道含义就彻底乱了。常见做法是手动建立一个映射表在加载数据时统一重映射。下面这段代码展示了如何把一个多分类标签映射到统一的20类结构T1-T12 L1-L5 背景import nibabel as nib import numpy as np # 定义统一标签空间0背景1-12T1-T1213-17L1-L5 MAPPING_VERTEBRA { 1: 1, 2: 2, 3: 3, 4: 4, 5: 5, 6: 6, 7: 7, 8: 8, 9: 9, 10: 10, 11: 11, 12: 12, # T1-T12 13: 13, 14: 14, 15: 15, 16: 16, 17: 17 # L1-L5 } def remap_label(input_path, output_path): label_nii nib.load(input_path) label_data label_nii.get_fdata().astype(np.int16) # 备份原始形状防止重映射时误改维度 assert label_data.ndim 3, fexpect 3D volume, got {label_data.ndim}D remapped np.zeros_like(label_data) for src, dst in MAPPING_VERTEBRA.items(): remapped[label_data src] dst out_nii nib.Nifti1Image(remapped, label_nii.affine, label_nii.header) nib.save(out_nii, output_path)这段代码的逻辑很清楚先读标签文件再逐类别映射。第7行的assert通过维度检查避免了把4D数据当3D处理。工程上需要注意两个细节一是映射关系必须提前人工核对不能用程序自动推断因为不同数据集的编号根本没有规律可循二是保存时要继承原始affine矩阵否则后续配准和可视化全部对齐不上。处理完标签之后还要确认标签和图像方向一致。这里有个快速验证技巧在冠状位和矢状位投影上分别检查标签轮廓是否落在椎体边缘。如果直接把CT数据集骨窗亮的标签用在MRI上由于椎体在MRI上边界模糊误标区域会明显偏大或偏移。2.3 重采样到各向同性体素直接决定分割边界质量MRI脊椎数据最大的问题就是层厚过大。3mm层厚的数据在z轴上的信息量远低于x/y方向如果直接用原始分辨率输入3D U-Net网络的z轴卷积核虽然能学到部分补偿但分割结果在矢状位上容易呈现锯齿状。业界常见做法是重采样到各向同性体素通常取1mm×1mm×1mm或1.5mm×1.5mm×1.5mm。重采样必须同时对图像和标签做且插值方式不同——图像用三线性插值标签用最近邻插值。这个不同很多人会忽略直接对标签用三线性结果椎体边界出现第3个标签值网络训练时直接报错或产生虚假类别。下面对重采样做一个完整实现import nibabel as nib import numpy as np from scipy.ndimage import zoom def resample_to_isotropic(image_path, label_path, target_spacing1.0): img_nii nib.load(image_path) label_nii nib.load(label_path) img_data img_nii.get_fdata() label_data label_nii.get_fdata().astype(np.int16) old_spacing img_nii.header.get_zooms()[:3] zoom_factors np.array(old_spacing) / target_spacing # 图像三线性插值 resampled_img zoom(img_data, zoom_factors, order3) # 标签最近邻插值保持整数值 resampled_label zoom(label_data, zoom_factors, order0) new_affine img_nii.affine.copy() # affine对角线除以zoom因子更新体素间距 new_affine[:3, :3] new_affine[:3, :3] np.diag(1.0 / zoom_factors) out_img nib.Nifti1Image(resampled_img, new_affine) out_lbl nib.Nifti1Image(resampled_label, new_affine) return out_img, out_lblzoom_factors的计算是旧分辨率除以新目标分辨率大于1表示放大采样。第7行的order3对应三线性插值第8行的order0是最近邻。第10行更新affine时用矩阵乘法把旧的缩放因子替换成新的——这里有一个常见的错误是只改affine而不重采样数据导致分割结果空间位置全部错位。重采样之后体积数据会变大。以1mm各向同性为例一个覆盖T1-L5的矢状位MRI大约需要240×240×180的体素规模对应单通道float32大约41MB这在GPU显存范围内是可以接受的。3. 3D U-Net的输入输出设计patch、归一化与损失函数3.1 patch大小怎么选感受野、显存与标签平衡3D U-Net无法直接吃整张MRI体积哪怕是1mm重采样后也要分块训练。patch尺寸的选择涉及三个约束GPU显存上限、网络感受野、以及每个patch内正样本比例。感受野这个因素经常被忽略。以标准的3D U-Net为例4次下采样后encoder路径感受野大约为64³。如果patch取96³网络看到的上下文刚好覆盖椎体以及部分周围组织。patch太小如64³时位于图像边缘的椎体因为上下文不足经常被错误切割patch太大如192³时显存和batch size会互相打架。另外一个困扰是正负样本失衡。整张MRI中背景占了绝大多数如果随机裁剪很多patch里可能只有几个体素的标签。经验做法是做一个随机采样一半patch从标签非零区域中心裁剪一半patch全图均匀采样。这样可以保证每个batch都有一定比例的正样本命中率。需要注意的是正样本裁剪时锚点要在暴力体素内不能简单在图像中心取否则椎体边缘的结构永远学不好。3.2 数据归一化的两种路线全局统计与实例归一化MRI的灰度值没有物理单位T1和T2的图像对比度差异巨大因此归一化策略直接影响训练收敛速度。最稳妥的方案是每个训练样本独立计算均值和标准差执行Z-Score归一化。这里的均值是对整个volume做统计还是只对前景区域做统计结果差别很大。当背景区域占95%以上时全局统计的均值几乎等于背景灰度会压暗椎体区域的对比度。建议对前景区域标签0的体素单独统计或者用全图的2%和98%分位数做截断后再归一化。拿T2加权像来说脑脊液是亮的、椎体是相对暗的而T1加权正好相反。如果两个序列混在一起训练不做序列区分网络学到的特征就会被Gating掉。业界有两种做法一是把序列类型作为条件信息输入网络二是简单粗暴地在预处理时把每个volume都做直方图匹配向一个标准模板对齐。后者在3D U-Net的输入层面更简单落地上游刃有余。3.3 损失函数选型Dice Loss之外的另一个配置空间医学图像分割的默认损失函数往往是Dice Loss或其变体针对脊椎MRI纯粹的Dice Loss有一个隐性问题如果某个类别的体积特别小比如椎间盘Dice对这类目标的变化十分敏感。可以考虑在Dice基础上加一个辅助的Focal Loss或者切换成Tversky Loss用β系数控制假阳性和假阴性的权重。下面给出一个混合损失函数的实现适应脊椎多类别分割import torch import torch.nn as nn import torch.nn.functional as F class DiceFocalLoss(nn.Module): def __init__(self, num_classes, alpha0.5, beta0.7, gamma2.0): super().__init__() self.num_classes num_classes self.alpha alpha self.beta beta self.gamma gamma def forward(self, logits, targets): # logits: [B, C, D, H, W], targets: [B, D, H, W] probs F.softmax(logits, dim1) # 沿类别维做softmax targets_onehot F.one_hot(targets, num_classesself.num_classes) targets_onehot targets_onehot.permute(0, 4, 1, 2, 3).float() # Dice项忽略背景类别防止整体dice被高占比背景拉高 dims (2, 3, 4) intersection torch.sum(probs[:, 1:] * targets_onehot[:, 1:], dimdims) union torch.sum(probs[:, 1:] targets_onehot[:, 1:], dimdims) dice (2.0 * intersection 1e-6) / (union 1e-6) dice_loss 1.0 - dice.mean() # Focal项缓解easy negative对梯度的主导 ce_loss F.cross_entropy(logits, targets, reductionnone) pt torch.exp(-ce_loss) focal_weight (1 - pt) ** self.gamma focal_loss (focal_weight * ce_loss).mean() return self.alpha * dice_loss (1 - self.alpha) * focal_loss第9行的one_hot将标签变成独热编码之后再调整维度。第10行permute把类别维移到通道维适应PyTorch的NCDHW布局。第12行从probs[:, 1:]开始切跳过背景类别避免大背景的体素墙让Dice虚高。第19行F.cross_entropy默认接收未归一化的logits内部自带softmax这是PyTorch的标准用法。alpha和gamma这两个参数在脊椎分割上通常这样配置alpha0.5让两个损失贡献相当gamma2.0是focal loss的默认值如果发现前期Dice上升缓慢可以把gamma降到1.0让困难样本权重增加更多。beta在上述代码中没有直接使用如果替换成Tversky Loss则需要通过beta调节假阴性的惩罚。3.4 训练时的数据增强弹性形变要慎用MRI脊椎分割中简单的翻转、旋转和小范围平移都是安全的。但有一个增强手段需要特别小心弹性形变。脊椎是刚性结构相邻椎体之间的相对位置在解剖学上是固定的弹性形变会让椎体形状变得不自然比如某个椎体被扭曲成中间细两端粗这既不符合生理结构也会让模型对真实的解剖变异产生错误的建模。可用但需要控制参数幅度的做法是轻度随机缩放各方向缩放因子在0.9到1.1之间用于模拟不同身高人群的椎骨尺度差异。另一种有效增强是对比度扰动——在Z-Score归一化之后叠加随机高斯噪声或者对图像做随机的gamma校正增强模型对MRI灰度波动的鲁棒性。4. 从零搭建一个脊椎MRI分割训练管线torchio加载、训练与推理4.1 使用torchio构建Dataset和DataLoader的完整代码在脊椎MRI分割这个场景torchio是现实中最常见的选择。它原生支持NIfTI读取、patch采样和增强而且和PyTorch的DataLoader无缝衔接。下面是构建训练数据管道的完整代码import torch import torchio as tio from torch.utils.data import DataLoader def build_subjects(image_paths, label_paths): subjects [] for img_p, lbl_p in zip(image_paths, label_paths): subject tio.Subject( imagetio.ScalarImage(img_p), labeltio.LabelMap(lbl_p), ) subjects.append(subject) return subjects def build_dataloader(subjects, batch_size2, patch_size96, num_workers4): transform tio.Compose([ tio.ToCanonical(), # 转为标准RAS方向 tio.Resample((1.0, 1.0, 1.0)), # 各向同性重采样 tio.RandomAffine(scales(0.95, 1.05), degrees5, p0.5), tio.RandomNoise(std0.02, p0.2), tio.ZNormalization(masking_methodtio.ZNormalization.mean), tio.RandomSampler(patch_sizepatch_size, num_patches4), ]) dataset tio.SubjectsDataset(subjects, transformtransform) patches_sampler tio.data.UniformSampler(patch_size) patch_dataset tio.Queue( dataset, max_length16, samples_per_volume4, samplerpatches_sampler, num_workersnum_workers ) loader DataLoader(patch_dataset, batch_sizebatch_size, num_workers0) return loader这里需要解释一下RandomSampler和UniformSampler的区别。transform中出现的RandomSampler会把每个volume内部随机裁patch并且由num_patches控制每次抽几个patch。而Queue中定义的UniformSampler是均匀采样的兜底两者配合可以控制正样本patch的出现频率。max_length16控制Queue内部缓存的patch队列长度。samples_per_volume4指每个volume每次最多采样4个patch。这里有一个工程经验如果max_length太小GPU在等待数据时会经常空闲利用率上不去如果num_workers太高内存占用会激增因为每个worker都保存了一个volume的拷贝。注意ZNormalization(masking_methodtio.ZNormalization.mean)的用法这里的mean指的是用图像中非零区域的均值做归一化中心适用于MRI中背景为全黑的情况。4.2 训练循环中的三个关键细节梯度裁剪、验证、checkpoint训练3D U-Net时最典型的崩溃方式不是loss变成NaN而是梯度爆炸导致模型参数数值溢出。之所以多见于MRI分割是因为heavy tail的灰度分布少数极高信号区域会产生特别大的梯度。解决方案很简单梯度裁裁剪是一个必要操作。标准训练循环如下直接拿PyTorch写不需要额外框架def train_one_epoch(model, loader, optimizer, criterion, device): model.train() running_loss 0.0 for batch in loader: image batch[image][tio.DATA].to(device) label batch[label][tio.DATA].squeeze(1).long().to(device) optimizer.zero_grad() logits model(image) loss criterion(logits, label) loss.backward() # 梯度裁剪max_norm1.0是常用起始值 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() running_loss loss.item() return running_loss / len(loader)torch.nn.utils.clip_grad_norm_对全部网络参数做二范数裁剪max_norm1.0是保守配置。如果训练正常可以把max_norm提高到2.0让模型收敛更快。还有一个容易踩的坑是label.squeeze(1)——torchio返回的label shape是[B, 1, D, H, W]而CrossEntropyLoss期望的target是[B, D, H, W]忘记squeeze会直接报维度错误。验证环节除了算Dice之外建议额外记录每个类别的Dice分布。因为椎体和椎间盘的Dice天然差10个百分点以上只看一个平均Dice掩盖了模型在小结构上的失败。验证频率方面每1000个iteration验证一次比较现实毕竟验证集整个inference一次就要几十秒。4.3 推理阶段的patch拼接方法重叠采样避免边界伪影推理时不能把整个volume直接喂给网络。业界常用滑窗法推理但patch之间如果不做重叠处理拼接边界会出现明显的接缝。下采样导致的stride不对齐也会让相邻patch的输出分布不一致。重叠采样的逻辑是一个patch中心点滑动stride小于patch尺寸让相邻patch有重叠区域。重叠区域用高斯权重合并中间权重高、边缘权重低这样能保证最终分割结果是连续的。下方是一个朴素的实现框架import numpy as np def sliding_window_inference(model, volume, patch_size96, stride48, devicecuda): # volume: [D, H, W] 经过预处理的numpy数组 D, H, W volume.shape output np.zeros((D, H, W), dtypenp.float32) weight_map np.zeros((D, H, W), dtypenp.float32) for z in range(0, D - patch_size 1, stride): for y in range(0, H - patch_size 1, stride): for x in range(0, W - patch_size 1, stride): patch volume[z:zpatch_size, y:ypatch_size, x:xpatch_size] patch_tensor torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).float().to(device) with torch.no_grad(): logits model(patch_tensor).squeeze(0) # [C, D, H, W] pred torch.argmax(logits, dim0).cpu().numpy() output[z:zpatch_size, y:ypatch_size, x:xpatch_size] pred weight_map[z:zpatch_size, y:ypatch_size, x:xpatch_size] 1.0 # 取平均得到最终分割 final output / np.maximum(weight_map, 1e-6) return final.astype(np.uint8)stride48时重叠率达到50%对应的推理时间是stride96时的约8倍。如果追求速度stride可以设成64接缝不会太明显。代码中weight_map 1.0是均匀权重实际生产场景会用高斯权值使patch中心占比更大具体做法是预生成一个与patch同尺寸的高斯权重矩阵累加到weight_map而不是加常数。4.4 推理输出与后处理连通域过滤和形状先验分割输出直接保存往往是“毛刺”很多的mask。用连通域分析过滤小体积的噪点是第一件要做的事。椎体的尺寸有明确的解剖学范围成年人单节椎体体积大约在10cm³到30cm³之间重采样到1mm各向同性后对应大约1万到3万个体素。低于3000体素的连通域基本可以认定为噪声。另外一个脊柱特有的是椎体排列的连续性。在矢状位上分割出来的椎体mask应该是沿着脊柱曲线依次排列的。可以用形态学方法检查mask中是否存在断裂对每一节标签做质心提取计算相邻质心距离是否在合理范围。如果某两个椎体质心距离异常偏大说明中间可能漏分割了。这种后处理并不复杂但对于下游的疾病诊断和手术规划场景意义重大。5. 把分割结果变成可用资产标签平滑、硬例挖掘与误差修正validation Dice到了0.9并不意味着工作结束。脊椎分割项目的实际价值在于输出几何测量——椎体高度、椎间盘退变等级、脊柱侧弯Cobb角——这些都和mask质量紧密相关。这里有三个量产级别的技巧可以在不更换模型的前提下把结果再推高2到3个百分点。第一个技巧是标签平滑推理。3D U-Net生成的概率图不需要直接arargmax取整标签。可以先对概率图做轻度高斯平滑sigma0.5到1.0体素再取argmax。这能抹平单个体素的误判代价是略微损失边界精度。边界精度比平滑前的Dice稍有下降但临床医生看渲染效果更顺眼主观评分提升明显。第二个技巧是硬例挖掘。训练后期把验证集上每类Dice最低的几个volume抽出来加入下一轮训练集。在脊椎MRI场景里最难分割的往往是严重退变的椎间盘信号丢失导致边界模糊和重度脊柱侧弯的椎体形态严重变形。手动把这些病例的标签修正一遍比无限增大网络规模更有效。第三个技巧集中在误差修正上。如果你有多个模型——比如一个3D U-Net和一个2D U-Net——可以做一个简单的投票融合。3D模型在矢状位分割效果好2D模型在轴向切面更准。投票融合时两个模型都预测只有两者类别一致才输出该类别分歧区域交给高斯平滑概率图处理。这个trick在生产环境中几乎稳定上涨Dice代价只是双倍推理时间。最后值得提醒的是你的最终mask一定要回写到原始DICOM空间。因为绝大多数下游应用比如手术导航工作在原始扫描分辨率下你的所有预处理——重采样、Z-Score、方向校正——都要在保存结果时反变换回去。建议在推理管线的最后一步保存两个文件一个是在预处理分辨率下的分割mask一个是重采样回原始spacing和方向的对齐mask。后者才是临床真正需要的东西。本文还有配套的精品资源点击获取