简介:这份资源是面向医疗影像AI开发者与医学研究人员的SIIM-ACR气胸X光分割数据集,源自Kaggle气胸分割竞赛,用于训练和验证气胸区域的自动检测与分割模型。压缩包共约2000个文件,包含2671个png掩模图、2669个jpeg胸片图像、2669个json标注文件以及4个Python脚本,整体约575.56MB;其中png对应气胸区域二值掩模,jpeg为原始胸片,json提供RLE解码后的可读标注,py脚本则用于数据解析与预处理。目前已有809人学习下载。借助RLE标注与JSON结构化信息,读者可快速完成数据清洗、掩模生成与标注可视化,并基于CNN等模型开展分割实验,是入门与进阶气胸检测算法研究的实用素材。
1. 气胸 X 光分割到底难在哪:从 SIIM-ACR 数据集说起
气胸在胸片上表现为肺野与胸壁之间那条细窄的透亮带,早期可能只有几个像素宽。SIIM-ACR-Pneumothorax-Segmentation 这个数据集把问题摆到了台面上:上千张胸片,标注由多位放射科医生用多边形勾画,同一张图不同医生的勾画差异极大。我第一次跑基线模型时 Dice 卡在 0.72 上不去,可视化一看,模型把肋骨边缘和肩胛骨影当成了气胸区域。这个任务的真实难点不在模型结构,而在标注噪声、正负样本极度不均衡、以及小目标分割的边界模糊。如果你手头有胸片数据想做分割,或者正在复现 SIIM-ACR 的竞赛方案,这篇笔记会从数据读取、预处理、模型选型、训练策略到推理后处理,把能落地的路径和踩过的坑讲清楚。
2. 数据读取与预处理:RLE 解码和图像归一化的四个关键参数
2.1 从 RLE 到掩码:解码逻辑与常见错误
SIIM-ACR 的标注以 RLE(Run-Length Encoding)格式存储在 CSV 中,格式是「起始位置 连续长度」成对出现。很多新手直接按像素顺序解码,结果掩码完全错位。关键在于 RLE 是按列优先(column-major)展开的,不是按行。
import numpy as np import pandas as pd def rle_decode(rle_str, shape=(1024, 1024)): """ rle_str: '1 3 10 5' 形式的字符串,起始位置从1开始计数 shape: (height, width) 返回: 二值掩码,1为气胸区域 """ if pd.isna(rle_str) or rle_str == '-1': return np.zeros(shape, dtype=np.uint8) s = rle_str.split() starts = np.asarray(s[0::2], dtype=int) - 1 # 转为0-based lengths = np.asarray(s[1::2], dtype=int) # 按列优先展开,再reshape img = np.zeros(shape[0] * shape[1], dtype=np.uint8) for start, length in zip(starts, lengths): img[start:start + length] = 1 # 关键:Fortran order 对应列优先 return img.reshape(shape, order='F')逻辑说明:RLE 的起始位置是 1-based,必须先减 1。reshape 时必须用order='F',否则掩码会整体旋转 90 度。参数方面,shape固定为 1024×1024,这是 SIIM-ACR 原始 DICOM 的尺寸,不要提前 resize 再解码,否则 RLE 索引对不上。
2.2 图像归一化:为什么不能直接用 ImageNet 均值
胸片是灰度图,像素值范围通常在 0-255 或 0-4095(取决于 DICOM 的位深)。直接套用 ImageNet 的 mean=[0.485, 0.456, 0.406] 是常见翻车点,因为胸片的亮度分布和自然图像完全不同。
我一般会做两步:先把 DICOM 的像素值线性拉伸到 0-255,再除以 255 归一化到 [0,1],最后减 0.5 除 0.5 拉到 [-1,1]。如果数据里混有不同设备来源的胸片,建议按数据集统计均值和方差,而不是拍脑袋用 0.5。
import cv2 def preprocess_xray(img, target_size=512): """ img: 原始灰度图,uint8 或 uint16 target_size: 统一缩放到该尺寸 """ # 转 float32 避免溢出 img = img.astype(np.float32) # 百分位截断,去掉极端亮暗点 p1, p99 = np.percentile(img, (1, 99)) img = np.clip(img, p1, p99) # 归一化到 [0,1] img = (img - p1) / (p99 - p1 + 1e-6) # resize 到目标尺寸 img = cv2.resize(img, (target_size, target_size), interpolation=cv2.INTER_LINEAR) # 标准化到 [-1,1] img = (img - 0.5) / 0.5 return img参数说明:p1和p99用百分位而不是 min/max,是为了避免个别坏点拉偏整体对比度。target_size设 512 是精度和显存的折中,设 256 会丢失小气胸的边界细节,设 1024 则 batch size 只能开到 2 以下。插值方式用INTER_LINEAR就够了,INTER_CUBIC在灰度图上反而容易产生振铃。
2.3 正负样本不均衡的处理策略
SIIM-ACR 训练集里大约 20%-30% 的样本有气胸标注,且阳性样本中气胸区域面积占比中位数不到 2%。如果按随机采样,一个 batch 里可能全是负样本。常见做法是用WeightedRandomSampler给阳性样本更高权重,或者在 loss 里用pos_weight给正类加权。
我一般会先统计每个样本的阳性像素占比,然后按占比分桶:无气胸、小面积(<1%)、中面积(1%-5%)、大面积(>5%),每个 batch 里保证至少有一个小面积样本。这样比单纯调 loss 权重更稳定,因为小目标才是 Dice 上不去的主因。
3. 模型选型与训练:U-Net 够用,但别忽略编码器
3.1 为什么我最终选了 EfficientNet 编码器 + U-Net 解码器
纯 U-Net 从零训练在 SIIM-ACR 上大概能到 0.80 Dice 左右,但收敛很慢,需要大量数据增强。换成在 ImageNet 上预训练的 EfficientNet-B3 或 B4 做编码器,Dice 能稳定提升 3-5 个点。原因是胸片虽然和自然图像差异大,但底层边缘、纹理特征是可迁移的。
用segmentation_models_pytorch可以一行代码切换编码器:
import segmentation_models_pytorch as smp model = smp.Unet( encoder_name="efficientnet-b3", encoder_weights="imagenet", in_channels=1, # 灰度图,不是3 classes=1, activation=None # 输出logits,loss里再sigmoid )参数说明:in_channels=1必须改,否则第一层卷积会报维度错误。activation=None是因为后面用BCEWithLogitsLoss或DiceLoss,如果在模型里就 sigmoid 了,数值稳定性会变差。encoder_weights用imagenet预训练权重,不要用None,除非你有至少 5000 张标注胸片。
3.2 损失函数:Dice + BCE 的组合与权重
单独用 BCE 会被负样本淹没,单独用 Dice 在训练初期梯度不稳定。我一般用0.5 * BCE + 0.5 * Dice,或者用 Tversky Loss 调整 FP/FN 的惩罚比例。
import torch import torch.nn as nn class DiceBCELoss(nn.Module): def __init__(self, bce_weight=0.5): super().__init__() self.bce_weight = bce_weight self.bce = nn.BCEWithLogitsLoss() def forward(self, pred, target): # pred: logits, target: 0/1 mask bce_loss = self.bce(pred, target) pred_sigmoid = torch.sigmoid(pred) intersection = (pred_sigmoid * target).sum() dice_loss = 1 - (2. * intersection + 1e-6) / (pred_sigmoid.sum() + target.sum() + 1e-6) return self.bce_weight * bce_loss + (1 - self.bce_weight) * dice_loss逻辑说明:Dice 的分子分母都加了1e-6防止除零。bce_weight设 0.5 是起点,如果验证集上小气胸漏检多,可以降到 0.3,让 Dice 主导;如果假阳性太多,就升到 0.7。
3.3 数据增强:哪些有用,哪些是负优化
胸片增强和自然图像增强逻辑不同。水平翻转可以用,因为左右肺对称;垂直翻转不能用,因为胸腔上下结构不对称。随机旋转 ±15 度以内可以,超过 30 度会出现大量黑色填充区域,模型会学到这些伪影。
我常用的增强组合:RandomBrightnessContrast(亮度对比度扰动)、ShiftScaleRotate(平移缩放旋转)、ElasticTransform(弹性形变,模拟呼吸差异)。GridDistortion和OpticalDistortion在胸片上效果不稳定,有时会把肋骨纹理扭曲成假气胸,建议慎用。
import albumentations as A train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), A.ElasticTransform(alpha=1, sigma=50, p=0.3), ])参数说明:scale_limit=0.1表示缩放 ±10%,再大可能把气胸区域缩到几个像素。ElasticTransform的alpha控制形变强度,sigma控制平滑度,alpha=1, sigma=50是经过几次试验后比较温和的设置。
4. 推理与后处理:阈值不是 0.5,小目标要单独处理
4.1 阈值搜索:在验证集上按 Dice 最大化找最优阈值
模型输出 logits,sigmoid 之后得到概率图。默认 0.5 做二值化在气胸任务上通常不是最优的,因为正样本太少,模型倾向于输出偏低的概率。我一般会在验证集上从 0.3 到 0.7 以 0.05 为步长搜索,取 Dice 最高的阈值。
import numpy as np def find_best_threshold(model, val_loader, device): model.eval() all_probs = [] all_targets = [] with torch.no_grad(): for imgs, masks in val_loader: imgs = imgs.to(device) logits = model(imgs) probs = torch.sigmoid(logits).cpu().numpy() all_probs.append(probs) all_targets.append(masks.numpy()) all_probs = np.concatenate(all_probs) all_targets = np.concatenate(all_targets) best_thr, best_dice = 0.5, 0 for thr in np.arange(0.3, 0.71, 0.05): preds = (all_probs > thr).astype(np.uint8) intersection = (preds * all_targets).sum() dice = 2 * intersection / (preds.sum() + all_targets.sum() + 1e-6) if dice > best_dice: best_dice = dice best_thr = thr return best_thr, best_dice逻辑说明:这个搜索是在像素级别算全局 Dice,实际比赛中可能按图像算再平均,两者会有差异。如果验证集里小气胸样本多,全局 Dice 会被大面积样本主导,建议按图像分别算 Dice 再取平均。
4.2 连通域过滤:去掉孤立的小噪点
模型输出常有零星几个像素的假阳性,尤其在肋骨边缘。用cv2.connectedComponents去掉面积小于某个阈值的连通域,能提升 1-2 个点。
import cv2 def remove_small_regions(mask, min_area=30): """ mask: 二值掩码,0/1 min_area: 最小连通域面积(像素数) """ 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 return cleaned参数说明:min_area=30是在 512×512 分辨率下的经验值,对应原始 1024×1024 上约 120 像素。如果漏检比假阳性更严重,可以降到 15;如果假阳性太多,可以升到 50。connectivity=8比 4 更宽松,适合气胸这种不规则形状。
4.3 多模型集成与 TTA
单模型 Dice 到 0.85 左右后,提升空间主要靠集成。我一般训 3-5 个不同编码器(EfficientNet-B3、ResNet34、DenseNet121)的 U-Net,推理时对每张图做水平翻转 TTA,再把概率图平均。
def predict_with_tta(model, img, device): """ img: 单张预处理后的图像,shape (1, H, W) """ model.eval() with torch.no_grad(): # 原始 logits1 = model(img.unsqueeze(0).to(device)) prob1 = torch.sigmoid(logits1) # 水平翻转 img_flip = torch.flip(img, dims=[-1]) logits2 = model(img_flip.unsqueeze(0).to(device)) prob2 = torch.sigmoid(logits2) prob2 = torch.flip(prob2, dims=[-1]) # 平均 prob = (prob1 + prob2) / 2 return prob.squeeze().cpu().numpy()逻辑说明:TTA 对水平翻转有效是因为胸片左右对称,垂直翻转和旋转的 TTA 收益很小甚至为负。集成时不同模型的概率图直接平均即可,不需要加权,除非某个模型在验证集上明显更差。
5. 避坑与排查:气胸分割中五个血泪教训
5.1 Dice 很高但可视化全是噪点
现象:验证集 Dice 到 0.85,但把预测掩码叠到原图上,发现气胸区域没标出来,反而肋骨边缘全是零星白点。
原因:全局 Dice 被大面积负样本主导。一张 512×512 的图有 26 万像素,气胸区域可能只有 500 像素。即使模型把所有像素都预测为 0,Dice 也能到 0.9 以上。模型学到的是「全部预测为背景」这个捷径。
解决:改用按图像算 Dice 再平均,或者用SoftDiceLoss并在采样时保证每个 batch 都有阳性样本。另外,监控IoU和Precision/Recall,不要只看 Dice。
5.2 训练 loss 震荡不收敛
现象:前 10 个 epoch loss 下降,之后开始剧烈震荡,验证集 Dice 忽高忽低。
原因:学习率太大,或者 batch size 太小导致 BatchNorm 统计量不稳定。胸片分割常用 batch size 8-16,如果显存不够降到 4,BatchNorm 的 running mean 会偏得很厉害。
解决:把 BatchNorm 换成 GroupNorm,或者用梯度累积模拟大 batch。学习率用1e-4起步,配合CosineAnnealingLR,不要用 StepLR 那种突降。
5.3 小气胸全部漏检
现象:大面积气胸 Dice 能到 0.9,但面积小于 1% 的样本 Dice 不到 0.3。
原因:损失函数被大面积样本主导,小目标的梯度贡献微乎其微。另外,下采样过程中小目标的信息在编码器里就丢了。
解决:用 Focal Loss 或 Tversky Loss 给难样本更高权重。解码器里加注意力模块(如 SCSE)或者用 HRNet 这种保持高分辨率的结构。数据增强时不要过度缩放,scale_limit控制在 0.1 以内。
5.4 推理速度太慢,单张图要 2 秒
现象:模型在验证集上跑一次要半小时,部署时单张推理超过 2 秒。
原因:输入分辨率 1024×1024 太大,或者模型用了 EfficientNet-B7 这种重型编码器。另外,如果每次推理都重新加载模型,IO 开销也很大。
解决:推理时把输入降到 512×512,Dice 损失通常不到 1 个点。用torch.cuda.amp做半精度推理,速度能快 1.5-2 倍。模型只加载一次,常驻显存。
5.5 不同设备胸片表现差异大
现象:在某个医院的设备数据上 Dice 0.88,换一批设备数据掉到 0.65。
原因:不同设备的管电压、曝光时间不同,导致胸片对比度和噪声分布差异大。模型过拟合到了训练设备的成像特征。
解决:训练时加更强的亮度和对比度扰动,brightness_limit和contrast_limit可以开到 0.3。用CLAHE做直方图均衡化,减少设备间差异。如果目标域数据有少量标注,做 5-10 个 epoch 的微调。
6. 进阶技巧:用伪标签和半监督把 Dice 再推两个点
标注数据永远不够,这是医学图像分割的常态。SIIM-ACR 训练集有上千张,但如果你自己的数据只有几百张标注,伪标签是性价比最高的提升手段。具体做法:先用标注数据训一个基线模型,在未标注数据上推理,取概率大于 0.9 或小于 0.1 的像素作为伪标签,加入训练集重新训练。阈值设得极端一些,宁可少用一些伪标签,也不要引入太多噪声。
我一般会跑两轮伪标签:第一轮用 0.9/0.1 的阈值,第二轮用 0.8/0.2。每轮重新训练时,伪标签样本的 loss 权重设为真实标注的 0.5,避免噪声主导梯度。这个策略在我自己的胸片数据上把 Dice 从 0.82 推到了 0.86,代价是训练时间翻倍。
另一个技巧是深监督:在 U-Net 解码器的每个上采样层都接一个 1×1 卷积输出侧分支,计算辅助 loss。这些侧分支的 loss 权重从 0.1 到 0.4 递减,能让浅层特征也学到分割相关的信息。实现上就是在forward里收集每个解码块的输出,训练时把辅助 loss 加起来。
class UNetWithDeepSupervision(nn.Module): def __init__(self, encoder_name="efficientnet-b3"): super().__init__() self.base = smp.Unet(encoder_name=encoder_name, encoder_weights="imagenet", in_channels=1, classes=1) # 假设解码器有5层,取前4层做深监督 self.aux_heads = nn.ModuleList([ nn.Conv2d(16, 1, 1) for _ in range(4) ]) def forward(self, x): # 这里需要修改smp.Unet的forward来返回中间特征 # 实际使用时建议直接改源码或继承后重写 features = self.base.encoder(x) decoder_out = self.base.decoder(features) main_out = self.base.segmentation_head(decoder_out) aux_outs = [] for i, feat in enumerate(features[1:5]): aux_outs.append(self.aux_heads[i](feat)) return main_out, aux_outs这段代码需要根据smp的实际版本调整,因为不同版本的encoder返回的特征层数不一样。核心思路是:主输出用完整解码器,辅助输出用编码器的中间特征直接上采样到原图尺寸算 loss。辅助 loss 只在训练时用,推理时只取主输出。
最后说一个我自己的习惯:每次实验必须固定随机种子,并且把验证集的可视化结果存成图片。模型指标只是数字,真正能发现问题的是把预测掩码叠到原图上肉眼检查。我至少有三次是靠可视化发现模型在学肋骨边缘而不是气胸区域,如果只看 Dice 曲线,可能到项目结束都发现不了。希望帮到你。
本文还有配套的精品资源,点击获取