简介:用于脊椎SPINE分割、MRI三维分割任务的数据集采用医学影像标准nii.gz格式存储,面向医学影像分析与深度学习方向的开发者、研究者及研究生,可直接用于模型训练、算法验证或相关课题研究,特别适合从事3D医学图像分割工作的算法工程师进行实验对比与效果评估。压缩包共222个文件,其中220个为nii格式图像,从命名规律看包含原始MRI扫描文件与对应分割标注,另附1个txt索引说明和1个dataset.json配置文件,便于了解数据组织方式与任务定义;整体包体约374.58MB,兼顾样本量与下载便携性。目前已有299人浏览学习,适合需要脊椎分割基准数据、开展消融实验或完成课程设计/毕业设计的医学图像算法学习者。数据文件编号清晰、目录结构直观,实践中可省去繁琐的格式转换与预处理时间,使开发者能将更多精力放在分割网络优化、Loss设计及最终评估上。
1. 拿到一批nii.gz的3D MRI脊椎分割数据,先别急着训练:先弄清楚spine数据长什么样
你刚拿到一批3D MRI人体脊椎分割数据集,文件全是nii.gz,每个病例对应一张T1或T2加权图、一张label,里面是从颈椎到腰椎的椎体或椎间盘标注。外面看「脊椎SPINE分割、MRI分割、3D分割」就是读个数据、跑个网络,但真上手会发现,最先卡住你的往往不是神经网络,而是nii.gz背后的坐标空间。同一个序列,有人读取后图像和标签左右翻转,有人重采样后label出现半像素偏移,最后Dice死活上不去。这篇笔记按我实际处理这类数据集的顺序展开:先把nii.gz和MRI的特性读明白,再做预处理,然后搭一个能跑通的3D分割基线,最后用临床认可的指标验证。适合刚拿到数据不知道从哪下手的工程师,也适合从2D图像分割转到3D体数据的老手。
2. 读懂nii.gz与MRI体数据:文件结构、坐标变换与3D MRI分割的可视化
2.1 nii.gz是什么:头文件、体素间距与affine矩阵
拿到nii.gz之后,第一步永远是把文件结构确认清楚。一个.nii.gz不是单纯的「三维数组文件」,它是被gzip压缩后的NIfTI-1格式。NIfTI文件里除了体素数组,还带一个约348字节的header,里面存着数据维度(dim)、体素物理间距(pixdim)、整数类型(datatype)以及最重要的affine矩阵。affine是一个4x4的仿射变换,作用是把数组下标(i,j,k)映射到物理坐标(x,y,z)。如果只在numpy里读数组而丢掉这个矩阵,后面所有重采样、裁剪、标签对齐都会变成空中楼阁。
处理spine数据的第一件事,就是打印每个样本的shape、dtype、spacing和affine。T1/T2的spine MRI体数据常见shape在(240,240,30)到(512,512,64)之间,dtype可能是int16或uint16,z方向体素间距往往大于x/y方向。这个信息决定后面是走各向同性重采样,还是按原spacing直接训练。
import nibabel as nib import numpy as np img = nib.load("spine_001_t1.nii.gz") data = img.get_fdata() header = img.header affine = img.affine print("shape:", data.shape) print("dtype:", data.dtype) print("spacing (mm):", header.get_zooms()) print("orientation:", nib.orientations.aff2axcodes(affine))这里有几个容易忽略的点。get_fdata()返回float64数组,原始整数会被转成浮点,方便后续归一化;标签文件读取后要立刻转成np.uint8再参与计算。header.get_zooms()返回三个方向的体素间距:如果z轴spacing是3mm而x/y是1mm,一个体素实际是1×1×3mm的扁立方体,直接按数组索引做卷积,卷积核在三视图方向的有效感受野并不一致。aff2axcodes返回类似('R','A','S')的方向码,表示体数据在物理空间中右侧(Right)、前侧(Anterior)、上侧(Superior)的走向。不同来源的病例方向码可能一个是('R','A','S'),另一个是('L','A','S')。不统一方向,图像和标签在batch里就是错位的,这也是很多自动化预处理脚本暗藏的雷。
提示:不要用
img.get_data(),这个接口在NiBabel新版里已经废弃。统一用get_fdata(),然后把标签转成uint8。
2.2 用NiBabel读入3D MRI体数据并切片可视化
MRI和CT不同,同一个病人的T1、T2信号范围可以差好几倍,肉眼看一遍三个方向的切片,能发现很多标注和伪影问题。下面代码把轴向(axial)、冠状(coronal)、矢状(sagittal)三个方向的中位切片画出来。
import matplotlib.pyplot as plt def show_three_planes(data): # data 是 nib.load().get_fdata() 读出的体数据数组 ax = data[:, :, data.shape[2] // 2] # 轴向切片,沿 z 取中间 co = data[:, data.shape[1] // 2, :] # 冠状切片,沿 y 取中间 sa = data[data.shape[0] // 2, :, :] # 矢状切片,沿 x 取中间 plt.figure(figsize=(14, 4)) for i, slice_data in enumerate([ax, co, sa]): plt.subplot(1, 3, i + 1) plt.imshow(slice_data.T, cmap="gray", origin="lower") plt.axis("off") plt.show() show_three_planes(data)注意这里做了.T和origin="lower",因为NiBabel数组的第一个轴对应影像学里的x方向,直接imshow会把图像左右或上下反过来。这是读nii.gz最容易踩的地方:看到椎体是反的,不代表数据坏了,而是显示时没有处理方向。另外,如果三个轴的spacing差别很大,这个代码画出来的冠状位和矢状位会被拉伸变形,可以在imshow里加extent把物理坐标画准,但训练流程里不需要,只要心里有数即可。如果读出来的数据维度是4个,比如多期增强MRI,那就要用data[..., 0]取第一个时间点,并确认每个vol是否严格对齐。
2.3 3D分割与2D分割的本质差异:为什么不能无脑切2D
有人喜欢把3D MRI按z轴逐片切成2D图再跑U-Net,省显存,但结果通常不理想。脊椎在z轴方向是连续结构,相邻切片上的椎体和椎间盘形态高度相关;切成2D后,模型只能看到单层灰度,很容易把椎间盘和椎骨边界搞混。3D卷积核在体积内同时聚合三个方向的信息,输出也是完整的三维体素标签,这是3D分割在MRI脊椎任务上的常规选择。
但3D分割真正的代价是显存和训练时间。一个全尺寸的3D MRI脊椎体数据可能是320×320×30,直接塞进3D U-Net几乎放不下,所以必然要裁剪、重采样、限制patch size。这些操作不是随便做做,而是要在「保留解剖结构」和「符合显存边界」之间找平衡。之前有个项目里,同事把原始数据直接resize到128×128×128,结果椎间盘边界全糊了——因为z方向原本只有30层,强行插值到128层会制造大量假信息。基本原则是:能在3D空间解决的问题不降成2D,能用小patch跑通的不盲目加大patch,需要重采样时先想清楚目标spacing。
3. 把nii.gz的3D MRI脊椎分割数据集整理成可训练样本:重采样、裁剪与标签核对
3.1 重采样与裁剪:统一spacing和体素尺寸
预处理的第一刀通常是重采样。不同设备、不同序列出来的spacing差异很大,统一到目标spacing后再进模型,才能让卷积核在不同样本上学到物理尺度一致的语义。我一般会先把图像和标签都重采样到各向同性1mm,如果显存紧张,第一个妥协方案是z轴放宽到2mm,x/y保留1mm。
from scipy.ndimage import zoom import numpy as np def resample_to_spacing(data, old_spacing, new_spacing, is_label=False): factor = np.array(old_spacing) / np.array(new_spacing) order = 0 if is_label else 1 return zoom(data, factor, order=order) # 图像用线性插值,标签用最近邻 old_spacing = np.array(img.header.get_zooms()) new_spacing = np.array([1.0, 1.0, 1.0]) resampled_img = resample_to_spacing(data, old_spacing, new_spacing, is_label=False) resampled_lbl = resample_to_spacing(label, old_spacing, new_spacing, is_label=True)核心点是order=0。标签用最近邻插值保证输出还是0、1、2这样的整数,避免线性插值产生0.6之类的伪标签;图像用一阶线性插值就够,不要用三阶样条,因为MRI噪声本身较大,过高阶插值只会放大伪影。zoom的factor是旧spacing除以新spacing,比如z轴从3mm变1mm就是3,这个操作会直接把数组在z方向放大三倍,接着再做裁剪时要注意体素对应的物理区域没有漂移。
裁剪是为了把脊柱区域从大体积背景里提出来。MRI图像里背景和软组织占比很高,直接全图训练会让网络花大量容量学背景。常见做法是先用标签的nonzero体素算一个包围盒(bounding box),再向外膨胀20~30个像素,既保留上下文,又去掉空背景。
def bbox_from_label(label, margin=20): idx = np.argwhere(label > 0) mins = idx.min(axis=0) - margin maxs = idx.max(axis=0) + margin mins = np.maximum(mins, 0) maxs = np.minimum(maxs, np.array(label.shape)) return mins, maxs mins, maxs = bbox_from_label(resampled_lbl, margin=20) cropped_img = resampled_img[mins[0]:maxs[0], mins[1]:maxs[1], mins[2]:maxs[2]] cropped_lbl = resampled_lbl[mins[0]:maxs[0], mins[1]:maxs[1], mins[2]:maxs[2]]这段代码的问题是它切在数组索引空间,而不是物理空间。如果只是训练用,没问题;但推理时要保存原始空间的预测,必须把mins和maxs映射回原始affine坐标系。我的习惯是每个样本预处理后只存一张nii.gz和一个同名的npy,把affine、spacing、bbox坐标打包进一个json,免得后面推理还原时猜参数。不少项目就是在这里翻车:训练时crop了,推理时没还原,预测mask直接少了一大块。
3.2 灰度归一化与窗宽窗位
CT有固定的亨氏单位,窗宽窗位是确定的;MRI没有这种统一量纲,T1加权、T2加权、STIR序列的灰度值范围完全不同,同一个序列在不同设备上的信号强度也可能差很多。所以MRI预处理里最稳妥的灰度归一化是逐样本的百分位截断加z-score,而不是用全局的均值和方差。
def normalize_mri(data): p_low, p_high = np.percentile(data, (0.5, 99.5)) data = np.clip(data, p_low, p_high) mean = data.mean() std = data.std() return (data - mean) / (std + 1e-8) # 1e-8 防 std=0 的边界情况百分位选择有讲究。0.5%和99.5%是比较常见的组合,能把血管、脂肪等强信号压住,又不会让低噪声主导归一化。如果背景比例特别大,可以把下限提到1%,上限保持99.5%,否则z-score后软组织对比度不够。注意这里每个volume独立计算,不能拿训练集的全局参数去归一化测试集,因为MRI本身就是相对成像,没有标准标度。如果数据里有T1和T2两种序列,最佳做法是分开处理:分别统计各自的均值和方差,或者干脆在输入通道上额外传一个序列类型标签。
数据增强方面,3D MRI分割可用的方法比CT多,但要克制。随机翻转沿z轴要谨慎,因为颈椎和腰椎的解剖方向有临床意义;绕z轴旋转90度是安全的,其他角度在spacing各向异性下会造成物理变形。灰度扰动可以适当做:随机对比度、随机偏置、伪影模拟,能提升模型对MRI采集参数变化的鲁棒性。
3.3 标签核对:区分椎骨、椎间盘与背景,类别不平衡
预处理完,不要立刻开训,先花十分钟把标签统计一遍。脊椎SPINE分割数据集的标签设计并不统一:有的是二类,0背景、1椎骨;有的三类,0背景、1椎骨、2椎间盘;还有的每个椎体一个ID,比如T1~L5按level编号。不同设计对损失函数的影响很大。
label = cropped_lbl.astype(np.uint8) vals, counts = np.unique(label, return_counts=True) print("label values:", vals) print("pixel counts:", counts)如果看到vals里只有0和255,说明标签没转好——很多标注工具导出的mask是0/255,要除以255映射成1。如果某个类别的counts占比低于5%,像椎间盘在整卷里往往只占很小比例,直接跑标准Dice Loss会出现「大类别主导梯度、小类别永远学不对」的现象。我的习惯是给每个类别算一个权重,背景权重设为0,目标类别权重按频率倒数归一化,然后在Dice Loss里加权。
处理多类别时,建议把脊椎解剖先分组。椎骨和椎间盘是两类完全不同组织的结构,椎骨内部还有皮质骨和松质骨的信号差异;如果数据集标注的是「整个脊柱」而不是「每块椎骨」,那模型的输出规则需要重新定义。对于医生来说,他们更关心的是椎体压缩性骨折、椎间盘突出区域是否被正确分割,单纯一个二值spine mask很难满足临床诊断需要的边界精度。所以动手前最好和提供数据的人确认:标注的单位是什么,是椎体、椎间盘,还是包括椎弓根和棘突在内。这一条直接决定后处理逻辑和评估口径。
4. 从3D U-Net到nnU-Net:MRI脊椎分割训练管线与关键参数
4.1 为什么3D U-Net是MRI分割的默认起点
3D U-Net并不是什么新鲜结构,但它确实是最稳的起点。编码器逐级下采样得到语义特征,解码器逐级上采样恢复分辨率,跳跃连接把编码器每层的空间细节并回解码器,正好匹配医学图像里「病灶/器官边界需要高分辨率信息、区域判别需要全局语义信息」这一对矛盾。对MRI脊椎分割,椎间盘和椎骨边界在多个序列里对比度不同,跳跃连接能帮模型同时利用浅层的边缘特征和深层的类别语义。
网络参数上,我建议初期保持经典结构:编码器四层,每层两个3x3x3卷积加ReLU,再加stride为2的3D池化;底层的通道数从32起步,逐层翻倍到256。输入用patch-based训练,PyTorch里的tensor形状是(B, C, D, H, W),分别对应batch、通道、深度、高度和宽度。MRI脊椎数据D方向通常是重采样后的z轴层数,H和W对应轴位面的两个方向。
设计patch size时,先问显存。以12GB显存为例,(1, 1, 64, 128, 128)配合四层U-Net可以训练;如果数据已经crop到脊柱附近,patch可以再小一点。关键是z方向不要小于32,否则3D卷积在z方向上几乎没有上下文,退化成伪2.5D。
4.2 最小训练管线:数据加载、损失函数、评估指标Dice
下面这套是我经常用来验证数据质量的最小管线,不追求SOTA,但足以暴露数据问题。Dataset负责从预处理好的npy里裁剪patch,训练时80%的概率在标签附近采样,20%随机采样全图,这样不会出现一个batch里全是背景。
import torch from torch.utils.data import Dataset class SpinePatchDataset(Dataset): def __init__(self, image_npy, mask_npy, patch_size=(32, 96, 96), foreground_prob=0.8): self.image = torch.from_numpy(image_npy).float() self.mask = torch.from_numpy(mask_npy).long() self.patch_size = patch_size self.foreground_prob = foreground_prob def __len__(self): return 1024 # 每个epoch采样张数,可根据数据量调整 def __getitem__(self, idx): d, h, w = self.patch_size if torch.rand(1).item() < self.foreground_prob: # 从标签前景点随机取一个作为patch中心 foreground = torch.nonzero(self.mask > 0) center = foreground[torch.randint(len(foreground), (1,))][0] else: center = torch.tensor([ torch.randint(d // 2, self.mask.shape[0] - d // 2), torch.randint(h // 2, self.mask.shape[1] - h // 2), torch.randint(w // 2, self.mask.shape[2] - w // 2) ]) slice_d = slice(center[0] - d // 2, center[0] + d // 2) slice_h = slice(center[1] - h // 2, center[1] + h // 2) slice_w = slice(center[2] - w // 2, center[2] + w // 2) return self.image[slice_d, slice_h, slice_w].unsqueeze(0), \ self.mask[slice_d, slice_h, slice_w]这个代码的关键是前景采样。脊椎MRI中前景体素占比通常不到5%,纯随机采样会浪费大量iter在空patch上。foreground_prob=0.8表示80%的patch中心取自标注区域,20%随机保证模型见过背景,防止推理时把噪声误判为目标。如果你的标注类别不平衡特别严重,可以再加一个类别轮转,保证每个batch里椎间盘这类小目标不被漏掉。
损失函数我用的是Dice Loss和CrossEntropy的组合。Dice Loss对类别比例不敏感,但单独用容易让训练早期梯度不稳;组合损失可以加速收敛:
def combined_loss(pred, target, alpha=0.5): ce = torch.nn.functional.cross_entropy(pred, target) # pred_bg 是背景类概率,只对前景类计算 Dice pred_softmax = torch.softmax(pred, dim=1)[:, 1:] target_onehot = torch.nn.functional.one_hot(target, num_classes=pred.size(1))[:, :, :, :, 1:].permute(0, 4, 1, 2, 3).float() intersection = (pred_softmax * target_onehot).sum() dice = 1.0 - (2.0 * intersection + 1.0) / (pred_softmax.sum() + target_onehot.sum() + 1.0) return alpha * ce + (1.0 - alpha) * dicealpha=0.5表示两项各占一半。如果发现椎间盘类别始终分不出来,可以增大alpha降低Dice的平滑作用,或者按类别独立算Dice再取平均。优化器我用AdamW,学习率3e-4,weight decay 1e-4,配合cosine退火。MRI数据量通常不大,过拟合比欠拟合更常见,所以验证集Dice在训练集Dice到达0.95后开始下降时,要果断加早停。
4.3 nnU-Net作为开箱即用方案:参数怎么改、patch size和spacing怎么设
如果不想从零调网络,直接把数据按nnU-Net要求的目录结构放好,用它做预处理和训练是业界很常见的做法。nnU-Net会自动分析spacing和label分布,输出一份plans.json,里面包括重采样目标spacing、patch size、网络拓扑、batch size等。MRI数据处理上,它默认的灰度归一化就是z-score,和上一节讲的一致。
# 按 nnU-Net 的 DATASET 结构放置 nii.gz 后 nnUNetv2_plan_and_preprocess -d 1 -c 3d_fullres nnUNetv2_train 1 3d_fullres 0这两行命令里,-d 1是数据集ID,-c 3d_fullres表示在原始分辨率上做3D全分辨率训练。nnU-Net规划的patch size通常基于GPU显存,12GB显存下可能给出32x128x128之类的结果。如果这个patch size在z方向太小,我一般会在plans.json里手动把D方向调大,同时把batch size降为1。另外,建议检查nnU-Net自动重采样时的插值顺序:它的label插值用的是最近邻,这是正确的,但如果你自己先做了预处理,必须把图像和标签的spacing填准确,否则nnU-Net的planner会被错误spacing误导。
nnU-Net还有个很容易被忽略的价值:它的predict阶段会自动执行与训练完全一致的预处理和后处理,包括重采样回原始空间。这省掉了很多手写管线的bug。手写方案里容易出问题的部分,正是nnU-Net已经解决了很多次的部分。当然,它的代价是训练时间较长、需要按它的目录规范组织数据,且对显存要求不低。如果只做一次验证,我建议至少先跑通nnU-Net,拿到一个可信的Dice基线,再回头手写模型优化特定环节。
5. 脊椎分割常见问题与避坑:nii.gz读取、训练与推理的血泪经验
5.1 现象:训练loss下降但Dice始终在0附近
原因:图像和标签的affine方向不一致,或者重采样时图像用了线性插值、标签用了最近邻但两边的坐标原点没有对齐,导致网络在学一个乱码对应关系。另一个常见原因是标签值域是0/255,网络把255当作一个类别,而目标mask里只有0和1,Dice按前景计算自然全是0。
解决:训练前打印每个样本的方向码和label unique值,确认aff2axcodes完全一致;把255的标签映射成1。图像和标签用同一套affine做重采样,不能一个用原始spacing一个用resample后的spacing。最后,可视化重叠几张图看边界是否align上,肉眼确认比任何指标都靠得住。
5.2 现象:推理结果整体偏移半个椎体
原因:训练时做了crop或resample,推理时没有把预测结果映射回原始spacing和bbox位置。模型输出是在预处理空间的预测,直接保存成nii.gz,预测体素坐标和原始图像坐标是两套系统,偏半个椎体是很常见的。
解决:保存预测前,用预处理时记录的affine和bbox参数做逆变换。如果是resample,要先记录旧spacing->新spacing的factor,推理完再用zoom的逆操作把mask还原;如果是crop,把预测补回原数组的对应位置。最稳的做法是把预测写nii.gz前,调用nib.Nifti1Image并传入原始图像的affine和header。
5.3 现象:标签文件读出来全是0或255,mask可视化一片黑
原因:数据集的标签可能以uint8、int16甚至float32存储,有些工具用255代表目标,有些把标注文件里的背景写成0、目标写成255,直接读入网络训练,等价于只有背景类。还有一种情况是,标签文件里的值在0~1之间,比如0.5,被astype(np.uint8)截断成0。
解决:读标签后立刻打印np.unique。看到255就用// 255映射成1,看到小数就在转整型前round。所有样本的标签约定统一后,再进预处理管道。不要指望网络去自动学一种「把255识别为目标但输出1」的非线性关系。
5.4 现象:显存足够但patch设不大,椎间盘边缘断裂
原因:patch太小,感受野不足以覆盖椎间盘和邻近椎骨的关系;另一个隐蔽原因是3D BatchNorm在小patch上统计不稳定,batch里方差估计不准,导致输出边界抖动。
解决:显存允许时优先加大z方向patch,而不是无脑加大batch size。如果batch size只有1,把BatchNorm换成InstanceNorm或GroupNorm,能明显稳定训练。还可以加deep supervision,让解码器中间层也能参与loss计算,浅层特征对边缘恢复有帮助。MRI脊椎分割对边界要求高,patch边缘的预测通常可靠性低,推理时可以采用带overlap的滑窗并把重叠区加权平均,减小边缘断裂。
5.5 现象:训练Dice接近0.95,但临床医生认为分割结果没用
原因:Dice对体积重叠高度敏感,对边界距离不敏感。脊椎分割里医生关心的是某个椎体是否被裁掉半截、椎间盘是否少了一段,而Dice在小结构上的微小偏移就会被背景体积稀释,指标好看但解剖不可用。
解决:在Dice之外加一个边界指标,比如Hausdorff距离95%(HD95),同时把可视化结果按冠状位最大密度投影导出成gif或视频,让医生直接看连续断层。Dice高、HD95差的情况,通常要回到数据预处理检查z方向重采样是否过度,或标注本身在z方向就有粗插值。不要迷信单一指标,也不要迷信高分模型。
6. 用Dice与HD95验证分割结果,并保存成和原图对齐的nii.gz
验证阶段最常做的事是:把预测mask和原始图像叠加,计算Dice、IoU、HD95,然后把预测按原始affine保存。Dice看体积重叠,HD95看最大边界偏差,两者一起看才能反映边界质量。计算HD95用SimpleITK比较省事:
import SimpleITK as sitk import numpy as np def dice_score(pred, label): inter = np.logical_and(pred, label).sum() return 2 * inter / (pred.sum() + label.sum() + 1e-6) def hd95(pred, label): pred_sitk = sitk.GetImageFromArray(pred.astype(np.uint8)) label_sitk = sitk.GetImageFromArray(label.astype(np.uint8)) hd = sitk.HausdorffDistanceImageFilter() hd.Execute(pred_sitk, label_sitk) return hd.GetHausdorffDistance()注意SimpleITK的GetImageFromArray默认不做方向对齐,如果pred和label来自相同预处理空间,可以用;如果来自不同空间,要改成先归一化到同一affine坐标。实际操作里我见过不少团队直接对两个重采样后的数组算HD95,但两个空间不一致,数值完全失真。
保存预测nii.gz时,最关键的是用原始图像的affine和header。如果你在推理阶段只拿到一个numpy数组,没有原始NIfTI对象,等于把自己放在猜测方向的危险位置:
def save_prediction(pred_mask, reference_img, output_path): pred = pred_mask.astype(np.uint8) out = nib.Nifti1Image(pred, affine=reference_img.affine, header=reference_img.header) nib.save(out, output_path)这样保存出来的文件才能直接被医生的工作站打开,并自动对齐到原图。如果预测是在重采样空间生成的,保存前要做反重采样,用最近邻插值把预测恢复到原始shape,再传入这个函数。反向重采样的插值顺序不能错:预测mask只能是最近邻,不能因为追求平滑用线性插值,否则会出现非整数的类别边界,临床工具会直接报错或者把0.6当背景。
我的习惯是把预测保存成与原图同名的_pred.nii.gz,同时输出一张冠状位叠加图。有一次因为忘了把crop的bbox补偿回去,预测mask比原图小了一大圈,肉眼看起来只是「缩小版」,Dice却低得离谱,事后排查发现就是在save前没有恢复数组位置。这个教训让我把「affine + bbox + spacing」三件套写成了固定的预处理注释块,谁拿到这份数据都不会猜。希望帮到你。
本文还有配套的精品资源,点击获取