
简介心脏CT分割数据集面向医学图像分割入门与算法验证场景按2类标签构造0为背景、255为心脏所有图片与mask均为512×512的PNG格式并附有类别说明txt文本。数据已划分为训练集与测试集其中训练集包含667张原始图像及对应掩膜测试集包含285张原始图像及对应掩膜可直接用于训练、评估和对比分割模型。压缩包共1907个文件除1905张PNG图像外还提供1个类别文本和1个数据可视化脚本整体大小约111.22MB。可视化脚本无需修改运行后会随机抽取一张图片将原始图像、GT掩膜以及GT叠加在原图上的结果保存到当前目录方便快速检查标签质量与模型效果。目前已有268人学习下载适合需要现成心脏CT分割数据及配套可视化工具的研究者、学生或开发者使用。1. 心脏CT分割2类数据集先弄清你拿到的到底是什么上周有个做医学影像的朋友来找我说他拿到一份“心脏CT分割2类”数据集解压之后几十个文件夹里面有图像也有标签但谁对应谁、标签里到底哪两个类、数值是0和1还是0和255他对着文档看了半天还是云里雾里。这种原始带标签的数据包其实在医学图像分割场景里特别常见尤其是想做心脏CT分割这类入门级任务的人第一步往往不是调模型而是先把手里的图像分割数据集对齐、看清、跑通可视化。这篇笔记就用这类数据集最常见的组织方式从文件结构、标签编码、可视化验证到训练前的检查项把整条落地路径拆开讲清楚适合刚拿到数据集不知道怎么下手的同学也适合想快速验证这批数据能不能直接丢给UNet这类模型训练的人。2. 数据集文件构成图像、标签与文件夹结构如何对齐2.1 图像文件格式与预处理从原始CT到可训练张量心脏CT分割数据集里图像部分的“长相”决定了你要写什么样的读取脚本。最常见的有三种存储形态NIfTI.nii.gz、NumPy数组.npy、以及切片后的PNG序列。.nii.gz是医学影像标准格式保留了像素间距、切片厚度等元数据好处是能用SimpleITK或NiBabel直接读取坏处是初次见面的人容易在维度顺序上翻车。.npy是预处理过的张量省事但看不见元数据你得自己确认它是(z, h, w)还是(h, w, z)。PNG序列则通常是有人帮你把CT窗口调好之后导出的可视性最好但信息量已经打了折。拿到手第一件事不是写模型而是写一个“读文件清单”的脚本遍历整个目录把每个子文件夹里的文件后缀、文件数量、单个文件shape打出来。心脏CT数据往往是一个病人一个文件夹里面有几十到几百张切片如果你发现某个病人的图是100张标签只有90张那就说明这批数据本身有残缺后面对齐的时候会非常痛苦。import os import SimpleITK as sitk import numpy as np from collections import defaultdict # 假设数据集根目录下每个子文件夹代表一个病例 root ./heart_ct_dataset for case in sorted(os.listdir(root)): case_dir os.path.join(root, case) if not os.path.isdir(case_dir): continue file_map defaultdict(list) for f in os.listdir(case_dir): ext f.rsplit(., 1)[-1].lower() file_map[ext].append(f) for ext, files in file_map.items(): if ext in (nii, gz): sample sitk.ReadImage(os.path.join(case_dir, files[0])) print(f{case}: {ext} x{len(files)}, shape{sitk.GetArrayFromImage(sample).shape}) elif ext npy: sample np.load(os.path.join(case_dir, files[0])) print(f{case}: npy x{len(files)}, shape{sample.shape}) else: print(f{case}: {ext} x{len(files)})这段脚本的逻辑很直接遍历每个病例文件夹把不同后缀的文件数量统计出来并随机挑一个文件打印shape。.nii.gz文件经过sitk.GetArrayFromImage后得到的shape通常是(z, h, w)也就是切片数在前这是后续做切片训练时要先明确的维度约定。如果打印出来的shape第二位和第三位不一样比如(z, 512, 512)和(z, 512, 480)说明这批数据的像素尺寸没有统一后处理时要么resize要么padding。参数上最需要注意的就是这个shape输出它决定了你训练时要给模型什么样的输入尺寸。2.2 标签文件与2类掩膜的编码方式“2类分割”这个词在医学影像里有两种常见解释。一种是“背景目标”二分类标签只有0和11代表心脏区域另一种是“目标A目标B背景”的三分类但人家说“2类”指的是两个前景类别背景不算类。这两种约定直接决定你训练代码里num_classes填2还是3也决定损失函数怎么算。心脏CT分割最常见的目标是左心室血池和心肌这两者在外观上灰度接近、边界模糊所以很多数据集的标签会把它们分别标成1和2背景是0。标签文件的格式一般和图像保持同步如果图像是.nii.gz标签通常也是.nii.gz如果图像是PNG切片标签大概率是PNG的掩膜图像素值要么是0/1要么是0/255。我的建议是拿到数据后立刻统计一下标签里到底有哪几个像素值不要相信文档里写的“1是左心室2是心肌”——实际标注工具导出时可能把类别顺序搞反也可能把255当成1写进文件里。import numpy as np import SimpleITK as sitk import os label_dir ./heart_ct_dataset/labels for f in sorted(os.listdir(label_dir)): if not f.endswith(.nii.gz): continue label sitk.GetArrayFromImage(sitk.ReadImage(os.path.join(label_dir, f))) unique, counts np.unique(label, return_countsTrue) total label.size ratio counts / total print(f{f}: 像素值 {dict(zip(unique, counts))}) for val, r in zip(unique, ratio): print(f 类别 {val} 占比 {r:.4f})这段代码会告诉你两件事标签里有哪几个类别以及每个类别占整个体数据的比例。正常情况下背景占比应该最高两个前景类占比都在个位数百分比到百分之二三十之间。如果你发现某个标签文件里只有0和2没有1或者出现了3这个值那基本可以断定数据文件有问题不是格式坏就是和图像没对齐。类别占比还有另一个用处——如果某个前景类占比只有0.1%模型会很容易忽略它这时要决定是收集更多数据还是在损失函数里加类别权重。3. 用可视化代码快速验证数据标注质量3.1 最小可视化脚本叠加显示CT与掩膜很多人拿到数据后第一件事是看图像长什么样但“看图像”和“看标注质量”是两回事。你要验证的不是心脏在不在图像里而是掩膜边界是不是贴合解剖结构、有没有错位、有没有标到别的器官上。最有效的验证方式是把CT图像和掩膜叠加显示出来并且调节CT的窗宽窗位让软组织对比度足够明显。import matplotlib.pyplot as plt import SimpleITK as sitk import numpy as np def show_slice_with_mask(image_path, label_path, slice_idx, win_width800, win_level400): # 读取图像和标签 img sitk.GetArrayFromImage(sitk.ReadImage(image_path)) label sitk.GetArrayFromImage(sitk.ReadImage(label_path)) # 窗宽窗位截断到 [level-width/2, levelwidth/2] lower win_level - win_width / 2 upper win_level win_width / 2 img_clipped np.clip(img, lower, upper) # 归一化到 0-255 img_norm (img_clipped - img_clipped.min()) / (img_clipped.max() - img_clipped.min()) * 255 fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(img_norm[slice_idx], cmapgray) axes[0].set_title(CT) # 掩膜叠加只显示前景区域 overlay img_norm[slice_idx].copy() colored np.zeros_like(overlay, dtypenp.float32) mask label[slice_idx] colored[mask 1] 200 # 类别1用亮色 colored[mask 2] 120 # 类别2用暗一点的色 axes[1].imshow(img_norm[slice_idx], cmapgray, alpha0.7) axes[1].imshow(colored, cmaphot, alpha0.5) axes[1].set_title(Overlay) axes[2].imshow(label[slice_idx], cmapgray) axes[2].set_title(Mask) plt.tight_layout() plt.show() show_slice_with_mask(patient_001/image.nii.gz, patient_001/label.nii.gz, slice_idx85)这段可视化代码的作用是把同一张切片并列显示原始CT、叠加掩膜和纯标签三张图。参数上最值得调的是win_width和win_level——心脏CT的HU值范围大概在-1000到1000之间但心脏软组织和血池的对比度集中在一个窄区间里默认的窗宽窗位设置不当会让图像要么全黑要么全白。我一般先用800/400试如果心脏区域对比不明显再降到600/300或者400/200直到能清楚分辨心肌边界。slice_idx建议选在图像序列中间偏后的位置那里往往是心脏最完整的一层。3.2 按切片与按病例浏览看清数据集的完整结构单张切片看不出数据集整体问题。有的病例标注很完整有的病例切片标注缺了中间十几层有的病例整份掩膜都偏移了。为了避免一个个手动翻我觉得有必要写一个批量预览脚本把每个病例的中间层、最大心脏层的切片和掩膜合成一张拼图输出快速扫一遍就能发现哪些病例有问题。import os import numpy as np import matplotlib.pyplot as plt import SimpleITK as sitk def browse_cases(image_root, label_root, case_list, grid(4, 4)): for case in case_list: img_path os.path.join(image_root, case _image.nii.gz) lbl_path os.path.join(label_root, case _label.nii.gz) img sitk.GetArrayFromImage(sitk.ReadImage(img_path)) lbl sitk.GetArrayFromImage(sitk.ReadImage(lbl_path)) # 找到掩膜面积最大的那一层 areas [(lbl[z] 0).sum() for z in range(lbl.shape[0])] z_best int(np.argmax(areas)) fig, axes plt.subplots(1, 3, figsize(12, 4)) axes[0].imshow(img[z_best], cmapgray) axes[0].set_title(f{case} CT z{z_best}) axes[1].imshow(lbl[z_best], cmapgray) axes[1].set_title(Mask) # 叠加 axes[2].imshow(img[z_best], cmapgray, alpha0.7) axes[2].imshow(lbl[z_best], cmapjet, alpha0.4) axes[2].set_title(Overlay) plt.tight_layout() plt.show() # 这里可以换成你数据集里实际的名字 browse_cases(images, labels, [patient_001, patient_002, patient_003])这段代码用的逻辑是计算每一层的掩膜像素数取面积最大的那一层作为代表层因为心脏核心区域的掩膜面积通常最大这里也是标注最值得检查的地方。输出后你要关注的不只是边界的精细程度还有叠加层相对原始CT的位置——如果掩膜整体往某个方向偏移了一个固定像素距离比如血管边缘整体向左移了两三个像素那就是配准出了问题这种错位用肉眼在中心层看叠加图最容易暴露。4. 基于UNet训练这个2类分割数据集划分与参数设置4.1 数据划分避免同病人切片泄漏心脏CT分割数据集和自然图像数据集最大的区别在于同一个病人的连续切片高度相似。如果把同一个病人的切片同时放进训练集和验证集验证指标会被严重虚高模型根本没见过的病人来了之后性能会暴跌。这种问题在自然图像里不常出现但在CT这种三维体数据里非常致命。正确的做法是按病人划分而不是按切片划分。import os import random from collections import defaultdict # 假设每个病例文件夹包含同一个病人的所有切片 cases sorted(os.listdir(./heart_ct_dataset)) random.seed(42) random.shuffle(cases) n_train int(len(cases) * 0.7) n_val int(len(cases) * 0.15) train_cases cases[:n_train] val_cases cases[n_train:n_train n_val] test_cases cases[n_train n_val:] print(f总病例数: {len(cases)}) print(f训练集病例: {len(train_cases)}) print(f验证集病例: {len(val_cases)}) print(f测试集病例: {len(test_cases)}) with open(train.txt, w) as f: f.write(\n.join(train_cases)) with open(val.txt, w) as f: f.write(\n.join(val_cases)) with open(test.txt, w) as f: f.write(\n.join(test_cases))这段代码的核心逻辑是把病例名作为最小单位做随机划分然后保存成文本清单供训练脚本按清单加载数据。比例上我习惯用70/15/15如果你的病例总数很少少于20例这个划分会让验证集只有三四个病例这时候建议改成5折交叉验证。需要注意的是有些数据集会在一份.nii.gz里放多个病人或者一个病人多次扫描如果遇到这种情况划分粒度得更细得按“一次扫描”来划分而不是按文件名。4.2 训练输入参数图像尺寸、损失函数与Dice评估心脏CT分割的训练管道我的常见做法是先用UNet做基线模型。第一部分已经把图像读取和标签编码讲清楚了接下来只需要把3D体数据按切片拆开把单张2D切片输入UNet就行。因为CT图像的像素尺寸通常是512×512直接输入UNet会占显存一般会crop或resize到256×256或192×192。这是速度和标注精度的折中——尺寸太小心脏边界细节会被损失掉尺寸太大训练速度和显存压力会成倍增加。损失函数方面心脏CT分割最常用的组合是Dice Loss加Cross Entropy的加权混合。Dice Loss对类别不平衡不敏感在背景占绝大多数的场景里能稳定收敛。而CE Loss能让梯度更平滑地更新两者按1:1加权是我试过比较稳的组合。评估指标上不要只看像素准确率因为背景占99%时全预测背景也能有99%的准确率一定要看每个类别的Dice系数。心脏分割里如果模型预测的Dice能达到0.90以上基本可以认为模型学到了结构低于0.80则需要回查训练集划分或标注对齐有没有出问题。训练阶段还有一个容易忽略但非常耗时的参数num_classes。你的标签如果只有0和1num_classes2如果标签有0、1、2num_classes要填3否则模型输出的通道数和标签索引对不上训练代码会报target index out of range之类的错误甚至不报错但指标一直很低。这部分建议在训练脚本的第一个Epoch里就打印出输出的shape和标签的unique值做一次硬性检查能省掉后面排查的半天空闲时间。5. 心脏CT分割数据集的常见坑与排查手册5.1 现象图像和掩膜尺寸不一致训练时直接报错有一类数据集解压后图像被resize过标签保留原始尺寸或者反过来图像是512×512标签却是256×256。这种问题在训练时会在数据加载阶段暴露出来报错信息往往是“shape mismatch”或者“cannot broadcast”之类。原因通常是数据预处理脚本对不同病例做了不同的resize操作又没统一标签处理逻辑。解决办法分两步先写一个全局遍历脚本把所有图像和标签的shape都打印出来找出到底哪些病例的尺寸不一致然后对它们做统一的resize或padding。我习惯的做法是把所有数据resize到同一个固定尺寸比如256×256并且在resize标签时用最近邻插值不能使用线性插值——线性插值会把0和1混合成0.4这种非整数类别模型损失函数直接就崩了。5.2 现象Dice系数训练时正常验证时跳动极大训练集Dice到0.9验证集Dice却只有0.4、0.5而且每次跑都不一样。排除数据划分泄漏问题后最常见的原因是CT图像的窗宽窗位在验证时没有做和训练时一致的预处理。有些数据集的原始图像范围不一致如果训练时对每张图独立做min-max归一化验证时也按同样的做法那么不同病人的灰度分布其实被拉到了不同的水平。解决方法是把灰度归一化的参数固定下来使用统一的窗宽窗位截断比如固定为[-200, 400]的HU值范围再在这个范围内做归一化这样模型看到的灰度语义是相对一致的验证集的波动会明显收敛。5.3 现象训练正常推理时整张mask全是背景这是最常见但最让人头疼的情况训练时指标正常测试时拿到一张新图模型输出的掩膜全是零。原因大多出在输入图像的归一化方式上。训练时你对每张切片单独做了min-max归一化推理时拿到的数据如果整体偏亮或偏暗min和max值与原训练集分布不一致模型输入分布就偏移了。标准的解决办法是在训练阶段保存一份固定的归一化参数比如用训练集全体像素计算出的均值、方差、min、max推理时直接套用而不是针对单张图重新计算。另一个可能原因是模型在导出前被无意改动了输入尺寸比如训练时输入是256×256导出模型时输入被设成了512×512这也会导致全背景输出因为每个像素的感受野和原本训练时完全不同建议检查一下导出模型的输入signature。5.4 现象标签文件读取后全是0没有前景用可视化代码看时某些病例的标签图里只有背景。一种原因是标注确实缺失整层或整份数据就没画。另一种更隐蔽的原因是标签文件的取值不是0/1/2而是0/255而你是用mask 1和mask 2在筛选类别结果只能筛出背景。这种情况在PNG格式的掩膜里非常常见因为导出的调色板可能把类别映射成了255。检查方法是直接打印标签数组中所有出现的像素值不要只看最小值最大值。如果是255的话将255替换成1再把mask 2的映射逻辑改为mask 255问题就解决了。如果同时存在255和128这样的值则说明是多标注工具导出的调色板不一致得先统一映射关系再进训练流程。6. 让可视化脚本成为数据合规检测工具批量质量检查与样本卡生成前面介绍的叠加可视化代码已经能帮你人工检查单个病例的标注质量但当你面对的是一个几十上百病例的数据集时靠肉眼逐个打开图片效率太低而且容易漏掉不显眼的错位。我的做法是把可视化脚本改造成一个批量质量检测工具在训练前跑一遍输出一份“样本卡”包含每个病例的代表层、掩膜类别占比、掩膜与图像边界偏移量、中心区域是否完全空洞等信息快速锁定有问题的病例清单。这个工具的核心逻辑其实不复杂先复用browse_cases里的最大掩膜层逻辑然后针对该层计算掩膜的质心坐标以及掩膜边界的最小外接矩形再统计这个矩形区域内的类别占比。如果类别占比严重失衡比如某个类别的占比不足0.1%就说明该类的标注可能缺失或极不完整。如果质心坐标偏离图像中心超过一定阈值则说明可能的ROI没有对准需要回去检查配准或crop环节。批量检测脚本验收的标准是跑完后能把每个病例的检测结果整理成一个表格异常病例单独输出截图标注人员可以只盯着异常列表工作。这类数据合规操作属于“越早做越省心”的步骤。我自己的习惯是不管数据集文档写得多么清楚拿到手第一件事必然是写检查脚本在进入训练之前把数据质量看一遍这比训练到一半、返回头查数据对齐要节省好几天的调试时间。因为一旦模型跑出低指标你会陷入“是数据问题还是超参问题还是模型问题”的三方排查里而数据检查脚本能在最开始就把数据因素排除掉这是整个工作流里性价比最高的一段投入。希望这些做法能帮你在自己的数据集上少踩几个坑。本文还有配套的精品资源点击获取