ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CTSpine1K三切面脊柱分割数据集:10789张图像预处理与可视化解析

CTSpine1K三切面脊柱分割数据集:10789张图像预处理与可视化解析 简介面向医学影像分割研究人员与算法工程师这份数据集提供骶骨腰痛脊椎CT分割所需的完整素材按轴位面横断面、冠状面、矢状面三个切面分别切分出2D图像与对应掩膜共5个类别并附有类别说明文件和可视化脚本。压缩包共2000个文件以png格式图像与掩膜为主配合classes.txt和show.py整体大小约472.47MB目录按x、y、z轴组织便于按切面直接开展实验。目前已有224人学习适合用于脊椎分割、器官识别等任务的模型训练与验证。图像经windowing对比度增强并统一缩放到512×512且剔除了ROI占比不足3%的切片掩膜为8bit灰度png非零像素表示前景零像素为背景配合可视化代码可快速检查标注质量有效降低数据预处理成本。1. 骶骨腰痛脊椎分割数据集10789张三切面切片与标签/可视化代码全解析医学图像分割里最耗时的环节往往不是模型结构怎么搭而是训练数据怎么来。三维CT要先切成2D图像切完还得自己过滤空切片、调窗宽窗位、逐张核对掩码是否和原图对齐——任何一个环节出问题训练出来的分割模型就会把背景误判成椎体整个实验等于白跑。这套资源把CTSpine1K中的骶骨腰痛脊椎区域预先处理成了可直接训练的样本按轴位、冠状位、矢状位三个切面切出10789张512×512的2D图像每张配一个8bit uint8的png掩码共5个前景类别另外附classes.txt类别对照表和show.py可视化代码。适合正在做2D脊柱分割、又不想重走数据清洗流程的人拿到数据先跑一遍可视化脚本确认掩码质量再进训练管线省下大量前期调试时间。2. 切面与预处理原理轴位、冠状位、矢状位的ROI过滤与windowing参数2.1 为什么三个切面都要保留解剖视角与2D分割网络的训练分布CT扫描输出的是一个三维体素矩阵轴位横断面、冠状位、矢状位只是同一个体积在不同方向上的切片结果。临床医生读片时会同时在三个平面来回切换因为每个平面对不同病灶的敏感度差很多轴位是椎间盘突出、椎管狭窄最常用的观察角度椎体横断面的轮廓在这个平面上最清晰冠状位能直观看到脊柱是否侧弯、椎体左右是否对称矢状位则对椎间盘高度、椎体滑脱和椎管前后径的测量更有效。放到分割任务里如果只提供单一平面模型只会见过椎体在某一种空间投影下的形态。三个切面合在一起相当于让2D网络隐式地获得了一点“三维感知”同一个椎体在轴位是近似圆形在矢状位是带弧度的矩形在冠状位又变成了细长条。模型通过这些形态差异能更好地泛化到新CT上。这个数据集的三轴数量本来就不同x轴2113张、y轴2272张、z轴6404张不是均匀分配。我一般不会把三个目录直接合并打乱而是当成三个子数据集分别验证下面这个表把切面用途列了一下。切面目录图像数量主要解剖观察点轴位横断面x_axis2113椎间盘突出、椎管狭窄、椎体横断面形态冠状位冠状面y_axis2272脊柱侧弯、椎体左右对称性、压缩性骨折矢状位矢状面z_axis6404椎间盘高度、椎体滑脱、椎管前后径2.2 3% ROI过滤与切片坐标计算去除空切片的数学条件三维CT沿坐标轴方向切片的效率很高代码本质上就是在某个轴上按固定步长取二维平面。但这个过程中必然会产生大量首尾空切片椎体还没进入扫描视野或者已经离开扫描范围切出来的都是纯背景。如果把这些样本全部喂给网络模型会学到一种偷懒的解法——把所有像素都预测成背景反正背景占比极高损失也不大但从分割角度看模型什么都没学会。数据集的预处理流程中做了一个ROI过滤对于每个切片统计掩码中的前景像素占比如果低于3%就直接丢弃。判断逻辑用下面这段代码就能表达foreground_ratio (mask 0).sum() / mask.size if foreground_ratio 0.03: discard True else: discard Falsemask是uint8灰度图像素值为0的是背景1到5是不同类别。mask 0会把所有前景像素统一成True完全不关心具体是哪个类别所以这里的过滤只看整体前景面积。一个512×512的切片总共有262144个像素3%的阈值意味着至少要有7866个前景像素才能被保留。一个正常的椎体横断面面积远大于这个数字所以过滤掉的主要是真正常见的首尾空白切片而不是小结构。提示如果以后你要复现这套预处理流程不要把3%当成固定规则。当分割目标是小结构比如椎间盘在轴位上的面积可能连1%都不到时3%会把真实目标过滤掉。建议先画出所有切片前景比例的直方图找到分布的拐点再决定阈值。2.3 windowing对比度增强与重缩放从HU值到0-255灰度图的映射CT设备输出的原始值是HUHounsfield Unit范围从-1000到3000。人体不同组织的HU值差异很大空气是-1000水是0软组织在40到80附近骨骼普遍超过300。如果直接把原始数值当灰度图看软组织区域几乎是全黑的根本分不清椎间盘和硬膜囊需要对关注的灰度区间做放射学上常见的“windowing”处理。windowing有两个关键参数窗位WLwindow level和窗宽WWwindow width。WL决定映射范围的中间位置WW决定你保留的灰度跨度。脊柱分割里最常用的是骨窗大约在WL400、WW1800附近。映射实现可以这样写import numpy as np def window_ct(hu_image, wl400, ww1800): lower wl - ww / 2 # 这里算出-500 upper wl ww / 2 # 这里算出1300 windowed np.clip((hu_image.astype(np.float32) - lower) / (upper - lower), 0, 1) return (windowed * 255).astype(np.uint8)关键点在于先clip再乘255把数值范围限制在0和1之间后再转uint8负值不会变成0以下的黑洞超过1的值也不会直接过曝。转换完的图像再用双线性插值重缩放到512×512。但注意掩码png的重缩放必须用最近邻插值不能用双线性或三次插值否则在类别边缘会产生原本不存在的中间灰度值。这个数据集在预处理阶段已经统一处理成了512×512但如果你自己写数据管线原图和掩码的插值方式一定得分开设置。3. 目录结构与5类别标签规范jpg原图与uint8掩码对齐的细节3.1 三轴目录组织与文件命名规则拿到手的资源解压后目录结构大致是这样的CTSpine1K_preprocessed/ ├── classes.txt ├── show.py ├── x_axis/ │ ├── images/ *.jpg │ └── masks/ *.png ├── y_axis/ │ ├── images/ *.jpg │ └── masks/ *.png └── z_axis/ ├── images/ *.jpg └── masks/ *.pngx_axis对应轴位切面y_axis对应冠状位z_axis对应矢状位。每个轴目录下面都是images和masks两个平行子目录同名文件一一对应只是后缀不同。原图是jpg格式掩码是png格式。jpg体积小、读取快适合当网络输入png是无损格式适合保存精确到像素的标签不会引入额外的压缩误差。文件命名带的是切片索引类名称比如切片来源、序号这类信息。实际使用的时候不要过度依赖文件名去判断内容有些文件名可能带着历史遗留前缀这和原本的三维CT体数据命名习惯有关。你只需要保证images里的jpg和masks里的png按basename能一一配对名字本身是什么并不重要这一点在第3.3节会给出校验方法。3.2 classes.txt的5类别像素值与8bit掩码约束classes.txt里记录的是类别名和像素值对应关系掩码中非0为前景、0为背景。掩码文件是8bit灰度png具体格式是uint8也就是说每个像素占用一个字节取值只能是0到255。这里有两个细节需要强调。第一个是jpg有损压缩的问题CT切片存成jpg一定会引入块效应区别只在压缩质量的高低。你拿到手后建议抽几张图放大看椎体边缘如果边缘出现明显的方块状马赛克说明压缩质量偏低。训练时可以对输入做一次轻微的随机高斯模糊弱化压缩伪影给网络带来的干扰。第二个是位深问题8bit灰度png的像素值范围是0-255如果把掩码用某些图像编辑工具另存成16bit像素值范围会变成0-65535。训练框架读取后如果没做dtype转换one-hot编码阶段就会报错更常见的是类别信息被压缩丢失。读取掩码的正确姿势是用cv2.IMREAD_UNCHANGED保持原始位深然后立刻检查np.unique(mask)是否是[0, 1, 2, 3, 4, 5]看到这个结果才能继续往下走。3.3 批量读取与尺寸校验用glob加断言检查目录完整度我的习惯是拿到任何分割数据集第一件事不是开训练而是先写一段目录校验脚本把三组图像的配对关系、数量、尺寸全部检查一遍。几行代码就能完成不要省这一步。import os from glob import glob for axis in [x_axis, y_axis, z_axis]: img_paths sorted(glob(f{axis}/images/*.jpg)) mask_paths sorted(glob(f{axis}/masks/*.png)) assert len(img_paths) len(mask_paths), \ f{axis}: images {len(img_paths)} ! masks {len(mask_paths)} for img_p, mask_p in zip(img_paths, mask_paths): img_name os.path.basename(img_p)[:-4] mask_name os.path.basename(mask_p)[:-4] assert img_name mask_name, fname mismatch: {img_name} vs {mask_name} print(f{axis}: {len(img_paths)} pairs matched)这段脚本的核心是basename比对把jpg和png去掉后缀后必须完全一致只要有任何一张对不上后面训练加载必然错位。验证通过后每对img/mask再用cv2读取检查shape是否为(512, 512)并用np.unique确认类别数正确。如果数量没问题但个别尺寸异常通常是切片重缩放的边界情况直接定位到具体文件名删掉即可不影响整体使用。4. 可视化代码show.py实战把灰度CT和uint8掩码叠加成伪彩色对比图4.1 运行环境与show.py依赖项show.py这个脚本解决的是一个很实际的问题掩码是8bit灰度图直接打开看的话类别1到5的灰度差异非常小肉眼看不清椎体边缘到底分到了哪个结构。只有把掩码转成伪彩色并叠加到原图上才能直观判断分割边界是否合理。脚本依赖就两个Python包opencv-python和numpyPython版本3.7以上都够用。核心叠加逻辑可以用下面这段代码表达import cv2 import numpy as np import os from glob import glob def visualize_pair(img_path, mask_path, save_dirvis): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) mask cv2.imread(mask_path, cv2.IMREAD_UNCHANGED) assert img is not None and mask is not None, fread failed: {img_path} assert img.shape mask.shape, fsize mismatch: {img.shape} vs {mask.shape} # 掩码类别值本身只有1-5乘40后变成40-200 # 让类别1和类别2在伪彩色映射下有明显色差 color_mask cv2.applyColorMap((mask * 40).astype(np.uint8), cv2.COLORMAP_JET) overlay cv2.addWeighted( cv2.cvtColor(img, cv2.COLOR_GRAY2BGR), 0.7, color_mask, 0.3, 0 ) os.makedirs(save_dir, exist_okTrue) out_path os.path.join( save_dir, os.path.basename(img_path).replace(.jpg, _overlay.png) ) cv2.imwrite(out_path, overlay) # 打印当前掩码里实际出现的类别号 uniq np.unique(mask) print(os.path.basename(img_path), classes:, uniq.tolist())4.2 叠加与保存细节解读为什么mask要先乘40灰度掩码经过applyColorMap前先做了一步mask * 40。原因是类别像素值1到5都集中在灰度最暗端直接映射到伪彩色后类别1和类别2的颜色差异极小几乎分不出来。乘以40以后类别1变成40类别5变成200整个色带被拉开不同结构的边界一下子就清楚了。要注意这里的乘法会覆盖背景0不变背景在JET色带里是深蓝色叠加到CT图上不会干扰前景观察。addWeighted的前景权重取0.7、掩码权重取0.3CT解剖结构是主体掩码只做半透明叠加。如果你觉得掩码颜色太重看不清椎体纹理可以把掩码权重降到0.2反过来想突出边界就把掩码权重调到0.5。这两种参数组合都不用改原图重新生成叠加图即可不影响掩码本身。4.3 从可视化结果反推数据质量五个检查点每次跑完可视化脚本我会按下面这几个点快速过一遍发现异常就直接定位到具体图像椎体结构在原图上是否清晰可辨前景是否集中在图像中央区域如果整体偏到角落说明切片裁剪偏移了。掩码着色的边界是否平滑椎体边缘是否出现锯齿状伪影出现则考虑jpg压缩质量问题。五个类别是否都有出现出现次数是否均衡有的类别一整组图都没出现说明切片过滤阈值把某个小结构误伤了。同一切片在多个轴位是否结构位置连续相邻切片之间的掩码形状是否稳定突变明显的切片需要单独检查。掩码是否出现了超出1到5范围的值比如255说明掩码在转换过程中被误改成了三通道或拉伸成了别的灰度。这五个检查点不需要全看抽样每类三五十张就能暴露大部分数据问题。可视化这步我建议留在数据管道的最前面因为纯靠训练指标排查数据问题是又慢又累的弯路。5. 避坑指南切面数据集落地时最常踩的五个坑5.1 mask读出来全黑或全白被imread的默认通道坑了现象用cv2.imread(mask_path)直接读掩码打印出来的像素只有0和255类别1到5的信息全丢了训练loss算出来直接是NaN或恒定值。原因cv2.imread默认以BGR三通道模式读入8bit单通道png会被复制到三个通道后续做mask 0判断时所有前景类别都被混成一个True类别数量从5变成1。如果改用IMREAD_GRAYSCALE虽然读出来是单通道但有些opencv版本会返回8bit值也有的批量处理脚本会顺手做归一化导致掩码被压到0和255两个极端。解决读掩码统一用cv2.imread(mask_path, cv2.IMREAD_UNCHANGED)保持原始dtype不变。读进来后马上执行一次np.unique(mask)确认输出是[0, 1, 2, 3, 4, 5]而不是[0, 255]检查通过再进训练管线。5.2 windowing后图像变糊或过曝窗位窗宽没有统一参数现象同一批数据里一部分图像整体发白椎体边缘完全被亮度吞没另一部分图像整体发黑软组织细节根本看不见。原因预处理阶段虽然对CT数据做了windowing但如果原始CT体数据的扫描参数管电压、层厚差异较大固定WL400、WW1800并不适配所有样本。或者你在自己的数据管线上又做了一遍归一化把已经映射到0-255的图再次拉伸相当于做了二次截断正常结构被压到了过曝区。解决拿到数据后每个轴目录随机抽50张图统计灰度均值和标准差确认分布范围接近。如果标准差差异超过4倍说明这批数据不是整体统一windowing的建议对每一张图单独做动态窗口。注意只做一次windowing训练pipeline内部的归一化保持简单不要叠加。5.3 z轴切片指标总比xy差一头切片密度与ROI占比不一致现象三个轴分别训练后轴位dice明显高于矢状位或反过来不管怎么调学习率都不见好转。原因三个轴面的切片数量差异很大矢状位有6404张轴位只有2113张网络在样本量少的平面上学不充分。另外同一个椎间盘在矢状位上的切片里前景占比明显低于轴位小目标本身难学交叉熵会被背景主导。解决先对三个轴分别统计每个类别的像素占比做成类别权重向量传给损失函数。训练时用分组采样器让每个batch同时包含三个轴的样本避免某个batch全是单一平面的图像。评估时按轴位、冠状位、矢状位分别计算Dice再报平均否则一个高dice会掩盖另一个平面的真实水平。5.4 文件按liver历史前缀残留命名读文件不要依赖名称现象数据集里会出现liver_33_260.png这类和脊柱完全无关的文件名光看文件名会以为数据给错了甚至有人会把这类文件当噪声删掉。原因这个数据集的预切片工具沿用了另一个分割项目肝脏分割方向的命名模板切片序号和文件名模板没有改导致名称带liver前缀但内容其实是脊柱CT切片。解决处理时完全以目录为准图片从images目录读掩码从masks目录读用basename逐一配对不关心文件名前缀是什么。批量改名时只改前缀保留序号部分避免破坏配对关系。这个前缀不影响任何训练代码只要配对脚本按basename匹配就行。5.5 训练集随机切分导致同源数据泄漏按三维体分组现象随机切分训练验证集后验证集dice非常高接近0.98但模型换到真实新CT上效果骤降只有0.6左右。原因同一个原始三维CT体相邻切出的2D切片之间高度相似几乎是同一结构的轻微位移。随机切分会把同一个三维体的切片同时分进训练集和验证集模型在训练时已经见过验证集中切片的大致形态评估结果虚高。解决切分必须按原始三维体分组。最理想的做法是维护一个volume_id到切片路径的映射用GroupKFold做交叉验证。如果当前数据集没有显式提供volume_id保守方案是按切片序号分组相邻10到20张视为同一组不允许跨组出现在训练和验证集合里。这样评估结果才接近真实泛化水平。6. 把数据集接进分割训练分组切分、soft-Dice损失与分平面评估6.1 按切面分组切分训练测试集报指标也分平面报2D分割训练里最常见的高分假象就是随机切分带来的同源泄漏。正确做法是构造样本路径时同时带上组信息用GroupKFold按组切分。以这个数据集的场景为例合理的方式是按切面分别构造样本对相同解剖连续段算同一组import numpy as np from glob import glob from sklearn.model_selection import GroupKFold X [] groups [] for axis in [x_axis, y_axis, z_axis]: img_paths sorted(glob(f{axis}/images/*.jpg)) mask_paths [p.replace(images, masks).replace(.jpg, .png) for p in img_paths] for i, (img_p, mask_p) in enumerate(zip(img_paths, mask_paths)): X.append((img_p, mask_p)) # 相邻切片视为同源组避免同一段脊柱同时出现在训练和验证集 groups.append(f{axis}_{i // 20}) gkf GroupKFold(n_splits5) for train_idx, val_idx in gkf.split(X, groupsgroups): train_samples [X[i] for i in train_idx] val_samples [X[i] for i in val_idx]核心逻辑是groups数组告诉GroupKFold哪些样本来自同一解剖区域。这个写法比直接随机切分耗时少、效果扎实。如果你的原始CT数据能拿到volume_id优先用volume_id替代axis_i//20这种保守分组方式拿不到时就保持连续切片分组这也是医学影像分割的通用玩法。6.2 损失函数与评估指标soft-Dice加交叉熵报告逐类mDice五个脊柱结构类别面积差异很大直接只用交叉熵会让网络倾向把大面积背景学得最好小结构反而学不到。我一般会用soft-Dice和交叉熵的组合损失PyTorch实现大致是这样import torch import torch.nn as nn import torch.nn.functional as F class SoftDiceCE(nn.Module): def __init__(self, num_classes5): super().__init__() self.num_classes num_classes def forward(self, logits, mask): probs F.softmax(logits, dim1) target F.one_hot(mask.long(), num_classesself.num_classes) target target.permute(0, 3, 1, 2).float() smooth 1.0 intersection (probs * target).sum(dim(2, 3)) union probs.sum(dim(2, 3)) target.sum(dim(2, 3)) dice (2.0 * intersection smooth) / (union smooth) dice_loss 1.0 - dice.mean(dim0) ce_loss F.cross_entropy(logits, mask.long()) return dice_loss.mean() ce_lossdice.mean(dim0)先对每个类别单独求均值再把五个类别平均避免大类别主导损失。交叉熵起正则作用帮助网络在训练初期收敛更快。评估时按三个轴分别打印每个类别的Dice输出一个5行3列的矩阵一眼就能看出哪个平面上的哪个类别最弱。我自己用这套数据时吃过一次大亏当时偷懒用了全局随机切分验证集dice特别好看结果到了真实CT上完全崩盘。从那以后我每次拿到切面数据集都强制走一遍固定流程——先跑show.py抽样看掩码再做目录配对校验和类别占比统计最后按组切分训练验证集指标分平面报告。希望帮到你。本文还有配套的精品资源点击获取
返回列表