简介:本资源为面向医学影像处理与深度学习方向的3D MRI人体脊椎分割数据集,适用于脊柱结构识别、椎骨定位与分割模型训练等研究场景,适合具备一定医学图像处理基础的研究生、算法工程师及科研人员使用。压缩包采用7z格式,共包含222个文件,其中220个为nii格式的3D MRI体数据与对应标注文件,另附1个txt说明与1个json配置文件,整体约374.58MB,nii文件可直接用于体素级分割任务的读取与训练。数据以成对的原始影像与标注掩膜组织,便于构建监督学习流程,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_counts=True) 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, order=1) lbl_r = resample_to_output(lbl, target_spacing, order=0) 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, mask=None): 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_ratio=0.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, dtype=np.float32) l = np.zeros(patch_size, dtype=np.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_ratio=0.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, padding=1, bias=False), nn.InstanceNorm3d(out_ch), # MRI 小 batch 用 InstanceNorm 更稳 nn.LeakyReLU(0.01, inplace=True), nn.Conv3d(out_ch, out_ch, 3, padding=1, bias=False), nn.InstanceNorm3d(out_ch), nn.LeakyReLU(0.01, inplace=True), ) def forward(self, x): return self.block(x) class UNet3D(nn.Module): def __init__(self, in_ch=1, num_classes=8, base=16): 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, stride=2) self.dec3 = ConvBlock(base*8, base*4) self.up2 = nn.ConvTranspose3d(base*4, base*2, 2, stride=2) self.dec2 = ConvBlock(base*4, base*2) self.up1 = nn.ConvTranspose3d(base*2, base, 2, stride=2) 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], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1)逻辑说明:InstanceNorm3d在 batch size 只有 1~2 的 3D 任务上比 BatchNorm 稳,这是血泪经验。base=16是显存和精度的折中,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, eps=1e-5): probs = F.softmax(logits, dim=1) 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), overlap=0.5): model.eval() device = next(model.parameters()).device num_classes = 8 prob_map = np.zeros((num_classes,) + volume.shape, dtype=np.float32) count_map = np.zeros(volume.shape, dtype=np.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:xs+patch_size[0], ys:ys+patch_size[1], zs:zs+patch_size[2]] inp = torch.from_numpy(patch[None, None]).float().to(device) with torch.no_grad(): out = torch.softmax(model(inp), dim=1)[0].cpu().numpy() prob_map[:, xs:xs+patch_size[0], ys:ys+patch_size[1], zs:zs+patch_size[2]] += out count_map[xs:xs+patch_size[0], ys:ys+patch_size[1], zs:zs+patch_size[2]] += 1 count_map = np.maximum(count_map, 1) prob_map /= count_map[None] return prob_map.argmax(0)逻辑说明:stride由overlap算出,overlap=0.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_size=50): 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_size=50是体素级阈值,按你数据的分辨率调,分辨率高就调大。按类别分别处理是为了避免不同椎体段被合并。
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"], axcodes="RAS"), Spacingd(keys=["image", "label"], pixdim=(1.0, 1.0, -1), mode=("bilinear", "nearest")), ScaleIntensityRanged(keys=["image"], a_min=0, a_max=None, b_min=0.0, b_max=1.0, clip=True), RandCropByPosNegLabeld( keys=["image", "label"], label_key="label", spatial_size=(64, 64, 32), pos=2, neg=1, num_samples=4, ), ]) inferer = SlidingWindowInferer( roi_size=(64, 64, 32), sw_batch_size=2, overlap=0.5, mode="gaussian", )逻辑说明:Spacingd的pixdim第三位传-1表示保留原层厚,这是 MONAI 的约定。mode图像用bilinear、标签用nearest,和前面手写版一致。RandCropByPosNegLabeld的pos=2, neg=1表示前景 patch 是背景的两倍,num_samples=4表示每个病例裁 4 个 patch。SlidingWindowInferer的mode="gaussian"让重叠区域按高斯权重融合,比简单平均更平滑,代价是稍慢。
验证阶段我习惯用Spacingd统一到和训练一致的 spacing,再算 Dice。如果验证 Dice 和训练差很多,先查 spacing 和方向是否一致,再查归一化范围。脊椎 MRI 上ScaleIntensityRanged的a_min=0是因为背景是 0,a_max=None表示用数据最大值,这样归一化后范围是 0~1,和 z-score 效果接近但更简单。
最后说个我自己的习惯:每次换数据集,先跑一遍 2.1 的扫描脚本,把 shape、spacing、标签取值打印出来贴在实验记录里,再动手写训练。这个动作花不了五分钟,但能省掉后面几小时的排查。脊椎 3D MRI 分割没有银弹,预处理对齐了,后面的事才顺。希望帮到你。
本文还有配套的精品资源,点击获取