简介面向医学图像分割与心脏CT影像分析这份数据集提供了完整的2类心脏分割标注资源包含背景与心脏区域适合算法研究者、深度学习初学者及医疗影像开发者用于模型训练和效果验证也可用于分割算法对比实验与课堂教学。资源包共1907个文件其中1905张512×512的PNG图像含原图与对应mask、1个类别说明txt及1个可视化Python脚本整体采用7z压缩大小约111.22MB。数据划分为训练集与测试集训练集667张原图加667张掩膜测试集285张原图加285张掩膜mask中0代表背景、255代表心脏具体类别可在classes txt中查看。附带可视化脚本无需修改即可运行会自动随机提取一张图片将原始图像、GT掩膜以及在原图上叠加蒙版的结果展示并保存到当前目录便于快速检查数据质量与分割效果。目前该资源已有265人学习浏览是入门心脏分割任务较为轻量、规范的实践数据。1. 心脏CT分割数据集从一张PNG到完整训练闭环的落地拆解做医学图像分割的同行应该都有过这种体验模型结构不是瓶颈数据才是。一个标注规范、划分清晰、能直接喂给U-Net的心脏CT分割数据集往往比调几天网络结构更值钱。这份资源给的是512×512分辨率的PNG格式心脏CT切片2类分割背景0、心脏255训练集667对图、测试集285对图还带一个开箱即用的可视化脚本能把原图、GT掩膜、叠加效果一次性画出来。无论是入门医学分割、复现论文还是做课程设计这个规模都够用而且不需要额外清洗。本文就把这套数据从目录结构、加载方式到训练配置和踩坑点全部拆开讲。2. 数据集结构拆解目录组织、标签语义与可视化脚本运行逻辑拿到压缩包后先别急着写模型把目录结构和标签语义确认清楚能省下后面大量排查时间。这个数据集的组织方式很常规训练集和测试集各自独立每张原图对应一张同名mask图像和mask分目录存放。2.1 目录结构与文件命名规则解压后你看到的目录大致是这种形态heart_ct_segmentation/ ├── train/ │ ├── images/ # 667张 512x512 PNG │ │ ├── 2286.png │ │ ├── 2290.png │ │ └── ... │ └── masks/ # 667张 512x512 PNG │ ├── 2286.png │ └── ... ├── test/ │ ├── images/ # 285张 512x512 PNG │ └── masks/ # 285张 512x512 PNG ├── classes.txt # 标签类别说明 └── visualize.py # 可视化脚本文件命名是纯数字编号原图和mask通过文件名一一对应。这里有个细节值得注意train/images和train/masks下的文件名完全相同没有任何前缀或后缀差异。这种命名方式在设计数据加载器时最简单——直接拿文件名做匹配即可不需要解析额外元数据。classes.txt里定义的标签含义是0代表背景、255代表心脏这是二值分割最常用的编码方式非0即目标。相比0/1编码255的优点是可视化时直接映射到灰度图最亮值肉眼看起来非常清晰。但要注意如果直接把这个mask喂给某些框架的损失函数255这个数值会被当成像素值参与计算需要先归一化到[0,1]或压缩到{0,1}。2.2 标签格式的深层含义与预处理注意事项从格式上讲这属于典型的单通道灰度PNG掩膜不是三通道RGB彩色图。读出来后shape是(512, 512)每个像素值要么是0要么是255。很多新手会在这一步踩坑用Image.open()读出来后不检查mode直接转numpy数组后拿去算损失结果发现背景和目标像素值差异巨大损失函数震荡得厉害。我习惯的做法是加载mask后立刻做一步二值化压缩import numpy as np from PIL import Image mask np.array(Image.open(train/masks/2286.png)) print(原始取值:, np.unique(mask)) # [0 255] # 压缩到 0/1 mask_binary (mask 127).astype(np.uint8) print(压缩后:, np.unique(mask_binary)) # [0 1]这里(mask 127)把255变成True再转uint8就成了1。阈值的选取用了127也就是256灰度范围的中点因为这张数据集里只有0和255两个值任何介于0到255之间的阈值都能正确分割。但养成用127的习惯没坏处——如果以后换到多类别数据集每个类别是不同灰度值比如128、64这个阈值就得按实际标签值调整。2.3 可视化脚本的用法与原理这个visualize.py脚本值得先说结论它不需要改任何路径参数直接python visualize.py就能跑。脚本逻辑不复杂——随机抽一张测试集或训练集图片读取原图和对应mask用matplotlib画三个子图第一张是原始CT切片第二张是GT掩膜第三张是把mask以半透明红色叠加到原图上的效果最后保存到当前目录。这种叠加展示有个专业叫法overlay visualization是医学分割里最常用的定性评估方式。红色半透明区域代表模型或标注的心脏区域背景保持灰度CT原貌一眼就能看出分割边界和真实解剖结构是否吻合。如果你想把脚本改成自己遍历所有图片批量生成预览核心代码就两行改动import matplotlib.pyplot as plt # 原脚本逻辑单张随机展示 # 批量版本遍历目录下所有图片 for img_file in sorted(img_dir.glob(*.png)): img np.array(Image.open(img_file)) mask np.array(Image.open(mask_dir / img_file.name)) overlay img.copy() overlay[mask 0] [255, 0, 0] # 红色标记心脏区域 plt.imsave(foverlay_{img_file.stem}.png, overlay)注意这里overlay[mask 0]是在原图的numpy数组上直接修改像素值把心脏区域的像素强制设为纯红色。如果你的原图是灰度单通道需要先用np.stack([img]*3, axis-1)转成三通道再叠加否则赋值红色会报维度错误。2.4 数据划分的合理性分析667张训练、285张测试这个比例大约7:3在医学分割数据集里属于比较合理的划分。医学影像数据往往采集成本高、标注更贵7:3比自然图像的8:2或9:1更常见。实际使用中如果你觉得667张训练数据不够一个常见做法是把测试集的285张也拿来做交叉验证——但注意这会引入数据泄漏论文里不能这么干。我一般会把测试集当验证集用训练集内部再切一小部分做验证这样既不影响测试集的纯粹性又能观察训练过程中的过拟合情况。另外要留意CT切片之间的相关性。心脏CT一个序列通常包含数十张连续切片如果数据划分是随机切而非按病人切那么同一个病人的相邻切片可能同时出现在训练集和测试集里导致评估结果虚高。这个数据集没有提供病人级别的元信息使用时需要注意这一点必要时自己按切片间距做二次划分。3. U-Net训练实战数据加载器、增强策略与训练配置有了干净的数据集下一步就是把它喂给模型。医学图像分割的默认基线基本就是U-Net没有之一。这套数据集的512×512分辨率对U-Net来说不算大一张GTX 1080Ti就能轻松训练不需要分布式那套东西。3.1 数据加载器实现从路径到batch的完整流程写加载器时有个顺序问题先读图、再做归一化、再做增强、最后转Tensor。顺序反了会出现增强后像素值漂移的奇怪问题。下面是一个标准的PyTorch Dataset实现import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import albumentations as A class HeartCTDataset(Dataset): def __init__(self, image_dir, mask_dir, transformNone): self.image_paths sorted(list(image_dir.glob(*.png))) self.mask_dir mask_dir self.transform transform def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img_path self.image_paths[idx] mask_path self.mask_dir / img_path.name # 同名匹配 image np.array(Image.open(img_path).convert(L)) # 灰度 mask np.array(Image.open(mask_path).convert(L)) # 二值化到 0/1 mask (mask 127).astype(np.float32) # 归一化到 [0,1]CT值分布本身就有意义 image image.astype(np.float32) / 255.0 if self.transform: augmented self.transform(imageimage, maskmask) image augmented[image] mask augmented[mask] # 转成 CHW 格式并增加通道维 image torch.from_numpy(image).unsqueeze(0).float() mask torch.from_numpy(mask).unsqueeze(0).float() return image, mask几个关键设计决策解释一下用img_path.name做mask路径匹配保证了即使文件被移动过只要保持同名加载也不会错位归一化放到增强之前避免随机裁剪后统计值变化unsqueeze(0)增加通道维因为灰度图只有一个通道而PyTorch要求输入是(B, C, H, W)格式。3.2 数据增强策略医学图像的保守选择医学图像分割的数据增强有个原则不能破坏解剖结构的语义。常见自然图像的随机旋转90度、随机翻转在CT切片上可以谨慎使用但像随机擦除、剧烈色彩抖动就不合适。我建议用下面这套保守增强import albumentations as A transform A.Compose([ A.RandomRotate90(p0.5), # 90度旋转保持心脏朝向语义 A.HorizontalFlip(p0.3), # 水平翻转 A.ShiftScaleRotate(shift_limit0.05, scale_limit0.1, rotate_limit15, p0.5), # 小范围平移缩放旋转 A.ElasticTransform(alpha1.0, sigma10.0, alpha_affine5.0, p0.2), # 弹性形变模拟器官形变 ])这套增强里最关键的是ElasticTransform——医学图像分割最常用的增强手段用来模拟心脏在不同时间相位的形变。参数上alpha1.0控制形变强度调大了会让器官形状失真调小了等于没增强sigma10.0控制平滑程度。训练集只有667张加上这套五件套增强等效数据量能放大8-10倍。注意验证集不要做增强只做归一化和二值化。3.3 损失函数与评价指标选择2类分割最常用的损失是Dice Loss和BCE Loss的组合。Dice Loss解决类别不平衡问题BCE提供更稳定的梯度信号。心脏在512×512图像里通常只占10%-20%像素直接用BCE会让模型倾向于把全部像素预测为背景——因为这样损失已经很低了。import torch.nn as nn import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): pred torch.sigmoid(pred) pred_flat pred.reshape(-1) target_flat target.reshape(-1) intersection (pred_flat * target_flat).sum() return 1 - (2.0 * intersection smooth) / (pred_flat.sum() target_flat.sum() smooth) def combined_loss(pred, target): bce F.binary_cross_entropy_with_logits(pred, target) dice dice_loss(pred, target) return bce dice联合损失里BCE和Dice是相加关系没有加权系数。这个组合在绝大多数分割任务里都能直接跑出不错的效果比单独用任何一个都稳定。smooth1.0是为了防止分母为零同时起到平滑梯度作用。评价指标用Dice Coefficient和IoU就好不用太花哨。训练配置方面batch size建议4-8取决于显存学习率1e-4配AdamWepoch数50-100。512×512输入下U-Net的显存占用大约在4-6GBbatch size4对8GB显存的卡比较稳。4. 模型评估与推理从Dice分数到分割结果验证训练完模型后评估环节最容易流于形式。很多人只跑一个测试集Dice就收工了但医学图像分割的评估远比一张分数表复杂。这一章把评估流程做完整顺便给出验证分割质量的几个实用技巧。4.1 测试集评估脚本的完整实现写评估脚本时需要逐个图像预测、计算指标、最后汇总。这里有个容易出错的地方预测输出是logits计算Dice前必须先做sigmoid再二值化阈值一般取0.5。直接对logits做pred 0也能二值化但得到的结果对logits的尺度有依赖不如先sigmoid统一到[0,1]再取阈值。import torch import numpy as np from sklearn.metrics import roc_auc_score, precision_recall_curve def evaluate(model, dataloader, device): model.eval() dice_scores [] iou_scores [] all_probs [] all_targets [] with torch.no_grad(): for images, masks in dataloader: images images.to(device) masks_np masks.numpy() outputs model(images).cpu() probs torch.sigmoid(outputs).numpy() # 二值化阈值 0.5 preds (probs 0.5).astype(np.uint8) # 逐图计算 Dice 和 IoU for i in range(preds.shape[0]): p preds[i].reshape(-1) t masks_np[i].reshape(-1) intersection (p t).sum() dice (2.0 * intersection 1e-6) / (p.sum() t.sum() 1e-6) iou (intersection 1e-6) / ( p.sum() t.sum() - intersection 1e-6 ) dice_scores.append(dice) iou_scores.append(iou) all_probs.extend(probs[i].reshape(-1)) all_targets.extend(t) print(fmDice: {np.mean(dice_scores):.4f} ± {np.std(dice_scores):.4f}) print(fmIoU: {np.mean(iou_scores):.4f} ± {np.std(iou_scores):.4f}) return dice_scores, iou_scores这段代码里加1e-6是处理全预测背景或全预测目标的极端情况——心脏区域小的切片如果预测偏保守Dice可能为0加平滑项不会消除这一信息只是防止分母为零的数学错误。报告Dice时打印标准差很有必要医学影像中不同患者差异巨大只看均值容易被个别好样本拉高。4.2 分割结果可视化验证的三个层面指标只能说明整体趋势病灶位置、边界质量这些信息必须通过可视化来判断。我通常从三个层面来看分割结果第一层是切片级叠加图。把预测mask和GT用不同颜色叠到原图上GT用绿色预测用红色重叠区自然变黄。这样一眼就能看出漏分割和过分割的区域分布。第二层是沿轴向的切片序列对比。心脏CT是一个三维体积单张切片的指标好看不代表整个序列稳定。把不同层面的切片预测结果拼成一个大图检查是否出现某一层突然分割爆掉的情况。第三层是数值层面的对比比如每张切片的Dice分布直方图。如果直方图呈双峰分布说明模型在部分病例上系统性失败这时候需要回溯那些低分切片看是解剖结构差异还是增强过度导致。4.3 错误模式分析漏分割与过分割的判定标准U-Net做心脏分割最常见的两种失败模式是感兴趣区域边缘的小凸起被平滑掉欠分割以及把心腔与周围低密度组织混淆过分割。判定标准可以量化为GT面积与预测面积的比值偏差。如果这个比值长期大于1.2说明系统性地欠分割长期小于0.8则是过分割。用数据集做评测时还有一个要点测试集285张是整组预测还是逐张预测不同模式耗时差异很大。逐张预测单张512×512约需20msV100285张总耗时约6秒。但如果利用相邻切片的空间连续性做三维推理每次把相邻3-5张切片叠加成多通道输入分割的连贯性会有明显提升这也值得一试。5. 避坑指南标签混淆、图数不齐与训练翻车的四个典型问题这个数据集结构简单但简单不等于没坑。拆过的人都知道越基础的地方出了问题越难排查。下面是四个最常见的翻车场景每条都来自真实复现时的血泪经验。5.1 掩膜读出来全是0训练损失不下降现象训练几轮后Dice一直徘徊在0.1以下模型几乎全部预测背景。检查可视化结果掩膜区域是有的但训练时加载出来却是全零。原因大概率是读取mask时用PIL默认模式读成了L8位灰度没问题但如果用了Image.open(mask_path).convert(RGB)再转numpy最终数组shape变成(512, 512, 3)送入损失函数时和目标(512, 512)对不上。更隐蔽的问题是把255当成了普通数值如果直接拿mask原始值0和255计算BCE Loss模型会困惑——目标值不是0/1梯度方向不完全一致。解决加载后立即执行(mask 127).astype(np.uint8)把255压成1并把mask reshape成(B, 1, H, W)这两步缺一不可。5.2 图数对不齐训练报错shape mismatch现象Dataloader抛异常报错信息类似Expected input batch_size (4) to match target batch_size (3)。原因某些mask文件损坏或没下载完整导致Image.open()读取后shape异常比如(511, 512)或(512, 512, 3)。这种错误很隐蔽因为文件名都存在只有读进去才知道不对。解决在Dataset的__init__里加一道完整性校验一次性过滤掉所有尺寸异常的样本。# 过滤有问题的图像 valid_pairs [] for img_path in self.image_paths: mask_path mask_dir / img_path.name if not mask_path.exists(): continue with Image.open(img_path) as img: if img.size ! (512, 512): continue with Image.open(mask_path) as mask: if mask.size ! (512, 512): continue valid_pairs.append(img_path) self.image_paths valid_pairs这样做的好处是把筛选放在数据准备阶段训练过程中不再出现任何IO异常。我一般在拿到任何数据集后第一件事就是写这个校验脚本跑一遍下来什么文件缺了、哪张图尺寸不对一目了然。5.3 训练集和测试集图像来自不同分布现象训练时Dice到0.85测试集只有0.5不到差距巨大。原因这个数据集如果按时间或检查批次划分训练集和测试集的CT扫描参数可能不一致——比如窗宽窗位设置不同导致同一组织在图片里灰度值偏差很大。模型学到的是训练集的灰度分布特征测试集分布一旦偏移表现立刻掉下来。解决先做全局灰度统计对比看两个集合的均值、标准差是否有显著差异。import numpy as np from PIL import Image train_means, test_means [], [] for img_file in train_images: arr np.array(Image.open(img_file)) train_means.append(arr.mean()) for img_file in test_images: arr np.array(Image.open(img_file)) test_means.append(arr.mean()) print(f训练集灰度均值: {np.mean(train_means):.1f} ± {np.std(train_means):.1f}) print(f测试集灰度均值: {np.mean(test_means):.1f} ± {np.std(test_means):.1f})如果差距超过30个灰度级别就需要做直方图匹配或用归一化把分布拉齐。一个常用的做法是z-score归一化(img - mean) / std其中mean和std用训练集的全局统计值测试集也用同一组统计值做变换而不是各自独立归一化。5.4 可视化脚本输出全黑或全白的图像现象跑完visualize.py后生成的叠加图要么整张是黑的要么心脏区域是纯白一片看不到CT细节。原因CT原图的像素值范围通常达不到0-255全覆盖而是集中在一个较窄的区间直接转RGB后整体偏暗。此外如果把255的心脏mask直接叠加到原图上心脏区域会完全变成白色把下方的CT纹理遮住。解决先对原图做窗宽窗位调整把CT值映射到人眼友好的范围。常见做法是线性拉伸到2%与98%分位数然后再叠加mask。def normalize_ct(image): p2, p98 np.percentile(image, (2, 98)) clipped np.clip(image, p2, p98) return ((clipped - p2) / (p98 - p2 1e-6) * 255).astype(np.uint8) # 叠加时把 mask 设为半透明 overlay img_rgb.copy() overlay[mask 0] (255, 0, 0) # 纯红色 blended (0.7 * img_rgb 0.3 * overlay).astype(np.uint8)叠加时用0.7和0.3的权重做alpha混合既能看清红色区域的心脏位置又保留了CT图像的纹理细节。这是医学分割可视化的通用做法直接替换纯色叠加就能获得更好的演示效果。6. 进阶用法混合损失调优、交互式标注辅助与多类别扩展的三个方向这个数据集做完基础训练后还有几个值得动手的进阶方向。每个方向都能在原有基础上获得实质性的性能或效率提升而不是为了花哨而花哨。6.1 用边界损失强化边缘质量前面dice_loss加BCE的组合虽然在整体指标上表现稳定但有一个通病对细小的边缘凸起不敏感。U-Net输出的mask边缘往往偏圆滑和GT尖锐的边界有差距。一个被验证有效的改进是加入边界惩罚项。实现不复杂先对GT mask提取边界然后对预测结果在边界周围施加更高的损失权重。import cv2 import torch.nn.functional as F def boundary_weighted_loss(pred, target, boundary_r3): # target: (B, 1, H, W) float tensor target_np target.cpu().numpy().astype(np.uint8) boundary_maps [] for b in range(target_np.shape[0]): mask target_np[b, 0] edges cv2.Canny(mask, 0, 1) dist cv2.distanceTransform(1 - edges, cv2.DIST_L2, 3) weight np.clip(dist, 0, boundary_r) / boundary_r 1.0 boundary_maps.append(weight) weight_tensor torch.from_numpy(np.stack(boundary_maps)).unsqueeze(1).float().to(pred.device) bce F.binary_cross_entropy_with_logits(pred, target, weightweight_tensor) dice dice_loss(pred, target) return bce dice这里cv2.distanceTransform计算每个像素到边界的最短距离离边界越近权重越高。boundary_r3控制边界影响范围单位是像素。加入边界权重后虽然整体Dice可能只提升0.5-1个点但边缘上的分割质量改善肉眼可见。6.2 用预测结果辅助半自动标注扩充数据医学分割项目的数据永远不够这个道理做过的都懂。拿到这个数据集后一个实用思路是用已训练模型做半自动标注模型预测后人工修正再把新数据加入训练集迭代提升。具体流程是收集新的心脏CT影像用已有模型跑推理得到mask然后写一个小脚本把原图和预测mask叠加显示人工只需要检查并修正错误区域。我一般会做两轮筛选第一轮直接丢弃模型置信度低的所有样本比如预测概率最大值都低于0.7的这些样本往往需要大量人工修正性价比太低第二轮对高置信度样本做快速人工抽检确认无误后直接进训练集。def select_high_confidence(model, image_dir, threshold0.7): selected [] model.eval() with torch.no_grad(): for img_file in sorted(image_dir.glob(*.png)): img np.array(Image.open(img_file)) / 255.0 img_t torch.from_numpy(img).unsqueeze(0).unsqueeze(0).float().to(device) prob torch.sigmoid(model(img_t)).cpu().numpy() max_prob prob.max() if max_prob threshold: selected.append(img_file) return selected这个方法在心脏CT分割这类解剖结构相对标准、模型容易学到稳定模式的任务里特别有效。当然如果目标数据集和这个心脏CT分布差异很大需要先做迁移学习直接用原始权重推理的效果会打折扣。6.3 扩展到多类别分割的改造思路虽然这份资源是2类分割但很多人的下一步需求是三分类甚至更多左心室、右心室、心肌。如果后续拿到多类别标签的心脏数据改造路径整体是模型输出通道数从1改成类别数N激活函数从sigmoid换成softmax损失函数从binary CE换成multi-class CE或generalized Dice。# 2类 → N类的关键改动 model.final_conv nn.Conv2d(64, N_CLASSES, kernel_size1) # 损失函数改为多类别Dice def multiclass_dice_loss(pred, target, num_classes): pred_softmax F.softmax(pred, dim1) dice_sum 0.0 for cls in range(num_classes): p pred_softmax[:, cls] t (target cls).float() intersection (p * t).sum() dice (2.0 * intersection 1e-6) / (p.sum() t.sum() 1e-6) dice_sum dice return 1 - dice_sum / num_classestarget的shape变为(B, H, W)存储的是类别索引0,1,2,...而不是one-hot。解码的时候逐像素取argmax(dim1)得到每个像素的类别。如果你手头还有灰度值不是0/255而是128、64这种多标签的mask压缩逻辑变成mask / 255 * num_classes并round到最近整数。6.4 一个值得养成的习惯做了这么多分割项目后我现在拿任何数据集的第一步都不是看论文、跑模型而是先写一个完整的统计脚本把图像数量、分辨率、灰度分布、mask类别比例全部打印出来。这些看起来琐碎的信息在训练中后期排查问题时就是最有力的线索。这个数据集好在结构干净但依旧建议你把上面5.2节的校验脚本跑一遍再开始训练。数据集只是起点想要好的分割效果还得在增强策略和损失函数上持续迭代这套数据足够支撑你完成一轮完整的实验闭环希望帮到你。本文还有配套的精品资源点击获取