简介:面向医学图像分割研究者与深度学习初学者的脊椎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-12=T1-T12,13-17=L1-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, f"expect 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_spacing=1.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, order=3) # 标签:最近邻插值,保持整数值 resampled_label = zoom(label_data, zoom_factors, order=0) 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行的order=3对应三线性插值,第8行的order=0是最近邻。第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, alpha=0.5, beta=0.7, gamma=2.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, dim=1) # 沿类别维做softmax targets_onehot = F.one_hot(targets, num_classes=self.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:], dim=dims) union = torch.sum(probs[:, 1:] + targets_onehot[:, 1:], dim=dims) 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, reduction='none') 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这两个参数在脊椎分割上通常这样配置:alpha=0.5让两个损失贡献相当;gamma=2.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( image=tio.ScalarImage(img_p), label=tio.LabelMap(lbl_p), ) subjects.append(subject) return subjects def build_dataloader(subjects, batch_size=2, patch_size=96, num_workers=4): transform = tio.Compose([ tio.ToCanonical(), # 转为标准RAS方向 tio.Resample((1.0, 1.0, 1.0)), # 各向同性重采样 tio.RandomAffine(scales=(0.95, 1.05), degrees=5, p=0.5), tio.RandomNoise(std=0.02, p=0.2), tio.ZNormalization(masking_method=tio.ZNormalization.mean), tio.RandomSampler(patch_size=patch_size, num_patches=4), ]) dataset = tio.SubjectsDataset(subjects, transform=transform) patches_sampler = tio.data.UniformSampler(patch_size) patch_dataset = tio.Queue( dataset, max_length=16, samples_per_volume=4, sampler=patches_sampler, num_workers=num_workers ) loader = DataLoader(patch_dataset, batch_size=batch_size, num_workers=0) return loader这里需要解释一下RandomSampler和UniformSampler的区别。transform中出现的RandomSampler会把每个volume内部随机裁patch,并且由num_patches控制每次抽几个patch。而Queue中定义的UniformSampler是均匀采样的兜底,两者配合可以控制正样本patch的出现频率。
max_length=16控制Queue内部缓存的patch队列长度。samples_per_volume=4指每个volume每次最多采样4个patch。这里有一个工程经验:如果max_length太小,GPU在等待数据时会经常空闲,利用率上不去;如果num_workers太高,内存占用会激增,因为每个worker都保存了一个volume的拷贝。
注意ZNormalization(masking_method=tio.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_norm=1.0是常用起始值 torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() running_loss += loss.item() return running_loss / len(loader)torch.nn.utils.clip_grad_norm_对全部网络参数做二范数裁剪,max_norm=1.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_size=96, stride=48, device='cuda'): # volume: [D, H, W] 经过预处理的numpy数组 D, H, W = volume.shape output = np.zeros((D, H, W), dtype=np.float32) weight_map = np.zeros((D, H, W), dtype=np.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:z+patch_size, y:y+patch_size, x:x+patch_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, dim=0).cpu().numpy() output[z:z+patch_size, y:y+patch_size, x:x+patch_size] += pred weight_map[z:z+patch_size, y:y+patch_size, x:x+patch_size] += 1.0 # 取平均,得到最终分割 final = output / np.maximum(weight_map, 1e-6) return final.astype(np.uint8)stride=48时重叠率达到50%,对应的推理时间是stride=96时的约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取整标签。可以先对概率图做轻度高斯平滑(sigma=0.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。后者才是临床真正需要的东西。
本文还有配套的精品资源,点击获取