肺分割数据集实战指南:从标注口径到Dice提升的完整流程
简介本资源为面向医学图像分割任务的肺部数据集适合深度学习入门者、医学影像方向研究者及需要肺部分割实验数据的开发者使用。数据在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, axis0)})这段代码做三件事按文件名前缀聚合病例、统计每个 split 的切片总量、检查图像尺寸是否统一。参数上case_id的切分规则要按你实际文件名调整有的数据集用patient001_03.png这种下划线分隔那就改成f.split(_)[0]。如果发现训练集和测试集的病例编号有重叠直接判定划分有问题别急着往下训。2.2 图像格式、位深与掩码取值肺分割数据的图像常见两种格式PNG 和 NIfTI.nii/.nii.gz。PNG 是 8 位灰度方便直接喂给常规 CNNNIfTI 保留原始 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, dtypenp.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), augmentFalse): 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, augmentTrue) test_ds LungSegDataset(lung_seg_dataset, test, augmentFalse) train_loader DataLoader(train_ds, batch_size8, shuffleTrue, num_workers4) test_loader DataLoader(test_ds, batch_size1, shuffleFalse)shuffleTrue只在训练集开测试集必须False否则你没法把预测结果和原图对应回去做可视化。num_workers在 Windows 上建议设 0多进程读图容易卡死这是血泪经验。batch_size8是 256×256 输入下的保守值显存够可以往上加但医学分割 batch 太大反而让 BatchNorm 统计不稳常见做法是配 GroupNorm 或 InstanceNorm。3. 训练肺分割模型的完整流程损失函数与评估指标怎么配3.1 损失函数选型Dice 与 BCE 的组合逻辑肺在 CT 里占比不大背景像素远多于前景纯交叉熵会让模型倾向于全预测背景loss 降得好看但 Dice 极低。常见做法是 Dice Loss 加 BCE 的加权组合Dice 直接优化重叠度BCE 提供稳定的像素级梯度。权重上我一般用bce_weight0.5, dice_weight0.5如果小目标比如肺内空洞漏检严重把 Dice 权重提到 0.7。import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, bce_weight0.5, dice_weight0.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 和 HD9595% 豪斯多夫距离。HD95 衡量的是预测边界到真实边界的最远距离的第 95 百分位对边界漂移敏感。指标含义适用场景注意点Dice2×交集/(预测真实)整体重叠度小目标敏感度低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_channels1, out_channels1).to(device) # 常见做法是 U-Net 起步 criterion DiceBCELoss() optimizer torch.optim.Adam(model.parameters(), lr1e-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(fepoch {epoch1} test dice{dice:.4f}) if dice best_dice: best_dice dice torch.save(model.state_dict(), best_lung_unet.pth)lr1e-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 再 resizeIO 会成为瓶颈。解决预先把所有图像转成.npy或 HDF5训练时直接内存映射或者把num_workers调到 CPU 核数的 0.7 倍。Windows 上多进程有坑设 0 反而稳。4.5 现象换一台机器推理结果全黑大概率是归一化参数不一致。训练时用/255.0推理时忘了除输入值域变成 0 到 255模型第一层就饱和了。解决把预处理逻辑封装成一个函数训练和推理共用别在两处各写一遍。这个坑我踩过不止一次后来强制自己所有预处理只留一个入口。5. 进阶验证与后处理技巧把 Dice 从 0.88 推到 0.925.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(dim0)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再加 TTA2.0~3.5推理时间翻倍5.3 验证集划分与最终报告口径最后提醒一个容易被忽略的点测试集只能用一次。如果你反复在测试集上调阈值、选模型测试集就变成了验证集报出来的 Dice 不再可信。正确做法是从训练集里再切出 10% 到 15% 做验证集调参和选模型都在验证集上做测试集只在最后跑一次。我现在的习惯是任何肺分割项目开工前先把三个 split 的病例编号写进配置文件训练脚本只读配置不手动改路径。从那以后我再也没遇到过「测试集 Dice 虚高、上线就翻车」的情况。希望这份拆解能帮你把这份肺分割数据集真正用起来少走几天弯路。本文还有配套的精品资源点击获取