简介面向医学影像分割与深度学习研究人员这套骶骨腰痛脊椎分割数据集提供完整的训练与验证素材。数据源自CTSpine1K分别沿轴位面、冠状面和矢状面切分出2D图像共划分5个类别并去除ROI不足3%的切片采用医学影像常用的windowing对比度增强统一缩放至512×512。包内图像为jpg格式、标签为png格式8 bit灰度uint8非零像素为前景、0为背景附classes.txt说明类别含义。压缩包共2000个文件以png标签文件为主1998个另有1个txt类别说明和1个show.py可视化脚本可快速叠加查看mask与原始图像整体约472MB。目前已有224人学习下载适合作为脊椎分割算法训练、验证及教学演示的基准数据集。1. 医学图像分割数据集到手先别急着训练这份骶骨腰痛脊椎分割包该怎么用围绕“腰痛”的影像检查骶骨和腰椎的分割是所有后续定量分析的前置条件——椎间盘退变评级、椎管狭窄测量、手术规划都要先知道每一层CT或MRI图像上骨头在哪、椎间盘在哪。可这类带标注的医学图像分割数据集公开的极少因为逐层勾画必须由有经验的影像科医生完成一个病例可能要画几个小时。这份数据集把骶骨、腰椎区域拆成3个切面横断位、矢状位、冠状位按5个类别给出标签文件还带可视化代码意味着你可以直接读图、直接训练省掉最苦的“自己标数据”环节。适合正要跑通一个医学分割baseline的研究生也适合想快速评估骶骨腰痛分割效果的算法工程师。2. 先把数据吃透目录结构怎么读、标签映射关系、切面坐标如何还原2.1 三个切面不是三份数据是同一体数据的三次切分医学图像分割数据集最容易出现的认知错位是把“3个切面”当成三批独立的数据去训练三个模型。不是的。原始检查是一份三维体数据横断位、矢状位、冠状位只是沿着不同方向把这个体切成2D切片。所以这份数据集里的图像和标签本质上是同一组病例在不同方向上的投影切片。这三个切面是怎么来的如果拿到的是NIfTI.nii.gz格式的原始数据切面的生成就是对三维数组做切片横断位沿z轴逐层取平面矢状位沿y轴逐层取冠状位沿x轴逐层取。只要原始体数据和spacing信息还在三个切面之间就能互相换算如果只有切好的2D切片那就要靠文件名里的切面标识和切片序号来还原位置关系。假设数据集解压之后长这样这是最常见的一种组织形式sacrum_lumbar_dataset/ ├── images/ │ ├── axial/ # 横断位切片命名 ax_patient03_015.png │ ├── sagittal/ # 矢状位切片命名 sag_patient03_008.png │ └── coronal/ # 冠状位切片命名 cor_patient03_012.png ├── labels/ │ ├── axial/ │ ├── sagittal/ │ └── coronal/ ├── labels_info.json # 类别名与像素值的映射 └── visualize.py # 叠加可视化脚本有的版本会把三个切面放在同一个images目录里靠文件名前缀区分有的会保留原始nii.gz放在raw目录下。但无论哪种labels目录必须与images目录一一对应切片名一致才能直接配对训练。到手第一步别急着开训练先核对目录完整性和文件数量。文件数对不上多半是切图时把边界切片丢了这类问题后面避坑章节展开说。2.2 标签文件不是彩色图是每个像素一个类别的索引图很多第一次用医学图像分割数据集的人打开标签文件看到一张“接近黑色”的图就以为数据坏了。其实正常的标签文件是一张单通道索引图每个像素存的是一个0到N-1的整数代表该像素属于第几类。因为类别之间的像素值只有1、2、3这种微小差别肉眼看起来几乎全黑这是正常的。彩色效果要靠可视化时用调色板映射出来这也是为什么标题里强调“带可视化代码”——没有可视化索引图根本没法人眼质检。这份数据集的“5类别”通常这么划分0为背景1为腰椎椎体2为骶骨3为椎间盘4为棘突。不同数据集对最后一类的归类口径略有差异有的把第5类定义为椎管所以拿到手第一件事是打开labels_info.json确认类别编号和名称的映射{ 0: background, 1: lumbar_vertebra, 2: sacrum, 3: intervertebral_disc, 4: spinous_process }这份json同时决定两件事训练时分类头输出几个通道以及可视化时每个类别用什么颜色。如果训练代码里输出通道数是5而标签最大索引是4那通道数正确如果有的数据集把背景排除在类别之外、标签最大索引到5分割头的输出通道就要设成6否则one-hot编码时直接索引越界。2.3 用一个通用读取函数把图像和标签同时加载起来不管标签是PNG索引图还是nii.gz建议先把读取逻辑封装成一个函数统一返回numpy数组。这一步做扎实后面的Dataset类、可视化脚本、统计脚本全部复用不用每个脚本重写一遍读取逻辑。import numpy as np import nibabel as nib from PIL import Image def load_image_and_label(image_path, label_path): 统一读取图像与标签支持 PNG 与 nii.gz 两种格式。 返回 image: (H, W) 或 (H, W, C) 的 numpy 数组dtype 保持原样 label: (H, W) 的 numpy 数组dtypenp.int64像素值 0~N-1 # 图像MRI/CT 切片最常见是 PNG 单通道或三通道 if image_path.endswith(.nii.gz) or image_path.endswith(.nii): image nib.load(image_path).get_fdata() else: image np.array(Image.open(image_path)) # 标签必须是索引图不能用 RGB 彩色图 if label_path.endswith(.nii.gz) or label_path.endswith(.nii): label nib.load(label_path).get_fdata() else: label np.array(Image.open(label_path)) # 标签统一转成整数类型并去掉多余的通道维度 if label.ndim 3 and label.shape[-1] 1: label label[..., 0] label label.astype(np.int64) if image.ndim 2: image image[..., np.newaxis] # 统一变为 HWC方便后续归一化 return image, label有两个细节值得说明。label读取后做了astype(np.int64)原因是一些工具保存nii.gz时会顺手把标签存成float32像素值是0.0、1.0这种浮点数不转成整数后续one-hot编码会直接报错如果是PNG索引图像素值本身是uint8转了也不损失信息。image统一转成HWC格式是为了和预训练模型常见的“通道数1或3”的输入约定对齐灰度图变成(512, 512, 1)三通道图保持(512, 512, 3)后续做归一化时就不用再分情况处理。2.4 切面与坐标轴的对应关系决定了预处理方向三个切面各自对应体数据的一个朝向。以医学影像常用的RAS右-前-上坐标系为例如果把原始体数据形状记作(C, S, A)也就是(冠状位数量, 矢状位数量, 横断位数量)那么三个切面的对照关系如下切面切片方向切片尺寸axial横断位沿头脚方向逐层冠状 × 矢状sagittal矢状位沿左右方向逐层冠状 × 横断coronal冠状位沿前后方向逐层矢状 × 横断这个表格在预处理阶段有直接后果。第一三个切面切出来的图像长宽比不一样做resize时必须搞清楚原始分辨率统一resize到(512, 512)或(256, 256)都行但interpolation要区别对待图像用双线性标签一定用最近邻否则会引入不存在的新类别像素。第二如果想把三个切面的预测结果融合回三维空间要注意切面尺寸里哪个维度对应哪个空间轴轴搞错了三维重建出来的椎体会直接错位。2.5 切片命名里藏着病例信息跨切面配对的隐藏前提文件名看起来只是“ax_patient03_015.png”这种字符串但里面同时编码了三个关键信息切面类型ax/sag/cor、病例IDpatient03、切片序号015。这三段信息决定了你后续能做按病例划分、跨切面配对、三维重建。写一个提取函数把每个切片的属性稳定地解析出来import re def parse_slice_info(file_name): 从文件名解析切面、病例ID和切片序号。 支持 ax_patient03_015.png、sag_patient07_008.png 这类命名。 返回 dict例如 {plane: ax, patient: patient03, slice: 15} pattern re.compile(r^(ax|sag|cor)_(patient\d)_(\d)\.png$) match pattern.match(file_name) if not match: raise ValueError(f无法解析文件名: {file_name}) plane, patient, slice_idx match.groups() return { plane: plane, patient: patient, slice: int(slice_idx), }有了这个解析函数后面所有需要“按病例分组”或“跨切面对齐”的地方都能用它拿到一致性结果。值得提醒的是有些数据集在切图时会保留原始层厚信息层厚比如文件名变成“ax_patient03_015_3mm.png”正则要相应调整。文件名规则一旦理解错了后面按病例划分训练集验证集时就会把同一个患者的切片拆到两侧造成数据泄漏训练指标虚高。3. 训练一个可用的分割模型数据划分、Dataset实现与类别不平衡3.1 按病例划分训练验证集别按切片随机划分这个坑我见太多人踩过。直接把所有PNG文件名丢进train_test_split随机分发现验证Dice高得离谱一上真实场景就崩。原因很简单同一个患者的相邻切片极其相似如果训练集和验证集里混进同一个患者的切片验证集就相当于开卷考试。这是典型的泄漏也是医学图像分割数据集和自然图像数据集在划分逻辑上最大的区别。正确做法是先从文件名里提取病例ID以病例为最小单位划分。一个病人的所有切面、所有切片必须全部落在同一侧不能跨数据域。给出一份可以直接用的划分实现import os import random from collections import defaultdict def split_by_patient(label_root, val_ratio0.15, seed42): 按病例划分 train/val同一个病人的切片不会跨数据集。 参数 label_root: labels 目录下某个切面的路径例如 labels/axial val_ratio: 验证集病例占比 seed: 随机种子固定后结果可复现 返回 train_files, val_files: 每个元素为 (image_path, label_path) # 1. 扫描所有标签切片提取病例 ID patient_slices defaultdict(list) for file_name in sorted(os.listdir(label_root)): if not file_name.endswith(.png): continue parts parse_slice_info(file_name) patient_slices[parts[patient]].append(file_name) # 2. 对病例 ID 做随机划分而不是对切片做随机划分 patient_ids list(patient_slices.keys()) random.seed(seed) random.shuffle(patient_ids) val_count max(1, int(len(patient_ids) * val_ratio)) train_patients patient_ids[val_count:] val_patients patient_ids[:val_count] # 3. 把病例 ID 映射回切片路径 image_root label_root.replace(labels, images) train_files [] val_files [] for pid in train_patients: for f in patient_slices[pid]: train_files.append((os.path.join(image_root, f), os.path.join(label_root, f))) for pid in val_patients: for f in patient_slices[pid]: val_files.append((os.path.join(image_root, f), os.path.join(label_root, f))) return train_files, val_files这里有几个参数值得关注。seed不固定的话每次跑出来的训练验证划分都不一样调试时对比实验就没法做了所以务必固定。val_ratio在病例总数少的时候别取太小如果总共就二三十个病例验证集至少留5例以上否则验证指标的波动会很大一个病例的错标就能把Dice拉下去几个点。image_root那一行用了replace(labels, images)前提是images和labels的目录层级完全对称如果你的目录组织不是这个结构改成直接接收image_root参数更稳妥。3.2 写一个能直接跑训练的Dataset类有了文件列表下一步是Dataset。这里给出一份兼顾新手的完整实现把读取图像、归一化、读取标签、类型转换、数据增强整条链路都串起来。torchvision的resize函数做了类型判断所以这里直接用它处理tensor避免在PIL和numpy之间来回切换的麻烦。import torch from torch.utils.data import Dataset import numpy as np from PIL import Image from torchvision.transforms import functional as F class SegmentationDataset(Dataset): 骶骨腰痛脊椎分割数据集的标准 Dataset。 参数 file_list: list of (image_path, label_path) 元组 size: (H, W)resize 目标尺寸 image_mean / image_std: 归一化统计量None 时用 0-1 归一化 augment: 是否启用在线增强 n_classes: 类别数含背景默认 5 def __init__(self, file_list, size(512, 512), image_meanNone, image_stdNone, augmentFalse, n_classes5): self.file_list file_list self.size size self.image_mean image_mean self.image_std image_std self.augment augment self.n_classes n_classes def __len__(self): return len(self.file_list) def __getitem__(self, idx): image_path, label_path self.file_list[idx] image np.array(Image.open(image_path).convert(L)) label np.array(Image.open(label_path)) # resize图像双线性标签最近邻 image_t torch.from_numpy(image).float().unsqueeze(0) label_t torch.from_numpy(label).long().unsqueeze(0) image_t F.resize(image_t, self.size, interpolationF.InterpolationMode.BILINEAR) label_t F.resize(label_t, self.size, interpolationF.InterpolationMode.NEAREST) # 归一化有统计量用统计量没有就用 0-1 if self.image_mean is not None and self.image_std is not None: image_t (image_t - self.image_mean) / self.image_std else: image_t image_t / 255.0 # 在线增强只在训练集开启 if self.augment: if torch.rand(1) 0.5: image_t torch.flip(image_t, dims[2]) label_t torch.flip(label_t, dims[2]) if torch.rand(1) 0.5: image_t image_t * (0.9 0.2 * torch.rand(1)) label_t label_t.squeeze(0).long() return image_t, label_t这段代码里有几个点要着重说。resize部分图像用双线性、标签用最近邻这一行是整个预处理里最容易被忽略但后果最严重的配置——标签一旦用了双线性插值边界上会出现“类别平均”出来的新像素值比如2.5这种无效索引模型训练时直接崩。convert(L)把图像转成单通道灰度适配CT和MRI的灰度特性如果数据本身是RGB三通道去掉这个转换、直接读原始通道即可。augment里的水平翻转要谨慎矢状位和冠状位的左右翻转通常可以接受但横断位的翻转需要先确认数据集的左右方向标注规范拿不准就只保留亮度扰动。最后label_t做了squeeze(0)去掉通道维度因为CrossEntropyLoss期望输入是(N, H, W)而不是(N, 1, H, W)。3.3 类别不平衡是这套数据最大的训练坑骶骨腰痛分割任务里背景像素占绝对多数椎体面积大但椎间盘和棘突是小目标像素占比可能不到5%。如果不做任何处理模型收敛后的最省事策略就是“全预测成背景”整体准确率照样很高但Dice系数惨不忍睹。解决手段一般有三种加权损失、Dice与CE混合、验证时按类别观察指标而不是只看整体。加权损失的做法是给每个类别一个权重小目标类别权重拉高权重直接从训练集标签里统计得到。DiceLoss和CrossEntropy混合使用也很常见Dice对前景背景不敏感CE收敛稳定两者互补。给出一个直接可用的混合损失实现import torch import torch.nn as nn import torch.nn.functional as F class WeightedDiceCE(nn.Module): 加权 Dice CE 混合损失适合类别不平衡的医学分割任务。 参数 class_weights: 长度为类别数的张量通常是各类像素占比的倒数归一化 smooth: 平滑系数防止分母为 0建议 1e-5 def __init__(self, class_weightsNone, smooth1e-5): super().__init__() self.class_weights class_weights self.smooth smooth def forward(self, logits, targets): # logits: (N, C, H, W) targets: (N, H, W) n, c, h, w logits.shape probs F.softmax(logits, dim1) targets_onehot F.one_hot(targets, num_classesc) targets_onehot targets_onehot.permute(0, 3, 1, 2).float() dice_loss 0.0 for cls_idx in range(c): inter (probs[:, cls_idx] * targets_onehot[:, cls_idx]).sum() union probs[:, cls_idx].sum() targets_onehot[:, cls_idx].sum() dice (2.0 * inter self.smooth) / (union self.smooth) weight 1.0 if self.class_weights is not None: weight self.class_weights[cls_idx] dice_loss (1.0 - dice) * weight dice_loss dice_loss / c ce_loss F.cross_entropy(logits, targets) return dice_loss ce_lossclass_weights不推荐拍脑袋定建议在训练脚本里先遍历训练集所有标签用np.bincount统计每个类别的像素总数取倒数再归一化得到权重。注意类别0背景的权重会被压得很低这是正常现象DiceLoss本身还在约束区域重叠不会导致训练发散。如果发现训练初期loss下降极慢可以先去掉Dice部分只跑CE几轮等模型能大致区分前景背景后再把Dice加回来。3.4 验证指标别只盯整体每个类别的Dice才是真正的尺子很多人在验证时只report一个mDice或者mIoU这在这份数据集上是不够的。椎体面积大Dice天然高即使椎间盘完全没分割出来整体mDice也可能被椎体拉上去看起来“效果不错”实际临床没法用。正确的做法是对每个类别分别计算Dice单独观察。验证时按类别统计Dice的代码很简单核心是遍历batch里的每个类别对预测和标签的每个类别分别算交集并集。重点不是代码本身而是观测顺序先看背景和椎体的Dice确认模型没跑偏再看骶骨最后看椎间盘和棘突——这两个小目标才是这套数据的难点所在。如果椎间盘Dice长期在0.3以下先别急着换网络结构回头检查标签里椎间盘类别的像素占比是不是太低了或者增强策略是不是把小目标切片给裁掉了。4. 训练推理中的避坑清单复现不出精度的四个典型原因4.1 切面对齐出错标签和图像“看起来都在实际错位”现象训练loss正常下降但可视化出来的分割边缘整体偏移或者三个切面的统计结果对不上。原因最常见的是文件配对时出了问题。有些数据集的images和labels目录里文件名相同但排序不同如果用sorted()遍历后按索引配对就会把“病人A的第10张图”和“病人B的第10张图”配在一起。另一个原因是图像和标签尺寸不一致原始CT没做统一裁剪或缩放就切出来的切片不同层级图像尺寸可能不同标签被resize后边缘出现偏移。解决配对时永远按文件名映射不按索引。在Dataset的__getitem__里加一行断言image.shape label.shape不对的直接抛异常宁可训练中断也别带着错位数据跑。resize用前面说的“图像双线性标签最近邻”统一规则同时建议把长边统一到固定尺寸避免长宽比失真后标签位置被拉伸。4.2 某些类别在整个训练集里像素为0损失函数直接nan现象训练到某个epochloss突然变成nan或者某个类别的Dice一直是0。原因一份数据集的标签里背景、椎体、骶骨大部分切片都有但椎间盘和棘突只出现在特定切面。如果横断位切片层厚较厚、分层间隔大可能一整批切片里一个椎间盘像素都没有。这时按全局统计算类别权重某个类别的权重会异常大反向传播梯度爆炸loss直接nan。另一个常见原因是one-hot编码时类别数写错target里出现了num_classes之外的索引。解决训练前对每个类别的像素分布做一次统计并打印出来看到某个类别像素数接近0就别急着跑。两个处理方向把该类别从当前训练的loss权重里剔除、重新归一化剩余类别权重或者干脆在该任务里不启用这对小目标类别的加权只靠Dice约束。loss里的smooth设为1e-5能兜底但这只解决数值稳定性解决不了类别缺失本身——先确认数据没问题再谈调参。4.3 数据增强把解剖结构搞乱左右翻转并不总是安全现象矢状位模型精度尚可横断位模型训练集Dice很高、验证集Dice突然掉5个点以上。原因横断位的左右翻转在临床语境里意味着患者的左右位置互换。如果数据集没有统一的左右方向规范翻转后椎体左右关系变了、标签没有跟着改——这不是模型问题是增强策略改变了任务定义。矢状位的上下方向有明确解剖约束翻转一般影响不大但横断位需要特别谨慎。解决现在的默认选择是关掉空间翻转增强只保留轻度亮度扰动和随机裁剪。如果数据集内部已经做了标准化的方向对齐所有横断位图片都按“患者左侧在图像右侧”的统一规范存储那水平翻转反而会制造错误样本直接不开。实在要做翻转增强先在训练前把某几张图翻转后叠加可视化一遍确认解剖结构没有出现违和感再用。4.4 从2D切面预测结果拼回3Dspacing不一致导致错位现象三个切面分别预测都正常但把横断位预测结果堆叠回三维体数据后椎骨看起来是“锯齿”或“断裂”的。原因2D切片训练时通常会把固定尺寸resize成256或512推理时再恢复到原始尺寸。如果恢复时的比例没保持一致或者切图时没有记录体素间距每个切面的物理尺寸就不统一。横断位切片间距如果是5mm矢状位间距是1mm直接按切片索引堆叠三维重建出来的椎体形状必然变形。解决训练时记录每个切片的原始shape和resize比例保存一份meta.json存每个切面文件的原始尺寸与spacing。推理结束后用原始shape把预测结果resize回去再堆叠。如果后续要做三维体绘制或测量建议直接用原始nii.gz数据做处理不要拿2D预测图拼3D后者的误差会一路累积到测量结果里。5. 用叠加可视化当质检员三个切面的标注一致性检查方法5.1 一步到位的叠加可视化代码标签拿到手最重要的事是在训练前把标签“看”一遍确认它真的落在骨头、椎间盘的正确位置上。这个步骤不花钱但能省掉后面数小时调参时间。可视化脚本的核心逻辑是把灰度原图、索引标签图、彩色叠加图三张并排输出人眼直接核对import matplotlib.pyplot as plt import numpy as np from PIL import Image def visualize_overlay(image_path, label_path, class_colors, save_pathNone): 灰度图与分割标签叠加显示。 参数 image_path: 原图路径 label_path: 标签路径 class_colors: 每个类别的RGB颜色如 {1: [255,0,0], 2: [0,255,0]} save_path: 指定后保存否则弹窗显示 image np.array(Image.open(image_path).convert(L)) label np.array(Image.open(label_path)) canvas np.stack([image, image, image], axis-1).astype(np.float32) canvas canvas / 255.0 overlay canvas.copy() for cls_idx, color in class_colors.items(): mask label cls_idx color_norm np.array(color) / 255.0 overlay[mask, 0] color_norm[0] overlay[mask, 1] color_norm[1] overlay[mask, 2] color_norm[2] fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(image, cmapgray) axes[0].set_title(Original) axes[1].imshow(label, cmaptab10) axes[1].set_title(Label) axes[2].imshow(overlay) axes[2].set_title(Overlay) for ax in axes: ax.axis(off) plt.tight_layout() if save_path: plt.savefig(save_path, dpi150) else: plt.show()可视化这一步有明确的质检目的第一张看图像质量比如噪声、伪影、方向是否统一第二张看标签是否贴合解剖结构有没有出现类别区域糊成一团的情况第三张看类别间的边界是否清晰。如果第三张里分割边界明显偏出骨性结构这个病例的标注大概率有问题要标记出来从训练集里剔除。批量检查时建议配合一个循环把每个病例每个切面的第一张和最后一张切片都输出覆盖整个标注范围而不是随机抽几张看。5.2 跨切面一致性检查用矢状面验证横断面的标注连续单张切片可视化只能发现局部错标跨切面一致性检查能发现系统性问题。做法不复杂取同一个病例的横断位标签按切片序号叠成一个三维数组再沿矢状方向切出一层做可视化。如果横断位标签真的是沿头脚方向连续切出来的相邻两层之间同一结构应当保持连通。对骶骨这种形态变化大的结构这个检查尤其管用。骶骨在横断位上的截面形状逐层变化明显但连续切下来之后在矢状位上应当是一条连续的弧线。如果这个弧线出现断裂、错位说明某些横断位切片标注时没有对齐。同理也可以用冠状位检查横断位的左右一致性。这个技巧成本极低半小时能扫完几十个病例能筛掉一批肉眼在单张图上根本看不出来的系统错标。我做这类数据集时养成的一个习惯是开工前跑一次全量可视化把可疑病例存到一个“待复核”列表里同时输出一份各类别像素占比的统计表。宁可少用几个病例也不要让错标数据进训练集——医学图像分割翻车九成不是模型不行是标签有洞。这套流程跑一遍大约半小时能省掉后续三天的“玄学调参”。希望这套流程对你也能起到同样的作用。本文还有配套的精品资源点击获取
