心脏CT分割数据集实战:从PNG到U-Net训练闭环与避坑指南
2026/9/24 18:07:28 网站建设 项目流程

简介:面向医学图像分割与心脏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/imagestrain/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(f"overlay_{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, transform=None): 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(image=image, mask=mask) 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(p=0.5), # 90度旋转,保持心脏朝向语义 A.HorizontalFlip(p=0.3), # 水平翻转 A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.5), # 小范围平移缩放旋转 A.ElasticTransform(alpha=1.0, sigma=10.0, alpha_affine=5.0, p=0.2), # 弹性形变模拟器官形变 ])

这套增强里最关键的是ElasticTransform——医学图像分割最常用的增强手段,用来模拟心脏在不同时间相位的形变。参数上alpha=1.0控制形变强度,调大了会让器官形状失真,调小了等于没增强;sigma=10.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, smooth=1.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是相加关系,没有加权系数。这个组合在绝大多数分割任务里都能直接跑出不错的效果,比单独用任何一个都稳定。smooth=1.0是为了防止分母为零,同时起到平滑梯度作用。评价指标用Dice Coefficient和IoU就好,不用太花哨。

训练配置方面,batch size建议4-8(取决于显存),学习率1e-4配AdamW,epoch数50-100。512×512输入下U-Net的显存占用大约在4-6GB,batch size=4对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(f"mDice: {np.mean(dice_scores):.4f} ± {np.std(dice_scores):.4f}") print(f"mIoU: {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约需20ms(V100),285张总耗时约6秒。但如果利用相邻切片的空间连续性做三维推理,每次把相邻3-5张切片叠加成多通道输入,分割的连贯性会有明显提升,这也值得一试。

5. 避坑指南:标签混淆、图数不齐与训练翻车的四个典型问题

这个数据集结构简单,但简单不等于没坑。拆过的人都知道,越基础的地方出了问题越难排查。下面是四个最常见的翻车场景,每条都来自真实复现时的血泪经验。

5.1 掩膜读出来全是0,训练损失不下降

现象:训练几轮后Dice一直徘徊在0.1以下,模型几乎全部预测背景。检查可视化结果,掩膜区域是有的但训练时加载出来却是全零。

原因:大概率是读取mask时用PIL默认模式读成了L(8位灰度)没问题,但如果用了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_r=3): # 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, weight=weight_tensor) dice = dice_loss(pred, target) return bce + dice

这里cv2.distanceTransform计算每个像素到边界的最短距离,离边界越近权重越高。boundary_r=3控制边界影响范围,单位是像素。加入边界权重后,虽然整体Dice可能只提升0.5-1个点,但边缘上的分割质量改善肉眼可见。

6.2 用预测结果辅助半自动标注扩充数据

医学分割项目的数据永远不够,这个道理做过的都懂。拿到这个数据集后,一个实用思路是用已训练模型做半自动标注:模型预测后人工修正,再把新数据加入训练集,迭代提升。

具体流程是:收集新的心脏CT影像,用已有模型跑推理得到mask,然后写一个小脚本把原图和预测mask叠加显示,人工只需要检查并修正错误区域。我一般会做两轮筛选:第一轮直接丢弃模型置信度低的所有样本(比如预测概率最大值都低于0.7的),这些样本往往需要大量人工修正,性价比太低;第二轮对高置信度样本做快速人工抽检,确认无误后直接进训练集。

def select_high_confidence(model, image_dir, threshold=0.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_size=1) # 损失函数改为多类别Dice def multiclass_dice_loss(pred, target, num_classes): pred_softmax = F.softmax(pred, dim=1) 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_classes

target的shape变为(B, H, W),存储的是类别索引0,1,2,...而不是one-hot。解码的时候逐像素取argmax(dim=1)得到每个像素的类别。如果你手头还有灰度值不是0/255而是128、64这种多标签的mask,压缩逻辑变成mask / 255 * num_classes并round到最近整数。

6.4 一个值得养成的习惯

做了这么多分割项目后,我现在拿任何数据集的第一步都不是看论文、跑模型,而是先写一个完整的统计脚本,把图像数量、分辨率、灰度分布、mask类别比例全部打印出来。这些看起来琐碎的信息,在训练中后期排查问题时就是最有力的线索。这个数据集好在结构干净,但依旧建议你把上面5.2节的校验脚本跑一遍再开始训练。数据集只是起点,想要好的分割效果还得在增强策略和损失函数上持续迭代,这套数据足够支撑你完成一轮完整的实验闭环,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询