简介:本资源为面向医学影像处理与深度学习方向的3D MRI人体脊椎分割数据集,适用于脊柱结构识别、椎骨定位与分割模型训练等研究场景,适合具备一定医学图像处理基础的研究生、算法工程师及科研人员使用。压缩包采用7z格式,共包含222个文件,其中220个为nii格式的3D MRI体数据与对应标注文件,另附1个txt说明与1个json配置文件,整体约374.58MB,nii文件可直接用于体素级分割任务的读取与训练。数据集以成对的原始影像与标注掩膜组织,便于搭建监督学习流程,json文件可用于记录数据划分或类别信息。目前已有299人学习下载,可作为脊椎MRI分割实验的起点,帮助读者快速验证网络结构、调试预处理与评估流程,节省自行采集与标注数据的时间成本。
1. 从一份 3D MRI 脊椎数据集说起:nii.gz 格式到底怎么用
如果你做过医学影像分割,大概率遇到过这种局面:模型代码跑通了,loss 也降了,但 Dice 死活上不去,最后发现是数据格式没对齐——图像和标签的仿射矩阵不一致,或者标签值不是从 0 开始的连续整数。脊椎 MRI 分割尤其容易翻车,因为脊柱是长条形结构,椎体之间边界模糊,3D 上下文一旦丢失,分割结果就会在矢状面上断成一截一截。
这份 3D MRI 人体脊椎分割数据集,核心就是解决“数据从哪来、格式怎么对”的问题。它提供的是 nii.gz 格式的 3D 体积数据,每套包含原始图像和对应的分割标签,命名上采用133.nii与133_0000.nii成对出现的方式,另有dataset.json描述元信息。适合正在做 spine 3D 分割、MRI 多模态分割、或者想拿医学数据练 3D U-Net 的从业者。如果你之前只碰过 2D 切片,这份数据能让你把 3D 分割的完整链路走一遍。
2. 拆开 nii.gz:文件结构、命名规则与 dataset.json 的读法
2.1 成对文件到底谁是谁
先看目录里出现的文件:133.nii、133_0000.nii、109.nii、109_0000.nii、99.nii、99_0000.nii、1.nii、1_0000.nii、15.nii。这种命名不是随便起的。带_0000后缀的文件通常是模型输入通道,不带后缀的是对应的标签或原始图像。具体谁是 image 谁是 label,不能靠猜,必须用dataset.json确认。
常见做法是:_0000表示第一个模态的输入图像,无后缀文件表示分割 mask。但不同来源的数据集约定不同,有的反过来。我一般会先读dataset.json,再用可视化工具打开一对文件对比,确认标签里只有整数、图像里有连续灰度,才敢往下走。
2.2 dataset.json 里真正要看的字段
dataset.json是这套数据的“黑匣子说明书”。它通常包含以下几个关键字段:
| 字段 | 含义 | 你要确认什么 |
|---|---|---|
labels | 标签类别映射 | 0 是背景还是椎体?椎体编号是否连续? |
modality | 模态信息 | 是 T1、T2 还是 STIR?影响预处理策略 |
tensorImageSize | 体积维度 | 是 3D 还是 4D?通道数是多少? |
numTraining | 训练样本数 | 和实际文件数是否对得上 |
file_ending | 文件后缀 | 确认是.nii还是.nii.gz |
读的时候不要只看键名,要把labels里的值打印出来。如果标签值是 1、2、3……但背景是 0,那 softmax 输出通道数就是类别数加一。如果标签值跳号,比如只有 1、3、5,必须先重映射,否则交叉熵会直接报错。
import json with open('dataset.json', 'r') as f: meta = json.load(f) # 打印标签映射,确认是否连续 print("labels:", meta.get('labels')) print("modality:", meta.get('modality')) print("numTraining:", meta.get('numTraining')) # 常见坑:labels 是 dict,key 是字符串,value 是整数 # 如果 value 不连续,需要建立重映射表 label_map = {int(k): v for k, v in meta['labels'].items()} sorted_vals = sorted(label_map.values()) print("sorted label values:", sorted_vals)这段代码的逻辑很简单:先把 JSON 读进来,然后重点看labels的 value 是否从 0 或 1 开始连续排列。参数上,meta['labels']的 key 通常是字符串形式的数字,value 是实际标签值。如果发现sorted_vals不是[0,1,2,3,...],后面做 one-hot 之前必须加一步重映射。
2.3 用 nibabel 验证图像与标签的几何一致性
nii.gz 格式最容易被忽略的是头信息里的 affine 矩阵。图像和标签如果 affine 不一致,即使 shape 相同,叠加显示时也会错位。验证方法很直接:
import nibabel as nib import numpy as np img = nib.load('133_0000.nii') lab = nib.load('133.nii') # 检查 shape 和 affine print("image shape:", img.shape) print("label shape:", lab.shape) print("affine allclose:", np.allclose(img.affine, lab.affine)) # 检查标签值分布 lab_data = lab.get_fdata() unique_vals = np.unique(lab_data) print("unique label values:", unique_vals) # 检查图像强度范围 img_data = img.get_fdata() print("image intensity range:", img_data.min(), img_data.max())逻辑说明:nib.load读入后,shape确认体积维度是否一致,affine用np.allclose判断是否在浮点误差内相等。get_fdata()返回 float64 数组,标签值应该是整数,如果出现 0.999 这种值,说明标签被插值过,需要最近邻重采样。图像强度范围用来决定是否要做 z-score 或百分位裁剪。
提示:如果
affine allclose返回 False,不要直接重采样标签,先确认是不是读取时用了不同的 orientation。常见做法是用nib.as_closest_canonical()统一方向后再比较。
3. 从 nii.gz 到训练张量:预处理、重采样与 DataLoader 搭建
3.1 为什么不能直接把 nii.gz 塞进网络
3D MRI 体积的原始尺寸往往很大,比如 512×512×200,直接送进网络显存扛不住。而且不同病例的 spacing 不一样,有的层厚 1mm,有的 3mm,如果不做重采样,网络学到的“椎体大小”就是错的。脊椎是长条结构,矢状面和冠状面的分辨率差异会直接影响分割边缘。
常见做法是:先统一 spacing 到各向同性,比如 1mm×1mm×1mm,再做前景裁剪,把非零区域裁出来,最后随机切 patch 送进网络。patch size 一般取 64×64×64 或 96×96×96,具体看显存和椎体平均尺寸。
3.2 重采样与强度归一化的代码实现
import nibabel as nib import numpy as np from scipy.ndimage import zoom def resample_volume(img, lab, target_spacing=(1.0, 1.0, 1.0)): # 获取原始 spacing orig_spacing = img.header.get_zooms()[:3] # 计算缩放因子 scale = [o / t for o, t in zip(orig_spacing, target_spacing)] # 图像用三线性插值,标签用最近邻 img_data = img.get_fdata() lab_data = lab.get_fdata() img_resampled = zoom(img_data, scale, order=1) lab_resampled = zoom(lab_data, scale, order=0) return img_resampled, lab_resampled def normalize_intensity(img_data): # 百分位裁剪,去掉极端值 p1, p99 = np.percentile(img_data, (1, 99)) img_clip = np.clip(img_data, p1, p99) # z-score 归一化 mean = img_clip.mean() std = img_clip.std() return (img_clip - mean) / (std + 1e-8)逻辑说明:zoom的order参数是关键,图像用 1 表示三线性插值,标签用 0 表示最近邻,否则标签会出现小数。scale的计算是原始 spacing 除以目标 spacing,注意顺序要和数组维度对应。归一化先做百分位裁剪再 z-score,能避免 MRI 中的亮斑把整体分布拉偏。
参数上,target_spacing不要盲目设成 0.5mm,那会插值出大量冗余体素,训练变慢但精度不一定涨。脊椎 MRI 一般 1mm 各向同性就够用。如果原始层厚是 3mm,插到 1mm 是合理的;如果原始已经是 0.8mm,插到 1mm 反而降精度,这时候保持原样或统一到 0.8mm 更稳。
3.3 构建 3D patch 采样的 Dataset
import torch from torch.utils.data import Dataset class SpinePatchDataset(Dataset): def __init__(self, img_list, lab_list, patch_size=(64, 64, 64), samples_per_vol=8): self.img_list = img_list self.lab_list = lab_list self.patch_size = patch_size self.samples_per_vol = samples_per_vol def __len__(self): return len(self.img_list) * self.samples_per_vol def __getitem__(self, idx): vol_idx = idx // self.samples_per_vol img = nib.load(self.img_list[vol_idx]).get_fdata() lab = nib.load(self.lab_list[vol_idx]).get_fdata() # 随机裁剪 patch,优先包含前景 d, h, w = img.shape pd, ph, pw = self.patch_size # 简单策略:随机选中心点,确保不越界 cd = np.random.randint(pd//2, d - pd//2) ch = np.random.randint(ph//2, h - ph//2) cw = np.random.randint(pw//2, w - pw//2) img_patch = img[cd-pd//2:cd+pd//2, ch-ph//2:ch+ph//2, cw-pw//2:cw+pw//2] lab_patch = lab[cd-pd//2:cd+pd//2, ch-ph//2:ch+ph//2, cw-pw//2:cw+pw//2] # 转为 tensor,增加通道维 img_tensor = torch.from_numpy(img_patch).float().unsqueeze(0) lab_tensor = torch.from_numpy(lab_patch).long() return img_tensor, lab_tensor逻辑说明:samples_per_vol控制每个体积采多少个 patch,太小会欠拟合,太大会过拟合。__getitem__里先算体积索引,再随机选中心点。这里用的是简单随机裁剪,实际项目中我一般会加一步“前景优先”策略:先算标签非零区域的 bounding box,再在 box 内随机选点,这样正样本比例更高。
参数上,patch_size取 64 的立方体是 3D U-Net 的常见配置,显存占用约 2-4GB(取决于通道数)。如果椎体在矢状面跨度大,可以把深度方向设大一点,比如 96×64×64。samples_per_vol取 8 到 16 之间,看训练集大小调整。
注意:如果标签值不连续,在
__getitem__里要加重映射,否则CrossEntropyLoss会报 “Target out of bounds”。重映射表从dataset.json里读,不要硬编码。
4. 避坑与排查:脊椎 MRI 分割里最容易翻车的五件事
4.1 标签值跳号导致 loss 直接 NaN
现象:训练第一个 iteration 就报Target out of bounds或 loss 变成 NaN。原因:dataset.json里 labels 的 value 是 1、3、5 这种跳号,但网络输出通道数按最大标签值加一算,中间空出来的类别没有样本,softmax 后概率为 0,交叉熵取 log 时炸掉。解决:读dataset.json后建立old_label -> new_label的连续映射,在 Dataset 里对标签做一次np.vectorize重映射,确保标签值从 0 到 N-1 连续。
4.2 affine 不一致导致图像和标签错位
现象:可视化时图像和标签叠加,椎体位置对不上,但 shape 完全一样。原因:两个文件的 affine 矩阵不同,可能是采集时重建参数不一致,或者标签是后来配准生成的。解决:用nib.as_closest_canonical()统一方向,再检查 affine 是否 allclose。如果仍不一致,以图像 affine 为准,用nib.resample_from_to把标签重采样到图像空间,插值方式选最近邻。
4.3 层厚差异大导致各向异性伪影
现象:模型在矢状面分割还行,冠状面一塌糊涂。原因:原始数据层厚 3mm,层内分辨率 0.5mm,重采样到各向同性时,层间插值产生阶梯伪影,网络学到的边缘是模糊的。解决:不要强行插到 1mm 各向同性,先看原始 spacing 分布。如果层厚普遍大于 2mm,可以只做层内归一化,深度方向保持原样,或者用各向异性 patch 训练。
4.4 前景占比过低导致全背景预测
现象:训练几个 epoch 后,模型输出全是背景,Dice 为 0。原因:脊椎在 3D 体积里占比可能不到 5%,随机裁剪 patch 时大部分是背景,正负样本极度不平衡。解决:在 Dataset 里加前景优先采样,先算标签非零区域的 bounding box,再在 box 内随机选中心点。或者用加权交叉熵,背景权重设 0.1,前景设 1.0。
4.5 验证集 Dice 高但测试集崩盘
现象:验证集 Dice 0.85,换一批数据掉到 0.5。原因:验证集和训练集来自同一批扫描仪,spacing 和强度分布一致,模型过拟合了设备特征。解决:划分数据时按病例分,不要按切片分。如果数据量允许,留出不同扫描参数的病例做测试。预处理时加随机强度扰动,比如 gamma 变换和偏置场模拟,提升泛化。
5. 进阶技巧:用滑窗推理把 3D 分割推到临床可用
训练完模型只是第一步,真正落地要靠推理。3D 体积太大,不能整卷送进网络,常见做法是滑窗推理加重叠融合。滑窗的窗口大小和训练 patch 一致,步长一般取窗口的 1/2 或 1/4,重叠区域用高斯权重融合,避免拼接缝。
import numpy as np import torch def sliding_window_inference(model, volume, patch_size=(64,64,64), overlap=0.5): model.eval() d, h, w = volume.shape pd, ph, pw = patch_size step_d = int(pd * (1 - overlap)) step_h = int(ph * (1 - overlap)) step_w = int(pw * (1 - overlap)) # 输出累加器和权重累加器 output = np.zeros((d, h, w), dtype=np.float32) weight = np.zeros((d, h, w), dtype=np.float32) # 高斯权重核 gauss = np.outer(np.hanning(pd), np.hanning(ph)) gauss = np.outer(gauss, np.hanning(pw)).reshape(pd, ph, pw) with torch.no_grad(): for dd in range(0, d - pd + 1, step_d): for hh in range(0, h - ph + 1, step_h): for ww in range(0, w - pw + 1, step_w): patch = volume[dd:dd+pd, hh:hh+ph, ww:ww+pw] patch_tensor = torch.from_numpy(patch).float().unsqueeze(0).unsqueeze(0) pred = model(patch_tensor) pred = torch.argmax(pred, dim=1).squeeze().cpu().numpy() output[dd:dd+pd, hh:hh+ph, ww:ww+pw] += pred * gauss weight[dd:dd+pd, hh:hh+ph, ww:ww+pw] += gauss # 归一化,避免除零 output = output / (weight + 1e-8) return np.round(output).astype(np.int16)逻辑说明:overlap控制重叠比例,0.5 表示步长是窗口的一半。gauss是三维汉宁窗外积,用来给 patch 边缘降权,中心加权。累加后除以权重累加器,得到平滑输出。np.round把浮点转回整数标签。
参数上,overlap不要低于 0.25,否则拼接缝明显;也不要高于 0.75,否则推理时间成倍增加。patch_size必须和训练时一致,否则 BatchNorm 统计量对不上。如果显存够,可以把patch_size设大一点,比如 96×96×96,减少滑窗次数。
验证推理结果时,我习惯用nib.save把预测存成 nii.gz,再用nib.load读回来和标签做 Dice。注意保存时要带上原始 affine,否则下次打开位置全错。
import nibabel as nib # 假设 pred_volume 是滑窗推理结果,ref_img 是原始图像 pred_nii = nib.Nifti1Image(pred_volume.astype(np.int16), ref_img.affine, ref_img.header) nib.save(pred_nii, 'pred_133.nii.gz')从那以后我每次跑完推理,都强制走一遍“保存-重载-算 Dice”的闭环,不看到重载后的 Dice 不写进报告。希望帮到你。
本文还有配套的精品资源,点击获取