☰
肺结节CT分割数据集从数据校验到PyTorch训练全流程避坑指南
2026/10/1 2:29:11 网站建设 项目流程

简介:面向医学影像分析与深度学习分割任务,这份数据集基于CT成像的肺部结节图像语义分割,约29,000张图片与对应标签,已经划分好训练集与测试集,其中训练集约10,000张、测试集约4,300张,并附包含背景与结节二类别的classes说明文件。适合研究U-Net、SwinUNet、TransUNet等分割网络以及用于医学影像分割算法的入门与调优。资源共2000个文件,以PNG图像为主(1998个),包含原始影像与对应mask标签,另有1个类别说明txt和1个可视化脚本py;整个压缩包约285.23MB。已有216人学习下载,说明该数据受到一定关注。除数据本身外,还提供可视化脚本,可随机抽图展示原始图、GT及其在原图上的蒙版效果,便于快速检查标注质量;配套医学图像分割网络及改进专栏可结合参考,有助于读者理解模型输入输出并开展实验对比。

1. 为什么一份29,000张的肺结节分割数据集,不能直接拿去训练模型

拿到一份基于CT成像的肺部结节图像语义分割数据集,第一反应通常是“数据有了、标签有了,直接写个训练脚本”。但从我处理医学图像分割项目的经验看,这类数据集的坑恰好在“直接训练”这一步。所谓29,000张,可能是数百个病例切出的2D切片,也可能是独立样本;标签可能是单通道整数,也可能是彩色PNG;CT值没有做窗宽窗位截断就当自然图像归一化的训练,几乎必然翻车。下面按“先看清结构,再准备数据,最后设计训练”的顺序,把数据读取、重归一化、患者级划分、PyTorch训练管线和常见排查串成一条可复现的落地路径,适合做医学图像分割、语义分割模型或迁移实验的从业者。

2. 数据集的内部结构:从原始CT到29,000张配对标签的核对方法

拿到数据集别急着写训练脚本。第一步是确认三件事:图像和标签如何对应、标签用什么编码、切片是怎么来的。这三个问题决定了后续所有代码怎么组织,也决定了训练出来的模型是否可信。

2.1 先看目录树和命名规律,再决定数据加载管线

基于CT的肺部结节分割数据集有两种常见目录形态。第一种是images与masks两个平铺目录,同名PNG一一对应,适合用glob直接配对;第二种是按患者编号组织,每个患者目录下放着若干切片和同名mask,信息更完整,但需要自己写配对逻辑。我一般先把目录树打出来,确认命名规律后再动手写代码。

# 打印前两层目录结构,确认数据集的真实组织方式 find . -maxdepth 2 -type d | sort | head -50 # 统计PNG文件数量,与标题里约29,000张的表述做核对 find . -name "*.png" | wc -l

第一条命令查看目录层级,第二条统计文件总数。这里假设数据分发格式是PNG,如果实际是npy、jpg或npz,对应替换后缀即可。这一步的作用不只是跑通流程,而是尽早发现文件数量与描述不符、目录嵌套层级比预期复杂这类基础问题。如果images目录有29,000张而masks目录只有28,000张,说明有缺失文件。先处理缺失再进入下一步,否则训练时要么直接报错中断,要么在文件名配对逻辑写得宽松时静默学到错位样本。

2.2 用Python核对图像与mask的配对、尺寸和位深

目录结构只反映文件层面,真正要验证的是图像和mask是否在像素层面逐一对齐。CT数据的mask如果是从DICOM导出分割结果,通常是单通道uint8,像素值就是类别ID;如果标注工具导出后又被转成JPG,就会变成有损三通道,这种必须重新转换。用一段小脚本抽前200张做检查,不要在29,000张全部读完之后才暴露通病。

import os import numpy as np from PIL import Image img_dir = "images" mask_dir = "masks" img_files = sorted([f for f in os.listdir(img_dir) if f.endswith(".png")]) # 只检查前200张,避免在大量文件读完后才发现共性问题 for i, fname in enumerate(img_files[:200]): mask_path = os.path.join(mask_dir, fname) if not os.path.exists(mask_path): print(f"[缺失] {fname} 在masks目录里不存在") continue img = np.array(Image.open(os.path.join(img_dir, fname))) mask = np.array(Image.open(mask_path)) print(f"{fname} | img={img.shape} {img.dtype} | mask={mask.shape} {mask.dtype}") if mask.ndim == 2: print(f" 类别值: {np.unique(mask)}") else: print(f" 通道数: {mask.shape[2]},不要直接当类别ID用") if img.shape[:2] != mask.shape[:2]: print(f"[错误] {fname} 尺寸不一致 img={img.shape[:2]} mask={mask.shape[:2]}") break

img.shape出现(512, 512, 4)说明PNG带alpha通道,训练前要拆掉。np.unique(mask)是检查核心:如果返回[0, 1, 2],说明是稀疏类别编码,可以直接用;如果返回[0, 128, 255]一类颜色值,说明被存成了彩色标签,需要建立颜色映射表转成单通道。这一步必须人工确认,不能用模型类别数反推。

2.3 标签编码规则:单通道整数、二值或者彩色

语义分割模型输出层通常有num_classes个通道,标签要么是单通道整数编码,要么是one-hot向量。这份数据集名称里明确写了“语义分割”,标签大概率是单通道整数,但我见过不少分发版把mask存成彩色PNG,每类一种颜色。直接取像素值当类别ID会得到互不相干的数字,损失函数也会静默错掉。建议先输出类别值分布表,再决定要不要转换。

出现的像素值含义能否直接训练
[0, 1, 2]稀疏类别ID可以直接用
[0, 255]二值前景/背景需要做二分类或按文档映射
[0, 128, 255]等颜色值彩色标签必须建立颜色映射转单通道

提示:CT肺结节语义分割数据集常见的类别定义是背景0、结节1、其他结构2。具体含义以数据集自带的README、JSON或标注文档为准,不要凭模型类别数量反推语义。

标签编码这一层,决定训练代码里CrossEntropyLoss的ignore_index如何设置、预测输出要不要做argmax。如果漏掉这步直接进入训练,整个管线会建立在错误假设之上,训练时间越长返工成本越高。

3. 训练前的数据准备:HU截断、患者级划分与切片级过滤

3.1 HU值截断:CT不能按自然图像的方式标准化

CT成像记录的是组织对X射线的衰减系数,单位是Hounsfield Unit,范围通常从-1024到3071。把这样的数值直接丢进神经网络前,如果不做截断就把整个范围线性归一化到[0,1],肺部和空气区会占掉大段灰度带,软组织区域的对比度被压缩得几乎不可见。医学图像分割常见做法是先按窗宽窗位截断HU,再做线性归一化。肺结节一般用软组织窗,窗宽400HU、窗位40HU。

import numpy as np def normalize_ct(img, window_width=400, window_level=40): """ 将CT HU值截断到窗宽窗位范围内,再归一化到[0,1]。 window_width 决定刻度范围,window_level 决定亮度中心。 """ lower = window_level - window_width / 2.0 upper = window_level + window_width / 2.0 img = np.clip(img, lower, upper) # 把窗口外的值压到边界 img = (img - lower) / (upper - lower) # 线性映射到[0,1] return img.astype(np.float32) # 假设ct_slice是从原始CT体数据读出的单张轴向切片,数据类型应为float ct_slice = np.load("sample_hu.npy") norm = normalize_ct(ct_slice, window_width=400, window_level=40) print(f"归一化范围验证: {norm.min():.3f} ~ {norm.max():.3f}")

参数说明:窗宽400HU表示把400个HU单位的范围拉伸到[0,1],窗位40HU让范围中心落在肺部软组织附近。若想多看肺实质,可以用更宽的窗,例如窗宽1200HU、窗位-600HU,此时结节的灰度层次完全不同。到底选哪个窗口没有绝对正确,但有两种常见方案:固定选用软组织窗输入单通道;或把多个窗口并成多通道输入。无论如何,不做截断直接用整段HU范围归一化是错误做法。值得提醒的是,读取时必须使用float类型加载CT数据,如果先用uint8去读原始数据,负HU值会被无符号整数溢出,整个数据分布直接报废。

3.2 数据划分:按切片随机切分会让评估分虚高

29,000张切片通常来自数百个病人。同一个患者相邻切片内容高度相似,如果按切片随机划分训练集和测试集,同一患者的切片会同时出现在两边,模型相当于在“见过相似图像”的情况下做评测,Dice虚高到不可信。正确做法是按患者ID划分,让训练集和测试集在病人维度完全不相交,这样评估才接近临床应用的真实场景。

import glob import re from sklearn.model_selection import GroupShuffleSplit files = sorted(glob.glob("images/*.png")) # 从文件名提取患者ID,假设命名形如 patient_003_slice_045.png def extract_patient_id(path): name = path.split("/")[-1] m = re.match(r"^(patient_\d+)", name) if m is None: raise ValueError(f"无法从文件名提取患者ID: {name}") return m.group(1) patient_ids = [extract_patient_id(f) for f in files] # groups参数让同一患者ID下的所有切片全部进入同一个划分 splitter = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) train_idx, val_idx = next(splitter.split(files, groups=patient_ids)) train_patients = set(patient_ids[i] for i in train_idx) val_patients = set(patient_ids[i] for i in val_idx) print(f"训练集切片数: {len(train_idx)},患者数: {len(train_patients)}") print(f"验证集切片数: {len(val_idx)},患者数: {len(val_patients)}") print(f"患者重叠: {train_patients & val_patients}")

关键参数是test_size=0.2,20%的患者进验证集;random_state=42固定抽样种子,保证每次运行得到的划分完全一致,方便复现和对比。如果数据集自带官方划分文档,优先沿用官方划分,不要自行再切,否则后续跟论文或基准结果对比时指标口径不一致。患者重叠如果输出为空集,说明划分在患者维度已经隔离,可以做后续训练了。如果数据量足够,还可以把验证集再按患者ID拆出一份留作最终测试,避免在验证集上反复调参导致过拟合。

3.3 切片级过滤:纯背景切片会稀释训练信号

一份29,000张的数据集,如果发布者按完整序列输出切片,其中会有相当比例不包含结节的纯背景样本。这些样本虽然也是“图像分割数据”,但对模型没有正向贡献,会让背景类在损失函数里占比过大。更稳妥的做法是先统计每个mask里的前景像素数,再过滤过低样本,保留有信息量的切片。

import numpy as np from PIL import Image def filter_low_foreground(img_paths, mask_paths, min_foreground_pixels=50): """ 保留mask中前景像素数量超过阈值的样本。 min_foreground_pixels 设得太低会保留噪声切片,太高会误删小结节。 """ kept_imgs = [] kept_masks = [] for img_p, mask_p in zip(img_paths, mask_paths): mask = np.array(Image.open(mask_p)) if mask.ndim == 3: mask = mask[:, :, 0] # 彩色标签取单通道做近似判断,正式处理要靠颜色映射表 fg_count = int((mask > 0).sum()) if fg_count >= min_foreground_pixels: kept_imgs.append(img_p) kept_masks.append(mask_p) return kept_imgs, kept_masks

阈值50像素只是一个参考起点。对于直径几毫米的小结节,切片上的真实像素数量可能只有几十到几百,阈值设得太大容易把所有纯背景和大多数小结节一起滤掉,模型能学的阳性样本更少。建议先跑一遍全数据集的mask像素分布直方图,再根据分布形态选择阈值。这个过滤逻辑同时是后续类平衡采样的前置条件。

4. 用PyTorch搭建训练管线:Dataset加载、同步增强与损失函数设计

4.1 自定义Dataset:加载配对样本并处理张量形状

PyTorch训练的第一步是写数据加载器。医学图像分割样本在训练时需要同时返回图像张量和mask张量,这里有两个细节:mask必须保持为长整型张量,且不能像分类任务那样自动转成one-hot。加载时把重归一化和mask转换都放在__getitem__里,可以省去预先把全部数据读入内存的步骤。

import os import torch import numpy as np from PIL import Image from torch.utils.data import Dataset class LungsSegDataset(Dataset): def __init__(self, img_paths, mask_paths, transform=None): self.img_paths = img_paths self.mask_paths = mask_paths self.transform = transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): # 图像按单通道读入,CT切片本身是灰度数据 image = np.array(Image.open(self.img_paths[idx]).convert("L"), dtype=np.float32) image = normalize_ct(image) # 复用3.1的截断归一化函数 image = torch.from_numpy(image).unsqueeze(0) # (1, H, W) # mask保持长整型,语义分割标签不能是float mask = np.array(Image.open(self.mask_paths[idx])) if mask.ndim == 3: mask = mask[:, :, 0] # 如果是彩色标签,必须有明确的颜色映射表 mask = torch.from_numpy(mask.astype(np.int64)) # (H, W) if self.transform is not None: image, mask = self.transform(image, mask) return image, mask

unsqueeze(0)把形状从(H, W)扩成(1, H, W),凑出PyTorch要求的通道维。注意image的dtype是float32,mask却是int64,这两者在后续CrossEntropyLoss里是硬性要求:交叉熵的target必须是整型,输入必须是浮点。convert("L")把PNG按灰度读入,避免偶尔出现的RGBA通道干扰。如果数据集附带的像素间距或层厚元数据有用,建议在初始化时一并读入,后面做跨数据集验证或重采样时会用到。

4.2 同步增强:翻转、旋转和缩放必须同时作用于图像与mask

训练分割模型的增强有个容易翻车的点:图像做了旋转,mask没跟着转;或者图像做了随机裁剪,mask还在原坐标。结果是模型在幻觉数据上训练。所以增强函数要同时接收image和mask,并用同一个随机数驱动两次几何变换。

import random import torch.nn.functional as F import torch class SyncTransform: def __init__(self, p=0.5): self.p = p def __call__(self, image, mask): # 水平翻转必须同步,注意image的通道维在第0维,翻转的是宽度方向 if random.random() < self.p: image = torch.flip(image, dims=[2]) mask = torch.flip(mask, dims=[1]) # 90度旋转也必须同步,旋转参数来自同一个随机分支 if random.random() < self.p: k = random.randint(0, 3) image = torch.rot90(image, k, dims=[1, 2]) mask = torch.rot90(mask, k, dims=[0, 1]) # 随机裁剪:用同一组裁剪坐标裁图像和mask,同时统一输入尺寸 if random.random() < self.p: _, h, w = image.shape crop_h, crop_w = int(h * 0.9), int(w * 0.9) top = random.randint(0, h - crop_h) left = random.randint(0, w - crop_w) image = image[:, top:top + crop_h, left:left + crop_w] mask = mask[top:top + crop_h, left:left + crop_w] return image, mask

关键点是dims参数:图像shape是(1, H, W),翻转通道维2对应宽度方向;mask shape是(H, W),翻转宽度方向用dims=[1]。两者维度不对齐,很容易写错。随机裁剪则保证image和mask共用同一组top/left坐标,不会错位。弹性形变、仿射变换这类更复杂的增强,推荐直接用torchvision的v2接口或albumentations库,它们原生支持同时变换图像和mask,不用自己手工对齐随机数。

4.3 损失函数选型:CrossEntropy与Dice Loss的参数配合

肺结节分割的类不平衡问题天然存在:单张切片里背景像素常占95%以上,用纯CrossEntropyLoss训练会偏向预测背景。语义分割算法里处理类不平衡的常见做法是主损失用CrossEntropy,辅以Dice Loss,在线性加权下组合。Dice Loss关注区域重叠本身而不是逐像素分类正确率,能有效把学习焦点拉回结节区域。

import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): # logits: (B, C, H, W) targets: (B, H, W) num_classes = logits.shape[1] targets_onehot = F.one_hot(targets, num_classes).permute(0, 3, 1, 2).float() probs = torch.softmax(logits, dim=1) # 逐类别计算Dice,再取平均;忽略背景是另一种常见策略 intersection = (probs * targets_onehot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets_onehot.sum(dim=(2, 3)) dice = (2.0 * intersection + self.smooth) / (union + self.smooth) return 1.0 - dice.mean() def combined_loss(logits, targets, ce_weight=0.5, dice_weight=0.5): ce = F.cross_entropy(logits, targets) dice = DiceLoss()(logits, targets) return ce_weight * ce + dice_weight * dice

ce_weight=0.5, dice_weight=0.5是两个损失各占一半的初始化。实际操作中如果发现训练早期就不稳定,可以把dice_weight调低到0.2;如果发现模型在背景上过拟合反而漏掉小结节,提高dice_weight。F.one_hot生成的张量要经过permute把类别维从最后一维搬到通道维,才能和softmax概率逐元素相乘。

提示:Dice Loss在类别极少时会波动很大,尤其是某个batch里完全没有结节样本时,Dice分子为0、分母接近0,数值不稳定。这种情况建议在Dataset层面做有结节样本的采样,而不是完全随机抽样。

5. 避坑与排查:训练异常时的5个排查方向

5.1 训练loss收敛但测试Dice为0:图像与mask存在静默错位

现象:训练loss正常下降,验证Dice却始终接近0。排查时随机选一张训练样本,把mask叠加在图像上可视化,发现mask明显位移或翻转。

原因:目录配对逻辑用了glob的隐式排序,而两个目录里文件名大小写或前后缀不一致,sorted后顺序不同,代码却按索引一一对应取文件。

解决:不要靠“第几个文件”做对应,用文件名做字典配对;文件名完全相等时才能进入训练列表。配对完成后用2.2的脚本逐对校验形状和尺寸。可视化是排查这个问题最快的手段,直接把图像和mask重叠成一张图,任何错位一眼可见。

5.2 模型输出全背景:类别编码在某个环节被静默改变

现象:训练结束后模型预测全部输出背景类,Dice为0,但训练集上Dice正常。

原因:数据加载时对mask也做了convert("L")或灰度归一化。如果mask原本是单通道类别编码,convert("L")会保留数值,但很多增强库会把mask插值到[0,255]后再缩放,类别ID被破坏成浮点值,取整后全部变0。

解决:mask禁止缩放、禁止插值、禁止通过任何影响像素强度的方式做变换。检查管线里有没有出现mask先.float()再参与归一化,导致0/1/2被当成灰度归一化到[0,1]再取整变0的情况。正确做法是mask永远保持int64,直到进入损失函数。

5.3 同一患者切片被拆到训练集和测试集:评估分虚高的隐蔽来源

现象:测试Dice高达0.94,部署到新CT上明显掉点。按患者ID检查划分结果,发现同一患者既有切片在训练集又有切片在测试集。

原因:直接用train_test_split按切片切分,没有用GroupShuffleSplit按患者分组。

解决:重做患者级划分。先从文件名提取患者ID,验证两份集合的患者ID交集为空。这个坑通常不报错,只在部署阶段以性能下滑的形式暴露,代价最高。

5.4 背景与前景数量悬殊导致结节区域被忽略

现象:loss下降稳定,但预测mask只包含大片背景结构,小结节完全没有,Dice一直在低位。

原因:CrossEntropyLoss被背景像素淹没,梯度几乎全部用于把背景学准,结节相关特征得不到更新。

解决:先过滤纯背景切片,再引入DiceLoss加重区域重叠约束。如果结节太小,还可以考虑以结节中心裁剪成小块再训练,让模型在局部尺度上把注意力放到结节边界。类不平衡在肺结节分割里是结构性问题,不做处理基本学不出可用模型。

5.5 增强破坏了结节形态:旋转、翻转和插值导致形状失真

现象:训练时Dice正常,验证集偶尔出现单像素断裂的预测区域,直径几毫米的小结节边界尤其不稳定。

原因:几何增强用了torch.nn.functional.interpolate或affine_grid,对mask也做了双线性插值,类别ID在插值后变成非整数,再四舍五入产生边界断裂和伪形状;或者翻转只作用在mask上而没作用于图像,造成配对错位。

解决:mask只能用最近邻插值,禁用双线性插值;翻转、旋转、裁剪必须由同一组随机参数驱动。宁可减少增强种类,也不要让形状信息失真。

6. 进阶验证:值得做的三件事——跨数据集测试、伪标签和逐样本追踪

6.1 跨数据集验证:自测准确不等于部署可用

自己切出的验证集只能说明同一个数据分布内的表现,真实部署面对的CT来自不同设备、不同重建参数、不同层厚。把训练好的模型直接推到一个公共的肺结节分割数据集上,不加适配,统计Dice。如果自己验证集上Dice高、公共数据集上骤降,说明模型学到的是批次特有纹理而不是结节本质;如果只是略降,说明特征偏向解剖结构。公共数据集的标注标准可能不同,直接对比绝对Dice不公允,但趋势已经足够暴露问题。

6.2 伪标签自训练:用高置信度样本扩展训练池

29,000张数据集的主要瓶颈往往不是模型结构,而是正样本不够丰富。用训练好的模型对未标注CT切片推理,按置信度阈值筛选高置信结节区域,作为伪标签补充训练。核心控制点是置信度筛选:只保留预测类别概率超过0.9且区域面积落在合理结节范围内。这类自训练必须每轮之后回到保留的公共数据集上评估,一旦Dice下降,说明伪标签里混入了系统性错误,需要降低阈值或加重过滤条件。

6.3 逐样本误差追踪:把平均Dice拆开看

验证集平均Dice没有可操作性。我会在训练结束时对每个验证切片单独计算Dice并降序排列,把最低的20个样本可视化出来。通常能看到三种固定模式:小结节边缘模糊、贴近胸膜的结节被切掉、贴近血管的结节被归为血管结构。这三种正好是临床上最常出错的区域,多尺度输入或边界感知损失往往比更换backbone更能解决其中一两个问题。

我从一开始做医学图像分割时,也在图像与mask静默错位上栽过,当时排查了一整天才意识到是两个目录的排序规则不一致导致错位配对。现在我的习惯是:每次拿到新数据集,先跑数据核对脚本,再谈训练。数据本身不会骗人,但文件组织方式会,代码会,训练过程中那些看不见的隐式假设更会。先把数据结构和划分边界确认清楚,后面每一步才能放心往前走。希望帮到你。

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

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

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

立即咨询