简介:本资源为面向医学图像分割任务的肺部分割数据集,适合从事医学影像分析、深度学习分割算法练习与验证的开发者及学生使用。数据统一为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(f"size mismatch: {img_path.name} {img.size} vs {mask.size}") if missing: print(f"missing 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_counts=True) 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), interpolation=transforms.InterpolationMode.NEAREST), transforms.ToTensor() ])图像用双线性插值 resize,掩码必须用最近邻,否则边缘会出现 0.3、0.7 这种值,二值化后边界抖动。Normalize的均值和方差可以先按 0.5 估,跑一遍训练集统计真实值再替换。注意ToTensor会把 0-255 缩到 0-1,所以Normalize里不要再按 255 算。如果显存吃紧,把 256 降到 192 或 224,Dice 系数通常只掉 1 到 2 个点,但 batch size 能翻倍,训练更稳。
3. 用 U-Net 跑通肺分割训练:从 DataLoader 到第一个 epoch
3.1 自定义 Dataset 与训练集/测试集划分
PyTorch 的Dataset是连接数据和模型的中间层。肺分割数据已经给了训练集和测试集,但训练集内部通常还要切一部分做验证,用来观察过拟合。常见做法是按 8:2 从训练集里分,或者直接用测试集当验证集——后者不推荐,因为测试集参与调参后就不再是干净的评估集。
from torch.utils.data import Dataset, DataLoader import cv2 class LungSegDataset(Dataset): def __init__(self, root, split, img_size=256): 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), interpolation=cv2.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_size=8, shuffle=True, num_workers=2) val_loader = DataLoader(val_ds, batch_size=8, shuffle=False, num_workers=2)cv2.IMREAD_GRAYSCALE保证读进来是单通道,省去后面转灰度。掩码 resize 用INTER_NEAREST,和前面 transforms 里的逻辑一致。unsqueeze(0)把 H×W 变成 1×H×W,匹配 U-Net 输入通道。batch_size=8在 256×256 下大约占 2GB 显存,如果卡小就降到 4。num_workers在 Windows 上设 0 更稳,Linux 下设 2 到 4。注意shuffle=True只给训练集,验证集和测试集不要打乱,否则评估指标会波动。
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, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_ch=1, out_ch=1): 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, stride=2) self.up_conv1 = DoubleConv(256, 128) self.up2 = nn.ConvTranspose2d(128, 64, 2, stride=2) 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], dim=1)) u2 = self.up2(u1) u2 = self.up_conv2(torch.cat([u2, d1], dim=1)) return torch.sigmoid(self.out(u2)) def dice_loss(pred, target, eps=1e-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(), lr=1e-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(f"epoch {epoch+1}, 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_size=1, shuffle=False) 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(f"pred_{i:03d}.png", pred_np.astype(np.uint8)) print(f"test dice: {dice_total/len(test_loader):.4f}") print(f"test iou: {iou_total/len(test_loader):.4f}")batch_size=1方便逐张保存预测图。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(f"threshold {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 度、水平翻转、亮度对比度扰动是性价比最高的组合,弹性形变对肺边界有帮助但计算慢,可以放在后期。注意水平翻转时掩码要同步翻转,左右肺标签如果分左右,翻转后要交换标签,否则模型学出矛盾。如果测试集和训练集来自不同设备,先做直方图匹配再推理,能减少分布偏移带来的掉点。这些习惯是我踩过几次坑之后固定下来的:先确认掩码编码,再统一插值方式,最后才调模型。希望帮到你。
本文还有配套的精品资源,点击获取