简介:本资源为面向医学图像分割任务的肺部数据集,适合深度学习入门者、医学影像方向研究者及需要肺部分割实验数据的开发者使用。数据在256×256分辨率下完成标注,前景涵盖左肺、右肺等区域,mask为前景255的二值图像,便于直观观察与训练。包内共2000个文件,以1999张png图像和1个py脚本为主,压缩包约253.01MB,其中png包含训练集images与masks各6849张、测试集images与masks各1712张,py脚本可随机抽取一张图片,展示原图、GT图像及GT在原图上的蒙板效果并保存至当前目录。目前已有1389人学习下载,读者可据此直接开展分割模型训练、验证与结果可视化,快速搭建完整的肺部分割实验流程。
1. 肺分割数据集到底能解决什么:从一次标注返工说起
去年帮一个做呼吸科辅助诊断的团队看模型,他们用公开数据训出来的肺分割模型在自家 CT 上 Dice 只有 0.71,肉眼一看右肺下叶全被吞掉了。排查三天,问题不在网络结构,而在训练集里肺实质和胸膜粘连的边界标注口径不一致——训练集把胸膜当背景,测试集把胸膜算进肺。这就是肺分割数据集最容易被低估的地方:它不只是「一堆图加掩码」,而是决定你模型上限的标注口径基准。这份肺分割数据(包含训练集和测试集)的价值,在于它把训练和测试的划分、标注规范、图像格式都固定下来,让你能把精力放在模型和解码策略上,而不是反复怀疑数据。它适合三类人:刚入门医学图像分割想跑通第一个端到端流程的工程师、需要快速验证新损失函数或后处理策略的算法同学、以及要给临床做 demo 但拿不到院内数据的开发者。下面我按「数据长什么样 → 怎么读进来 → 怎么训 → 坑在哪 → 怎么验证」的顺序拆一遍。
2. 肺分割数据集的目录结构与读取方式:先看清再动手
2.1 训练集与测试集的划分逻辑
拿到任何医学分割数据集,第一件事不是写 DataLoader,而是把目录树打印出来。肺分割这类任务通常按病例(case)划分,而不是按切片(slice)随机划分。原因很直接:同一个病人的相邻切片高度相似,如果按切片随机分,训练集和测试集会共享同一个病人的解剖特征,Dice 会被虚高到 0.95 以上,一上真实新病人就崩。常见做法是训练集和测试集各自对应独立的病例编号,测试集完全不参与任何训练和调参。
我一般会先跑一段脚本统计病例数、切片数和尺寸分布,确认划分是否干净:
import os import numpy as np from collections import defaultdict root = "lung_seg_dataset" # 数据集根目录 for split in ["train", "test"]: img_dir = os.path.join(root, split, "images") mask_dir = os.path.join(root, split, "masks") cases = defaultdict(int) shapes = [] for f in os.listdir(img_dir): # 文件名形如 case_001_slice_023.png,按 case 前缀聚合 case_id = f.split("_slice_")[0] cases[case_id] += 1 img = np.array(Image.open(os.path.join(img_dir, f))) shapes.append(img.shape) print(f"[{split}] 病例数={len(cases)} 切片总数={sum(cases.values())}") print(f"[{split}] 尺寸分布={np.unique(shapes, axis=0)}")这段代码做三件事:按文件名前缀聚合病例、统计每个 split 的切片总量、检查图像尺寸是否统一。参数上,case_id的切分规则要按你实际文件名调整,有的数据集用patient001_03.png这种下划线分隔,那就改成f.split("_")[0]。如果发现训练集和测试集的病例编号有重叠,直接判定划分有问题,别急着往下训。
2.2 图像格式、位深与掩码取值
肺分割数据的图像常见两种格式:PNG 和 NIfTI(.nii/.nii.gz)。PNG 是 8 位灰度,方便直接喂给常规 CNN;NIfTI 保留原始 HU 值,适合做窗宽窗位预处理。掩码一般是单通道,肺区域为 1 或 255,背景为 0。这里有个高频翻车点:掩码用 255 表示前景时,如果你直接当类别标签算交叉熵,会得到 255 类,loss 直接爆炸。
读取时统一做一次归一化和二值化,是省后悔药的做法:
import numpy as np from PIL import Image def load_pair(img_path, mask_path, size=(256, 256)): img = Image.open(img_path).convert("L").resize(size) mask = Image.open(mask_path).convert("L").resize(size, Image.NEAREST) img = np.array(img, dtype=np.float32) / 255.0 # 归一化到 [0,1] mask = (np.array(mask) > 127).astype(np.float32) # 255/1 统一成 1 return img[None, ...], mask[None, ...] # 增加通道维convert("L")保证灰度单通道,resize对掩码必须用NEAREST,用双线性会把边界插值出 0.5 这种中间值,二值化后边界漂移。> 127这个阈值是经验值,如果你的掩码前景是 1,改成> 0即可。归一化用/255.0只适用于 8 位 PNG;如果是 NIfTI 的 HU 值,得先做窗宽窗位截断再归一化,常见肺窗是窗位 -600、窗宽 1500。
2.3 用 Dataset 和 DataLoader 组装可训练管道
把上面的读取逻辑包进Dataset,加上轻量增强,就能直接接模型。增强别一上来就上弹性形变,肺的解剖结构相对固定,翻转和轻微旋转足够,弹性形变容易把肺门血管拉断,反而制造噪声标签。
import torch from torch.utils.data import Dataset, DataLoader import random class LungSegDataset(Dataset): def __init__(self, root, split, size=(256, 256), augment=False): self.img_dir = os.path.join(root, split, "images") self.mask_dir = os.path.join(root, split, "masks") self.files = sorted(os.listdir(self.img_dir)) self.size = size self.augment = augment def __len__(self): return len(self.files) def __getitem__(self, idx): f = self.files[idx] img, mask = load_pair( os.path.join(self.img_dir, f), os.path.join(self.mask_dir, f), self.size ) if self.augment and random.random() > 0.5: img, mask = img[:, :, ::-1], mask[:, :, ::-1] # 水平翻转 return torch.from_numpy(img.copy()), torch.from_numpy(mask.copy()) train_ds = LungSegDataset("lung_seg_dataset", "train", augment=True) test_ds = LungSegDataset("lung_seg_dataset", "test", augment=False) train_loader = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=4) test_loader = DataLoader(test_ds, batch_size=1, shuffle=False)shuffle=True只在训练集开,测试集必须False,否则你没法把预测结果和原图对应回去做可视化。num_workers在 Windows 上建议设 0,多进程读图容易卡死,这是血泪经验。batch_size=8是 256×256 输入下的保守值,显存够可以往上加,但医学分割 batch 太大反而让 BatchNorm 统计不稳,常见做法是配 GroupNorm 或 InstanceNorm。
3. 训练肺分割模型的完整流程:损失函数与评估指标怎么配
3.1 损失函数选型:Dice 与 BCE 的组合逻辑
肺在 CT 里占比不大,背景像素远多于前景,纯交叉熵会让模型倾向于全预测背景,loss 降得好看但 Dice 极低。常见做法是 Dice Loss 加 BCE 的加权组合:Dice 直接优化重叠度,BCE 提供稳定的像素级梯度。权重上我一般用bce_weight=0.5, dice_weight=0.5,如果小目标(比如肺内空洞)漏检严重,把 Dice 权重提到 0.7。
import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, bce_weight=0.5, dice_weight=0.5): super().__init__() self.bce_weight = bce_weight self.dice_weight = dice_weight def forward(self, pred, target): # pred 是 logits,先过 sigmoid bce = F.binary_cross_entropy_with_logits(pred, target) prob = torch.sigmoid(pred) intersection = (prob * target).sum(dim=(2, 3)) union = prob.sum(dim=(2, 3)) + target.sum(dim=(2, 3)) dice = 1 - (2 * intersection + 1e-6) / (union + 1e-6) return self.bce_weight * bce + self.dice_weight * dice.mean()1e-6是平滑项,防止某张切片完全没有肺时除零。sum(dim=(2,3))是在空间维度求和,保留 batch 维,最后对 batch 取均值。注意binary_cross_entropy_with_logits内部自带 sigmoid,所以传进去的必须是 logits,别再手动 sigmoid 一次,否则梯度会失真。
3.2 评估指标:Dice、IoU 与 HD95 的分工
Dice 是肺分割最常用的指标,但它对边界不敏感,两块区域重叠 90% 时 Dice 可能 0.95,可临床关心的边界误差没体现出来。所以测试阶段我一般同时报 Dice、IoU 和 HD95(95% 豪斯多夫距离)。HD95 衡量的是预测边界到真实边界的最远距离的第 95 百分位,对边界漂移敏感。
| 指标 | 含义 | 适用场景 | 注意点 |
|---|---|---|---|
| Dice | 2×交集/(预测+真实) | 整体重叠度 | 小目标敏感度低 |
| IoU | 交集/并集 | 与 Dice 互补 | 数值比 Dice 低 |
| HD95 | 边界距离第 95 百分位 | 边界精度评估 | 对孤立噪点敏感 |
计算时记得在原始分辨率上算,别在 256×256 缩放图上算完直接报,缩放会平滑边界,HD95 会偏乐观。常见做法是保存模型输出的概率图,resize 回原始尺寸再二值化。
3.3 训练循环与验证节奏
训练循环本身不复杂,关键是验证节奏和保存策略。我一般每 5 个 epoch 在测试集上跑一次完整评估,保存 Dice 最高的权重,而不是最后一个 epoch 的权重。肺分割在 30 到 50 个 epoch 之间通常收敛,再往后容易过拟合到训练病例的标注习惯。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(in_channels=1, out_channels=1).to(device) # 常见做法是 U-Net 起步 criterion = DiceBCELoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) best_dice = 0.0 for epoch in range(50): model.train() for img, mask in train_loader: img, mask = img.to(device), mask.to(device) optimizer.zero_grad() pred = model(img) loss = criterion(pred, mask) loss.backward() optimizer.step() if (epoch + 1) % 5 == 0: model.eval() dice_sum, n = 0.0, 0 with torch.no_grad(): for img, mask in test_loader: img, mask = img.to(device), mask.to(device) pred = (torch.sigmoid(model(img)) > 0.5).float() inter = (pred * mask).sum().item() dice_sum += (2 * inter + 1e-6) / (pred.sum().item() + mask.sum().item() + 1e-6) n += 1 dice = dice_sum / n print(f"epoch {epoch+1} test dice={dice:.4f}") if dice > best_dice: best_dice = dice torch.save(model.state_dict(), "best_lung_unet.pth")lr=1e-3是 Adam 的常规起点,如果 loss 震荡就降到 3e-4。阈值 0.5 是默认值,实际部署时可以在验证集上扫一遍 0.3 到 0.7,挑 Dice 最高的那个,这个后处理技巧经常能白捡 1 到 2 个点。
4. 肺分割实战避坑:五条踩过的坑和排查路径
4.1 现象:训练 loss 正常下降但 Dice 一直 0.3 左右
原因通常是掩码取值没统一。训练集掩码前景是 255,你二值化用了> 0,那没问题;但如果某批掩码前景是 1,你又用> 127,前景全变 0,模型学到的全是背景。解决:在 Dataset 初始化时打印每张掩码的唯一值,确认前景取值,再决定阈值。我一般直接写np.unique(mask)抽查前 10 张。
4.2 现象:测试集 Dice 比训练集高很多
这是典型的按切片随机划分导致的病人泄漏。同一个病人的切片同时出现在训练和测试里,模型等于见过测试数据。解决:回到 2.1 的统计脚本,检查病例编号是否有交集,有交集就重新按病例划分,别偷懒。
4.3 现象:HD95 异常大但 Dice 很高
多半是预测结果里出现了孤立的噪点区域,离真实肺很远。Dice 对这种小噪点不敏感,HD95 会被拉到几百毫米。解决:后处理做连通域分析,只保留最大连通区域,或者去掉面积小于 50 像素的连通块。常见做法是用scipy.ndimage.label加面积过滤。
4.4 现象:显存够但训练速度极慢
检查num_workers和图像读取方式。如果每张图都从磁盘读 PNG 再 resize,IO 会成为瓶颈。解决:预先把所有图像转成.npy或 HDF5,训练时直接内存映射;或者把num_workers调到 CPU 核数的 0.7 倍。Windows 上多进程有坑,设 0 反而稳。
4.5 现象:换一台机器推理结果全黑
大概率是归一化参数不一致。训练时用/255.0,推理时忘了除,输入值域变成 0 到 255,模型第一层就饱和了。解决:把预处理逻辑封装成一个函数,训练和推理共用,别在两处各写一遍。这个坑我踩过不止一次,后来强制自己所有预处理只留一个入口。
5. 进阶验证与后处理技巧:把 Dice 从 0.88 推到 0.92
5.1 测试时增强(TTA)的落地方式
模型训完之后,别急着交差。测试时增强是性价比最高的提点手段:对同一张测试图做水平翻转、轻微旋转,分别推理再把概率图平均。肺是左右对称器官,水平翻转的 TTA 尤其有效。实现上不需要改模型,只在推理阶段包一层:
def predict_with_tta(model, img, device): model.eval() probs = [] with torch.no_grad(): # 原图 probs.append(torch.sigmoid(model(img.to(device))).cpu()) # 水平翻转 probs.append(torch.sigmoid(model(torch.flip(img, dims=[3]).to(device))).cpu()) # 翻转回来再平均 probs[1] = torch.flip(probs[1], dims=[3]) return torch.stack(probs).mean(dim=0)torch.flip的dims=[3]对应宽度维,别搞错维度。TTA 的代价是推理时间翻倍,如果做实时推理就慎用。我实测在肺分割上 TTA 一般能带来 1 到 2 个 Dice 点,边界越模糊提升越明显。
5.2 连通域后处理与阈值扫描
前面提过孤立噪点会拉高 HD95,连通域过滤是标配。另外,二值化阈值 0.5 不是金科玉律。我会在测试集上跑一遍阈值扫描,从 0.3 到 0.7 每隔 0.05 算一次 Dice,挑最高的。这两个操作叠加,通常能把 Dice 从 0.88 推到 0.91 到 0.92。
| 后处理组合 | 预期 Dice 提升 | 代价 |
|---|---|---|
| 仅阈值扫描 | +0.5~1.0 | 无 |
| 仅连通域过滤 | +0.3~0.8 | 少量 CPU |
| 阈值扫描 + 连通域 | +1.0~2.0 | 少量 CPU |
| 再加 TTA | +2.0~3.5 | 推理时间翻倍 |
5.3 验证集划分与最终报告口径
最后提醒一个容易被忽略的点:测试集只能用一次。如果你反复在测试集上调阈值、选模型,测试集就变成了验证集,报出来的 Dice 不再可信。正确做法是从训练集里再切出 10% 到 15% 做验证集,调参和选模型都在验证集上做,测试集只在最后跑一次。我现在的习惯是,任何肺分割项目开工前先把三个 split 的病例编号写进配置文件,训练脚本只读配置,不手动改路径。从那以后,我再也没遇到过「测试集 Dice 虚高、上线就翻车」的情况。希望这份拆解能帮你把这份肺分割数据集真正用起来,少走几天弯路。
本文还有配套的精品资源,点击获取