颈椎CT骨骼分割数据集实战:三轴2D切面与mask处理全解析
发布时间:2026/10/2 8:32:31来源:尧图网络
简介这是一份面向医学图像分割算法研究与深度学习实践的颈椎CT骨骼分割数据集包含横断面、冠状面、矢状面三个切面的二维图像与对应像素级mask标签适合用于颈椎骨骼识别、器官分割模型训练与验证。资源共2000个文件以png图像与标签为主1998个png另含1个txt说明与1个Python可视化脚本show.py压缩包约348.63MB。数据按x、y、z三轴组织x轴为512×512分辨率的3734组图像与masky轴为512×196分辨率的4252组z轴为512×196分辨率的2426组mask灰度值为0、1、2阈值图可作为分割监督信号。配套可视化代码可快速查看掩膜叠加效果降低数据检查成本。目前已有210人学习下载适合医学图像分割入门及进阶研究者参考使用。1. 颈椎CT骨骼分割数据集先想清楚这三组2D切面到底能干什么很多人一听到“CT骨骼分割”第一反应就是上3D U-Net、跑nnU-Net觉得数据集也应该是NIfTI体积。而这个项目反着来它把颈椎CT拆成了横断面、冠状面、矢状面三组2D PNG每组都配好同尺寸的maskmask像素值只有0、1、2三个灰度。换句话说你拿到的是已经切好的骨分割监督信号不需要自己从DICOM里重建直接喂给2D分割模型就能开始训练。我拆完这套数据后最直观的感受是它特别适合两类人——一是刚入门医学图像分割、想快速跑通Unet和DeepLab的二是做2D/3D混合训练需要把同一病例从三个方向都喂给模型做一致性约束的。以下内容围绕数据组织、可视化、训练接入展开会把参数和踩坑都放在明面上。2. 三轴切面与0/1/2阈值图先看懂数据再谈训练2.1 横断面、冠状面、矢状面在二维png里怎么排医学CT的原始体数据是一个三维矩阵通常用(D, H, W)表示D是切片层数H和W是每层的行和列。沿着三个相互垂直的方向切就得到三种切面横断面axial是从头到脚的方向冠状面coronal是从前到后矢状面sagittal是从左到右。这个项目里直接用x轴、y轴、z轴来描述切面但不代表x轴就是横断面你需要在数据里核对分辨率再确认对应关系。从摘要看x轴切面是512×512分辨率y轴是512×196z轴也是512×196。按我的经验512×512一般是横断面和CT原始层内分辨率一致而512×196这种非正方形尺寸大概率是冠状面或矢状面经过重采样后的结果。但注意不同采集设备、不同重建协议这三个轴的顺序和物理方向不一定相同。所以拿到数据后第一件事不是训练而是抽出几个文件名用可视化脚本把图像和mask同时打印出来确认每个轴到底对应身体哪个方向。我用一个常见做法来验证把一组切面图按文件名排序后连续播放形成“滑动切片”的假动画。横断面滑动时椎体和椎管轮廓从圆变扁再变圆冠状面滑动时能看到颈椎右侧和左侧的对称结构矢状面滑动时能看到椎体前缘和后缘的弧度。如果动画里的变化方向和你预期的解剖结构方向不符说明轴标签需要交换。2.2 mask灰度0/1/2不是目标检测框是像素级语义标签项目里的mask不是彩色图也不是0-255的普通灰度图而是阈值图灰度值只有0、1、2三个整数。这种写法在医学分割里非常常见0表示背景1和2表示两种不同的目标结构。具体到这个数据集颈椎骨骼分割里1和2分别对应哪种椎体或骨骼区域需要看原始标注约定但无论如何训练时你不能再把这张图当作三通道彩色图读进来而是要用cv2.IMREAD_GRAYSCALE或PIL的modeL读取否则会得到错误的值。常见的翻车点是有人用plt.imread()读mask由于PNG是8位灰度读出来的是一个二维数组值0-255但plt.imread()在某些环境下会自动把灰度PNG映射成三维RGBA或RGB导致shape变成(H,W,3)或(H,W,4)。你如果直接拿这个当标签输入损失函数就会报错或得到维度不匹配的梯度。正确做法是读取后强制np.squeeze并断言np.max(mask) 2。另一个容易被忽略的点是“阈值图”这个说法。它意味着原始标注可能来自某个阈值的二值化或三分割而不是手工精细勾画的轮廓。所以个别mask里可能出现噪点、断点、或椎体边界粘连。这并不一定是数据错而是标注方式带来的特性。我在训练分割网络时会对mask做一次连通域过滤和形态学开闭运算来去噪但要注意不要过度处理否则会破坏真实边界。2.3 数据规模的误区10412张图不等于10412个病例项目摘要里给出三组数据x轴512×512共3734对y轴512×196共4252对z轴512×196共2426对。加起来图像与mask各约10412张初看体量不小但必须意识到这是“切片张数”不是“病例数”。同一患者的颈椎CT在不同轴上可能切出几十张甚至上百张切片所以这10412对样本很可能只来自几十个病例。这个认知直接影响你划分训练集、验证集、测试集的方式如果你随机按单张切片划分同一病例的不同切片会同时落到训练集和验证集里导致验证集指标虚高。正确的划分单位是“病例级patient-level”。你需要从文件名中提取病例ID比如HN_P004_265.png里的HN_P004就是病例标识265是层号。先对病例ID去重再把病例集合按比例划分最后按病例分配切片。这样得到的验证集指标才有参考价值。后文第5章会给出具体划分代码。3. 数据装载与show.py可视化从文件名到像素的完整链路3.1 目录结构与文件命名规律拿到这份资源后建议先建一个干净的目录结构不要动原始文件。我的习惯是data/ images/ x_axis/ # 3734张 png y_axis/ # 4252张 png z_axis/ # 2426张 png masks/ x_axis/ # 3734张 png y_axis/ # 4252张 png z_axis/ # 2426张 png show.py这个结构不是项目自带的而是我拿到资源后自己整理的。原因很简单原始文件名有统一前缀和编号但混在一起容易读错。文件名格式类似HN_P004_265.png其中HN_P004可以当作病例ID265是切片序号。如果原始压缩包里的文件没有按类别分目录你需要先写一段脚本把它们归类。下面这段脚本把散装文件按轴文件名里的序列特征分类到对应目录。实际项目中如果文件名里没有明确的轴标识你就要根据文件夹名或尺寸来分。import os import shutil from pathlib import Path src Path(raw_data) dst_root Path(data) # 假设原文件按 image_xxx.png 和 mask_xxx.png 命名 for png_path in src.glob(*.png): name png_path.name if name.startswith(mask_): kind masks else: kind images # 通过读取图片高度/宽度决定轴方向 # 注意不要用文件名里的x/y/z字符串有些数据集命名不准确 import cv2 img cv2.imread(str(png_path), cv2.IMREAD_GRAYSCALE) h, w img.shape[:2] if w 512 and h 512: axis x elif w 196 and h 512: # 或反过来取决于你的实际shape axis y elif w 512 and h 196: axis z else: axis unknown out_dir dst_root / kind / f{axis}_axis out_dir.mkdir(parentsTrue, exist_okTrue) shutil.copy2(png_path, out_dir / name)这段脚本判断轴方向的依据是分辨率512×512优先归入x轴512×196和196×512要小心因为OpenCV读取shape是(h,w)可能和直觉相反。你最好先手动打印两个样本的img.shape确认到底谁是高谁是宽。我在这里犯过糊涂把y轴和z轴的分辨率搞反了后面训练才发现。3.2 读取png并叠加mask的参考代码show.py的核心职责是“方便观察mask”所以它至少要能完成三件事读取原图、读取mask、把mask变成彩色叠加层并保存。实际提供的show.py我没有逐行看过但大概率就是这么做的。下面是我按照这类资源最常见做法补全的参考代码你可以直接用来对比行为。import cv2 import numpy as np import matplotlib.pyplot as plt def visualize(image_path, mask_path, save_pathNone): # 1) 读灰度图 image cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 2) 归一化图像到 [0,1] 便于显示 img_norm cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX) # 3) 给 mask 着色0透明/黑1红2绿 color_mask np.zeros((mask.shape[0], mask.shape[1], 3), dtypenp.uint8) color_mask[mask 1] [255, 0, 0] # 红色 color_mask[mask 2] [0, 255, 0] # 绿色 # 4) 叠加图像作为背景mask以半透明方式叠上 alpha 0.6 overlay cv2.addWeighted( cv2.cvtColor(img_norm, cv2.COLOR_GRAY2BGR), 1 - alpha, color_mask, alpha, 0 ) if save_path: cv2.imwrite(save_path, overlay) else: plt.figure(figsize(10, 5)) plt.subplot(1, 3, 1); plt.imshow(image, cmapgray); plt.title(CT) plt.subplot(1, 3, 2); plt.imshow(mask, cmapgray); plt.title(mask) plt.subplot(1, 3, 3); plt.imshow(overlay); plt.title(overlay) plt.show()代码里参数说明alpha0.6表示mask透明度这个值越大mask颜色越浓越容易遮挡骨骼细节。观察颈椎边缘时我习惯alpha0.4看整体分割区域时用alpha0.7。另外cv2.normalize不是必须的如果原始CT是12位存储转PNG后有些图像整体偏暗归一化能避免叠加后背景太黑看不清。使用这段代码时如果mask的像素值不是0/1/2而是[0, 0, 255]或[255, 0, 0]这种RGB值说明之前读取时把三通道彩色图当成了灰度图或者原始mask本来就是RGB编码。此时要先检查mask.shape是(H,W)还是(H,W,3)。医学分割标注一般不会存成RGB但有些工具导出的PNG会带调色板需要用PIL.Image.open(mask_path).convert(P)读取索引值。3.3 show.py还能当作数据质量检查工具除了可视化单张图像show.py这类脚本最常见的延伸用法是生成一个“拼图矩阵”把同一病例相邻的切片放在一起快速观察分割连续性。尤其是颈椎这种长条结构横断面上椎体轮廓应该连续变化如果中间某张mask完全消失大概率是标注缺失或文件配对错了。下面这段代码读取同一病例连续10个切片并排保存import glob def grid_show_series(file_list, mask_list, rows2, cols5, savegrid.png): fig, axes plt.subplots(rows, cols, figsize(20, 4*rows)) for i, (img_p, msk_p) in enumerate(zip(file_list, mask_list)): img cv2.imread(img_p, cv2.IMREAD_GRAYSCALE) msk cv2.imread(msk_p, cv2.IMREAD_GRAYSCALE) overlay make_overlay(img, msk, alpha0.5) # 用前面定义的逻辑 axes[i // cols][i % cols].imshow(overlay) axes[i // cols][i % cols].axis(off) # 打印切片号方便核对 axes[i // cols][i % cols].set_title(img_p.split(/)[-1]) plt.tight_layout() plt.savefig(save, dpi100)这里的参数rows2, cols5表示一次看10张如果切片数超过100建议改成cols10一次看一长条。我复盘这套数据时就是用这个矩阵图检查出y轴某病例有一张mask发生了水平翻转——单独看一张根本看不出问题但连起来播放时椎体左右突然反了这才意识到预处理和标注之间存在坐标约定差异。4. 避坑三轴切面数据最容易翻车的四个地方4.1 现象图像和mask错位椎体和标签对不上肉眼检查单张时觉得mask轮廓好像大致在骨骼区域但训练时loss震荡剧烈验证集dice极低。原因多半是图像和mask的文件名配对不是按字符串排序而是按字典序或数字序混排。解决方法是统一配对逻辑。不要直接用os.listdir的顺序配对要提取文件名排序键并严格对齐image_paths sorted(glob.glob(data/images/x_axis/*.png), keylambda p: int(p.split(_)[-1].split(.)[0])) mask_paths sorted(glob.glob(data/masks/x_axis/*.png), keylambda p: int(p.split(_)[-1].split(.)[0])) assert len(image_paths) len(mask_paths)这里排序键用的是最后一个下划线后的数字如果文件名包含其他下划线解析时要注意。断言长度相等只是最低要求更严格的做法是检查两组文件名完全一一对应我用过最笨但有效的方法把mask文件名里的mask替换成image对比列表是否和image列表相同。4.2 现象把阈值图当成三通道图训练时loss出现NaN读取PNG mask时用默认的cv2.imread返回三通道BGR或plt.imread返回RGB得到的数组shape是(H,W,3)每个通道值相同或不同。如果你的数据集里mask是调色板PNG三通道读取后值可能变成[0,0,255]这类编码而不再是0/1/2。解决方法是强制灰度读取并做值校验mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) assert set(np.unique(mask)).issubset({0, 1, 2}), np.unique(mask)如果你的mask里存在255这类背景值通常是因为读取时自动做了颜色反转或者标注软件把背景值设成了255。处理方式不是直接归一化而是先做mask mask // 255再检查。我看到不少人在这一步偷懒结果背景类权重计算全错了。4.3 现象训练时内存爆掉加载全部图像导致OOM很多人习惯把所有图像和mask一次性读入numpy数组三轴加起来约10GB量级512×512×3734×2字节约2GB加上另外两个轴更多再用生成器喂模型。在低显存机器上数据加载进程会先被OS交换到磁盘训练速度骤降。解决方法是构建数据生成器按批次读取class SegDataset(torch.utils.data.Dataset): def __init__(self, img_paths, mask_paths): self.imgs img_paths self.masks mask_paths def __len__(self): return len(self.imgs) def __getitem__(self, idx): img cv2.imread(self.imgs[idx], cv2.IMREAD_GRAYSCALE) mask cv2.imread(self.masks[idx], cv2.IMREAD_GRAYSCALE) img img.astype(np.float32) / 255.0 mask mask.astype(np.int64) # 交叉熵需要LongTensor return img, mask参数说明cv2.IMREAD_GRAYSCALE读取后是0-255归一化到0-1可减少BN层收敛压力但如果你用预训练Encoder也许需要ImageNet的mean/std这另说。mask转int64是因为PyTorch交叉熵要求标签为Long型而且不支持负值。4.4 现象验证集指标高实际推理时错得离谱原因大概率是验证集划分时按单张切片随机切同一个病例的相邻切片同时出现在训练和验证中。因为相邻切片外观高度相似模型相当于“背”了答案。解决方法是按病例划分。代码逻辑很简单先提取病例IDimport random from collections import defaultdict case_dict defaultdict(list) for p in image_paths: case_id p.split(/)[-1].split(_)[1] # 例如 HN_P004_265 - P004 case_dict[case_id].append(p) cases list(case_dict.keys()) random.seed(42) random.shuffle(cases) train_cases cases[:int(len(cases)*0.8)] val_cases cases[int(len(cases)*0.8):] train_imgs [p for c in train_cases for p in case_dict[c]] val_imgs [p for c in val_cases for p in case_dict[c]]这里split(_)[1]假设文件名是HN_P004_265如果你的命名不同需要调整索引。这个划分逻辑的坑在于如果同一病例在三个轴都有数据且三个轴放在同一个目录里那么光按文件名分组是不够的还要先把轴分开。我的建议是训练时默认先按轴拆成三个独立任务或者在做三轴混合训练前用确定性代码保证同一病例的三个轴切片归到同一侧否则依然存在泄漏。5. 把数据集接入分割训练流程划分、增强与评估5.1 用UNet做基线训练时应该怎么组织训练集这套2D数据最直接的训练方案是2D U-Net。输入尺寸需要统一。x轴本身就是512×512可以直接用y轴和z轴是512×196偏窄我一般是padding到512×224或者512×256而不是resize。resize会改变椎体长宽比例影响分割边界精度。padding补零不会引入额外信息但要记得在mask上也pad同一圈0。训练代码里最关键的是collate函数确保同批图像尺寸一致import torch.nn.functional as F def pad_to(img, mask, h512, w256): _, ih, iw img.shape # (C,H,W) pad_h h - ih pad_w w - iw img F.pad(img, (0, pad_w, 0, pad_h), value0) mask F.pad(mask, (0, pad_w, 0, pad_h), value0) return img, maskF.pad参数顺序是左、右、上、下这里(0, pad_w, 0, pad_h)表示右边补pad_w下边补pad_h。mask用value0正好对应背景。不要用value255或value1那会把边界语义弄乱。5.2 数据增强必须同步操作图像和mask且不能破坏0/1/2值分割训练常用的增强有随机翻转、旋转、缩放、弹性形变。用imgaug或albumentations时注意同步增强API。我用的是albumentations它允许用Compose(transforms, additional_targets{mask2: mask})把image和mask一起增强。但随机旋转和缩放会引入插值插值会让mask出现非整数灰度值例如0.7、1.2这些值在交叉熵label里会被当作类标报错或自动取整导致错误分类。正确的做法是对mask使用最近邻插值对图像使用双线性插值。在albumentations里可以单独为mask指定interpolationcv2.INTER_NEAREST。如果你手写增强这一点非常容易漏。示例import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.ShiftScaleRotate( shift_limit0.05, scale_limit0.1, rotate_limit10, interpolationcv2.INTER_LINEAR, mask_interpolationcv2.INTER_NEAREST, p0.8), A.RandomBrightnessContrast(p0.3), ])参数说明ShiftScaleRotate里interpolation控制图像插值mask_interpolation控制mask插值这里显式设置成最近邻。rotate_limit10表示最多旋转10度颈椎这种细长结构旋转角度太大容易让椎体离开图像或产生幻觉边界。另外HorizontalFlip对左右对称的解剖结构是安全的但如果你处理的是左右不对称结构比如心脏偏左就要谨慎。5.3 评估指标不该只看整体Dice还要分轴统计三轴数据的分割难度差异很大。横断面512×512上椎体呈圆形或椭圆形边界相对清晰冠状面和矢状面上椎体呈矩形或长条而且相邻椎体之间间隙很小容易过分割。所以训练时我建议每50个epoch记录一次三轴的Dice、IoU分开观察。一个实用的评估函数如下def dice_coef(pred_mask, true_mask, eps1e-5): pred (pred_mask 0.5).astype(np.float32) true (true_mask 0.5).astype(np.float32) inter (pred * true).sum() union pred.sum() true.sum() return (2 * inter eps) / (union eps)这里eps防止分母为0。注意pred 0.5是二值化阈值但对0/1/2三分类来说你真正应该计算的是每个类别的Dice而不是把所有非背景像素混在一起算。多类别Dice常见的实现是one-hot后逐类计算再取平均。如果把“1”和“2”两个类别混合一旦模型把1预测为2宏观Dice会下降但简单的二值Dice会判断为正确指标被污染。5.4 类不平衡背景像素占比极高时怎么设损失颈椎CT图像中骨骼边界只占整个图像的很小区域尤其三轴切面上背景占比可能超过90%。使用普通的交叉熵损失时模型会无脑预测背景也能得到很低的loss。我一般首选交叉熵加上Dice项Combo Loss权重设为dice_weight。一个简单的实现class ComboLoss(nn.Module): def __init__(self, ce_weight0.5, dice_weight0.5, num_classes3): super().__init__() self.ce nn.CrossEntropyLoss() self.dice_weight dice_weight self.ce_weight ce_weight self.num_classes num_classes def forward(self, logits, targets): ce_loss self.ce(logits, targets) # 转one-hot求每个类别的dice probs F.softmax(logits, dim1) # (B,C,H,W) dice_loss 0 for c in range(self.num_classes): p probs[:, c] t (targets c).float() dice (2 * (p * t).sum() 1e-5) / (p.sum() t.sum() 1e-5) dice_loss (1 - dice) dice_loss / self.num_classes return self.ce_weight * ce_loss self.dice_weight * dice_lossce_weight和dice_weight之和不一定要等于1但建议在0.3~0.7之间调。CrossEntropy部分稳定梯度Dice部分缓解类别不平衡。如果你发现训练到后期Dice项剧烈波动可以把dice_weight调低到0.3甚至用scheduled weight前50个epoch用CE主导后面用Dice主导。这个损失还不够完善的地方是它没有考虑正负样本比例但用在0/1/2类别上基本够用。6. 进阶用切片脚本反向验证mask完整性并制作自己的三轴数据6.1 从体积重建三轴切面验证现有png之间的坐标一致性如果你手头还有原始的NIfTI或DICOM体数据可以自己从三维体积里切出任意方向的2D切片与项目的png做对比确认原始标注坐标系有没有转置。下面是一个用SimpleITK从NIfTI提取斜切面的示例import SimpleITK as sitk img sitk.ReadImage(case_001.nii.gz) data sitk.GetArrayFromImage(img) # 形状 (D, H, W) z轴在下标0 # 提取横断面第100层 axial_slice data[100, :, :] # 提取冠状面第150层沿H方向取 coronal_slice data[:, 150, :] # 提取矢状面第200层沿W方向取 sagittal_slice data[:, :, 200]这里的逻辑是SimpleITK读取后数组第一维对应z方向第二维对应y第三维对应x。你提取的轴向切面应该和项目的横断面png形状一致。如果发现形状对不上但转置后能对上说明数据预处理时做过rotate90或flip你需要在自己训练流程里也做同样的变换否则图像和mask的解剖朝向不一致。6.2 检查mask连通域用边界连续性找坏样本一个很高效的清洗技巧是计算每个mask中类别1和类别2的连通域数量并统计每个切片相对于相邻切片的面积变化率。颈椎椎体在二维切面上通常是单连通区域如果某个mask的类别1出现多个大块连通域很可能是标注时把软组织或其他强信号骨骼一起框了。用OpenCV做连通域分析num_labels, labels, stats, centroids cv2.connectedComponentsWithStats( (mask 1).astype(np.uint8), connectivity8) for label in range(1, num_labels): area stats[label, cv2.CC_STAT_AREA] if area 100: # 面积超过100像素的大块 print(flabel {label}: area{area})connectivity8指八邻域连通颈椎椎体区域用8连通比较合适。面积阈值100是经验值你可以根据图像分辨率调整。这个检测无法区分“大块”是真实结构还是噪声但能快速定位异常样本再回到可视化叠加图里人眼确认。最后一个习惯我一直保留不管数据是买的还是开源的拿到手先跑一次“文件名匹配唯一值检查连续切片播放”三件套再动训练。这次颈椎数据我最早也想省事直接用现成的listdir配对结果y轴有一百多张图因为文件名排序规则不一致全部错位了。从那以后我每次换新分割数据集都强制走一遍上述流程用可视化脚本把关键样本打印出来存档。希望这篇拆解能帮你把这套2D颈椎CT数据用出应有的效果少走我踩过的那些弯路。本文还有配套的精品资源点击获取
网站建设高端定制企业官网