
简介面向医学图像分割入门与算法验证提供心脏CT图像分割数据集图像尺寸统一为512×512PNG格式存储配套与图像同尺寸的PNG掩膜标签标签仅两类背景0与心脏255并附classes.txt说明类别信息。数据已划分训练集与测试集训练集667张图像及对应掩膜测试集285张图像及对应掩膜可直接用于U-Net、DeepLab等模型的训练与评估。压缩包共1907个文件含1905张PNG图像、1个类别说明txt与1个Python可视化脚本整体约111.22MB其中可视化脚本无需修改参数运行后随机抽取一张图像将原图、GT掩膜、GT叠加原图效果保存至当前目录便于直观检查分割效果。已有268人学习下载适合希望快速获得规范心脏CT分割数据与配套掩膜展示工具的开发者或研究人员。1. 心脏CT分割数据集从拿到手到训练出能用的2类分割模型做医学图像分割的人都知道CT影像的标注数据有多难攒。公开数据集要么是MRI为主要么是单器官的大尺寸切图真正能直接拿来喂U-Net的并不多。这份心脏CT分割数据集算是个不错的起点训练集667张、测试集285张图像分辨率统一512×512PNG格式标注只有两类——背景和心脏。类少、边界明确、数量够用拿来跑通一整套分割流程很顺手。我在本地跑了一遍从数据加载、可视化到训练验证基本没有遇到结构上的卡点。这篇文章就把这份数据集的真实结构和几个容易翻车的细节拆开讲适合正在找医学分割练手数据、或者刚接触分割任务想走通全流程的读者。2. 数据结构与标签约定512×512的PNG里哪些像素是心脏这一章先把数据集的物理结构讲透。拿到任何数据集第一步不是训练而是搞清楚文件怎么组织、标签怎么定义、像素值代表什么。这三件事没理清后面所有代码都是盲写。2.1 文件组织方式与数量核对数据集按最常见的图像分割格式组织images目录放原始CT切片masks目录放对应的标注掩膜训练集和测试集各自独立。目录结构展开后大概是这样的dataset/ ├── train/ │ ├── images/ # 667张 │ │ ├── 2286.png │ │ ├── 2290.png │ │ └── ... │ └── masks/ # 667张 │ ├── 2286.png │ ├── 2290.png │ └── ... ├── test/ │ ├── images/ # 285张 │ └── masks/ ├── classes.txt # 类别说明 └── visualize.py # 可视化脚本这个组织方式最大的好处是省去了自己划分训练测试集的步骤。很多公开数据集的原始文件是全部堆在一起的需要自己按比例切分切的时候还要注意同一样本的图像和掩膜不能被分到不同集合里。这份数据集已经切好直接按目录读取即可。我建议拿到手后先做一次数量核对用脚本确认每个目录的文件数并且检查图像与掩膜是否一一对应。文件名相同的图像和掩膜才是一对命名不一致会直接导致加载错位。import os for split in [train, test]: img_dir fdataset/{split}/images mask_dir fdataset/{split}/masks imgs sorted(os.listdir(img_dir)) masks sorted(os.listdir(mask_dir)) print(f{split}: images{len(imgs)}, masks{len(masks)}) mismatch [i for i in range(len(imgs)) if imgs[i] ! masks[i]] if mismatch: print( mismatch at:, mismatch[:10]) else: print( all filenames match)这段代码遍历训练集和测试集统计图像与掩膜数量并逐名对比。逻辑上先排序保证顺序一致再按索引比对同名文件。输出结果里如果有mismatch列表说明文件名对不上加载时一定要用zip(imgs, masks)的方式配对而不是分别通配符读取否则训练样本和标签会错位。2.2 标签语义与可视化解读classes.txt里定义的类别是0表示背景255表示心脏。这里有一个值得注意的细节很多分割框架的标准做法是用0和1表示二分类的类别而这份数据集用的是0和255。255是8位灰度图中的最大值视觉上直接呈现为白色区域方便肉眼确认心脏的位置。从CT切片来看心脏区域在整个512×512的画面中占比并不高大概只有10%到20%。这引出了分割任务的一个核心矛盾背景像素远多于前景像素。如果直接用交叉熵损失训练模型会倾向于把所有像素预测为背景因为这样loss已经很低了。后面第四章会具体讲怎么应对。心脏CT的灰度分布和自然图像完全不同CT值本身是有物理意义的但PNG存储的已经是归一化后的灰度图不再是原始的Hounsfield Unit。训练时不需要纠结CT值的窗宽窗位问题把图像当作普通的灰度图输入即可。但要注意灰度分布的范围最好在预处理时做标准化不要直接用0到255的原始值喂给网络。关于标签为什么不用1而用255我推测是标注工具导出时的默认设定。实际训练时需要在数据加载阶段做一个映射把255降为1因为绝大多数PyTorch的交叉熵损失函数要求类别索引从0开始连续编号。import numpy as np def load_mask(path): mask np.array(Image.open(path).convert(L)) mask (mask 0).astype(np.uint8) # 255 - 1 return mask def load_image(path): img np.array(Image.open(path).convert(L)) img (img - img.mean()) / (img.std() 1e-8) return img这两行代码是后续训练的前置。第一条把mask中所有非零像素映射为1第二条把灰度图做z-score标准化。需要注意归一化时加了1e-8防止除零标准差为零的图像来自纯色图虽然医学切片里不太可能出现但防御性编程值得保留。3. 可视化脚本与数据校验先看后训练拒绝盲跑训练分割模型之前至少要肉眼过一遍原始图和掩膜的对齐情况。这步跳过的话一旦标注有偏移或者类别定义理解错后面所有的训练都是在垃圾数据上炼丹。数据集自带的visualize.py就是干这个的但只看一张图远远不够我一般会做更系统的校验。3.1 复刻可视化脚本来检查掩膜质量数据集附带的可视化脚本逻辑很直接随机抽一张图把原始图像、掩膜、以及掩膜叠加在原图上的效果并排展示。这里给出一段等价的脚本逻辑不变只是加上了保存功能import matplotlib.pyplot as plt import numpy as np import random import os from PIL import Image img_dir dataset/train/images mask_dir dataset/train/masks idx random.choice(os.listdir(img_dir)) img_path os.path.join(img_dir, idx) mask_path os.path.join(mask_dir, idx) img np.array(Image.open(img_path).convert(L), dtypenp.float32) mask np.array(Image.open(mask_path).convert(L)) fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(img, cmapgray) axes[0].set_title(Original CT) axes[1].imshow(mask, cmapgray) axes[1].set_title(GT Mask) overlay np.zeros((*img.shape, 3), dtypenp.uint8) overlay[..., 0] img # R channel overlay[..., 1] img # G channel overlay[..., 2] img # B channel overlay[mask 0] [255, 0, 0] # 红色标记心脏区域 axes[2].imshow(overlay) axes[2].set_title(Overlay) plt.tight_layout() plt.savefig(visual_check.png, dpi150) plt.show()这段脚本的关键在于叠加图的构造逻辑。先把灰度图复制到RGB三个通道形成灰色底图再把mask中非零像素点改为红色。这样心脏区域在叠加图上会显示为红色与灰色背景形成强烈对比方便肉眼检查掩膜边界是否贴合心脏轮廓。三张图并排的布局适合快速判断原始图像和掩膜之间是否存在错位或漏标。3.2 更严格的数据校验清单随机抽一张能看出整体质量但要发现系统性问题需要批量统计。我每次拿到新数据集都会跑一遍下面这组检查第一检查掩膜是否存在全黑的情况。全黑的掩膜意味着这张切片没有标注目标在训练集里会成为纯背景样本干扰模型训练。如果数量不多比如少于10张建议直接剔掉。如果占比超过20%说明标注策略有问题需要回到数据集源头确认。第二检查掩膜中目标的连通性。正常的心脏区域应该是连通的一块如果出现大量分散的小像素块可能是标注噪声也可能是把其他组织一并标了进来。用scipy.ndimage.label统计连通域数量如果单张掩膜超过3个连通域值得拿出来单独看一眼。第三统计每张掩膜中前景像素的占比分布。前面提到心脏区域占图幅比例不高但这个占比应该在10%到30%之间浮动。如果某个样本的前景占比小于5%训练时会很难学如果大于50%则可能是标注把背景大块也框了进去。from scipy import ndimage mask np.array(Image.open(mask_path).convert(L)) labeled, num ndimage.label(mask 0) if num 3: print(fmulti-component: {mask_path}, components: {num})这类统计脚本跑完数据集的整体质量就有数了。医学图像的标注质量参差不齐一份看起来干净的数据集往往藏着几个异常样本。花10分钟做校验比后面训练翻车再回头查数据要省时得多。4. U-Net适配与训练参数把0/255转成模型想要的输入数据结构和质量都确认过了这章开始把数据集真正用起来。我以U-Net为例讲训练流程因为这是医学分割场景下最稳的选择没有之一。数据集的标签是2类不需要改网络输出通道数直接用单通道输出加Sigmoid激活即可。4.1 预处理与数据加载管道PyTorch的数据加载管道需要完成四件事读取图像、读取掩膜、同步变换、生成批次。这里的难点在于图像和掩膜必须做完全相同的几何变换所以随机增强只能在两者合并后操作。import torch from torch.utils.data import Dataset from PIL import Image import numpy as np class HeartCTDataset(Dataset): def __init__(self, img_dir, mask_dir, augmentFalse): self.img_dir img_dir self.mask_dir mask_dir self.files sorted(os.listdir(img_dir)) self.augment augment def __len__(self): return len(self.files) def __getitem__(self, idx): name self.files[idx] img np.array(Image.open(os.path.join(self.img_dir, name)).convert(L), dtypenp.float32) mask np.array(Image.open(os.path.join(self.mask_dir, name)).convert(L)) mask (mask 0).astype(np.float32) # 255 - 1.0 img (img - img.mean()) / (img.std() 1e-8) if self.augment: if np.random.rand() 0.5: img np.fliplr(img).copy() mask np.fliplr(mask).copy() if np.random.rand() 0.5: img np.flipud(img).copy() mask np.flipud(mask).copy() img_t torch.from_numpy(img).unsqueeze(0) mask_t torch.from_numpy(mask).unsqueeze(0) return img_t, mask_t这段代码有几个关键设计。掩膜读取后用了(mask 0)来统一二值化不管标注文件里写的是255还是1还是其他非零值最终都归一到0和1。图像标准化放在增强之前这样增强操作不会影响数值分布。增强用了水平翻转和垂直翻转后面第五章会细说为什么这么选。unsqueeze(0)的作用是在第0维增加一个通道维度把形状从(512, 512)变成(1, 512, 512)因为U-Net期望输入是四维张量(batch, channel, height, width)。4.2 U-Net训练配置与损失函数选择经典的U-Net编码器部分由若干卷积块和下采样层组成解码器通过上采样恢复分辨率。对于512×512的输入网络层数不用太深四层下采样到32×32分辨率足够。完整的模型代码比较长这里只给出训练时的关键配置。损失函数的选择在这个场景下有明确的倾向性。心脏区域占图幅比例低普通交叉熵会把大量注意力放在背景上所以实际训练时我用的是Dice Loss和交叉熵的组合import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self): super().__init__() def forward(self, pred, target): pred torch.sigmoid(pred) smooth 1.0 intersection (pred * target).sum() dice (2.0 * intersection smooth) / (pred.sum() target.sum() smooth) bce F.binary_cross_entropy(pred, target, reductionmean) return 0.5 * (1 - dice) 0.5 * bce这个损失函数把Dice Loss和BCE各取一半权重。Dice Loss解决类别不平衡问题它的计算方式是衡量预测掩膜和GT掩膜的区域重叠程度不受前景占比影响。BCE保留像素级梯度信息帮助边界部分收敛得更精细。两个凑在一起比单独用任何一个都稳。训练时的常用参数batch size设为8512×512的输入显存占用不算小初始学习率1e-4优化器用Adam训练50个epoch。如果显存不足第一选择是把batch size降到4而不是缩小输入分辨率。医学图像分割里512×512的分辨率是底线再往下缩会丢失边界细节。4.3 推理与后处理训练完之后推理阶段有一个容易被忽略的点预测输出是概率图需要阈值化才能变成mask。阈值默认取0.5但在这个数据集上我试过0.3到0.7的区间0.5到0.6之间效果差别不大边界形态上0.5更保守0.6会更紧贴心脏轮廓。def predict(model, img_tensor, threshold0.5): model.eval() with torch.no_grad(): output model(img_tensor.unsqueeze(0)) prob torch.sigmoid(output).squeeze(0) mask (prob threshold).float() return mask推理时务必加torch.no_grad()这个上下文管理器会关闭自动求导系统大幅减少内存占用和计算时间。unsqueeze(0)把单张图变成batch维度squeeze(0)再移走多余的batch维。输出的mask形状为(1, 512, 512)想保存成图片需要再squeeze(0)后乘255转成uint8。5. 避坑指南心脏分割训练中常见的五个翻车点这一章写我在实际跑这个数据集时踩过的坑每条都是真实经历按“现象到原因再到解决”的方式记录。这里的细节值得提前看一遍能省不少调试时间。5.1 翻车点一mask按RGB通道读取导致维度爆炸现象训练报错说输入输出维度不匹配或者loss变成负数。原因PIL的Image.open()默认按原格式读取如果mask文件被保存为PNG但实际上是三通道读出来的数组形状是(512, 512, 3)和模型的单通道输出对不上。解决读取时强制转灰度——Image.open(path).convert(L)。这一行代码就能把任何格式统一成单通道。我在写数据加载代码时最开始漏掉了.convert(L)结果每个掩膜读出来都是三维数组还以为是数据集有问题。排查半天问题出在自己代码上。注意任何医学分割数据集加载图像和掩膜时都要强制指定通道模式不要依赖文件原本的格式。5.2 翻车点二原图的黑色背景区干扰叠加图判断现象叠加图显示心脏区域边缘有一圈红边但和原图CT结构对不上。原因CT图像的四个角通常有大量纯黑像素这些区域没有组织信号但在视觉上和心脏区域外的暗区连成一片。如果不看原图的灰度分布只凭mask在叠加图上的位置判断边界会把黑色背景区误认为心脏区域的一部分。解决判断标注质量时一定把原始CT图和mask叠加图放在一起看不要单独看mask。另外可以通过统计mask中前景像素的灰度值分布来验证心脏区域在CT上应该有正常的组织灰度范围如果mask覆盖的位置全是灰度值为0的像素那标注一定有问题。5.3 翻车点三类别不平衡loss低但mIoU不高现象训练到后期loss降到很低但验证集的mIoU只有0.6左右而且不涨了。原因背景占图幅太高的比例交叉熵只需要把所有像素预测为背景loss就已经很低。模型没有学到心脏的形态特征只是找到一个loss最低的懒策略。解决换用第四章提到的DiceBCE混合损失。这不是调参玄学而是从原理上解决问题——Dice Loss让模型必须关注前景区域的预测质量否则loss下不去。我还试过给交叉熵设类别权重weight参数设成[0.2, 0.8]效果不如Dice Loss稳定。5.4 翻车点四数据增强破坏了心脏解剖结构的对称性现象用了随机水平翻转增强后验证集表现反而比不增强时更差。原因心脏在胸腔内的位置不是绝对中心对称的左右心室厚度、心尖方向都有一定偏移。如果无脑做水平翻转相当于制造了现实中不存在的解剖变体模型被迫学习这些伪变化反而增加拟合难度。解决增强策略要克制。水平翻转只做一半概率垂直翻转可以保留小幅度的旋转±10度以内也可以加。另一个稳妥选择是只做训练集的随机裁剪再缩放回512×512这种增强更贴近CT切片的天然变化。记住一个原则医学图像的增强不能做过度解剖结构的先验信息是分割质量的关键。5.5 翻车点五训练集和测试集混用导致指标虚高现象测试集mIoU高达0.9但换一批真实数据表现暴跌。原因代码里在准备测试集时误用了训练目录的路径或者数据加载时做了和训练一样的增强导致测试时看到的不是原始分布。更隐蔽的是如果测试时没有关掉augmentTrue随机翻转会让预测结果不稳定同样的图每次跑的mIoU都不一样。解决测试集读取时强制设置augmentFalse并且从路径层面隔离训练和测试目录。我后来在代码里加了断言如果splittest时传了augmentTrue直接抛异常。这种防御性写法能防止低级错误在夜深人静赶工时悄悄溜进代码。6. 验证与进阶mIoU计算、模型导出与批量推理最后一章讲点实际操作层面的技巧。训练完的模型不能只盯着loss看要算清楚了才知道模型真实水平。除此之外模型导出和批量推理也有不少容易被忽略的细节。6.1 mIoU和Dice的本地验证评估分割模型最简单的指标是mIoU和Dice系数。两者本质上都衡量预测结果和GT的相似程度只是计算角度不同。这里给出一段可以独立运行的评估脚本def compute_metrics(pred_mask, gt_mask, eps1e-7): pred pred_mask 0.5 gt gt_mask 0.5 intersection (pred gt).sum() union (pred | gt).sum() iou intersection / (union eps) dice (2 * intersection) / (pred.sum() gt.sum() eps) return iou, dice注意eps的作用是防止除零。极端情况下某张图的GT是全黑没有心脏区域预测也是全黑union为0直接除会得NaN。加上epsilon后这种情况输出0表示预测完全错误。真实的评估不能只看平均值要同时看min值和分布。我习惯把每张测试图的mIoU存起来找出分数最低的5张图打印文件名然后逐张看。如果最低分不是个位数模型整体就可以用。6.2 批处理与模型导出技巧医学分割项目最终一般要处理整个测试集或者换一批全新的CT数据。批量推理时有一个小习惯用DataLoader而不是手写for循环。DataLoader会自动处理batch维度、shuffle和并行加载代码更干净。loader DataLoader(test_dataset, batch_size8, shuffleFalse, num_workers4) all_preds [] with torch.no_grad(): for imgs, _ in loader: preds torch.sigmoid(model(imgs)) all_preds.append(preds.cpu().numpy())模型导出方面如果项目要部署PyTorch的.pth文件足够用于研究和验证但不适合直接上生产。我一般会把训练好的模型转成TorchScript或ONNX。ONNX格式兼容性好可以和推理框架对接单张推理速度快不少。转换时有一个坑U-Net里如果有nn.Upsample导出ONNX时需要指定modebilinear否则默认的nearest会在导出时报错。从那以后我每次做分割训练都会强制走一遍完整流程核对文件名一致性和掩膜类别批量检查前景占比跑一次可视化脚本确认对齐训练前把数据增强策略写死并确认测试集不开增强评估时记录每张图的指标而不是只看平均。这套流程看着繁琐但每一步都能在问题变大之前把它拦住。数据越规整后面训练越省心。希望这篇拆解能让你少走几个弯路把时间花在真正有用的调参和验证上。本文还有配套的精品资源点击获取