简介:这是一份面向医学图像分割研究与实验的胸部X光片语义分割数据集,核心任务是气胸(Pneumothorax)区域的像素级标注与识别,适用于深度学习研究者、医学影像算法开发者以及相关专业学生开展模型训练、算法对比和课程实践。气胸是胸部X光片中常见的急症表现,准确分割对辅助临床诊断具有实际意义。压缩包共2000个文件,以1998张PNG图像为主,包含原始影像与对应的分割标签,另提供类别定义txt文件和Python辅助脚本,整体大小183.48MB;数据已经过对比度拉伸、resize等图像增广处理,并明确划分为训练集和验证集,可直接用于U-Net、DeepLab等常见分割模型的训练与评估。目前已有648人学习/浏览。借助该数据集,使用者可省去自行采集、清洗和标注的繁琐流程,将精力集中于模型结构设计与调优;配合作者提供的网络分割参考链接,还能快速掌握气胸分割任务的数据组织方式与实验思路,非常适合作为论文实验、毕业设计或医学图像分割入门练习的高质量基础数据。
1. 气胸分割数据集:拿到 2000 张图,真正卡人的不是模型
做医学图像分割的人第一次碰气胸(Pneumothorax)数据,最容易被一句“超过 2000 张数据和标签”带偏,以为数据量够了,剩下的只是套一个 U-Net 的事。实际跑起来会发现,气胸在 Chest X-Ray 上的特征和正常肺野、锁骨、肋骨边缘高度相似,模型经常把整个肺野都当成目标,或者干脆一张图都输出不了。这个数据集解决的是“有没有”的问题,而“能不能用”取决于你怎么处理标签、怎么做预处理、怎么划定训练和验证的边界。这篇笔记适合医学影像算法工程师、刚入手语义分割的学生,以及想评估“气胸分割到底值不值得做”的团队。我会从数据组织方式讲到最小训练管线,再讲到验证和踩坑,目标是让你照着能跑通,也让你知道坑在哪里。
2. 拆开这份 Chest X-Ray 数据集:文件结构、标签形态与三个预处理决策
2.1 从目录结构看起:图像、标签和元数据各扮演什么角色
气胸图像的语义分割数据集,最常见的组织方式不是把所有 PNG 堆在一个文件夹里,而是分为原始图像、标签图像和元数据三部分。原始图像一般是 Chest X-Ray 的 PNG 或 JPG,灰度图居多;标签图像是单通道 PNG,像素值只有 0 和 255(或 0 和 1);元数据 CSV 里记录文件名、是否有气胸、气胸位置等辅助信息。先别急着训练,第一步一定是把目录结构看清楚,把每个文件的格式、尺寸、通道数摸一遍,否则后面数据加载器会反复出问题。
用下面这段命令快速盘点目录结构和文件格式:
tree -L 2 data/ # 期望看到两个主要子目录和一个csv文件 # data/images # data/masks # data/metadata.csv # 统计图像和标签的格式、尺寸分布 python - <<'EOF' from PIL import Image from pathlib import Path for sub in ["images", "masks"]: files = list(Path("data", sub).glob("*")) print(sub, "文件数:", len(files)) img = Image.open(files[0]) print(" 示例:", files[0].name, "尺寸:", img.size, "模式:", img.mode) EOF这段代码的作用有两个:一是确认图像和标签的文件数量是否一一对应;二是看图像是灰度模式“L”还是 RGB 模式“RGB”,标签是不是单通道。常见的气胸数据集里,图像是灰度图,标签是二值图。参数说明:mode == "L"表示灰度,mode == "RGB"表示三通道,如果看到标签是三通道,说明它可能是把 mask 存成了伪彩色图,需要先转灰度再二值化,这一步漏了,损失函数里会出现“三个通道的 logits 对三个通道的 mask”这种维度错误。
再看一眼标签的像素值分布。很多翻车现场都是这里埋下的隐患:
import numpy as np from PIL import Image mask = np.array(Image.open("data/masks/0001.png")) print("mask 取值:", np.unique(mask)) print("mask 形状:", mask.shape) print("正类像素占比:", (mask > 0).mean())逻辑说明:np.unique(mask)告诉你标签是不是只有两个值。如果除了 0 和 255 之外还有 127、128 这类中间值,说明标签有灰度过渡带,这在医学分割里是常见的“标注边缘软”问题。对于气胸这种边界模糊的目标,我的建议是直接把大于 127 的像素置为 1,不要保留中间值,因为 U-Net 的输出是概率图,训练标签必须是硬标签,否则 Dice Loss 的计算结果会变得很奇怪。
2.2 气胸影像的特殊性:边缘模糊、肺野干扰与标注口径
气胸在 Chest X-Ray 上的典型表现是肺野外带出现无肺纹理的透亮区,脏层胸膜线清晰可见。听起来很好识别,但对语义分割模型来说,这是最麻烦的目标之一:它的边缘是“一条线”而不是一片边界清晰的色块;它的尺寸跨度极大,可以是只有几个像素的窄条,也可以是压缩整个肺野的大面积;它的位置高度依赖肺野边界,但 X 光片上锁骨、肋骨、纵隔阴影和正常肺纹理全都会参与干扰。很多团队第一版模型训练完,Dice 在验证集上有 0.6,可视化一看,模型其实是在分割整个肺野,气胸腔和正常肺组织被一视同仁地标成了正类。
理解这个之后,预处理的方向就清楚了。气胸分割不像 COCO 分割那样讲究“把物体从场景里抠出来”,它更像是在一张低对比度、多结构重叠的灰度图上做精细的边缘定位。因此,最常见的做法是先限制区域:如果你能拿到肺野分割结果(或者数据里自带肺野 mask),就把预测范围限制在肺野内,模型不需要学“哪里是肺”,只需要学“肺里哪里是气胸”。即使没有肺野 mask,也可以在数据增强阶段加入随机裁剪,把注意力集中到胸腔中部和两侧,而不是整张图均匀地学。
2.3 三个预处理决策:对比度、统一尺寸与标签形式
预处理不做复杂的事,但每个决策都影响后面训练和推理的一致性。我一般会固定在三个决策上,不来回改。
第一个决策是灰度对比度增强。X 光原图受设备、电压、患者体型影响很大,直接喂给网络会让模型记住设备的“色调风格”而不是解剖结构。常见做法是用 CLAHE(限制对比度自适应直方图均衡), clip limit 设置在 2.0~3.0,tile grid 用 8x8。气胸区域的灰度小幅变化比整图直方图均衡更重要,CLAHE 在局部做对比度拉伸,更适合这种场景。
第二个决策是统一输入尺寸。气胸图像尺寸差异大,从 512x512 到 3000x3000 都有。归一化到固定尺寸时,mask 必须用最近邻插值,不能双线性。原因很直接:双线性插值会在 0 和 1 之间产生小数,标签变成了概率值,损失函数里会出现“预测接近 0 但标签是 0.3”这种惩罚偏差,而且小数标签在计算 Dice 时会把边界区域权重放大,模型学到的边界会偏“胖”。
第三个决策是标签形态统一。打开每一张 mask 都做一次“转灰度、二值化、重采样”,而不是假设所有文件格式一样。
下面是一个完整的预处理管线:
import cv2 import numpy as np from albumentations import CLAHE, Resize def preprocess_image_and_mask(image_path, mask_path, size=(512, 512)): # 读取灰度图 image = cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # mask 二值化:大于127视为正类,统一为0/1 mask = (mask > 127).astype(np.uint8) # 图像增强:CLAHE aug = CLAHE(clip_limit=2.5, tile_grid_size=(8, 8)) image = aug(image=image)["image"] # 统一尺寸:注意 mask 用最近邻插值 image = cv2.resize(image, size, interpolation=cv2.INTER_LINEAR) mask = cv2.resize(mask, size, interpolation=cv2.INTER_NEAREST) # 归一化到[0,1] image = image.astype(np.float32) / 255.0 return image, mask逻辑说明:这个函数做了四件事——读取灰度图、把 mask 转成 0/1 硬标签、做 CLAHE、统一尺寸。值得注意的点是cv2.resize的插值方式:图像用INTER_LINEAR,标签用INTER_NEAREST。参数说明:clip_limit=2.5控制对比度增强的强度,数值越大对比度越强,但会放大噪声,胸片里如果可见噪点明显,降到 2.0 比较稳;size=(512, 512)是平衡显存和细节的经验值,气胸里的细线结构在 256x256 下容易消失,在 1024x1024 下显存占用会翻四倍,我建议先从 512 起步。
3. 用 U-Net 跑通气胸分割:从数据集划分到最小训练管线
3.1 为什么第一版选 U-Net,而不是 SegFormer 或 DeepLab
2 千张级别的医学图像数据集,第一版模型选择的标准是:结构简单、容易训练、对小目标敏感、显存友好。U-Net 恰好满足这四点。它在编码器部分逐层下采样提取语义,在解码器部分通过 skip connection 把浅层细节传回来,这正好对上了气胸“边界细节重要、整体语义简单”的特点。DeepLab 系列在空洞卷积和 ASPP 上做文章,对多尺度目标有优势,但气胸的主要难点不是多尺度,而是低对比度和边缘模糊,U-Net 的像素级细节保留能力更直接。SegFormer 这类 Transformer 需要更多数据才能发挥优势,2 千张胸片只能让它学到皮毛,推理速度也慢,调试周期长。
从显存和速度看,一个小型 U-Net 在 512x512 输入下,单卡 8GB 就能跑 batch size 8,训练一个 epoch 大约几十秒到几分钟。对比一下:
| 模型 | 输入尺寸 | 相对显存 | 在 2 千张医学图上的表现 | 调试成本 |
|---|---|---|---|---|
| U-Net(4 层) | 512x512 | 低 | Dice 基本盘,稳定 | 低 |
| DeepLabV3+(ResNet50) | 512x512 | 中 | 需更多调参,边缘略粗 | 中 |
| SegFormer-B2 | 512x512 | 中高 | 易欠拟合,依赖 pretrain | 中高 |
注意,这不是说 U-Net 一定最好,而是“第一个能跑的方案”应该用 U-Net。等 U-Net 在验证集上稳定出结果后,再换 DeepLabV3+ 或 Transformer 做提升,对比才有意义。
3.2 数据划分:按患者分层,别让验证集泄漏
数据划分是 2 千张医学数据集里最容易被跳过、也最影响可信度的一步。胸片数据经常是同一个患者的多张片子,如果随机按文件名划分,同一个人的前后两次胸片很可能分别落在训练集和验证集里,模型等于提前见过答案,验证集指标虚高,换到真实新患者身上就掉下来。正确做法是按患者 ID 划分,保证同一个人的所有图像只在训练集或只在验证集里。
另一个容易被忽略的问题是气胸样本占比。如果数据集里正负样本不平衡(无气胸片多,有气胸片少),随机划分会导致验证集里气胸样本太少,Dice 方差极大。常见做法是分层划分:以“是否有气胸”为分层依据,让训练集和验证集里正样本比例保持一致。
import pandas as pd from sklearn.model_selection import StratifiedGroupKFold # metadata 必须包含 patient_id 和 pneumothorax(0/1) 两列 meta = pd.read_csv("data/metadata.csv") meta["has_pneu"] = (meta["pneumothorax"] > 0).astype(int) split = StratifiedGroupKFold(n_splits=5, shuffle=True, random_state=42) train_idx, val_idx = next(iter(split.split(meta, meta["has_pneu"], groups=meta["patient_id"]))) train_meta = meta.iloc[train_idx] val_meta = meta.iloc[val_idx] print("训练集正样本占比:", train_meta["has_pneu"].mean()) print("验证集正样本占比:", val_meta["has_pneu"].mean())逻辑说明:StratifiedGroupKFold同时满足两个约束——按patient_id分组避免数据泄漏,按has_pneu分层保证正负样本比例一致。参数说明:n_splits=5表示做 5 折,你可以取第一折做验证,也可以用全部 5 折做交叉验证,最终报告平均 Dice;random_state=42固定随机种子,确保每次跑结果可比。这一步做了之后,后续所有模型迭代都在同一个验证集上对比,得出的结论才可信。
3.3 核心训练脚本:模型、损失函数与一次完整训练循环
下面是一个能直接跑起来的最小训练脚本,用 PyTorch 实现。模型部分直接用segmentation_models_pytorch的 U-Net,损失函数用 Dice + BCE 的组合,这是气胸分割最常用的配置。
import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset import segmentation_models_pytorch as smp class PneumothoraxDataset(Dataset): def __init__(self, meta, size=512): self.meta = meta self.size = size def __len__(self): return len(self.meta) def __getitem__(self, idx): row = self.meta.iloc[idx] # 复用上一章预处理函数,这里直接返回数组 image, mask = preprocess_image_and_mask( row["image_path"], row["mask_path"], (self.size, self.size) ) return ( torch.from_numpy(image).unsqueeze(0), # [1, H, W] torch.from_numpy(mask).unsqueeze(0), # [1, H, W] ) # 损失函数:BCE + Dice,Dice 权重更高 class DiceBCELoss(nn.Module): def __init__(self, dice_weight=0.7, bce_weight=0.3): super().__init__() self.dice_weight = dice_weight self.bce_weight = bce_weight def forward(self, logits, targets): probs = torch.sigmoid(logits) bce = nn.functional.binary_cross_entropy(probs, targets) smooth = 1.0 intersection = (probs * targets).sum() dice = 1 - (2.0 * intersection + smooth) / (probs.sum() + targets.sum() + smooth) return self.bce_weight * bce + self.dice_weight * dice # 模型初始化 model = smp.Unet( encoder_name="resnet34", encoder_weights="imagenet", in_channels=1, # 灰度图 classes=1, # 二分类 ) train_loader = DataLoader(PneumothoraxDataset(train_meta), batch_size=8, shuffle=True, num_workers=4) val_loader = DataLoader(PneumothoraxDataset(val_meta), batch_size=8, shuffle=False, num_workers=4) optimizer = torch.optim.AdamW(model.parameters(), lr=2e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) for epoch in range(30): model.train() for images, masks in train_loader: logits = model(images) loss = DiceBCELoss()(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 每个epoch后在验证集上计算Dice model.eval() val_dice = 0.0 with torch.no_grad(): for images, masks in val_loader: logits = model(images) probs = torch.sigmoid(logits) # 这里先按0.5二值化再算dice,更贴近实际指标 preds = (probs > 0.5).float() intersection = (preds * masks).sum(dim=(1, 2, 3)) dice = (2 * intersection + 1e-8) / (preds.sum(dim=(1, 2, 3)) + masks.sum(dim=(1, 2, 3)) + 1e-8) val_dice += dice.mean().item() print(f"Epoch {epoch+1}: val_dice = {val_dice / len(val_loader):.4f}")逻辑说明:脚本按“数据集类 -> 损失函数 -> 模型 -> 训练循环 -> 验证循环”组织。DiceBCELoss里把 Dice 权重设为 0.7、BCE 权重设为 0.3,因为气胸正类像素占比低,纯 BCE 会偏向背景,而 Dice 对不平衡不敏感,适合小目标。验证循环里对预测概率做了 0.5 二值化再算 Dice,这个口径和竞赛平台常用的评估方式一致,避免了“用概率算 Dice 虚高”的毛病。
参数说明:batch_size=8在 512 输入、ResNet34 编码器的 U-Net 下,大约占用 6~8GB 显存,如果你的卡只有 6GB,改成 4;lr=2e-4是 AdamW 处理分割任务的常见起点,如果 loss 震荡剧烈,下调到 1e-4;T_max=30需要与总 epoch 数保持一致,CosineAnnealing 的周期才会完整。这里没有做混合精度,如果你的 PyTorch 版本支持,可以把autocast加上,训练速度能快 30% 左右。
3.4 训练日志怎么看:三种早期病态表现
训练跑到第 3~5 个 epoch,日志里出现的几种现象,基本能预判这一版模型的结局。
第一种是 loss 不降,前几个 epoch 一直在 0.8 左右水平震荡。原因通常是学习率过高或数据加载出错,比如 mask 预处理后全为 0,模型没有正类梯度可学。检查方法很简单:单独打印一个 batch 的 mask 统计值,确认正类像素占比不是 0。
第二种是验证集 Dice 一直为 0,但训练 Dice 正常。最常见原因是验证集用了不同的插值方式或不同的归一化参数,模型在训练时见过 0~1 归一化,在验证时喂了 0~255 的值,特征分布完全不同。排查预处理的唯一原则是:训练和验证共用同一个函数,不要写两份。
第三种是训练 Dice 快速升高到 0.8,但验证 Dice 在 0.2 附近不再动。这是典型的过拟合,在 2 千张规模的数据集上很常见。处理方式不是盲目加数据增强,而是先检查数据划分是否有泄漏;确认划分没问题后,再考虑减小模型容量或加 dropout。
4. 调参与训练细节:Dice 从 0.6 到 0.8 的几个关键操作
4.1 损失函数怎么选:Dice、BCE、Focal 和组合策略
气胸分割的损失函数选择,本质是在“像素级精度”和“区域重叠度”之间找平衡。BCE 是逐像素的交叉熵,对每个像素独立惩罚,梯度稳定,但在正负类极不平衡时会偏向背景;Dice Loss 直接优化区域重叠度,对小目标更友好,但容易在小目标为 0 的样本上产生波动,而且对置信度不敏感,导致模型输出的概率值不够有区分度;Focal Loss 关注难分样本,适合边界模糊问题,但对类不平衡的敏感度不如 Dice 直接。
我最终常用的是 Dice + BCE 的组合,配比按数据集大小微调。气胸这种 B 超/X 光图像,建议配置如下:
| 目标特点 | 推荐损失 | 配比 | 理由 |
|---|---|---|---|
| 正类占比低、目标小 | Dice + BCE | 0.7 / 0.3 | Dice 管重叠,BCE 管梯度稳定 |
| 边界极其模糊 | Dice + Focal | 0.5 / 0.5 | Focal 加强难例学习 |
| 目标大且形状固定 | BCE 或 Dice | 纯 Dice 即可 | 背景干扰小,不需要 BCE 辅助 |
代码上,Dice + Focal 的组合比 Dice + BCE 复杂一些,但原理不复杂。Focal Loss 在标准交叉熵上增加了调制因子(1 - p_t)^gamma,gamma 越大对难分样本的关注越多。气胸数据集如果切片里包含大量“只有几像素细线”的样例,gamma 设 2 比较好;如果目标普遍明显,gamma 设 1 够了。
4.2 医学图像增强:哪几种增强有效,哪几种会破坏结构
医学图像增强不能直接照搬自然场景的套路。对胸片来说,随机的颜色抖动、grid distortion 这类改变灰度分布和局部几何结构的增强,大概率会让肺野结构变形,反而增加学习难度。真正稳定有效的是这几种:
- 水平翻转:气胸在左右肺都可能出现,这是最安全的增强
- 小范围旋转(±10°)和缩放(0.9~1.1):模拟摆位差异
- 弹性形变(sigma 3~5):模拟呼吸和体位变化导致的软组织结构微变
- Cutout / CoarseDropout:模拟金属物、胸腔引流管等遮挡物
用 Albumentations 实现的增强组合如下:
import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.Rotate(limit=10, border_mode=0, p=0.5), # 旋转,超出部分填充0 A.RandomScale(scale_limit=0.1, p=0.3), # 尺度扰动 A.ElasticTransform(alpha=1, sigma=10, p=0.2), A.CoarseDropout(max_holes=4, max_height=32, max_width=32, fill_value=0, p=0.3), ]) # 使用方式:在预处理之后调用 # augmented = train_transform(image=image, mask=mask)逻辑说明:Rotate里border_mode=0表示旋转后超出边界的区域填充为 0(黑色),胸片背景本身是黑色,这样不会引入伪结构;RandomScale的scale_limit=0.1控制在 10% 以内的缩放,避免肺野比例失真;ElasticTransform的alpha=1, sigma=10是一个保守参数,肉眼几乎看不出形变,但能增强模型对软组织变形的鲁棒性。参数说明:CoarseDropout(max_holes=4, max_height=32, max_width=32)相当于随机挖掉 4 块 32x32 的区域,模拟导管、纽扣遮挡,但正类区域小时,不要开太强,否则气胸区域可能被完整挖掉。训练结束后,验证集只做与训练一致的 resize 和归一化,不加任何随机增强,这一点务必保持一致。
4.3 训练超参设置:输入尺寸、学习率、EMA 与早停
2 千张图 30 个 epoch,足够判断一个方案是否可行。但要稳定拿到高 Dice,几个超参值得专门调整。
输入尺寸是第一个要找平衡的点。512x512 适合大多数场景,如果发现细小的气胸线总是被模型忽略,可以尝试两阶段策略:先在 384x384 上训练 20 个 epoch 收敛整体结构,再用 768x768 微调 10 个 epoch 细化边缘,这种 coarse-to-fine 方式在医学分割里很常用。第二个关键点是学习率调度。CosineAnnealingLR配合 30 个 epoch 是不错的选择,但要注意一个常见问题:如果前 5 个 epoch 验证 Dice 还在 0.1 附近,说明初始化或学习率有问题,早停不是先考虑的事,先调 lr。第三个被很多人忽略的配置是 EMA(指数移动平均):维护一组训练权重的滑动平均,推理时用平均权重而非最终权重,往往能带来 2~3 个点的 Dice 提升。
一个建议的参数参考:
| 超参 | 推荐值 | 调整思路 |
|---|---|---|
| 输入尺寸 | 512x512 | 细线结构丢失时调大到 768 |
| batch size | 8 | 显存不足时减半,同时按比例降 lr |
| 初始学习率 | 2e-4 | 预热 3 个 epoch 后余弦退火 |
| 训练轮数 | 30~40 | 超过 40 轮验证不提升,基本到平台期 |
| 权重衰减 | 1e-4 | 防止在 2 千张图上过拟合 |
| EMA 衰减 | 0.99 | 验证提升明显时可用 0.995 |
以 2 千张的规模,哪怕在 4GB 显存的卡上,这个配置也能在 2 小时内训练完一轮。没必要为了省时间降低输入尺寸,因为气胸细线结构对分辨率极其敏感。
5. 评测与避坑:为什么你的验证分总比想象中低
5.1 本地验证指标与线上评测不一致
现象:本地用 5 折交叉验证平均 Dice 0.83,放到评测集上只有 0.65,断崖式下跌。
原因:大多数情况下不是模型随机性,而是预处理不一致——本地验证时用了 0.5 阈值做二值化,评测标准用的是最大 Dice 搜索;或者本地把 mask 重采样成 512x512 算指标,评测时在原始分辨率上算;又或者数据划分时训练集包含评测集的近似重复样本,本地验证虚高。
解决:固定两套口径。第一套是训练时的内部监控,用 0.5 阈值、512x512 尺寸,只用来判断模型收敛方向;第二套是最终评测,必须和平台的 mask 采样方式完全一致,先查重采样插值方式,再查二值化阈值。我现在的习惯是,在项目里维护一个inference.py,训练流程和推理流程共用里面的预处理函数,而不是复制粘贴两份代码。
5.2 气胸区域太小,Dice 方差过大
现象:同一份验证集,训练两次,一次 Dice 0.78,一次 Dice 0.61,分数波动很大,无法判断改动是否有效。
原因:气胸的正类像素占比本来就低,如果验证集里包含大量“只有几十个像素”的极细线,边缘 1 个像素的偏移就会导致 Dice 大幅波动。这是 DICE 指标本身在小目标上的通病,不是模型不稳。
解决:在评估时引入“按样本大小分组”的维度。把验证集分为“大面积气胸(正类占比>10%)”和“小面积气胸(正类占比<5%)”,分别报告 Dice。这样你能清楚知道模型是在大目标上不行,还是在小目标上不行,避免用一个平均值掩盖问题。对于小目标样本,单看 Dice 不够,要配合可视化检查边缘像素偏差。
5.3 后处理缺失:预测图里有大量孤立小点
现象:模型输出的预测图在肺野之外出现一堆孤立的噪点,这些假阳性明显不是气胸,但在计算 Dice 时却被计入分母,拉低指标。
原因:U-Net 的逐像素分类天然会产生空间上零散的误检,尤其是边缘高对比度区域(如锁骨下缘、肋骨重叠处)。这是模型后验概率的空间平滑度不够,紧靠增大损失函数权重解决不了。
解决:在推理阶段加一个最小的后处理流程:先用一个大核腐蚀或连通域分析,把面积小于阈值的连通区域全部删除,再用闭运算填补轮廓上的小缺口。代码实现:
import cv2 import numpy as np def postprocess_mask(pred_prob, threshold=0.5, min_area=50): # 二值化 mask = (pred_prob > threshold).astype(np.uint8) # 连通域分析,删除小面积噪声 num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) cleaned = np.zeros_like(mask) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] >= min_area: cleaned[labels == i] = 1 # 闭运算填补边界小缺口 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) cleaned = cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel) return cleaned逻辑说明:这个函数先按 0.5 阈值把概率图变成二值 mask,然后用connectedComponentsWithStats找出所有连通域,把面积小于 50 像素的删除,最后做一次 5x5 的椭圆核闭运算。参数说明:min_area=50是一个经验值,气胸区域的面积再小也不至于低于这个值,但如果你处理的是超高分辨率图,这个值可以按原图尺寸等比例放大;threshold=0.5是通用选择,如果你想在“漏检”和“误检”之间找平衡,可以把这个参数放到验证集上搜索,选出使 Dice 最高的阈值。
5.4 数据泄漏:文件重复与训练验证重叠
现象:模型在验证集上对一个患者的四张片预测结果完全相同,而且 Dice 特别高,但换到另一个医院的片子马上失效。
原因:数据集在整理时,同一个患者的不同拍摄时间、不同视角的胸片被当成了独立样本,随机划分导致这些重复出现在训练和验证集里。更隐蔽的是图像级重复——某些平台生成的 mask 是洗不掉的“知识”,模型学会了记住图像指纹,而不是理解气胸形态。
解决:在数据加载前先做一次文件级去重,用 MD5 对图像做哈希,排除完全相同的图片;然后按患者 ID 做分组划分,这一点在第三章已经讲了做法。如果数据没有患者 ID 信息,退而求其次,用图像特征向量聚类,把相似度超过阈值的图像归为同一组,再做划分。这一步没有后悔药可吃,漏检一个重复文件,后续所有评测都不可信。
6. 进阶验证:用多折交叉与 TTA 确认模型有没有真的学会气胸
训练结束后,我一般不会马上提交或部署,而是花 20 分钟做一组鲁棒性验证,确认模型不是靠记忆样本混过验证集。做法分两步:先跑 5 折交叉验证看各折 Dice 的标准差,再对验证集做多尺度 TTA,比较 TTA 前后的指标差距。
TTA(Test-Time Augmentation)在气胸分割里最常用的是水平翻转和双尺度。推理时把原图和水平翻转图同时输入模型,把两张预测概率图取平均,再按原始方向还原,可以在不重新训练的情况下提升分割稳定性。代码思路如下:
import torch import torch.nn.functional as F def predict_with_tta(model, image): # image 形状 [1, 1, H, W],已归一化 model.eval() with torch.no_grad(): prob = torch.sigmoid(model(image)) # 水平翻转 prob_flip = torch.sigmoid(model(torch.flip(image, dims=[-1]))) prob_flip = torch.flip(prob_flip, dims=[-1]) # 多尺度:0.75倍与1.25倍 h, w = image.shape[-2:] prob_small = torch.sigmoid(model(F.interpolate(image, scale_factor=0.75, mode="bilinear"))) prob_small = F.interpolate(prob_small, size=(h, w), mode="bilinear") prob_big = torch.sigmoid(model(F.interpolate(image, scale_factor=1.25, mode="bilinear"))) prob_big = F.interpolate(prob_big, size=(h, w), mode="bilinear") # 平均融合 prob_final = (prob + prob_flip + prob_small + prob_big) / 4.0 return prob_final逻辑说明:torch.flip(image, dims=[-1])对最后一个维度做水平翻转,推理后再翻转回来,保证预测结果和原图对齐。多尺度部分分别用 0.75 和 1.25 倍率重新缩放推理,再插值回原始尺寸。四种结果取平均后,边缘置信度会明显更平滑。参数说明:TTA 不是越强越好,这里的尺度范围 0.75~1.25 对胸片来说足够,过大的缩放会引入畸变;如果你对推理速度有要求,可以只保留水平翻转,放弃多尺度,速度提升一倍,Dice 差距通常在 0.5 个点以内。
跑完 TTA 验证后,再看 5 折交叉的标准差。我把这个标准差叫做“数据集下限”:如果五折 Dice 在 0.78~0.86 之间波动,说明模型对数据规模敏感,加数据比调模型有效;如果五折都在 0.80 附近,波动不超过 0.02,说明方案已经稳定,可以放心去换更强的模型或做集成。
说一个我自己的教训:有次做气胸分割,单折验证 Dice 0.84,我以为稳了,直接提交,结果线上 0.61。后来查了一整天才发现,线上评测的 mask 是 RLE 编码转出来的,边缘自带一圈半透明过渡带,我的预处理直接二值化,把过渡带全算成了假阳性。从那之后,我每次评测前都会先画三张图——原图、模型概率图、二值化预测图——肉眼对比一遍,再谈指标。这一步多花五分钟,能帮你躲掉大部分评测埋的雷。希望这篇文章能让你在气胸分割这条路上少走点弯路,也希望你第一次跑通时,不仅看到 Dice 数字在涨,还能真正看清模型在哪里做对了、在哪里还在犯难。
本文还有配套的精品资源,点击获取