简介:这是一份面向医学影像分析与深度学习实践者的肺部图像分割数据集,专注解决肺实质、左肺与右肺等结构的分割建模问题,可直接用于模型训练、验证与测试。数据均为256×256分辨率,标签掩码为前景像素值255的二值图像,便于直接观察和计算Dice等分割指标。资源共2000个文件,压缩包大小约253.01MB,其中1999张PNG图像覆盖训练集与测试集的原始影像和对应掩码,另有1个Python可视化脚本,可随机抽取样本并生成原图、GT、GT蒙板对比图,辅助快速检查数据质量和评估分割效果。训练集包含6849对图像与掩码,测试集包含1712对,划分清晰,适合入门到进阶的医学图像分割项目实战。目前已有1387人学习下载。
1. 医学图像分割数据集:肺分割数据,训练集测试集一次配齐
做医学图像分割的人,最怕的不是模型调不通,而是数据不到位。这份肺分割数据集把训练集和测试集一次配齐,省掉最耗时的标注环节,拿到手就能开跑 U-Net,直接对照 Dice、IoU 验证思路。
它解决的是肺实质分割这个经典任务:输入一张胸部影像,输出对应的肺部区域掩码。适合刚入门医学图像分割的研究生、医疗 AI 算法工程师,以及想快速验证分割想法但不想从零标数据的从业者。
下面按「结构 → 训练 → 避坑 → 评估」的顺序,把这份资源从拿到手到出结果完整过一遍。
2. 数据集结构拆解:先看清标注格式和目录规则再动手
拿到数据集先别急着训练,把目录结构和标注格式摸清楚,能避免后面一半的报错。这一章只做两件事:搞清楚文件怎么组织、掩码长什么样。
2.1 目录结构与文件组织
这份资源最常见的组织方式是 images 和 masks 两个平级目录,各自内部再按 train、test 分好,原图与掩码分开存放,靠文件名一一对应。我拿到手的第一件事不是看 README,而是先跑一段命令确认文件数量和命名规律。
find . -type f | grep -E '\.(png|jpg|jpeg|nii|dcm)$' | head -50 find . -type f | grep -E '\.(png|jpg|jpeg|nii|dcm)$' | wc -l第一行查看前 50 个文件的完整路径,确认命名是否成对存在;第二行统计总数,用来和 README 里声称的数量对比。如果 images 和 masks 的文件数不一致,多半是漏标注或下载断档,先把数据补齐再谈训练,否则训练时索引会直接越界。
除了解压后的原生目录,我还会顺手检查有没有隐藏文件混进来,比如 macOS 的 .DS_Store、Windows 的 Thumbs.db。这些文件被 os.listdir 当样本读进来后,DataLoader 一跑就报错。常见做法是在遍历时加扩展名过滤,3.1 的数据加载器里会给出完整写法。
2.2 标注格式与关键参数
肺分割的掩码通常是单通道二值图:背景像素值为 0,前景(肺区)像素值为 255,用 PNG 或 JPG 存储。但有个细节容易翻车——部分资源的掩码被存成三通道 RGB 图,三个通道内容完全一样,直接读进来形状是 (H, W, 3),和模型的单通道输出对不上,loss 计算立刻报错。
拿到手后我一般用一行代码确认掩码的形状和取值:
from PIL import Image import numpy as np mask = np.array(Image.open('masks/train/0001.png')) print(mask.shape, mask.dtype, np.unique(mask)) # 期望输出类似 ((512, 512), uint8, array([ 0, 255])) # 如果 shape 是 (512, 512, 3),说明是三通道掩码,需转灰度提示:判断掩码是否三通道,最快的方法就是看 shape 的第二维和第三维是否一致。
输出里 shape 如果是三维,加载时就统一用 convert('L') 转成单通道;unique 的结果应该只有 0 和 255 两个值,如果出现 128 之类的中间值,说明原始标注被压缩过或做过平滑,训练前要重新二值化。还有一类资源掩码是 0/1 而不是 0/255,这不影响训练,加载器里统一处理即可。
图像尺寸这项,常见资源有 256×256、512×512 或原始大小不一。我的建议是训练前统一 resize 到 512×512:U-Net 在这个分辨率下能兼顾显存占用和分割细节,肺这种大器官目标,256 会明显损失边缘精度;如果原图本身比 512 还小,保持原尺寸做 padding 即可,不要强行放大引入插值伪影。
| 参数 | 常见取值 | 说明 |
|---|---|---|
| 图像尺寸 | 512×512 / 256×256 | 训练前统一 resize |
| 掩码通道 | 单通道 | 三通道需 convert('L') |
| 前景像素值 | 255 或 1 | 归一化后统一转 0/1 |
| 文件格式 | PNG / JPG | NIfTI 需换专用加载器 |
掩码在 resize 时有一条硬性要求:必须用最近邻插值(NEAREST),不能用双线性或三次插值。否则掩码边缘会出现 0 到 255 之间的过渡像素,训练时模型把过渡值当"软标签"去学,最终预测边界模糊。这一点在 3.1 会再次强调。
2.3 训练集与测试集的划分逻辑
这份资源已经把 train 和 test 分好了,省掉自己切分的麻烦。但拿到划分后还要确认测试集是否真的独立:有些数据集的不同切片来自同一批病例,测试集和训练集在病人维度上重叠,评估出来的指标会虚高。
验证方法很直接:
import os train_names = set(os.listdir('images/train')) test_names = set(os.listdir('images/test')) overlap = train_names & test_names print('重叠文件数:', len(overlap))如果重叠文件不少,报告指标时就要说明这一点,或者手动把重复文件从训练集剔除。我的习惯是优先信任资源自带划分,但重叠超过 5%,就按文件名前缀(比如病例 ID)重新分组切分,保证同一病人的所有切片只出现在一侧。这个检查花不了两分钟,却能避免后续报告里的数据泄漏硬伤。
3. 用 U-Net 跑通肺分割:数据加载、训练参数与评估脚本
U-Net 是医学图像分割的事实标准基线:编码器逐层下采样提取语义特征,解码器逐层上采样恢复空间分辨率,配合 skip connection 把浅层细节传给深层。肺这种边界相对清晰、结构稳定的器官,第一版 U-Net 就能拿到不错的 Dice,不需要一上来就换 Transformer 分割模型,先跑通基线再谈改进。
3.1 数据加载器:注意掩码插值方式
数据加载器是整个流程里最容易藏 bug 的地方。下面这份基础版本直接对应这份资源的目录结构。
import os import numpy as np import torch from torch.utils.data import Dataset, DataLoader from PIL import Image IMG_SIZE = 512 class LungSegDataset(Dataset): def __init__(self, img_dir, mask_dir, size=IMG_SIZE, augment=False): self.img_dir = img_dir self.mask_dir = mask_dir self.size = size self.augment = augment # 只保留图片文件,过滤 .DS_Store 等隐藏文件 self.names = sorted([ f for f in os.listdir(img_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg')) ]) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img = Image.open(os.path.join(self.img_dir, name)).convert('L') mask = Image.open(os.path.join(self.mask_dir, name)).convert('L') # 原图用 BILINEAR 缩放到统一尺寸 img = img.resize((self.size, self.size), Image.BILINEAR) # 掩码必须用 NEAREST,避免产生过渡像素 mask = mask.resize((self.size, self.size), Image.NEAREST) img = np.array(img, dtype=np.float32) / 255.0 mask = np.array(mask, dtype=np.float32) / 255.0 mask = (mask > 0.5).astype(np.float32) # 重新二值化 img = torch.from_numpy(img).unsqueeze(0) # (1, H, W) mask = torch.from_numpy(mask).unsqueeze(0) # (1, H, W) return img, mask这段代码有三个关键点。第一,convert('L') 统一转灰度,顺便解决三通道掩码问题;第二,原图和掩码使用不同的插值方式,这是分割任务的铁律,augment 参数预留给你后续扩展增强逻辑,mask 仍然要保持最近邻;第三,除以 255 并重新二值化,保证输入和标签的取值范围一致。依赖就四个:torch、Pillow、numpy、matplotlib,评估边界指标时再加 scipy。
实例化时按资源实际目录传路径即可,测试集记得把 augment 关掉:
train_ds = LungSegDataset('images/train', 'masks/train', augment=True) test_ds = LungSegDataset('images/test', 'masks/test', augment=False) train_loader = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=4) test_loader = DataLoader(test_ds, batch_size=8, shuffle=False, num_workers=4)batch_size 设为 8 是 512×512 分辨率下的稳妥值,显卡显存只有 8G 左右的话建议降到 4 并配合梯度累积,具体见 4.5。num_workers 在 Windows 上如果报错,改成 0 即可。
3.2 训练参数与 loss 选型
肺分割里背景像素远多于肺区像素,普通 BCE loss 会倾向把一切预测为背景。最常见的做法是 Dice loss,或者在 BCE 基础上叠加 Dice,两个思路我都试过,结论是 BCE + Dice 组合最稳。
def dice_loss(pred, target, smooth=1e-6): pred = torch.sigmoid(pred) inter = (pred * target).sum() union = pred.sum() + target.sum() return 1 - (2 * inter + smooth) / (union + smooth) def bce_dice_loss(pred, target, bce_weight=0.5): bce = torch.nn.functional.binary_cross_entropy_with_logits(pred, target) return bce_weight * bce + dice_loss(pred, target)Dice loss 对类不平衡天然鲁棒,分母同时包含预测和标签的前景像素总和,不会因为背景占多数就把梯度带偏;叠加 BCE 是给每个像素一个独立的梯度信号,防止 Dice 在训练初期对局部误差不敏感。bce_weight 一般取 0.5,如果训练初期 loss 震荡厉害,可以调到 0.3。
优化器用 Adam,初始学习率 1e-3,训练 50 轮后降到 1e-4 微调。肺分割任务收敛比一般自然图像分割快,50 轮以内 Dice 基本不再明显上涨,不用像大模型那样动辄几百轮。如果 20 轮后 Dice 还在 0.6 以下挣扎,问题大概率不在训练参数,而在数据加载或前处理,回头看第 4 章。
3.3 测试集评估流程
训练完成后,评估脚本要固定下来,不能每次手写。下面这段输出每个测试样本的 Dice 和 IoU,并打印平均值。
def evaluate(model, loader): model.eval() dice_list, iou_list = [], [] with torch.no_grad(): for img, mask in loader: pred = torch.sigmoid(model(img)) pred_bin = (pred > 0.5).float() inter = (pred_bin * mask).sum(dim=(1, 2, 3)) union = pred_bin.sum(dim=(1, 2, 3)) + mask.sum(dim=(1, 2, 3)) iou = inter / (union - inter + 1e-6) dice = 2 * inter / (union + 1e-6) dice_list.append(dice.cpu().numpy()) iou_list.append(iou.cpu().numpy()) dice_all = np.concatenate(dice_list) iou_all = np.concatenate(iou_list) print(f'Mean Dice: {dice_all.mean():.4f}, Mean IoU: {iou_all.mean():.4f}') return dice_all, iou_all注意这里逐样本计算指标再取平均,而不是把所有样本的混淆矩阵累计起来一次性算——后者会放大目标大样本的权重,对目标小的样本不公平。肺分割里不同病例的肺区大小差异不小,逐样本平均更符合临床评估习惯。
评估时阈值固定用 0.5。不要为了刷指标去测试集上调阈值,那等于把测试集变成了验证集。想用 Otsu 自适应阈值,就只允许在验证集上考察,测试集的结果必须是流程跑完之后一次性得到。
4. 肺分割训练避坑指南:新手最容易翻车的五个问题
这一章的五条坑,我按「训练日志能看到的 → 输出图像能看到的 → 数据本身的问题」来排序。指标不正常时先别急着换模型结构,按这个顺序排查,通常十分钟内能定位到根因。每一条都是现象、原因、解决三步走。
4.1 现象:loss 卡住不降,预测输出全黑
现象:训练了十几轮,loss 几乎不动,拿一张测试图推理,输出掩码全是背景。原因:最普遍的是掩码被当成三通道 RGB 读入,标签形状变成 (3, H, W),而模型输出是 (1, H, W),广播后梯度信号被稀释成噪声;另一类是掩码像素值 0/255,归一化时忘了除以 255,标签几乎全是 1,loss 卡在错误的高位平台。解决:加载器里统一 convert('L') 转灰度并除以 255,然后打印 np.unique 确认标签只有 0 和 1。我每换一个数据集,都会把这两行检查放到训练脚本最前面跑一遍,确认标签分布正常再开训。
4.2 现象:Dice 分数不错,但分割边界锯齿严重
现象:Dice 到了 0.94,感觉不错,把输出图放大看,边界全是锯齿和零散小点。原因:掩码在 resize 时用了双线性插值,产生 0 到 1 之间的过渡像素,模型学的是模糊边界而不是硬边界,阈值一卡就切成锯齿;另一个隐蔽来源是数据增强里的旋转缩放,很多增强库默认对 mask 做平滑插值。解决:掩码统一用 Image.NEAREST,增强配置里显式指定 mask 用最近邻。验证方法是对 resize 后的掩码跑 np.unique,出现接近 0.5 的小数就是插值出了问题。
4.3 现象:验证集指标好,测试集突然崩掉
现象:验证集 Dice 0.93,测试集只有 0.71,差距大得不合理。原因:最常见的是把测试集当验证集反复看,模型和超参都对测试集产生了隐式拟合;其次是训练时对测试数据也做了随机增强,或者测试集和训练集存在同一病人的重叠切片。解决:从第一天起就分成 train / val / test 三份,只有 train 和 test 两份的话,从 train 里再切 10% 当验证集,测试集完全锁起来不看。测试集预处理固定为 resize 加归一化,不做随机增强。如果确认资源划分里文件名或病例 ID 重复,就按病例重新切分。
4.4 现象:肺内小结节或血管区域被模型吞掉
现象:大块肺区分割正常,但边缘的小结节、血管末端被模型直接预测成背景。原因:肺分割里背景像素占比高,前景里大块肺区又占主导,Dice loss 对小目标区域的梯度贡献极小,模型选择牺牲细节换整体分数。解决:换 Tversky loss,把假阴性权重调高,beta 取 0.7。Tversky 和 Dice 的区别只在 alpha、beta 两个权重,Dice 等价于 alpha=beta=0.5,调 beta 本质上是在 Dice 基础上给漏检加罚。
def tversky_loss(pred, target, alpha=0.3, beta=0.7, smooth=1e-6): pred = torch.sigmoid(pred) tp = (pred * target).sum() fp = (pred * (1 - target)).sum() fn = ((1 - pred) * target).sum() return 1 - (tp + smooth) / (tp + alpha * fp + beta * fn + smooth)alpha 控制假阳性惩罚,beta 控制假阴性惩罚,beta 大于 alpha 就是让模型更不愿意漏前景。如果发现模型反而把背景大片预测成肺,就把 beta 调回 0.5。这是肺分割里处理小目标最常用的手段之一。
4.5 现象:显存报错 OOM
现象:512×512 分辨率,batch 设 16,跑两步直接 CUDA out of memory。原因:U-Net 四层编码器的中间特征图显存占用不小,batch 太大是新手最常踩的显存坑。解决:先把 batch 降到 4,还爆就用梯度累积模拟大 batch;或者把输入降到 384×384。梯度累积是攒几个 batch 再 optimizer.step(),但它对 BatchNorm 不友好——统计量仍按小 batch 更新,介意这个就用 GroupNorm 或直接降分辨率。我的经验是 512 分辨率配 batch 4~8,是大多数 8G 显存卡的舒适区。
5. 评估指标实战:Dice、IoU 与分割边界可视化
指标算得准,结果才可信。这一章讲两件事:指标怎么算不踩坑,以及怎么把分割结果可视化后人工复核。经常有人训练完只看一个 Dice 数字,其实输出图里藏着大量指标反映不出来的问题,而指标算法的口径不同,数字也完全不可比。
5.1 Dice 与 IoU 的正确计算方式
3.3 里用的是逐样本平均,这里再解释一次为什么不用全局混淆矩阵:全局方式把全部样本的预测像素和标签像素累计到一起再算,大目标样本在累计里占的权重大,小目标样本的误差被稀释。肺分割里不同病例的肺面积差异可以接近一倍,逐样本平均更公平。如果两份结果的评估方式不一致,数字不能直接对比。
补充一个容易算错的细节:空目标样本的处理。测试集里如果存在某张图完全没有肺区(mask 全黑),Dice 分子分母都趋近于 0,直接算会得到 0 或 NaN。
def safe_metrics(pred_bin, mask): inter = (pred_bin * mask).sum() union = pred_bin.sum() + mask.sum() - inter if union == 0: # 标签为空且预测也为空,视为完全正确 return 1.0, 1.0 dice = 2 * inter / (pred_bin.sum() + mask.sum() + 1e-6) iou = inter / (union + 1e-6) return dice.item(), iou.item()空目标样本在报告时要单独说明数量,不能静默剔除。如果数据里有正常肺区很小的样本,IoU 天然偏低,这是尺度效应,不代表模型变差。比较不同模型时,测试集、预处理、指标口径三者必须完全一致,否则对比没意义。
5.2 逐样本可视化与失败案例筛选
指标之外,最重要的是把预测结果画出来人眼过一遍。我习惯在评估时输出一个图片网格,每个测试样本放三张图:原图、标签掩码、预测掩码,并标注 Dice。
import matplotlib.pyplot as plt def save_overlay(img, mask, pred, dice, path): fig, axes = plt.subplots(1, 3, figsize=(12, 4)) axes[0].imshow(img.squeeze(), cmap='gray') axes[1].imshow(mask.squeeze(), cmap='gray') axes[2].imshow(pred.squeeze(), cmap='gray') axes[0].set_title('input') axes[1].set_title('label') axes[2].set_title(f'pred dice={dice:.3f}') for ax in axes: ax.axis('off') plt.savefig(path, bbox_inches='tight', dpi=100) plt.close()把这套函数跑在全部测试集上,然后按 Dice 升序排列,重点看排在最后 10% 的样本,单独复制到一个 fail 目录里逐个打开。失败案例分几类要心里有数:整体偏移说明模型空间定位有问题;边缘大量锯齿说明插值或增强配置不对;胸腔积液区域被误判成肺,说明训练数据里负样本不够,需要补充。这类定性结论,是单一指标永远给不了的。
5.3 再看一个边界指标:HD95
Dice 和 IoU 都是面积重叠类指标,对边界质量不敏感。医学图像分割里最常用的边界指标是 95% Hausdorff 距离(HD95),衡量两组轮廓之间的距离,单位是像素。HD95 越低,预测边界越贴近标签边界,很多高 Dice 的预测在 HD95 上会现出原形。
from scipy.ndimage import distance_transform_edt def hd95(pred_bin, mask_bin): pred_edt = distance_transform_edt(1 - pred_bin) mask_edt = distance_transform_edt(1 - mask_bin) d1 = pred_edt[mask_bin > 0] d2 = mask_edt[pred_bin > 0] hd = np.percentile(np.concatenate([d1, d2]), 95) return hd实现思路是计算两个方向的距离变换,取 95 分位数剔除离群点干扰。HD95 对像素级抖动很敏感,两个模型 Dice 差 0.01、HD95 可能差 2 个像素,在需要清晰边界的场景下是重要区分信号。注意 HD95 对孤立噪点非常敏感,一个远离主体的假阳性点就会让距离值跳升,算之前最好先做连通域后处理,把小于 50 像素的连通域去掉。
| 指标 | 衡量内容 | 边界敏感度 | 单位 |
|---|---|---|---|
| Dice | 前景面积重叠 | 低 | 无量纲 |
| IoU | 前景面积重叠 | 低 | 无量纲 |
| HD95 | 轮廓点距离 | 高 | 像素 |
报告指标时我习惯同时给 Dice、IoU、HD95 三个数,单提任何一个都说明不了全貌。
6. 进阶用法:从分割掩码到肺面积统计
分割出掩码之后,下一个自然诉求是把掩码转成有临床意义的量,肺面积占比是最常见的落地指标。思路是统计掩码前景像素数占整图的比例,再按物理分辨率换算成真实面积。
def lung_area_stats(mask, pixel_spacing_mm=None): mask_bin = (mask > 0.5).astype(np.uint8) area_pixel = mask_bin.sum() total_pixel = mask_bin.size ratio = area_pixel / total_pixel if pixel_spacing_mm is not None: area_mm2 = area_pixel * (pixel_spacing_mm ** 2) return ratio, area_mm2 return ratiopixel_spacing_mm 是单个像素对应的物理尺寸,来自 DICOM 头信息;如果资源给的是普通 PNG 没有物理信息,就只报占比。除了面积,我还会顺手做一步连通域分析,把预测掩码里面积小于阈值的孤立噪点去掉,避免把伪影当成肺区。
这份肺分割数据集的训练集和测试集是打包好的,下载后按前面几章的步骤一步步来,能直接复现从数据加载到指标输出的完整流程。从那以后,我每次拿到新的分割数据集,都会先跑一遍「结构检查 → 标签分布 → 基线训练 → 可视化 → 面积统计」五步,形成固定流程,不再临时手写脚本。希望这份拆解和踩坑记录能帮到你,少走几步弯路。
本文还有配套的精品资源,点击获取