
简介面向医学影像AI方向的3D MRI脊椎分割数据集专为训练与验证SPINE自动分割模型设计。包内为nii.gz格式人体脊柱MRI原始扫描与对应标注可用于监督学习流程另含dataset.json训练配置与txt说明文件便于基于nnUNet等框架直接组织数据并开展实验。资源共222个文件其中nii相关文件220个配套1个json和1个txt说明压缩包整体约374.58MB体积适中适合单GPU环境完成预训练与调优。当前已有298人学习/下载覆盖脊椎分割、MRI分割、3D分割等典型任务场景。入手后可使用标注数据复现分割流程也可结合目录结构自行划分训练集与验证集是医学影像算法入门及基准测试的实用数据来源。1. 拿到一批.nii.gz的3D MRI人体脊椎分割数据集第一件事不是训练而是先看懂它如果你手里正好有一批后缀全是.nii.gz的3D MRI人体脊椎分割数据集大概率和当年的我一样第一反应是打开ITK-SNAP看几层然后发现“看”和“跑训练”完全是两码事。标题里的脊椎SPINE分割、MRI分割、3D分割听起来像是三个名词的拼接实际上是一条需要串起来的流水线把NIfTI格式的三维MR图像读成模型能用的数组用3D分割网络把椎体、椎间盘或脊髓从背景里分离再把预测结果写回和原图对齐的nii.gz。这个过程中方向矩阵、体素间距、标签映射任何一个环节出错最后得出的Dice再高也是白搭。这篇笔记是给正要在这个数据集上动手的工程师写的从数据读取、预处理、模型训练到后处理和验证每一步给默认参数也给踩坑记录。2. 把nii.gz读成能训的数组3D MRI脊椎分割的预处理与数据基础2.1 NIfTI格式的三件套direction、spacing、origin为什么不能丢.nii.gz本质是压缩过的NIfTI它不只是一堆像素值还自带一个4x4的仿射变换矩阵决定每个体素在物理空间里的位置和方向。用SimpleITK读取时这几个信息会被拆成direction、spacing和origin三部分。很多初学者用nibabel转成numpy数组之后就把这些信息扔了等到训练完想保存结果发现预测出来的mask和原图对不上这种翻车我在项目里见过太多次。import SimpleITK as sitk img sitk.ReadImage(spine_subject01_t2.nii.gz) print(direction:\n, img.GetDirection()) print(spacing:, img.GetSpacing()) print(origin:, img.GetOrigin()) arr sitk.GetArrayFromImage(img) print(array shape:, arr.shape) # (z, y, x)这段代码最值得记住的是最后两行。SimpleITK的GetArrayFromImage返回的形状是(z, y, x)即第一维是切片方向。而nibabel的get_fdata返回的是(x, y, z)两种库的轴序不一样如果你混着用又没注意转置后面所有代码都会基于一个错掉的世界观在跑。我的习惯是项目里统一用SimpleITK并且写一个数据加载函数把所有case的shape、spacing、direction打印成表格先确认这批数据是不是同一个采集协议。2.2 重采样、裁剪和归一化一套可直接抄的预处理管线脊椎MRI的特点是z轴层数多常见有20到300多层不等x-y分辨率从0.5mm到1.5mm都有。不同中心、不同设备出来的数据spacing差异可以很大。处理这类数据的通用做法是先重采样到统一体素间距我自己常用1.0 x 1.0 x 1.0mm作为默认值等间距后所有后续操作都好写。def resample_to_spacing(img, new_spacing(1.0, 1.0, 1.0), interpolatorsitk.sitkLinear): orig_spacing img.GetSpacing() orig_size img.GetSize() new_size [ int(round(orig_size[i] * (orig_spacing[i] / new_spacing[i]))) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(new_spacing) resampler.SetSize(new_size) resampler.SetOutputDirection(img.GetDirection()) resampler.SetOutputOrigin(img.GetOrigin()) resampler.SetInterpolator(interpolator) return resampler.Execute(img)图像用sitkLinear做线性插值标签mask必须用sitkNearestNeighbor否则插值会造出Label 0.7这种不存在的东西。这里有个容易被忽略的细节new_size的计算在direction不是对角矩阵时有点问题但绝大多数轴位采集的脊椎数据direction近似单位阵这样算够用。如果你的数据有倾斜扫描严格做法是用direction矩阵把物理坐标变换考虑进去。MRI不像CT有固定的HU窗宽窗位所以归一化不能用那种写死的clip参数我一般取2%和98%分位做截断再做z-score。import numpy as np def normalize_mri(arr): lo, hi np.percentile(arr, (2, 98)) arr_clipped np.clip(arr, lo, hi) mean arr_clipped.mean() std arr_clipped.std() return (arr_clipped - mean) / (std 1e-8)归一化之后还要做前景裁剪。脊椎区域通常在身体中部整张512x512的图像大量是背景空气直接丢进模型不仅浪费显存还会让模型把大部分学习容量花在区分“空气”和“身体”上。常见做法是先算一个粗略的body mask比如用Otsu阈值在归一化前的图像上找到前景然后计算包围盒把训练patch的采样范围限制在这个包围盒内部。2.3 标签不是简单的0和1先看清楚label映射再动手脊椎分割数据集最常见的标注方式有三种整个脊柱一个label、每节椎骨一个label、椎体和椎间盘分开编号。遇到前两种都好办最怕的是混合标注比如前20个case是全部椎体一个值后面20个case每节椎体一个独立值。如果不做对齐模型会看到同一个解剖结构有两种不同的监督信号。import numpy as np from collections import Counter label_map sitk.GetArrayFromImage(sitk.ReadImage(label.nii.gz)) vals, counts np.unique(label_map, return_countsTrue) for v, c in zip(vals, counts): print(flabel {int(v)}: {int(c)} voxels) # 典型输出0背景, 1椎体, 2椎间盘, 或者 1~24 对应各节椎骨 # 如果发现不同case的label数量不一致建议统一映射到 0/1/2/3 的语义空间我的建议是无论原始label多细第一版基线模型先做语义合并比如背景、椎体、椎间盘三类。这样类别少、类间差异大模型容易收敛先跑通全流程。等基线稳定了再去做逐节椎骨编号那是另一个层面的细粒度分割问题。另外要注意有些数据集中label编号从1开始而不是0代码里做one-hot或CrossEntropy时容易整体错一位这类边界情况写进数据检查脚本里最稳妥。3. 用3D U-Net跑通脊椎分割模型选型与训练参数设定3.1 为什么是3D U-Net而不是2D滑窗或者TransformerMRI脊椎分割的本质是三维结构分割椎体在矢状面上看是矩形在轴位上看是圆形椎间盘和椎管又是完全不同的形态。直接用2D网络逐层分割会丢失层间连续性常见结果就是椎体边缘出现阶梯状锯齿。我见过有人用2D模型加CRF后处理来修补效果远不如老老实实上3D模型。当前医学图像分割里最通用的起点就是3D U-Net它的编码器逐级下采样捕捉语义解码器逐级恢复分辨率跳跃连接把浅层细节传给深层特征。如果不想自己调模型结构可以直接走nnU-Net这条成熟路线它会自动根据数据集统计信息推理出patch size、下采样策略和训练参数。但nnU-Net不是银弹对显存要求高如果训练资源只有单张24G显卡建议还是自己搭一个轻量3D U-Net把channel数调小。下面是一个可选参考结构编码器四个stage每个stage通道数分别是32、64、128、256z轴深度在最后两个stage会压缩到很小内部显存占用可控。3.2 训练参数怎么定patch size、batch size、学习率和损失函数我习惯把这些参数先定下来再动手调模型避免后期每个参数都变成玄学。以下是针对1.0mm重采样后、图像尺寸约512x512x200的脊椎数据实测比较稳的一组起点配置。参数推荐值说明patch size96 x 96 x 64长条形结构z轴深度不能太小否则椎体连续性学不到batch size2单卡24G显存的上限附近可配合梯度累积optimizerAdamW比Adam稳weight decay设1e-5学习率1e-4配合warmup 10个epoch避免早期震荡损失函数Dice Loss CrossEntropy按0.5:0.5加权兼顾类别平衡和边界锐度总epoch200配early stopping监控验证集HD95数据增强随机翻转、随机弹性、强度扰动翻转只能沿轴位x-y方向z轴翻转会改变头尾语义patch size是这里最值得说的参数。脊椎是长条结构椎体在矢状面上占的像素范围很大patch太小会让模型只看得到局部骨性结构看不到椎间盘之间的距离关系。96x96x64在1mm等间距下大概能覆盖两到三个椎体对首个基线版本够用。如果你的显存只有16G可以把patch降到64x64x64但要做好Dice掉1到2个点的心理准备。3.3 训练脚本骨架多类Dice Loss与训练循环多类分割我通常不用二值Dice而是对每个类别独立算Dice再取平均这样不会因为背景体素多把前景的损失稀释掉。下面这个实现直接可用。import torch import torch.nn as nn class DiceLoss(nn.Module): def __init__(self, n_classes, smooth1e-5): super().__init__() self.n_classes n_classes self.smooth smooth def forward(self, pred, target): # pred: (B, C, D, H, W), target: (B, D, H, W) long pred torch.softmax(pred, dim1) target_onehot torch.eye(self.n_classes, devicetarget.device)[target] target_onehot target_onehot.permute(0, 4, 1, 2, 3).float() dims (2, 3, 4) intersection torch.sum(pred * target_onehot, dimdims) union torch.sum(pred, dimdims) torch.sum(target_onehot, dimdims) dice (2.0 * intersection self.smooth) / (union self.smooth) return 1.0 - dice.mean()这段代码里target的形状是(B, D, H, W)通过torch.eye映射成one-hot后permute成(B, C, D, H, W)和pred形状对齐。smooth设为1e-5就够太大会让Dice值失真。训练循环没什么特殊技巧唯一要注意的是每个epoch结束一定要在验证集上算Dice和HD95不要只看loss曲线下降就以为万事大吉。for epoch in range(start_epoch, total_epoch): net.train() for x, y in train_loader: x, y x.cuda(), y.cuda() optimizer.zero_grad() pred net(x) loss dice_loss(pred, y) ce_loss(pred, y) loss.backward() optimizer.step() # 验证 net.eval() with torch.no_grad(): val_dice evaluate_dice(net, val_loader) print(fepoch {epoch}: loss{loss.item():.4f}, val_dice{val_dice:.4f})训练策略上一个容易被忽视的点是采样逻辑。随机在整图采patch大概率采到全是背景的块模型会陷入“什么都预测为背景”的假收敛。我一般把前景体素的坐标提前存好训练时70%概率从前景附近采样30%概率随机采样这样既保证正样本密度又保留背景判别能力。4. 脊椎分割避坑nii.gz方向错位、显存爆炸与标签错位的排查记录4.1 现象训练Dice挺高推理出的mask是镜像的这个问题我踩过不止一次典型症状是训练集Dice到0.8以上但把预测结果叠加到原图上发现椎体位置整体左右互换或者前后翻转。原因几乎都出在读取阶段如果用nibabel读图像、用SimpleITK读标签两个库的轴序不一致模型看到的输入和监督信号就不是同一个空间。解决方法是统一读取库并且在预处理流水线入口处写一个断言检查图像和标签的shape、spacing、direction完全一致。assert img.GetDirection() label.GetDirection() assert img.GetSpacing() label.GetSpacing() assert img.GetOrigin() label.GetOrigin() assert sitk.GetArrayFromImage(img).shape sitk.GetArrayFromImage(label).shape4.2 现象训练时直接OOM显存爆掉常见做法是把整个512x512x200的卷一次性喂进网络那当然爆。脊椎MR不像CT腹部那么大但整图进3D网络也远超24G显存。解决思路有两个一是把训练和推理都改成patch-based二是重采样时把x-y轴降到1.5mm或2mm减少单个体素数。如果你坚持用1mm分辨率patch采到80x80x64以后就别再加了配合batch size2和梯度累积单卡也能跑。推理阶段用滑窗加overlap比如窗口在z轴上步长设为32重叠区域取概率均值。4.3 现象Dice不低但肉眼看见椎体之间连成一片脊柱分割最怕的是相邻椎体粘连Dice却仍然很高因为椎体的体素大部分预测对了只有接缝处错一点点。原因是椎体之间的椎间盘类是少数类模型偷懒把它们归到椎体里Dice惩罚不够。解决方法是给椎间盘类别提高损失权重或者把椎体和椎间盘分别训练成两个独立二值模型。我在实际项目里发现两个二值模型加一个后处理合并往往比一个多类模型更稳。4.4 现象验证集Dice高换一批数据就打回原形这个坑和数据采集本身关系很大。MRI没有CT那种稳定的HU值不同中心、不同场强、甚至同一台设备不同重建算法出来的图像组织对比度都不同。比如最近西门子设备上常见的Deep Resolve类AI重建技术会让图像噪点更低、边缘更锐利这是一件好事但如果你只在老旧重建算法的数据上训练遇到这种图像反而会不习惯。解决思路是训练时做更强度的数据增强比如随机spacing扰动、随机gamma变换同时保留一小批跨设备数据做测试集不要只用一个来源的数据评估模型。4.5 现象同一张图推理两次结果不一样如果推理代码里忘了写model.eval()BatchNorm和Dropout会继续按训练模式运行Dropout随机丢弃特征结果就是两次推理有细微差异。脊椎边缘的小突起对这些差异非常敏感肉眼可能看不出但计算HD95时差值会非常明显。这个问题的排查很简单检查推理脚本里有没有net.eval()以及有没有用torch.no_grad()包住前向。这两个动作我建议直接写进推理函数不要依赖调用者记得。5. 从mask到可交付结果后处理、指标与可视化5.1 连通域分析与标签清洗去掉那些不该存在的“飘浮椎体”网络输出的mask直接写盘是不专业的至少要做一遍连通域分析。脊椎结构在物理上是连续的预测结果里不应该出现大量细碎的独立小区域。sitk.ConnectedComponent可以把mask拆成多个连通域然后按物理体积过滤掉小区域这个操作能有效清理远离主体结构的假阳性。import SimpleITK as sitk label_img sitk.ReadImage(pred.nii.gz) cc sitk.ConnectedComponent(label_img 0) stats sitk.LabelShapeStatisticsImageFilter() stats.Execute(cc) cleaned sitk.Image(label_img.GetSize(), sitk.sitkUInt8) cleaned.CopyInformation(label_img) for label in stats.GetLabels(): if stats.GetPhysicalSize(label) 10.0: # 小于10立方毫米的连通域直接丢弃 continue cleaned cleaned (cc label) * label这里要注意过滤阈值不能设得太大椎间盘本身就不算大过滤太狠会连真实结构一起删掉。我一般会先跑一遍统计打印每个连通域的物理体积看分布再做截断。如果发现大量大体积粘连结构说明模型层面的问题还没解决后处理不该用来兜底。5.2 不要只看DiceHD95和对称面距离才是脊椎分割的照妖镜Dice对长条结构的错位不敏感椎体整体平移两三个体素Dice可能只掉零点几但这对临床测量已经是灾难。HD95是95%分位Hausdorff距离衡量预测边界和真实边界的最大偏差能暴露局部错位和边缘毛刺。from scipy.ndimage import distance_transform_edt import numpy as np def hd95(pred, gt, spacing): # pred/gt: 0/1 三维数组 if pred.sum() 0 or gt.sum() 0: return float(inf) pred_edt distance_transform_edt(1 - pred, samplingspacing) gt_edt distance_transform_edt(1 - gt, samplingspacing) pred_surface pred_edt[gt 0].max() gt_surface gt_edt[pred 0].max() return max(pred_surface, gt_surface)这段代码用距离变换近似计算表面距离虽然没有直接提取表面网格但对工程评估足够。每类结构单独算HD95椎体和椎间盘要分开记录。如果椎体的HD95稳定在2mm以内椎间盘在3mm以内这个模型基本可以进入人工复核流程。5.3 预测结果写回nii.gz保持对齐的关键一步推理产出的往往是一个numpy数组直接sitk.GetImageFromArray再WriteImage会丢掉spacing和direction信息写出来的文件在ITK-SNAP里和原图对不上。正确做法是把原图的信息复制给预测结果。pred_arr np.argmax(logits, axis0).astype(np.uint8) # (D, H, W) pred_sitk sitk.GetImageFromArray(pred_arr) pred_sitk.CopyInformation(ref_img) # 关键复制原图的空间信息 sitk.WriteImage(pred_sitk, pred_final.nii.gz, useCompressionTrue)CopyInformation会同步spacing、origin、direction这样预测文件就能直接拖进ITK-SNAP和原图叠加查看。另一个细节是如果预处理时做过重采样最终mask要先重采样回原始spacing再写盘否则下游医生看到的解剖尺寸是错的。这个步骤我用一个专门的函数处理不接受任何跳过。6. 把分割结果用起来跨中心验证与三维渲染前的自检6.1 给预测mask加一道“椎体计数”的快速自检模型在单个case上表现好不代表全数据集都好我自己的教训是必须写一个自动化自检脚本逐case打印连通域数量和体积Top几的数值用来识别粘连和漏检。正常成年人的胸腰椎椎体数量是17到24节之间如果你预测出一个case有40个连通域几乎可以断定是椎间盘也被标成了独立区域反过来只有5个连通域那是严重粘连。pred sitk.ReadImage(pred_final.nii.gz) cc sitk.ConnectedComponent(pred 0) stats sitk.LabelShapeStatisticsImageFilter() stats.Execute(cc) volumes sorted([stats.GetPhysicalSize(l) for l in stats.GetLabels()], reverseTrue) print(num components:, len(volumes)) print(top 5 volumes (mm3):, volumes[:5])6.2 跨中心验证一份可复制的核对清单如果你要拿这个分割方案去接不同来源的数据不要只盯着网络结构调参先把以下项目列成表格逐项核对。核对项怎么查不匹配时怎么做体素间距打印全部case的spacing统一重采样到1.0mm扫描方向检查direction和origin统一重采样时保持原direction或转为标准轴位重建算法查DICOM头或设备信息记录差异跨中心验证时按中心分组评估信号强度分布统计2%/98%分位数归一化参数按数据集自适应不要用全局固定值标注语义检查label分布写映射表对齐到统一语义空间这套核对清单本身就是一个验证方法比只看模型指标更接近真实部署场景。我习惯每个新数据源先跑一遍数据自检脚本把表格打出来再决定后续动作而不是盲目微调。做脊椎分割一年多的血泪经验是最终决定项目成败的往往不是模型结构而是数据读取、空间信息对齐、标签语义统一这三件事。每次拿到新数据我都会先花半天时间写数据检查脚本把shape、spacing、label分布、方向矩阵全部打印出来逐项确认再开始搭模型。这一步看似慢实际是在给自己买后悔药省掉后面几周的排错时间。希望这篇笔记帮你在.nii.gz的3D MRI脊椎分割数据集上少走一段弯路。本文还有配套的精品资源点击获取