资讯动态

医学图像分割数据集CTSpine1K:三切面与5类别的骶骨腰椎分割指南

发布时间:2026/10/29 18:10:14 来源:尧图企业网站定制
简介面向医学图像分割与脊椎影像分析研究者这份数据资源以CTSpine1K为基础构建了涵盖骶骨、腰痛相关脊椎结构的五类别分割数据集。数据分别沿轴位面、冠状面、矢状面三个切面提取2D图像切片时剔除ROI占比不足3%的无效样本并完成窗宽窗位对比度增强与512×512统一缩放jpg原图与png八位灰阶标签一一对应类别含义可在classes.txt中查阅。压缩包共2000个文件以png图像/标签为主体同时包含类别说明txt与可视化脚本show.py整体包体约472.47MB。资源目前已有226人学习下载。借助三个切面已经整理好的x/y/z目录结构可直接用于2D分割模型训练配套的show.py脚本能快速叠加展示mask与原始图像便于检查标注质量降低数据预览与调试成本对开展脊椎分割、腰痛相关结构定位等实验具有实用价值。1. 医学图像分割数据集骶骨腰痛脊椎分割需要先看懂三切面和5类别医学图像分割数据集最怕两件事一是标签不干净二是只有图没有语义说明。骶骨腰痛脊椎分割这套资源把两件事都堵上了。它来自CTSpine1K的切片工程分别从轴位面横断面、冠状面、矢状面三个方向切出2D图像统一做过windowing对比度增强统一重采样成512×512大小x轴2113张、y轴2272张、z轴6404张每张都配了png格式的8bit灰度标签文件背景值为0、非0为前景共5个类别classes.txt写清楚了编号语义还额外给了show.py做标注可视化。适合谁想快速上手医学图像分割的新手做腰椎、骶骨、椎体相关研究的从业者以及需要一份干净基础数据去验证分割算法效果的人。它给的不是检测框不是分类标签而是像素级mask用之前最好先把三个切面的组织方式弄清楚。2. 三个切面与5类别CTSpine1K的数据组织与规模2.1 x/y/z轴各有几张为什么要按三个方向切CT本质是一组三维体数据直接拿来做2D分割需要先降维降维方式就是切片。轴位面是横着切冠状面是从前向后切矢状面是从左向右切三组切片合起来才能完整覆盖脊椎的解剖结构。骶骨和腰椎在轴位上能看到椎体横截面和椎管形态在矢状面上能看清楚腰椎的生理曲度和椎间盘高度在冠状面上则适合观察椎体两侧的对称结构。不同切面提供的纹理和形状线索不一样这也是同一个数据集要按三轴分开存放的原因。数据规模直接列出来方便你分配训练和验证资源。轴images数量masks数量图像格式标签格式x轴21132113jpgpng8bit灰度y轴22722272jpgpng8bit灰度z轴64046404jpgpng8bit灰度三轴加起来10789张配对数据标签全部是png图像全部是jpg。这里有个值得注意的细节z轴数据量是x轴的3倍后面混合训练时如果不做采样平衡模型很容易被z轴带着跑这个在第5章的避坑里专门讲。切片的筛选逻辑也要先说清楚。作者在生成数据时去掉了ROI区域不足3%的切片意思是某张2D图像里前景像素占总像素比例低于3%直接丢弃。这个操作让数据集整体比较“干净”不会出现一整张图里只有一个孤零零的小椎体边缘的情况但也意味着极端小目标场景在训练阶段根本看不到。你做推理时如果遇到低占比切片模型表现可能不如预期这个心理准备要有。2.2 classes.txt5类别的编号是语义不是灰度打开classes.txt之前别急着把mask当成普通的二值图。0是背景非0是前景这句话看起来简单但“非0”具体有几个编号、每个编号对应什么结构全在classes.txt里。5个类别的含义不能靠猜有人想当然地把大于0的像素全部当成“脊椎”等于把多分类当成二分类做整个网络的输出通道数都会设置错。正确读取方式是先看classes.txt里每一行写的类别名再拿mask里的整数编号和类别名一一对应。mask是png格式8bit灰度uint8每个像素存的是一个类别索引不是RGB颜色。比如某张mask里np.unique的结果是[0, 1, 2, 5]那0是背景1、2、5分别代表三个前景类别具体是椎体还是椎间盘还是骶骨classes.txt会告诉你。训练时直接把这张图当CrossEntropyLoss的目标索引图用不需要转成one-hot也不需要转成三通道。这里顺带提醒一个常见误区有人用cv2.imread读mask时不加参数默认按三通道读出来shape直接变成512×512×3跟模型输出的512×512×num_classes对上不号训练到一半必然报错。读mask必须走灰度模式后面第5章还会再强调。2.3 目录检查脚本先看缺不缺mask再看尺寸对不对拿到手的第一件事不是训练是把整个目录遍历一遍确认每张jpg都有对应的png确认每张png尺寸都是512×512。文件名里类似liver_33_257.png这样带数字的命名多半是切片序号但不同轴文件名规律未必完全一致直接按文件名前缀配对最稳妥。import cv2 from pathlib import Path root CTSpine1K axis z # 换成 x / y / z 分别核对 img_dir Path(root) / axis / images mask_dir Path(root) / axis / masks img_files sorted(img_dir.glob(*.jpg)) mask_files sorted(mask_dir.glob(*.png)) print(images数量:, len(img_files)) print(masks数量:, len(mask_files)) missing_mask 0 for img in img_files: mask mask_dir / (img.stem .png) if not mask.exists(): print(缺少mask:, img.name) missing_mask 1 bad_size 0 for mask in mask_files: m cv2.imread(str(mask), cv2.IMREAD_GRAYSCALE) if m is None: print(读取失败:, mask.name) continue if m.shape ! (512, 512): print(尺寸异常:, mask.name, m.shape) bad_size 1 print(缺失mask数量:, missing_mask) print(尺寸异常数量:, bad_size)这段代码的逻辑很简单先统计两边文件总数然后遍历所有jpg去masks目录里找同名png找不到就记录下来再遍历所有png检查shape是否为512×512。参数上注意cv2.imread的第二个参数IMREAD_GRAYSCALE必须写否则读出来的可能是三通道后面的shape判断就废了。我一般还会顺手统计一下每张png的行列数是否和对应jpg一致因为偶尔会出现“数量能对上但某一张尺寸是511×512”这种离奇情况。真正训练前把这类脏数据清掉比训练到一半再停下来排查要省心得多。3. 标签文件与可视化从8bit uint8 mask里读出真实语义3.1 为什么标签是png而不是jpg为什么是uint8标签用png格式是有讲究的。jpg是有损压缩会在边缘产生伪影如果标签也用jpg那些细小的椎体边界会被压缩得模模糊糊分割的边界精度直接受影响。png是无损格式每个像素的类别编号可以被精确保存。8bit灰度uint8意味着每个像素值范围是0到255对一份5类别分割标签来说完全够用而且内存占用比int32小得多。这个设计对你做训练是有实际影响的。PyTorch里CrossEntropyLoss接受的目标张量是long类型但mask读进来是uint8所以喂给loss之前要转成torch.long。有些人在dataloader里忘了这一步或者用了OneHotEncoder把标签变成float的one-hot都会导致训练图异常。正确做法是训练时直接公示类索引模型输出num_classes通道的logitsloss内部做softmax和交叉熵。3.2 show.py在做什么叠加显示mask的正确姿势资源里带了show.py目的就是让你快速看某张图对应的mask长什么样。这文件核心逻辑其实就是读jpg、读png、然后叠在一起画出来。我下面给一个等价的可视化版本逻辑一样但加了两行关键检查输出。import cv2 import numpy as np import matplotlib.pyplot as plt stem liver_33_257 # 示例文件名换成实际存在的即可 img cv2.imread(fimages/{stem}.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) mask cv2.imread(fmasks/{stem}.png, cv2.IMREAD_GRAYSCALE) print(图像shape:, img.shape) print(标签shape:, mask.shape) print(标签类别值:, np.unique(mask)) plt.figure(figsize(10, 5)) plt.subplot(1, 2, 1) plt.imshow(img) plt.title(CT image) plt.subplot(1, 2, 2) plt.imshow(mask, cmaptab10) plt.title(mask classes) plt.show()这里重点看两个参数。第一个是cv2.imread的IMREAD_GRAYSCALE读png标签时强制走灰度模式保证mask只有单通道。第二个是matplotlib的cmap参数mask是uint8且取值只有0到4或0到5这样几个离散整数用默认的viridis颜色映射会把不同类别显示成渐变色看着容易混改成tab10这类离散色图每个整数对应一种固定颜色配上图例就能直观分辨背景和各个结构。np.unique(mask)那行print特别重要。它直接告诉你这张mask里到底出现了几个类别值。如果一张mask的unique结果只有[0]说明这张标签全背景属于废样本训练时要把这类样本过滤掉或单独处理。3.3 像素级质量检查统计每个类别占比可视化只解决“肉眼看着对不对”解决不了“类别比例是不是失衡”的问题。真正训练前我会把全部mask扫一遍统计每个类别的像素占比这一步能提前暴露标签语义错误比如某张mask把椎间盘类和椎体类编号搞反了或者类别编号不连续导致网络多了一个没意义的输出通道。import cv2 import numpy as np from pathlib import Path mask_dir Path(CTSpine1K/x/masks) pixel_count {} file_count {} for mask_path in sorted(mask_dir.glob(*.png)): m cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) values, counts np.unique(m, return_countsTrue) for val, cnt in zip(values, counts): pixel_count[int(val)] pixel_count.get(int(val), 0) int(cnt) file_count[int(val)] file_count.get(int(val), 0) 1 for val in sorted(pixel_count.keys()): print(f类别{val:3d} 像素总数{pixel_count[val] // 1000}K f出现在{file_count[val]}张图中)这段代码遍历单个轴的全部mask按类别统计像素总量和出现张数。pixel_count存的是类别像素总占比file_count存的是该类别在多少张图中出现。输出结果里如果某个类别只出现在极少数图中说明这个类样本严重不足训练时要么加权要么做数据增强如果出现了classes.txt以外的编号比如7、8那这张mask可能是损坏的或者标签版本不一致要立即隔离检查。我自己拿到这份数据时是把三个轴都跑了一遍这个脚本发现确实存在个别mask的unique结果里带出不连续编号的情况这类样本不进训练集。宁可少几张图也不要让模型学到一个不存在的类别。4. windowing与512×512重采样预处理直接决定模型读图方式4.1 CT的HU值与windowing为什么CT不增强没法直接用CT图像原始的像素值是HUHounsfield Unit范围跨度很大空气是负值约-1000骨骼可以到1000以上。如果直接拿原始HU值进神经网络不同组织之间的数值差异会被压缩得很不明显模型很难学到有效特征。windowing操作本质上就是做一个带截断的线性映射先设定窗宽和窗位再把窗内的HU值线性映射到0到255窗外全部截断。数据集已经把这一步做完了你拿到的jpg就是windowing之后的灰度图不需要自己再处理。但你必须知道这个背景因为后续归一化时要考虑像素分布是被截断过的分布形态跟自然图像完全不相关。如果使用ImageNet预训练权重输入数据分布不匹配第一层卷积的统计量一下被打破模型cold start时loss会异常高甚至一直不收敛。4.2 512×512重采样与ROI不足3%的取舍所有切片都被重采样成512×512好处是模型输入尺寸固定不用做随机resize和padding坏处是不同切面的原始分辨率并不相同重采样过程中必然伴随插值边界信息会有轻微损失。对于椎体这类结构清晰的解剖目标这个损失通常不影响分割精度。但ROI不足3%被剔除这个策略要特别留意。作者在切片时把前景占比低于3%的切片干掉了这意味着训练数据里不存在“极小目标”的样本模型学到的前景尺度是相对稳定的。推理时如果遇到一张前景占比只有1%的切片模型大概率会漏检或者只分割出一小部分。这不算数据集的缺陷而是设计时的取舍——为了训练稳定性放弃了极端情况的覆盖。如果你论文里要强调模型对极小目标的鲁棒性这份数据集就不够支撑需要额外补充极端切片。4.3 自写windowing逻辑理解数据才能正确做归一化你拿到的已经是windowing后的成品不需要复现但如果遇到要用原始DICOM的场景或者想验证一下这套流程对最终结果的影响明白实现逻辑是必要的。常见做法是把HU值截断到窗宽范围内再做线性映射到0到255。import numpy as np def apply_window(image_hu, window_center, window_width): low window_center - window_width / 2.0 high window_center window_width / 2.0 image np.clip(image_hu, low, high) image (image - low) / (high - low) * 255.0 return image.astype(np.uint8) # 示意调用实际数据集不需要执行这段 # dicom_array read_ct_volume(case001.dcm) # out apply_window(dicom_array, window_center450, window_width1500)代码里window_center是窗位window_width是窗宽。脊椎和骨骼这类高密度组织常用骨窗窗位450、窗宽1500是比较常见的组合但不同机构扫描参数有差异具体窗口参数需要根据自己数据的情况微调。clip函数把窗外的值全截断小于low的变low大于high的变high然后线性映射到0到255。这个过程和数据集里做的windowing增强本质一致。关键点是读懂这段代码后你就明白输入模型的512×512×3图像三个通道的值其实是同一个灰度值复制了三遍而不是彩色自然图的三个通道。这时如果你用ImageNet的mean/std做标准化等于是拿自然图像统计量强套到CT灰度图上不是不行但需要重新统计这套数据的mean和std。我习惯的做法是在训练脚本里先跑一整个epoch的数据统计把均值方差打到log里再决定用不用ImageNet参数。5. 常见问题与避坑坐标配对、类不均衡与训练翻车5.1 cv2读mask默认读出三通道shape对不上现象训练时报tensor维度不匹配mask的shape是512×512×3模型输出是512×512loss直接抛异常。原因cv2.imread读png时不加第二个参数默认按三通道彩色读取即使图片本身是灰度图也会复制成三通道。解决所有读mask的地方强制用cv2.imread(path, cv2.IMREAD_GRAYSCALE)。我自己是把dataloader里读mask的代码统一封装成函数内部自动检测mask后缀名png一律走灰度模式jpg原图走IMREAD_COLOR再转BGR2RGB。这个坑几乎每个刚接触医学分割的人都会踩一次影响不大但很耽误时间。5.2 可视化一片全黑mask显示不出来现象matplotlib或OpenCV显示mask时画出来一片纯黑肉眼看不到任何结构。原因mask是uint8取值只有0到几个整数当你直接调用plt.imshow(mask)时matplotlib默认按float类型处理把255当作最大值做归一化而已有前景像素值是1、2、3这样的低值除以255后整个画面处在极暗区视觉上就接近黑色。解决显示前对mask做归一化用mask.max()作为除数或者直接指定cmap为tab10等离散色图。判断一张空mask是不是全黑也别肉眼看用np.unique(mask)看返回值里是否只有[0]。如果unique里有非0值但显示仍然全黑那就是归一化的问题不是数据问题。5.3 用了ImageNet预训练权重loss高到爆炸现象加载ResNet或U-Net的ImageNet预训练权重后第一个epoch的loss远高于正常水平甚至数值直接变成NaN。原因windowing后的CT灰度图像素分布和ImageNet的RGB自然图差别太大。ImageNet的均值方差是针对800万张自然图像统计的CT图经过截断后灰度分布完全不是那个形态预训练第一层卷积的初始化统计量被打破。解决两种路线。第一重新统计这个数据集的mean和std加载预训练权重后第一个epoch用它做归一化第二把输入通道改成单通道第一层卷积不加载预训练权重后面的层继续用。我一般用第二种因为CT灰度本质就是单通道信息复制三通道喂给网络在某些网络结构上会浪费容量。5.4 类不均衡背景占大头loss低了Dice却上不去现象训练时交叉熵loss一路下降Dice或IoU却一直烂在0.5以下可视化结果一片背景模型根本没学出前景结构。原因512×512的图像里脊椎和骶骨前景占比往往低于15%背景占比经常在85%以上。普通交叉熵被背景像素主导模型只要把所有像素预测成背景loss就能降到很低指标却完全没法看。解决换DiceLoss和交叉熵的组合或者给损失函数里的前景类别加权重再或者训练时对背景区域做下采样。我常用的是前端用交叉熵后端用DiceLoss两个loss按比例相加比如ce_weight0.3dice_weight0.7。DiceLoss天然对类不均衡不敏感因为它算的是集合相似度不是像素级概率。注意DiceLoss在预测概率和真实标签都是全背景时分子分母同时为0要在计算里加smooth否则得到NaN。5.5 z轴数据量是三倍混合训练把x/y轴带偏现象把三个轴的数据混合在一起训练整体Dice高但单独评估x轴或y轴时Dice比分开训练低了2到3个百分点。原因z轴6404张x轴2113张y轴2272张混合训练时每epoch里z轴样本占比接近60%模型学到的形状和纹理特征被z轴严重主导x/y轴这种占比低的切面被稀释了。解决要么三个轴分别训练分别评估要么在dataloader里按轴做加权采样让每个轴的样本量在训练时基本均衡。我倾向于先分开训练把三个轴的baseline分别跑出来做消融实验时再谈混合。这个坑在真实科研场景里很容易踩因为整体指标好看不等于每个切面都表现好审稿人一拆开看就露馅。医学图像分割的评审一般会要求按解剖方向分别给指标混合训练前的采样策略要提前想清楚。6. 进阶技巧用这份数据跑通一个轻量分割模型6.1 按病人分组划分训练集避免切片泄漏医学图像分割最忌讳随机划分数据因为z轴相邻切片之间高度相似同一病人的几十张切片如果随机分到训练集和测试集模型等于提前看到了“接近答案”的图像测试Dice虚高。常见做法是按病人ID或扫描ID分组整个病人的所有切片只进训练集或只进验证集这样Dice才真实反映泛化能力。6.2 一套最小的U-Net训练骨架我用PyTorch写过一个最小验证版本核心是把dataloader和loss正确接上。数据是512×512灰度图我把它转成单通道输入输出通道数设为5对应5个类别。训练骨架里最关键的是loss的写法import torch.nn.functional as F def dice_loss(pred_logits, mask, smooth1e-5): pred_probs F.softmax(pred_logits, dim1) n_classes pred_probs.shape[1] dice_sum 0 for c in range(1, n_classes): # 从1开始略过背景类 p pred_probs[:, c] t (mask c).float() inter (p * t).sum() dice_sum (2.0 * inter smooth) / (p.sum() t.sum() smooth) return 1 - dice_sum / (n_classes - 1)这里dice_loss从类别1开始遍历明确把背景排除在外避免背景像素占比过高把dice值抬上去。训练时总loss是交叉熵和dice_loss的加权和batch size设8就能在单卡上稳定跑。UNet的encoder浅层特征用预训练权重最后输出层随机初始化即可。6.3 Dice计算时注意空标签处理验证阶段算Dice不要直接套公式。某张mask里如果全背景即t的sum为0同时预测也全背景p的sum为0这时标准的Dice公式会变成0除以0。我按类别分别计算每个类别的预测和真实都为空时该类别Dice记1而不是0否则会把一张根本没有该结构的图像判成0分拉低整体指标。def compute_dice_per_class(pred_mask, gt_mask, n_classes): dice_list [] for c in range(1, n_classes): p (pred_mask c) t (gt_mask c) inter (p t).sum() if p.sum() t.sum() 0: dice_list.append(1.0) else: dice_list.append(2.0 * inter / (p.sum() t.sum())) return np.mean(dice_list)测出结果后我会把三个轴的Dice分开打印再打印每个类别的Dice矩阵看是椎间盘类拉胯还是骶骨类拉胯。从那以后我每次拿到新的医学分割数据集都强制先跑一遍第2章的目录检查和第3章的类别占比统计再决定预处理和训练策略这套流程帮我少走了很多弯路。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑