3D MRI脊椎SPINE分割实战:nii.gz预处理到滑窗推理全流程 简介本资源为面向医学影像处理与深度学习方向的3D MRI人体脊椎分割数据集适用于脊柱结构识别、椎骨定位与分割模型训练等研究场景适合具备一定医学图像处理基础的研究生、算法工程师及科研人员使用。压缩包采用7z格式共包含222个文件其中220个为nii格式的3D MRI体数据与对应标注文件另附1个txt说明与1个json配置文件整体约374.58MBnii文件可直接用于体素级分割任务的读取与训练。数据以成对的原始影像与标注掩膜组织便于构建监督学习流程json文件可用于记录数据集划分或类别信息。目前已有299人学习下载可作为脊椎MRI分割实验的起点帮助读者快速搭建训练与验证流程减少自行采集与标注的成本并在此基础上开展模型对比与调优工作。1. 脊椎 SPINE 分割遇上 3D MRI为什么 nii.gz 格式总让新手卡在第一步刚拿到一批 nii.gz 格式的 3D MRI 人体脊椎分割数据集时多数人的第一反应是直接nib.load然后丢进模型结果要么维度对不上要么标签值全是 0要么显存瞬间爆掉。脊椎 SPINE 分割在 3D MRI 上属于典型的「小目标、多类别、强各向异性」任务椎体、椎间盘、椎管在矢状面上挨得很近层厚方向分辨率又远低于平面内分辨率直接套 2D 分割那套流程基本会翻车。这篇笔记面向已经会用 PyTorch 或 MONAI、但还没跑通 3D MRI 脊椎分割的从业者从 nii.gz 的读取、方向对齐、强度归一化一路讲到滑窗推理和后处理每一步都给出可复现的命令和参数。读完你应该能判断这套方案值不值得投入以及自己数据上的坑大概在哪。2. 从 nii.gz 到可训练张量脊椎 MRI 预处理的四个关键动作2.1 先看清 nii.gz 里到底存了什么nii.gz 是 NIfTI 格式的 gzip 压缩版一个文件里同时装着体数据、仿射矩阵和头信息。脊椎 MRI 常见的情况是图像是int16标签是uint8但两者仿射矩阵不一致或者标签被存成了浮点。动手写 Dataset 之前先用下面这段脚本把每个病例的 shape、spacing、方向、标签取值扫一遍别急着写训练循环。import nibabel as nib import numpy as np from pathlib import Path def inspect_case(img_path, lbl_path): img nib.load(str(img_path)) lbl nib.load(str(lbl_path)) # 头信息shape 是 (X, Y, Z)spacing 来自 pixdim print(img shape:, img.shape, spacing:, img.header.get_zooms()) print(lbl shape:, lbl.shape, spacing:, lbl.header.get_zooms()) # 方向矩阵判断是否 LPS/RAS 混用 print(img affine:\n, img.affine) print(lbl affine:\n, lbl.affine) # 标签取值脊椎分割常见 0 背景 若干椎体类别 data np.asanyarray(lbl.dataobj) uniq, cnt np.unique(data, return_countsTrue) print(label values:, dict(zip(uniq.tolist(), cnt.tolist()))) for img_p in sorted(Path(data/images).glob(*.nii.gz)): lbl_p Path(data/labels) / img_p.name inspect_case(img_p, lbl_p)逻辑说明nib.load是惰性加载不会立刻把整个体数据读进内存np.asanyarray(lbl.dataobj)才真正触发读取。参数上重点看get_zooms()返回的三个 spacing脊椎 MRI 常见平面内 0.5~1.0 mm、层厚 3~5 mm这个差异直接决定后面重采样策略。标签取值那一步是为了确认类别编码如果出现 0.0、1.0 这种浮点说明标签被归一化过必须先取整再映射。2.2 方向对齐与重采样别让左右颠倒毁掉整批数据不同设备导出的 nii.gz 方向矩阵经常不一致有的用 RAS有的用 LPS直接混在一起训练模型学到的左右关系是乱的。标准做法是统一到同一个方向比如RAS再决定要不要做各向同性重采样。脊椎分割里我一般保留原始层厚方向只对平面内做轻微重采样因为强行插值到 1mm 各向同性会让层厚方向产生大量虚假细节。import nibabel as nib import numpy as np from nibabel.processing import resample_to_output def align_and_resample(img_path, lbl_path, target_spacing(1.0, 1.0, None)): img nib.load(str(img_path)) lbl nib.load(str(lbl_path)) # 统一到 RAS 方向避免左右颠倒 img nib.as_closest_canonical(img) lbl nib.as_closest_canonical(lbl) # 标签用最近邻图像用线性插值 if target_spacing[2] is None: target_spacing (target_spacing[0], target_spacing[1], img.header.get_zooms()[2]) img_r resample_to_output(img, target_spacing, order1) lbl_r resample_to_output(lbl, target_spacing, order0) return img_r, lbl_r逻辑说明as_closest_canonical把数据转到 RAS 方向这是 MONAI 和 nnU-Net 都默认的约定。resample_to_output的order参数是关键图像用 1三线性保证灰度平滑标签必须用 0最近邻否则会出现 0.5 这种不存在的类别。target_spacing第三位传None表示保留原层厚这是脊椎 MRI 上比较稳的选择。2.3 强度归一化MRI 没有 CT 的 HU 值别照搬窗宽窗位CT 分割可以直接用 HU 值做窗宽窗位MRI 不行同一台设备不同序列的灰度范围都能差好几倍。脊椎 MRI 常用的是 z-score 归一化但要注意只在非零体素上统计均值和标准差否则大量背景 0 会把分布拉偏。下面这个函数是我在多个数据集上验证过的版本。import numpy as np def zscore_normalize(volume, maskNone): volume volume.astype(np.float32) if mask is None: mask volume 0 if mask.sum() 0: return volume mean volume[mask].mean() std volume[mask].std() std max(std, 1e-8) # 防止除零 volume (volume - mean) / std volume[~mask] 0 # 背景强制归零 return volume逻辑说明mask volume 0是 MRI 上最常用的前景近似因为背景通常就是 0。std加一个极小值兜底避免全零切片导致 NaN。归一化后把背景重新置 0是为了让后续滑窗采样时能明确区分「真实背景」和「归一化后的负值」。2.4 滑窗采样与类别不平衡脊椎小目标怎么不被背景淹没3D MRI 脊椎分割里椎体占总体素的比例通常不到 5%如果整卷直接送进网络损失会被背景主导。常见做法是训练时随机裁 patch推理时滑窗。patch 大小要结合显存和脊椎尺寸我一般用(64, 64, 32)或(96, 96, 48)层厚方向给少一点因为原始层数本来就少。import numpy as np def random_patch(volume, label, patch_size(64, 64, 32), pos_ratio0.7): # pos_ratio 控制前景 patch 比例缓解类别不平衡 if np.random.rand() pos_ratio: # 前景 patch从标签非零位置随机选中心 fg np.argwhere(label 0) if len(fg) 0: center np.array(volume.shape) // 2 else: center fg[np.random.randint(len(fg))] else: center np.array(volume.shape) // 2 # 按中心裁 patch越界用 0 填充 starts np.maximum(center - np.array(patch_size) // 2, 0) ends np.minimum(starts np.array(patch_size), volume.shape) starts np.maximum(ends - np.array(patch_size), 0) v np.zeros(patch_size, dtypenp.float32) l np.zeros(patch_size, dtypenp.int64) slices tuple(slice(s, e) for s, e in zip(starts, ends)) v[:ends[0]-starts[0], :ends[1]-starts[1], :ends[2]-starts[2]] volume[slices] l[:ends[0]-starts[0], :ends[1]-starts[1], :ends[2]-starts[2]] label[slices] return v, l逻辑说明pos_ratio0.7表示 70% 的 patch 以脊椎前景为中心剩下 30% 随机取保证模型既见过目标也见过背景。starts和ends两次修正保证 patch 不越界越界部分补 0。这个采样器可以直接接进 PyTorch 的Dataset.__getitem__。3. 3D 分割网络选型U-Net 变体在脊椎 MRI 上的取舍3.1 为什么 2D 切片方案在脊椎上容易断链很多人第一版会先用 2D U-Net 逐层分割再堆叠结果在矢状面上椎体是连续的但层厚方向信息丢失相邻层预测不一致重建出来像被锯过。脊椎分割对上下连续性要求高尤其是椎间盘和椎管2D 方案在层间跳变处经常出现断裂。3D 网络虽然显存吃紧但能直接建模层间关系这是脊椎任务上 3D 方案更稳的根本原因。3.2 3D U-Net 的最小可跑配置下面是一个精简版 3D U-Net通道数按显存从 16 起步深度 4 层适合(64, 64, 32)的 patch。别一上来就堆到 64 通道先跑通再调。import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv3d(in_ch, out_ch, 3, padding1, biasFalse), nn.InstanceNorm3d(out_ch), # MRI 小 batch 用 InstanceNorm 更稳 nn.LeakyReLU(0.01, inplaceTrue), nn.Conv3d(out_ch, out_ch, 3, padding1, biasFalse), nn.InstanceNorm3d(out_ch), nn.LeakyReLU(0.01, inplaceTrue), ) def forward(self, x): return self.block(x) class UNet3D(nn.Module): def __init__(self, in_ch1, num_classes8, base16): super().__init__() self.enc1 ConvBlock(in_ch, base) self.enc2 ConvBlock(base, base*2) self.enc3 ConvBlock(base*2, base*4) self.enc4 ConvBlock(base*4, base*8) self.pool nn.MaxPool3d(2) self.up3 nn.ConvTranspose3d(base*8, base*4, 2, stride2) self.dec3 ConvBlock(base*8, base*4) self.up2 nn.ConvTranspose3d(base*4, base*2, 2, stride2) self.dec2 ConvBlock(base*4, base*2) self.up1 nn.ConvTranspose3d(base*2, base, 2, stride2) self.dec1 ConvBlock(base*2, base) self.out nn.Conv3d(base, num_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) d3 self.dec3(torch.cat([self.up3(e4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)逻辑说明InstanceNorm3d在 batch size 只有 1~2 的 3D 任务上比 BatchNorm 稳这是血泪经验。base16是显存和精度的折中8GB 显存跑(64,64,32)patch 基本够用。num_classes按你数据集的标签数改脊椎分割常见 7~8 类背景 若干椎体段。3.3 损失函数与深监督Dice 和 CE 怎么配脊椎分割类别不平衡严重纯 CE 会让模型偏向背景纯 Dice 在小目标上梯度不稳。我一般用Dice CE加权权重 0.5:0.5 起步。如果椎体类别多且样本少可以加深监督在 decoder 每一层加辅助输出。import torch.nn.functional as F def dice_loss(logits, target, num_classes, eps1e-5): probs F.softmax(logits, dim1) target_onehot F.one_hot(target, num_classes).permute(0, 4, 1, 2, 3).float() dims (0, 2, 3, 4) inter (probs * target_onehot).sum(dims) union probs.sum(dims) target_onehot.sum(dims) dice (2 * inter eps) / (union eps) return 1 - dice.mean() def combined_loss(logits, target, num_classes): ce F.cross_entropy(logits, target) dc dice_loss(logits, target, num_classes) return 0.5 * ce 0.5 * dc逻辑说明dice_loss里eps防止空类别除零dims把 batch 和空间维都聚合只保留类别维。combined_loss的权重可以按验证集 Dice 调如果某类一直学不出来适当提高 Dice 权重。4. 滑窗推理与后处理把 patch 预测拼回完整体积4.1 滑窗推理的步长与重叠怎么定训练用 patch推理必须滑窗否则整卷进不去。步长一般取 patch 的 1/2 到 1/3重叠越多边界越平滑但越慢。脊椎 MRI 层厚方向层数少我一般层厚方向步长取 patch 的 1/2平面内取 1/2。import numpy as np import torch def sliding_window_inference(model, volume, patch_size(64,64,32), overlap0.5): model.eval() device next(model.parameters()).device num_classes 8 prob_map np.zeros((num_classes,) volume.shape, dtypenp.float32) count_map np.zeros(volume.shape, dtypenp.float32) stride [max(1, int(p * (1 - overlap))) for p in patch_size] for x in range(0, max(1, volume.shape[0] - patch_size[0] 1), stride[0]): for y in range(0, max(1, volume.shape[1] - patch_size[1] 1), stride[1]): for z in range(0, max(1, volume.shape[2] - patch_size[2] 1), stride[2]): xs min(x, volume.shape[0] - patch_size[0]) ys min(y, volume.shape[1] - patch_size[1]) zs min(z, volume.shape[2] - patch_size[2]) patch volume[xs:xspatch_size[0], ys:yspatch_size[1], zs:zspatch_size[2]] inp torch.from_numpy(patch[None, None]).float().to(device) with torch.no_grad(): out torch.softmax(model(inp), dim1)[0].cpu().numpy() prob_map[:, xs:xspatch_size[0], ys:yspatch_size[1], zs:zspatch_size[2]] out count_map[xs:xspatch_size[0], ys:yspatch_size[1], zs:zspatch_size[2]] 1 count_map np.maximum(count_map, 1) prob_map / count_map[None] return prob_map.argmax(0)逻辑说明stride由overlap算出overlap0.5表示步长是 patch 的一半。xs/ys/zs用min修正保证 patch 不越界边缘区域会被多次覆盖count_map做平均。最后argmax得到硬标签。4.2 后处理连通域和形态学怎么用才不误伤滑窗推理后常有孤立小噪点尤其是椎管这种细结构。常见做法是保留最大连通域但脊椎是多节段不能一刀切。我一般按类别分别做连通域分析只去掉体积小于阈值的连通块。from scipy import ndimage import numpy as np def remove_small_components(label, min_size50): out np.zeros_like(label) for cls in np.unique(label): if cls 0: continue mask label cls cc, n ndimage.label(mask) for i in range(1, n 1): comp cc i if comp.sum() min_size: out[comp] cls return out逻辑说明min_size50是体素级阈值按你数据的分辨率调分辨率高就调大。按类别分别处理是为了避免不同椎体段被合并。5. 避坑与排查脊椎 MRI 分割最常见的五个翻车现场5.1 标签值全是 0 或只有 0 和 1现象训练时 loss 一直不降打印标签发现只有 0 和 1但你知道数据里有 8 类。原因标签在导出时被二值化或者读取时用了错误的 dtype。解决用 2.1 的脚本扫一遍原始标签确认np.unique输出如果是浮点先np.round再转int64。5.2 训练 loss 正常但验证 Dice 极低现象训练集 Dice 0.9验证集 0.1。原因训练和验证的归一化方式不一致或者验证时忘了做同样的方向对齐。解决把预处理写成一个函数训练和验证共用别在两处各写一遍。5.3 显存爆掉但 patch 已经很小现象(64,64,32)patch 还是 OOM。原因模型通道数太大或者num_classes设多了或者没开混合精度。解决base降到 8开torch.cuda.amp检查num_classes是否等于实际类别数。5.4 推理结果在层厚方向出现明显条纹现象重建出来的分割在矢状面上有横向条纹。原因滑窗在层厚方向步长太大或者 patch 层厚方向太小。解决层厚方向步长减半patch 层厚方向至少覆盖 32 层。5.5 不同病例 spacing 差异大导致模型泛化差现象在 A 设备数据上训练B 设备上 Dice 掉一半。原因spacing 没统一模型学到的尺度不一致。解决按 2.2 重采样到统一 spacing或者用 spacing 作为额外输入通道。6. 进阶技巧用 MONAI 把预处理和推理串成一条流水线如果你不想自己维护上面这些函数MONAI 提供了现成的 transform 和滑窗推理器能把预处理、增强、推理串成一条流水线。下面这段是我常用的组合重点看Spacing、Orientation和SlidingWindowInferer的参数。from monai.transforms import ( Compose, LoadImaged, EnsureChannelFirstd, Orientationd, Spacingd, ScaleIntensityRanged, RandCropByPosNegLabeld, ) from monai.inferers import SlidingWindowInferer train_transforms Compose([ LoadImaged(keys[image, label]), EnsureChannelFirstd(keys[image, label]), Orientationd(keys[image, label], axcodesRAS), Spacingd(keys[image, label], pixdim(1.0, 1.0, -1), mode(bilinear, nearest)), ScaleIntensityRanged(keys[image], a_min0, a_maxNone, b_min0.0, b_max1.0, clipTrue), RandCropByPosNegLabeld( keys[image, label], label_keylabel, spatial_size(64, 64, 32), pos2, neg1, num_samples4, ), ]) inferer SlidingWindowInferer( roi_size(64, 64, 32), sw_batch_size2, overlap0.5, modegaussian, )逻辑说明Spacingd的pixdim第三位传-1表示保留原层厚这是 MONAI 的约定。mode图像用bilinear、标签用nearest和前面手写版一致。RandCropByPosNegLabeld的pos2, neg1表示前景 patch 是背景的两倍num_samples4表示每个病例裁 4 个 patch。SlidingWindowInferer的modegaussian让重叠区域按高斯权重融合比简单平均更平滑代价是稍慢。验证阶段我习惯用Spacingd统一到和训练一致的 spacing再算 Dice。如果验证 Dice 和训练差很多先查 spacing 和方向是否一致再查归一化范围。脊椎 MRI 上ScaleIntensityRanged的a_min0是因为背景是 0a_maxNone表示用数据最大值这样归一化后范围是 0~1和 z-score 效果接近但更简单。最后说个我自己的习惯每次换数据集先跑一遍 2.1 的扫描脚本把 shape、spacing、标签取值打印出来贴在实验记录里再动手写训练。这个动作花不了五分钟但能省掉后面几小时的排查。脊椎 3D MRI 分割没有银弹预处理对齐了后面的事才顺。希望帮到你。本文还有配套的精品资源点击获取