资讯详情

肺分割数据集实战:从数据读取到U-Net训练与避坑指南

📅 2026/10/11 15:37:15 | 华诺云谱 👁 阅读
肺分割数据集实战:从数据读取到U-Net训练与避坑指南
简介本资源为面向医学图像分割任务的肺部分割数据集适合从事医学影像分析、深度学习分割算法练习与验证的开发者及学生使用。数据统一为256×256分辨率分割前景涵盖左肺、右肺等区域mask标签采用前景为255的二值图像便于直观观察与训练。压缩包共约2000个文件以1999张png图像和1个py脚本为主整体约253.01MB其中png包含训练集与测试集的原始图像及对应掩膜py脚本用于随机抽取一张图片展示原图、GT图像及GT在原图上的蒙板效果并保存至当前目录。训练集含6849张图像与6849个对应mask测试集含1712张图像与1712个对应mask划分清晰可直接用于模型训练与评估。目前已有1389人学习下载适合作为肺部分割实验的即用型数据基础。1. 肺分割数据集从胸部 X 光片到可训练掩码一条能跑通的链路手里有一批胸部 X 光片想训练一个能自动勾出肺野的模型第一步往往不是选网络而是卡在数据上。公开的肺分割数据大多以 PNG 图像加同名掩码的形式分发训练集和测试集已经切好但真正动手时你会发现灰度分布差异大、左右肺在掩码里是同一个标签、图像尺寸参差不齐。这个标题讲的正是这样一份「肺分割数据包含训练集和测试集」——它解决的是语义分割任务里最耗时的标注问题适合刚接触医学图像分割的算法工程师、做课程设计的学生以及想验证 U-Net 类模型在真实医学场景下表现的从业者。下面按「先看清数据长什么样、再跑通训练、最后避开翻车点」的顺序展开每一步都给出可复现的命令和参数。2. 肺分割数据集的结构与读取先搞清掩码编码方式2.1 训练集与测试集的目录约定和文件对应关系拿到一份肺分割数据第一件事是确认目录结构。常见做法是根目录下分train和test两个文件夹每个文件夹里再分images和masks图像与掩码同名不同后缀。也有把图像和掩码平铺在同一目录、靠文件名后缀区分的。无论哪种读取时都要保证一一对应否则训练时会出现图像和标签错位损失曲线看着正常但模型学不到东西。我一般先写一段脚本统计文件数量和配对情况顺便检查有没有缺失掩码或尺寸不一致的样本。这一步花两分钟能省掉后面几小时的排查。import os from pathlib import Path from PIL import Image def check_pairing(root, split): img_dir Path(root) / split / images mask_dir Path(root) / split / masks imgs sorted(img_dir.glob(*.png)) masks sorted(mask_dir.glob(*.png)) print(f{split}: {len(imgs)} images, {len(masks)} masks) missing [] for img_path in imgs: mask_path mask_dir / img_path.name if not mask_path.exists(): missing.append(img_path.name) continue img Image.open(img_path) mask Image.open(mask_path) if img.size ! mask.size: print(fsize mismatch: {img_path.name} {img.size} vs {mask.size}) if missing: print(fmissing masks: {missing[:5]} ... total {len(missing)}) else: print(all images have corresponding masks) check_pairing(./lung_seg, train) check_pairing(./lung_seg, test)这段代码做三件事统计图像和掩码数量、检查同名掩码是否存在、比对图像与掩码的尺寸。参数root指向数据集根目录split取train或test。如果输出里出现size mismatch说明有样本需要统一 resize 或裁剪如果missing masks不为空要么补掩码要么把对应图像从训练列表里剔除。注意掩码的像素值肺分割掩码常见的是 0 和 255 二值也有 0 和 1 的读取后要确认否则算损失时会出现梯度爆炸或全零预测。2.2 掩码像素值分布与二值化处理掩码的像素值直接决定损失函数怎么选。用PIL读进来是P模式或L模式转成 numpy 后先看唯一值。如果只有 0 和 255除以 255 得到 0/1如果出现 1、2、3 这种多类标签说明左右肺可能被分成了不同类别这时候要么保留多类做多通道输出要么合并成前景/背景二分类。import numpy as np from PIL import Image mask np.array(Image.open(./lung_seg/train/masks/0001.png)) unique, counts np.unique(mask, return_countsTrue) print(dict(zip(unique.tolist(), counts.tolist()))) # 二值化大于 0 的都算肺野 binary (mask 0).astype(np.float32) print(foreground ratio:, binary.mean())np.unique返回掩码里所有出现过的像素值及数量。如果唯一值是[0, 255]mask 0就能得到干净的 0/1 掩码。foreground ratio是前景像素占比肺野在胸片里通常占 15% 到 30%如果算出来只有 2% 或超过 60%大概率是掩码反了或者包含了背景标注需要回头检查数据来源。这个比例也影响损失函数的选择前景占比低时普通交叉熵容易被背景主导改用 Dice Loss 或带权交叉熵更稳。2.3 图像归一化与尺寸统一策略胸部 X 光片的灰度范围差异很大有的设备输出 8 位 0-255有的经过窗宽窗位调整后集中在某个区间。直接送进网络前按数据集统计均值方差做归一化比固定除以 255 更稳。尺寸方面肺分割常用 256×256 或 512×512前者训练快、显存占用低后者保留更多边缘细节。如果原始尺寸不统一建议先统一 resize 到目标尺寸同时把掩码用最近邻插值避免引入中间灰度值。import torch from torchvision import transforms img_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]) ]) mask_transform transforms.Compose([ transforms.Resize((256, 256), interpolationtransforms.InterpolationMode.NEAREST), transforms.ToTensor() ])图像用双线性插值 resize掩码必须用最近邻否则边缘会出现 0.3、0.7 这种值二值化后边界抖动。Normalize的均值和方差可以先按 0.5 估跑一遍训练集统计真实值再替换。注意ToTensor会把 0-255 缩到 0-1所以Normalize里不要再按 255 算。如果显存吃紧把 256 降到 192 或 224Dice 系数通常只掉 1 到 2 个点但 batch size 能翻倍训练更稳。3. 用 U-Net 跑通肺分割训练从 DataLoader 到第一个 epoch3.1 自定义 Dataset 与训练集/测试集划分PyTorch 的Dataset是连接数据和模型的中间层。肺分割数据已经给了训练集和测试集但训练集内部通常还要切一部分做验证用来观察过拟合。常见做法是按 8:2 从训练集里分或者直接用测试集当验证集——后者不推荐因为测试集参与调参后就不再是干净的评估集。from torch.utils.data import Dataset, DataLoader import cv2 class LungSegDataset(Dataset): def __init__(self, root, split, img_size256): self.img_dir Path(root) / split / images self.mask_dir Path(root) / split / masks self.names sorted([p.name for p in self.img_dir.glob(*.png)]) self.img_size img_size def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img cv2.imread(str(self.img_dir / name), cv2.IMREAD_GRAYSCALE) mask cv2.imread(str(self.mask_dir / name), cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (self.img_size, self.img_size)) mask cv2.resize(mask, (self.img_size, self.img_size), interpolationcv2.INTER_NEAREST) img img.astype(np.float32) / 255.0 mask (mask 0).astype(np.float32) img torch.from_numpy(img).unsqueeze(0) mask torch.from_numpy(mask).unsqueeze(0) return img, mask train_ds LungSegDataset(./lung_seg, train) val_ds LungSegDataset(./lung_seg, test) train_loader DataLoader(train_ds, batch_size8, shuffleTrue, num_workers2) val_loader DataLoader(val_ds, batch_size8, shuffleFalse, num_workers2)cv2.IMREAD_GRAYSCALE保证读进来是单通道省去后面转灰度。掩码 resize 用INTER_NEAREST和前面 transforms 里的逻辑一致。unsqueeze(0)把 H×W 变成 1×H×W匹配 U-Net 输入通道。batch_size8在 256×256 下大约占 2GB 显存如果卡小就降到 4。num_workers在 Windows 上设 0 更稳Linux 下设 2 到 4。注意shuffleTrue只给训练集验证集和测试集不要打乱否则评估指标会波动。3.2 U-Net 网络定义与 Dice Loss 选择U-Net 是肺分割里最稳的基线编码器逐层下采样提特征解码器逐层上采样恢复分辨率跳跃连接把浅层边缘信息传过去。肺野边界模糊跳跃连接对边缘的贡献很明显。损失函数用 Dice Loss 或 Dice BCE 组合前者直接优化重叠度后者稳定梯度。import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1): super().__init__() self.down1 DoubleConv(in_ch, 64) self.down2 DoubleConv(64, 128) self.down3 DoubleConv(128, 256) self.pool nn.MaxPool2d(2) self.up1 nn.ConvTranspose2d(256, 128, 2, stride2) self.up_conv1 DoubleConv(256, 128) self.up2 nn.ConvTranspose2d(128, 64, 2, stride2) self.up_conv2 DoubleConv(128, 64) self.out nn.Conv2d(64, out_ch, 1) def forward(self, x): d1 self.down1(x) d2 self.down2(self.pool(d1)) d3 self.down3(self.pool(d2)) u1 self.up1(d3) u1 self.up_conv1(torch.cat([u1, d2], dim1)) u2 self.up2(u1) u2 self.up_conv2(torch.cat([u2, d1], dim1)) return torch.sigmoid(self.out(u2)) def dice_loss(pred, target, eps1e-6): pred pred.view(-1) target target.view(-1) inter (pred * target).sum() return 1 - (2 * inter eps) / (pred.sum() target.sum() eps)DoubleConv是 U-Net 的基本块两次卷积加 BN 和 ReLU。ConvTranspose2d做上采样torch.cat把解码器特征和对应编码器特征拼起来。输出用sigmoid压到 0-1配合 Dice Loss。eps防止分母为零。训练时可以把 Dice Loss 和BCEWithLogitsLoss按 1:1 加权但注意 BCE 需要 logits所以网络输出不要先 sigmoid改成在损失里处理。我一般先用纯 Dice 跑通再尝试组合损失。3.3 训练循环与验证指标计算训练循环里每个 epoch 跑完训练集后在验证集上算 Dice 系数保存验证 Dice 最高的权重。优化器用 Adam学习率 1e-3跑 50 个 epoch 通常能看到收敛。如果验证 Dice 卡在 0.85 上不去先检查掩码有没有反再考虑加数据增强。device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet().to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-3) def evaluate(model, loader): model.eval() dice_sum 0 with torch.no_grad(): for img, mask in loader: img, mask img.to(device), mask.to(device) pred model(img) pred (pred 0.5).float() inter (pred * mask).sum() dice (2 * inter 1e-6) / (pred.sum() mask.sum() 1e-6) dice_sum dice.item() return dice_sum / len(loader) for epoch in range(50): model.train() loss_sum 0 for img, mask in train_loader: img, mask img.to(device), mask.to(device) optimizer.zero_grad() pred model(img) loss dice_loss(pred, mask) loss.backward() optimizer.step() loss_sum loss.item() val_dice evaluate(model, val_loader) print(fepoch {epoch1}, loss {loss_sum/len(train_loader):.4f}, val dice {val_dice:.4f}) if val_dice 0.9: torch.save(model.state_dict(), best_lung_unet.pth)evaluate里把预测二值化后算 Dice和训练时的软 Dice 略有差异但趋势一致。val_dice 0.9时保存权重避免后期过拟合。如果训练 loss 持续下降但验证 Dice 不涨说明过拟合加随机旋转、水平翻转、弹性形变。肺分割里水平翻转要小心左右肺对称翻转后掩码也要同步翻转否则标签错位。num_workers大于 0 时如果报错先设 0 排查。4. 肺分割训练避坑从掩码反转到显存溢出的排查记录4.1 掩码前景背景反转导致 Dice 长期低于 0.3现象训练 loss 缓慢下降但验证 Dice 一直在 0.2 到 0.3 之间预测结果看起来像把背景当成了肺。原因掩码像素值里肺野是 0、背景是 255二值化时mask 0把背景当成了前景。解决打印掩码唯一值和前景占比如果前景占比超过 60%大概率反了改成mask 0或先做反转。这个坑在肺分割里很常见因为不同标注工具对前景的编码习惯不同。4.2 图像与掩码插值方式不一致导致边缘抖动现象训练 Dice 能到 0.9但预测掩码边缘呈锯齿状和真实边界对不上。原因图像用双线性插值 resize掩码也用了双线性边缘产生 0 到 1 之间的过渡值二值化后边界漂移。解决掩码 resize 一律用最近邻cv2.INTER_NEAREST或InterpolationMode.NEAREST。如果已经用双线性处理过重新生成掩码缓存不要指望模型自己修正。4.3 batch size 过大导致显存溢出与训练中断现象跑几个 batch 后报CUDA out of memory或者程序直接被杀。原因256×256 下单张图占显存不大但 U-Net 第一层 64 通道、batch size 设到 16 或 32 时中间特征图叠加很快吃满显存。解决先把 batch size 降到 4 或 8用torch.cuda.empty_cache()清理缓存如果还不够把输入降到 192×192或者把 U-Net 第一层通道从 64 降到 32。混合精度训练也能省一半显存但要注意 Dice Loss 在 fp16 下的数值稳定性。4.4 测试集当验证集用导致评估指标虚高现象验证 Dice 0.95换一批新数据测试只有 0.8。原因训练过程中反复用测试集调超参、选模型测试集信息泄漏到了模型选择里。解决从训练集里切 10% 到 20% 做验证测试集只在最后评估一次。如果训练集本身很小用 K 折交叉验证每折的验证集轮流当验证最后取平均。肺分割数据如果训练集只有几十张K 折比单次切分更可靠。4.5 学习率过大导致损失震荡不收敛现象训练 loss 在前几个 epoch 上下跳动Dice 不升反降。原因Adam 默认学习率 1e-3 对 U-Net 有时偏大尤其 batch size 小的时候梯度噪声大。解决降到 1e-4 或 5e-4加余弦退火调度或者先用 1e-4 跑 10 个 epoch 预热再升到 1e-3。如果 loss 变成 NaN检查输入归一化有没有除零以及 Dice Loss 的eps是否足够大。5. 肺分割模型评估与推理把测试集跑成可视化结果5.1 测试集推理脚本与 Dice/IoU 指标输出训练完保存的权重要在测试集上跑一遍算 Dice 和 IoU同时保存预测掩码方便肉眼检查。推理时不要用数据增强保持和验证一致的预处理。model.load_state_dict(torch.load(best_lung_unet.pth)) model.eval() test_ds LungSegDataset(./lung_seg, test) test_loader DataLoader(test_ds, batch_size1, shuffleFalse) dice_total, iou_total 0, 0 with torch.no_grad(): for i, (img, mask) in enumerate(test_loader): img, mask img.to(device), mask.to(device) pred (model(img) 0.5).float() inter (pred * mask).sum() dice (2 * inter 1e-6) / (pred.sum() mask.sum() 1e-6) union pred.sum() mask.sum() - inter iou (inter 1e-6) / (union 1e-6) dice_total dice.item() iou_total iou.item() # 保存预测用于可视化 pred_np pred.squeeze().cpu().numpy() * 255 cv2.imwrite(fpred_{i:03d}.png, pred_np.astype(np.uint8)) print(ftest dice: {dice_total/len(test_loader):.4f}) print(ftest iou: {iou_total/len(test_loader):.4f})batch_size1方便逐张保存预测图。Dice 和 IoU 的计算逻辑和验证一致但这里只在测试集上跑一次。如果 Dice 和 IoU 差距很大说明边界区域预测不稳定可以看几张预测图确认是欠分割还是过分割。保存的pred_xxx.png和原图叠加后能直观看出模型在哪里出错。5.2 预测结果叠加原图的可视化检查把预测掩码以半透明红色叠在原图上能快速定位问题。如果肺野内部有空洞说明模型对低对比度区域不敏感如果边界外扩说明 Dice Loss 对边界的惩罚不够。可视化不是为了好看是为了决定下一步是加数据、换损失还是调阈值。import matplotlib.pyplot as plt img cv2.imread(./lung_seg/test/images/0001.png, cv2.IMREAD_GRAYSCALE) pred cv2.imread(pred_000.png, cv2.IMREAD_GRAYSCALE) overlay cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) overlay[pred 127] [255, 0, 0] plt.imshow(overlay) plt.axis(off) plt.show()overlay[pred 127]把预测为肺的区域染红。如果红色区域明显偏离肺野先检查测试集图像和训练集是否同分布比如曝光度、体位有没有差异。医学图像里设备差异很常见跨中心数据往往需要重新归一化或微调。5.3 阈值调整对 Dice 的影响与选择模型输出是 0 到 1 的概率图二值化阈值默认 0.5但肺分割里 0.4 或 0.6 有时能提升 Dice。写个循环扫一遍阈值在验证集上选最优再固定到测试集。thresholds [0.3, 0.4, 0.5, 0.6, 0.7] for t in thresholds: dice_sum 0 with torch.no_grad(): for img, mask in val_loader: img, mask img.to(device), mask.to(device) pred (model(img) t).float() inter (pred * mask).sum() dice_sum ((2 * inter 1e-6) / (pred.sum() mask.sum() 1e-6)).item() print(fthreshold {t}: dice {dice_sum/len(val_loader):.4f})阈值低于 0.5 会扩大预测区域适合欠分割的模型高于 0.5 会收缩适合过分割。选好阈值后写进推理脚本不要每次手动改。如果不同图像的最优阈值差异大说明模型置信度校准不好考虑加温度缩放或换损失函数。6. 肺分割数据进阶小样本下的迁移学习与数据增强组合肺分割公开数据通常只有几百张从头训练 U-Net 容易过拟合。我一般先用 ImageNet 预训练的编码器比如 ResNet34替换 U-Net 编码器解码器随机初始化学习率设成编码器 1e-4、解码器 1e-3跑 20 个 epoch 后再统一微调。这样验证 Dice 通常比从头训练高 3 到 5 个点。数据增强方面随机旋转 ±15 度、水平翻转、亮度对比度扰动是性价比最高的组合弹性形变对肺边界有帮助但计算慢可以放在后期。注意水平翻转时掩码要同步翻转左右肺标签如果分左右翻转后要交换标签否则模型学出矛盾。如果测试集和训练集来自不同设备先做直方图匹配再推理能减少分布偏移带来的掉点。这些习惯是我踩过几次坑之后固定下来的先确认掩码编码再统一插值方式最后才调模型。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑