简介:面向医学图像分割任务整理的骶骨腰痛脊椎分割数据集,基于CTSpine1K脊柱数据,适合医学影像算法工程师、科研人员和计算机视觉学习者用于模型训练与验证。资源按轴位面、冠状面、矢状面三个切面划分为三个子集,分别包含2113张、2272张、6404张图像及对应mask,并统一完成windowing对比度增强和512×512重采样,还剔除了ROI比例不足3%的无效切片,免除基础预处理工作。图像以jpg格式保存原始数据,mask为8bit灰度png,非0前景、0背景,附classes.txt类别说明与show.py可视化脚本,可快速查看分割标注。压缩包共2000个文件,以png标签文件为主,另有txt和py各1个,整体大小472.47MB,已有226人浏览学习。下载后可直接用于脊椎结构分割、腰痛病灶定位等研究,也可用于比较不同切面对分割效果的影响。
1. 骶骨腰痛脊椎分割:为什么一个5类别数据集要同时给3个切面
做腰痛相关的影像AI,最头疼的不是网络结构,而是数据长什么样。医学图像分割数据集里,骶骨腰痛脊椎分割这类带三切面、5类别标签的集合,表面上是把同一批患者的CT或MRI切成了轴位、冠状位、矢状位三套2D图片,实际上它解决了模型「只认一个视角」的毛病:腰痛患者的疼痛点往往要从横断面看椎间盘,从矢状面看滑脱,从冠状面看侧弯,单独喂一个切面很难覆盖临床阅片习惯。你在本地跑通这类数据集,等于同时拿到了数据组织规范、标签文件解析范式和可视化代码的试错样本。对刚转医学影像的算法工程师来说,这是一套能直接拿来练手的现成素材;对有经验的团队来说,它也是验证三切面融合策略的好起点。
2. 拆解数据集结构:3个切面、5类别标签文件和文件命名规则
2.1 一个典型的三切面分割数据集里装了什么
常见做法的目录结构长这样:三个切面文件夹分别叫 axial、coronal、sagittal,每个文件夹里再按 images 和 labels 各分一层。images 存原始切片,labels 存对应的分割掩膜。掩膜里的像素值不是随随便便的0和255,而是按 label_mapping.json 里约定的类别编号填进去的。实际解压后你会发现有些切面图像是 PNG,有些是 NIfTI,少数情况直接给两张 DICOM。不管哪种,第一步都应该先列文件,别急着训练。
| 文件用途 | 常见格式 | 说明 |
|---|---|---|
| 原始切面图像 | PNG / NIfTI (.nii.gz) | 按切面分开存放,文件名常带患者ID和切片序号 |
| 分割掩膜 | PNG / NIfTI | 像素值对应类别编号,背景为0,前景为1、2、3、4 |
| 标签文件 | JSON / YAML | 记录每个类别编号对应的解剖结构名称和RGB颜色 |
| 可视化代码 | Python (.py) | 用于把掩膜叠加到原图上,检查对齐和标注质量 |
我这里说的5类别通常约定为:0对应背景、1对应腰椎椎骨、2对应骶骨、3对应椎间盘、4对应脊髓硬膜囊。不同数据集会用别的顺序,但绝大多数会保留背景类并把解剖结构放在1到4。你拿到手第一件事不是写网络,而是把标签文件打开确认。5类别里最容易踩雷的是骶骨和椎骨交界处,有些标注把骶骨上关节突画进腰椎编号里,这种错在单张截图里看不出来,叠加到矢状位大图后边界就会歪。
2.2 标签文件是黑匣子?先读JSON再谈训练
我习惯把标签文件当作整个数据集的说明书。一个标准的 label_mapping.json 大概是这样的:
{ "0": {"name": "background", "color": [0, 0, 0]}, "1": {"name": "lumbar_vertebra", "color": [255, 0, 0]}, "2": {"name": "sacrum", "color": [0, 255, 0]}, "3": {"name": "intervertebral_disc", "color": [0, 0, 255]}, "4": {"name": "spinal_canal", "color": [255, 255, 0]} }读取这个文件不要手写正则,直接一行json加载,然后打印每一个键值对。下面这段是我每次拿到新数据集都要跑的第一段诊断代码:
import json import numpy as np with open("label_mapping.json", "r", encoding="utf-8") as f: label_map = json.load(f) for label_id, info in label_map.items(): print("编号:", label_id, "名称:", info["name"], "颜色:", info["color"]) mask = np.load("sample_label.npy") if False else None # 这里如果手头有掩膜,用 np.unique(mask) 查看实际像素值逻辑说明:json.load 会把键都变成字符串,所以后续做掩膜比较时记得 int(label_id) 或者把掩膜转成 str。参数说明:color 列表里三个值分别代表 RGB,上面用的 [255,0,0] 是纯红,显示时如果原始图像已经是伪彩色,颜色叠加会被覆盖,需要先归一化原始图像到0-1再叠加。
为什么强烈建议先读标签文件?因为很多公开或者自制的医学图像分割数据集的掩膜并不是连续编号0到4,有的会把“背景”定义为2,把“病变区域”定义为255。你按常规语义直接处理,可视化时就会看到整片整片的红色覆盖在软组织上。我遇到过一次 label_mapping.json 里写的是 BGR 顺序,而可视化代码按 RGB 解析,结果椎间盘在屏幕上从蓝色变成黄色。这类问题不看文件根本定位不了,训练出来的模型自然也是错的。
3. 把NIfTI切成三切面2D数据:采样步长与标签对齐的细节
3.1 为什么医学图像切面要单独存:轴位、冠状位、矢状位的坐标系差异
医学图像的标准坐标系和自然图像不一样。轴位(axial)是垂直于人体头尾方向的横截面,冠状位(coronal)是从前到后的方向,矢状位(sagittal)是从左到右的方向。同一个三维体积数据,沿着不同轴切片,你看到的是完全不同形态的解剖结构。CT和MRI的默认存储通常是轴位序列,但很多数据集中为了建模方便,直接把三个切面都渲染成2D PNG。这样做的好处是训练时可以独立加载某一个切面,不必在DataLoader里做重采样,缺点是如果不保留原始体素之间的Spacing,后续融合3D时各向异性会让人头疼。
如果你的数据集给的是 NIfTI 而不是图片,通常要自己切。同一个.nii.gz文件包含完整的体数据,我用SimpleITK来抽取三个切面,这样标签和图像同步变换,不容易错位。核心代码如下:
import SimpleITK as sitk import numpy as np def extract_sagittal_slices(image_path, label_path): image = sitk.ReadImage(image_path) label = sitk.ReadImage(label_path) # 转换为numpy数组,shape为(深度, 高度, 宽度) img_arr = sitk.GetArrayFromImage(image) lbl_arr = sitk.GetArrayFromImage(label) # 矢状位沿着宽度方向取切片,步长设为每隔4张取一张 num_width = img_arr.shape[2] for idx in range(0, num_width, 4): img_slice = img_arr[:, :, idx] lbl_slice = lbl_arr[:, :, idx] # 保存时用相同索引断命名,确保后续图-掩膜配对 np.savez_compressed(f"slice_{idx:04d}.npz", image=img_slice, label=lbl_slice)逻辑说明:GetArrayFromImage返回的数组顺序是轴位、冠状位、矢状位,也就是 z, y, x。上述代码沿第三个维度取切片,得到的是矢状位图像。参数说明:步长取4是为了减少冗余切片,因为矢状位连续相邻的切片内容重叠度很高;如果你关注椎间盘的细小信号,步长建议缩到2,否则会漏掉小结构。切片索引必须显式写进文件名,因为单独保存图像和标签时如果文件名不一致,后面对齐就是一场灾难。
类似的,取轴位切片时沿第一个维度,取冠状位时沿第二个维度。这里有个容易翻车的点:有些NIfTI文件经过设置后方向矩阵不是单位阵,直接取数组维度可能会导致图像左右翻转。因此我强烈建议读取后先用sitk.GetDirection打印方向矩阵,确认不是斜向切层。如果方向矩阵不是 [1,0,0;0,1,0;0,0,1],先调用sitk.DICOMOrient重定向到标准方向再切片。
3.2 标签如何跟着图像走:重采样与Spacing对齐
三切面数据集若本身是3D重采样的2D图像,就需要在保存时固定物理尺寸。我在处理这类数据时,第一步是从DICOM头里读取患者Spacing,然后以毫米为单位把图像统一重采样到例如 1mm x 1mm 的平面分辨率。标签的插值方式必须是最近邻,否则椎间盘和骶骨边界会出现介于两个类别之间的渐变像素,训练时这些伪像素会直接教坏模型。
target_spacing = [1.0, 1.0] def resample_slice(image_np, label_np, original_spacing): while image_np.shape[0] != 256: # 这里只是演示:真正做法是用SimpleITK的Resample new_shape = [int(round(image_np.shape[0] * original_spacing[0] / 1.0)), int(round(image_np.shape[1] * original_spacing[1] / 1.0))] # 后续填充sitk.Resample break上面的代码只给出了思路,实际项目中我用下面的方式更稳妥:
import SimpleITK as sitk def resample_to_spacing(image_sitk, label_sitk, target_spacing=[1.0, 1.0]): original_spacing = image_sitk.GetSpacing() original_size = image_sitk.GetSize() new_size = [int(round(orig * spc / target)) for orig, spc, target in zip(original_size, original_spacing, target_spacing)] resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(new_size) resampler.SetInterpolator(sitk.sitkLinear) resampled_image = resampler.Execute(image_sitk) resampler.SetInterpolator(sitk.sitkNearestNeighbor) resampled_label = resampler.Execute(label_sitk) return resampled_image, resampled_label参数说明:target_spacing 设为 [1,1] 是让每个像素代表实际1平方毫米;如果原图一直是各向同性,你可以不重采样,但训练时统一尺寸能让batch更稳定。标签重采样必须用最近邻,这一点没有商量余地。线性插值用在标签上会创造出类似像素值2.7之类的不存在类别,导致标签文件和5类别定义直接冲突。
做完这些切片和重采样,你得到的才是真正能喂给U-Net这类分割网络的2D训练样本。每个切片配对的时候,先用np.shape核对图像和标签尺寸,再用np.unique(lbl_slice)检查类别有没有在某个切片里突然消失。座椅姿势不同、病人体型不同,椎间盘在有些矢状位切片里可能只占三五个像素,这种情况就不能用固定步长抽样,要按类别像素比例决定是否保留该切片。
4. 用可视化代码第一版就能发现标注错位:重叠显示与类别统计
4.1 最小可视化脚本:把预测轮廓叠到原图上
拿到图像和标签文件后,最紧急的是写一个可视化代码尽快看到标注长什么样。我习惯把原始灰度图转成三通道RGB,再把分割掩膜转成彩色图,最后用透明度把两者叠加。这样背景仍然是黑白,椎骨是红色,骶骨是绿色,椎间盘是蓝色,硬膜囊是黄色,一眼就能看出有没有大范围偏位。
import cv2 import numpy as np def overlay_mask(image_path, mask_path, color_map): image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) image_rgb = cv2.cvtColor(image, cv2.COLOR_GRAY2RGB) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) mask_color = np.zeros_like(image_rgb) colors = { 1: [255, 0, 0], # 腰椎椎骨 2: [0, 255, 0], # 骶骨 3: [0, 0, 255], # 椎间盘 4: [255, 255, 0] # 硬膜囊 } for label_id, color in colors.items(): mask_color[mask == label_id] = color overlay = cv2.addWeighted(image_rgb, 0.6, mask_color, 0.4, 0) cv2.imwrite("overlay_result.png", overlay)逻辑说明:cv2.addWeighted的权重设置为原图0.6、掩膜0.4,能让解剖背景保持可辨认,掩膜轮廓也清晰。参数说明:如果你的标签文件里有类别5,记得在 colors 字典里补上,否则该类别在可视化里完全透明。如果掩膜路径读出来是彩色PNG,直接读灰度会得到像素值0-255,但这里5类别标签的像素值1、2、3、4如果重叠着读,会出现把多个通道的像素混合的情况,所以始终按单通道读掩膜。
另外一个关键点是图像旋转问题。医学图像切面保存时可能自带方向信息,但用OpenCV读PNG时横竖方向完全看存储时的矩阵顺序。我碰到过一个坑:矢状位图像看起来是左脑朝前,但分割标注是右脑朝前,视觉上整个脊柱轮廓错位。要避免这个问题,在可视化代码里先打印两对坐标:左右缘的解剖点。如果图像中心和掩膜中心明显不重合,说明有镜像翻转。用np.fliplr翻转后重新对比,直到边缘一致再继续训练。
4.2 统计每个类别的像素占比,是训练前必做的体检
可视化只能看几个样本,想全面体检还得统计整个数据集的类别分布。三切面、5类别标签很容易变成极端不平衡问题:背景可能占95%,椎间盘不到1%,硬膜囊有时候只有几个像素。这时候如果你的损失函数直接套用自然图像分割的CrossEntropy,网络大概率只学会背景。以下代码统计单张掩膜类别占比:
import numpy as np from collections import Counter def count_class_percent(mask_path): mask = np.load(mask_path)["label"] if mask_path.endswith(".npz") \ else cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) total = mask.size class_counts = Counter(mask.flatten()) percent_map = {cls: cnt / total for cls, cnt in class_counts.items()} for cls in [0, 1, 2, 3, 4]: print(f"类别{cls}: {class_counts.get(cls, 0)} 像素, {percent_map.get(cls, 0):.4%}")逻辑说明:统计完单张后,要把整个数据集的占比加权平均,而不是简单加起来再除以文件数,因为不同病人切片数量不一致。参数说明:np.load后取"label"是假设保存的npz文件里包含了label数组,如果你的标签文件是PNG,直接走imread分支即可。若发现某个类别的占比在所有切面里都低于1%,建议训练时使用带权重Dice Loss或Focal Loss,而不是普通CrossEntropy。
这些统计结果也能反过来校验标签文件。比如JSON里写了硬膜囊类别4,但统计结果在整个数据集里类别4从未出现,说明某个切面或某一部分切片的标注可能没跑全。如果三个切面之中只有轴位出现了类别4,而矢状位完全没有,那很可能是标注阶段只用轴位勾了硬膜囊,其余切面漏标。这种信息可视化看不出,统计后能立刻发现,属于数据体检里最有价值的一步。
5. 避坑:三切面分割数据集的常见问题与排查路径
5.1 标签像素值与JSON映射不一致
现象:可视化代码用JSON里定义的红色显示腰椎椎骨,但屏幕上看到骶骨区域变成了红色,或者几乎所有像素都落在类别255。 原因:掩膜文件里实际存储的像素值不是JSON里标定的0-4,可能是255代表前景,也可能是不同标注工具导出了不同的通道顺序。 解决:先跑统计代码,打印np.unique(mask),把所有出现过的像素值和JSON里的编号做差集。如果出现255,最常见做法是把255改成1,再对原JSON做一次字典反转。不要手动逐个像素改,直接用numpy的where替换:
import numpy as np mask = np.array([[0, 255, 0], [1, 255, 2]]) mask_fixed = np.where(mask == 255, 1, mask) # 这样就把255归一到腰椎椎骨类别参数说明:np.where的三个参数分别是condition、替换值、原数组。如果255已经是背景,那就替换成0。关键是先确认255对应的结构,不要在没统计前瞎换。标签文件和可视化代码同时错位的时候,肉眼真的看不出来,需要把原始切片单独显示一遍,再叠加彩色掩膜,看解剖位置是否吻合。
5.2 三切面图像尺寸与Spacing不一致
现象:轴位图像是512x512,冠状位是384x480,矢状位是300x400,直接用同一个DataLoader报维度错误。 原因:数据集为了节省空间,把每个切面按各自的最大包围盒裁剪了,没有统一到同一个物理视场。 解决:先查询每个切面的Spacing,再重采样到统一的毫米分辨率。常见做法是让所有切面都按照轴位的Spacing为基准,统一到1x1毫米,这样既能对齐全,又不会损失椎间盘的小细节。如果某些切面裁剪得太小,采用中心裁剪后再pad到固定尺寸,而不是直接resize,否则椎骨比例被拉伸后,训练出的模型泛化到新病人时会产生假的弧线条纹。
def pad_or_crop(slice_arr, target_h, target_w): h, w = slice_arr.shape[:2] # 先裁剪到目标尺寸以内 if h > target_h: start_h = (h - target_h) // 2 slice_arr = slice_arr[start_h:start_h + target_h, :] if w > target_w: start_w = (w - target_w) // 2 slice_arr = slice_arr[:, start_w:start_w + target_w] # 再补零到目标尺寸 pad_h = max(target_h - slice_arr.shape[0], 0) pad_w = max(target_w - slice_arr.shape[1], 0) return np.pad(slice_arr, ((pad_h // 2, pad_h - pad_h // 2)[:min(len(slice_arr.shape), 2)], (pad_w // 2, pad_w - pad_w // 2)), mode="constant")说明:这段代码演示了中心裁剪加补零的思路,真正写的时候要分别处理图像和标签,但不要补零后在3D方向再切割,否则两个切面之间空间关系会丢。Spacing问题一定在整个训练前解决,不要在模型里加resize层,那是给自然图像用的,医学数据里给椎骨做非等比放缩会影响诊断信息。
5.3 训练/验证划分泄漏
现象:验证集Dice看起来很高,但放到真实病人单切面上效果差得离谱。 原因:没有按患者ID划分数据集,同一个病人的三切面切片被随机分到了训练和验证里。 解决:在文件列表中提取患者标识,用患者ID做分组。下面的伪代码表示这个逻辑:
# 按患者ID分组,确保同一患者所有切面在同一条分割内 patient_to_files = {} for path in all_files: pid = path.split("/")[0] # 假设第一位是患者ID patient_to_files.setdefault(pid, []).append(path) patient_ids = list(patient_to_files.keys()) train_patients = patient_ids[:int(0.8 * len(patient_ids))] val_patients = patient_ids[int(0.8 * len(patient_ids)):]原因说明:医学图像分割数据集的验证集划分,核心原则不是文件级别随机,而是病人级别隔离。因为同一病人的三切面切片之间高度相关,把其中一部分放进训练集,模型相当于记住了这个病人的纹理,验证时遇到剩余切片自然效果好。使用“按病人划分”能避免这种虚假精度。参数说明:训练比例8:2常见,但如果数据集里病人数量小于10,建议改成K折交叉验证,防止验证集人数太少导致抖动太大。
5.4 可视化代码跑不起来:掩膜通道数不对
现象:OpenCV读出来的mask是三维数组,shape为(H,W,3),直接用mask == 1判断会得到HxWx3的布尔数组,叠加时报维度不匹配。 原因:标注工具导出彩色位图时把三个通道都写了相同的灰度值,或者本身存成BGR的伪彩色掩膜。 解决:先用mask.shape看看通道数,如果是3通道,直接转换成单通道。如果三个通道完全相同,取任意通道;若不同,说明是伪彩色标注,需要用标签文件里的颜色做反向映射。我经常用的反向代码:
mask = cv2.imread(mask_path, cv2.IMREAD_COLOR) gray_mask = np.zeros(mask.shape[:2], dtype=np.uint8) for label_id, color in colors.items(): # 颜色是RGB,但OpenCV读出来是BGR bgr = color[::-1] gray_mask[np.all(mask == bgr, axis=-1)] = label_id逻辑说明:这一步能把彩色掩膜转换成语义编号。参数说明:OpenCV默认读入格式是BGR,所以需要把colors里定义的RGB反转成BGR再匹配。如果标签文件里颜色没有严格按照RGB顺序写,这里匹配会不中,这时候先打印掩膜中最多的三个BGR值,再比对JSON。
5.5 背景类远大于前景导致验证指标虚高
现象:整体Dice达到0.95,但单独看椎间盘的Dice只有0.3,腰痛关键结构完全没分割出来。 原因:模型把所有像素预测成背景,因为背景占比95%,Sensitivity的权重完全倾向于背景。 解决:用逐类Dice评估模型,不要只看average。训练时优先使用Dice Loss或带类别权重的Tversky Loss。下面这个损失函数可以替换CrossEntropy:
def tversky_loss(y_true, y_pred, alpha=0.7, beta=0.3): smooth = 1.0 tp = (y_true * y_pred).sum() fp = ((1 - y_true) * y_pred).sum() fn = (y_true * (1 - y_pred)).sum() return 1 - (tp + smooth) / (tp + alpha * fn + beta * fp + smooth)参数说明:alpha和beta分别控制假阴性和假阳性的惩罚,对于椎间盘这类小目标,把alpha设得比beta大,模型会更倾向于召回小结构而不是把它们并进背景。实测时如果图像里背景占比极高,配合按类别频率计算的权重效果更好。每次调完参数,要用可视化代码重新叠加预测结果,不要迷信指标。
6. 把三切面结果融合回3D体空间:小目标与边缘修正的进阶处理
当你的三切面模型分别训练完毕后,如何合成一个完整的3D分割结果?一个常见做法是把轴位、冠状位、矢状位的预测概率各自映射回原体素的三个方向,然后用多数投票决定每个体素的最终类别。由于同一体素在三个切面中可能被预测成不同类别,投票时需要把椎间盘和硬膜囊这类小结构优先级提高:如果任意两个切面都预测为类别3,即便第三切面预测为背景,也要保留类别3。这样做可以显著减少小目标在单一视角下被噪声吞掉的概率。
在此基础上我习惯加一步形态学后处理。先对预测结果中的骶骨和腰椎椎骨区域做闭运算,填补椎体内部因噪声产生的空洞;再用连通成分分析去掉和主区域不连通的孤立点。实际操作中,我用SciPy的ndimage.binary_closing配合label就够了,不需要复杂的3D卷积网络。处理完后再重新做一次逐类Dice检查,你会发现边缘改善幅度不大,但背景中的假阳性点能下降好几个百分点。
有一个教训值得分享:融合前先确认三个切面是否都覆盖了完整解剖范围。某次我的矢状位数据只拍到L3,没拍到骶骨,融合时该区域必然缺少预测,导致骶骨整体缺失。如果你遇到这种情况,宁可只用有覆盖的两个切面做融合,也不要强行把缺失区域交给模型脑补。最后提醒一句:所有后处理参数都要在验证集上调,不要根据测试集反复试,希望这套落地流程能帮你在三切面分割任务上少走弯路。
本文还有配套的精品资源,点击获取