☰
心脏CT分割数据集实操:基于U-Net的二类分割训练全流程
2026/10/8 1:09:31 网站建设 项目流程

简介:面向医学图像分割入门与算法验证,提供心脏CT图像分割数据集,图像尺寸统一为512×512,PNG格式存储,配套与图像同尺寸的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×512,PNG格式,标注只有两类——背景和心脏。类少、边界明确、数量够用,拿来跑通一整套分割流程很顺手。我在本地跑了一遍,从数据加载、可视化到训练验证,基本没有遇到结构上的卡点。这篇文章就把这份数据集的真实结构和几个容易翻车的细节拆开讲,适合正在找医学分割练手数据、或者刚接触分割任务想走通全流程的读者。

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 = f"dataset/{split}/images" mask_dir = f"dataset/{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"), dtype=np.float32) mask = np.array(Image.open(mask_path).convert("L")) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img, cmap="gray") axes[0].set_title("Original CT") axes[1].imshow(mask, cmap="gray") axes[1].set_title("GT Mask") overlay = np.zeros((*img.shape, 3), dtype=np.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", dpi=150) 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(f"multi-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, augment=False): 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"), dtype=np.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, reduction="mean") return 0.5 * (1 - dice) + 0.5 * bce

这个损失函数把Dice Loss和BCE各取一半权重。Dice Loss解决类别不平衡问题,它的计算方式是衡量预测掩膜和GT掩膜的区域重叠程度,不受前景占比影响。BCE保留像素级梯度信息,帮助边界部分收敛得更精细。两个凑在一起,比单独用任何一个都稳。

训练时的常用参数:batch size设为8(512×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, threshold=0.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,但换一批真实数据表现暴跌。

原因:代码里在准备测试集时误用了训练目录的路径,或者数据加载时做了和训练一样的增强,导致测试时看到的不是原始分布。更隐蔽的是,如果测试时没有关掉augment=True,随机翻转会让预测结果不稳定,同样的图每次跑的mIoU都不一样。

解决:测试集读取时强制设置augment=False,并且从路径层面隔离训练和测试目录。我后来在代码里加了断言,如果split="test"时传了augment=True,直接抛异常。这种防御性写法能防止低级错误在夜深人静赶工时悄悄溜进代码。

6. 验证与进阶:mIoU计算、模型导出与批量推理

最后一章讲点实际操作层面的技巧。训练完的模型不能只盯着loss看,要算清楚了才知道模型真实水平。除此之外,模型导出和批量推理也有不少容易被忽略的细节。

6.1 mIoU和Dice的本地验证

评估分割模型,最简单的指标是mIoU和Dice系数。两者本质上都衡量预测结果和GT的相似程度,只是计算角度不同。这里给出一段可以独立运行的评估脚本:

def compute_metrics(pred_mask, gt_mask, eps=1e-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_size=8, shuffle=False, num_workers=4) 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时需要指定mode="bilinear",否则默认的nearest会在导出时报错。

从那以后我每次做分割训练都会强制走一遍完整流程:核对文件名一致性和掩膜类别,批量检查前景占比,跑一次可视化脚本确认对齐,训练前把数据增强策略写死并确认测试集不开增强,评估时记录每张图的指标而不是只看平均。这套流程看着繁琐,但每一步都能在问题变大之前把它拦住。数据越规整,后面训练越省心。希望这篇拆解能让你少走几个弯路,把时间花在真正有用的调参和验证上。

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

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

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

立即咨询