简介:对于医学图像分割研究和开发人员,这份气胸(Pneumothorax)胸部X光语义分割数据集提供了超过2000张原始图像与对应标签,可直接用于训练和验证气胸区域分割模型,解决肺塌陷病变区域自动识别问题;数据已做对比度拉伸、resize等增广处理,并划分好训练集和验证集,适合入门至进阶的医学影像算法学习与实践。压缩包共2000个文件,其中1998个PNG格式图像及掩码标签、1个类别说明txt文件和1个Python脚本,整体约183.48MB,结构紧凑便于管理,目前已有647人学习下载。借助该数据集,读者可获得带标注的气胸分割样本、数据划分方案和类别定义,方便快速搭建PyTorch或TensorFlow训练流程,同时可结合作者提供的网络分割专题博客,参考U-Net等模型的实现思路,缩短实验调参周期,提升在医学图像分割上的动手能力。
1. 气胸Chest X-Ray语义分割数据集:一个数据集和一个问题
气胸在胸片上可能只表现为一条纤细的脏壁线,甚至被肋骨或肺纹理遮挡,整张图里真正属于气胸的像素往往不足1%。所谓「超过2000张数据和标签」的Chest X-Ray语义分割数据集,真正的难点从来不是数据量够不够,而是类别极度不平衡条件下的分割稳定性。这个任务既适合刚入门医学图像分割的人理解掩码和Dice指标,也给做过普通语义分割的人提供了一个典型的稀疏目标场景。下面从拿到数据到最后验证,按一套可落地做法讲清楚。
2. 气胸数据集的结构与标签校验方法
2.1 常见目录组织方式与标签格式
这类医学图像分割数据集,目录结构通常是images/和masks/两个文件夹,文件名一一对应。图像多为PNG或JPG,掩码为单通道PNG,背景是0,气胸区域是1。有些版本会提供彩色掩码,读入后需要先判断通道数。先跑一段脚本探明数据底细,才有后续。
from PIL import Image import numpy as np from pathlib import Path img_dir = Path("data/images") mask_dir = Path("data/masks") mask = np.array(Image.open(next(mask_dir.glob("*.png")))) print(mask.shape, mask.dtype, np.unique(mask))输出如果是(512, 512) uint8 [0 1],说明是理想的单通道二值掩码。如果出现[0 255],需要把255归一化成1;如果len(shape)==3,说明是RGB掩码,要取第一个通道。这里统一整理后再进训练管线。
2.2 文件名对齐检查
常见问题集中在文件名错位和掩码与图像尺寸不一致。写一个快速校验脚本,顺序检查三条规则:同名文件存在、尺寸一致、掩码唯一值合法。
import numpy as np from pathlib import Path from PIL import Image def validate_pair(img_path, mask_path): img = np.array(Image.open(img_path)) mask = np.array(Image.open(mask_path)) assert mask.shape == img.shape[:2], f"size mismatch: {img_path.name}" assert set(np.unique(mask)).issubset({0, 1}), f"bad mask values: {mask_path.name}" return True failed = [] for mask_p in Path("data/masks").glob("*.png"): img_p = Path("data/images") / (mask_p.stem + ".png") if not img_p.exists() or not validate_pair(img_p, mask_p): failed.append(mask_p.name) print("failed files:", len(failed))这一步能过滤掉大部分后续训练报错的来源。图像尺寸不一致的数据集建议统一resize到固定输入尺寸,比如512x512,并在mask上同步resize,使用PIL.Image.BILINEAR保持边缘平滑。
2.3 样本级类别不平衡检查
气胸切片数据集中,不少掩码是全零。训练前统计一下全零掩码占比,这个数值直接决定采样策略。
| 全零掩码占比 | 处理策略 |
|---|---|
| < 30% | 正常训练,不做特殊采样 |
| 30%-50% | 按正样本权重过采样,或用带pos_weight的BCE损失 |
| > 50% | 建议先做负样本筛选,或改用patch级别训练,只裁取包含气胸的区域 |
统计全零占比的代码很简单,遍历掩码目录数一数就行。这一步通常在标注质量报告中体现,但实际拿到的数据集很少附带这个数字,自己算最稳妥。同时把气胸像素占全图比例超过10%的样本单独列出来,这类样本少但重要,训练中可以考虑提高它们在epoch中的出现频率。
3. 基于U-Net的气胸语义分割训练链路搭建
3.1 为什么选U-Net而不是DeepLabV3或Swin
气胸边缘薄、形状不规则,但结构相对简单,U-Net的编码器-解码器结构加上跳跃连接能同时保留高分辨率边缘信息和语义信息。DeepLabV3的空洞卷积擅长多尺度,但对这种极端稀疏目标反而容易过平滑边缘;Transformer类模型在数据量到不了百万级时,收益不明显。医学图像分割的常见基准和复现,U-Net依然是最稳的起点。
3.2 数据加载器设计与在线增强
输入尺寸取512x512,医学胸片分辨率天然大,直接用原图训练显存压力大。做法是先缩放到512x512固定尺寸,再在batch内做随机亮度对比度扰动、水平翻转、小角度旋转。在线增强控制在轻量程度,避免X光片的解剖结构失真。
import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import torchvision.transforms.v2 as T class PneumothoraxDataset(Dataset): def __init__(self, img_files, mask_files, size=512, augment=True): self.img_files = img_files self.mask_files = mask_files self.size = size self.augment = augment self.img_tf = T.Compose([ T.Resize((size, size)), T.ColorJitter(brightness=0.1, contrast=0.1), T.RandomHorizontalFlip(p=0.3), T.ToImage(), T.ToDtype(torch.float32, scale=True), T.Normalize(mean=[0.485], std=[0.229]) ]) self.mask_tf = T.Compose([ T.Resize((size, size), interpolation=T.InterpolationMode.NEAREST), T.RandomHorizontalFlip(p=0.3), T.ToImage(), T.ToDtype(torch.float32, scale=True) ]) def __getitem__(self, idx): img = Image.open(self.img_files[idx]).convert("L") mask = Image.open(self.mask_files[idx]).convert("L") seed = torch.randint(0, 2**32, ()).item() if self.augment else 0 torch.manual_seed(seed) img = self.img_tf(img) torch.manual_seed(seed) mask = self.mask_tf(mask) return img, mask注意mask在增强后是(1, 512, 512)的浮点张量,值域为[0,1]。Resize对掩码使用NEAREST插值,这是关键,因为BILINEAR会在边缘产生介于0和1之间的值,训练时带来误导性梯度。
def __len__(self): return len(self.img_files)3.3 最小化U-Net实现与训练循环
U-Net实现不引入复杂结构,用double conv + maxpool + upsample + skip connection把完整模型结构控制在约100行。为了可复现性,建议把torch.manual_seed(42)固定在训练脚本开头。
import torch.nn as nn 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, base=32): super().__init__() self.inc = DoubleConv(in_ch, base) self.down1 = nn.Sequential(nn.MaxPool2d(2), DoubleConv(base, base*2)) self.down2 = nn.Sequential(nn.MaxPool2d(2), DoubleConv(base*2, base*4)) self.down3 = nn.Sequential(nn.MaxPool2d(2), DoubleConv(base*4, base*8)) self.up1 = nn.ConvTranspose2d(base*8, base*4, 2, stride=2) self.conv1 = DoubleConv(base*8, base*4) self.up2 = nn.ConvTranspose2d(base*4, base*2, 2, stride=2) self.conv2 = DoubleConv(base*4, base*2) self.up3 = nn.ConvTranspose2d(base*2, base, 2, stride=2) self.conv3 = DoubleConv(base*2, base) self.outc = nn.Conv2d(base, out_ch, 1) def forward(self, x): x1 = self.inc(x) x2 = self.down1(x1) x3 = self.down2(x2) x4 = self.down3(x3) x = self.up1(x4) x = self.conv1(torch.cat([x, x3], dim=1)) x = self.up2(x) x = self.conv2(torch.cat([x, x2], dim=1)) x = self.up3(x) x = self.conv3(torch.cat([x, x1], dim=1)) return self.outc(x)base=32是常规起点。显存不足时降为16,效果会有损失,但训练速度几乎翻倍。
训练循环中,损失函数选BCEWithLogitsLoss + DiceLoss组合,这是医学图像分割的常见做法。单独BCE在气胸这类小目标上会让模型倾向把所有像素预测为背景;单独Dice在极端类别不平衡时梯度不稳定,两者相加能互相制约。
def dice_loss(pred, target, smooth=1.0): pred = torch.sigmoid(pred) intersection = (pred * target).sum() return 1 - (2.0 * intersection + smooth) / (pred.sum() + target.sum() + smooth) bce = nn.BCEWithLogitsLoss() def combined_loss(pred, target): return bce(pred, target) + dice_loss(pred, target)dice公式里的smooth建议不要设太大,1.0足够;过大时会掩盖小目标上的损失波动,导致验证集Dice看似稳定其实模型什么都没学到。
优化器用AdamW,学习率1e-4,weight decay1e-5。训练epoch数在气胸这类相对简单的分割任务上,通常30轮以内就能看到收敛迹象,给太多epoch反而会记住掩码中的标注噪声。
| 超参数 | 推荐值 | 说明 |
|---|---|---|
| 输入分辨率 | 512x512 | 太高显存不足,太低边缘细节丢失 |
| batch_size | 8 (单卡) / 16 (多卡) | 根据显存调整,4以下要降低学习率 |
| 初始学习率 | 1e-4 | 配合CosineAnnealing调度 |
| 学习率调度 | CosineAnnealingLR, T_max=30 | 比StepLR更平滑 |
| 损失权重 | BCE + Dice 1:1 | 不需要手动调权重,模型两端能平衡 |
| 优化器 | AdamW | 比SGD收敛快,weight decay可分离 |
3.4 训练与推理的最小命令
数据目录准备好后,训练入口脚本建议拆成train.py和predict.py。train.py只做训练和检查点保存,predict.py负责加载模型生产掩码。下面是一个极简的启动命令,会按完整流程训练并输出每轮验证Dice。
python train.py \ --data_dir data/ \ --batch_size 8 \ --lr 1e-4 \ --epochs 40 \ --output_dir checkpoints/推理阶段的代码长这样,用torch.no_grad()抑制梯度计算,单张图推断完成后用sigmoid > 0.5得到二值掩码。
import torch import numpy as np from PIL import Image def predict_single(model, img_path, threshold=0.5): model.eval() img = Image.open(img_path).convert("L").resize((512, 512)) x = torch.from_numpy(np.array(img, dtype=np.float32)).unsqueeze(0).unsqueeze(0) x = x / 255.0 with torch.no_grad(): logits = model(x) prob = torch.sigmoid(logits).squeeze().cpu().numpy() mask = (prob > threshold).astype(np.uint8) return mask4. 气胸分割的评估指标与后处理细节
4.1 Dice和IoU的计算方式
语义分割评估里,Dice和IoU是两套常见指标,气胸场景下缺一不可。Dice对正样本像素和负样本像素一视同仁,IoU对小目标更敏感。计算单张图的指标再取平均,比合在一起算更能反映模型在不同样本上的表现。
def dice_iou(pred_mask, true_mask): pred = pred_mask.astype(np.uint8) true = true_mask.astype(np.uint8) inter = np.sum((pred > 0) & (true > 0)) union = np.sum((pred > 0) | (true > 0)) dice_val = (2.0 * inter + 1e-6) / (np.sum(pred > 0) + np.sum(true > 0) + 1e-6) iou_val = (inter + 1e-6) / (union + 1e-6) return dice_val, iou_val注意+1e-6是为了避免两个全零掩码同时出现时除零,不影响正常数值。
4.2 阈值选择与模型校准
sigmoid输出概率分布通常集中在0.9以上和0.1以下,中间区间很窄。用0.5作为固定阈值在大多数情况下没问题,但更稳妥做法是在验证集上扫阈值。
| 阈值 | Dice | 适用场景 |
|---|---|---|
| 0.3 | 偏高 | 需要高召回,气胸区域不能漏 |
| 0.5 | 均衡 | 默认选择,UI演示通用 |
| 0.7 | 偏高精度 | 需要减少假阳性,比如辅助诊断 |
3dunet医学图像分割等任务会习惯用更大的目标类别阈值,但气胸目标稀疏,建议先扫3-5个阈值画出Dice曲线,再选峰值。把阈值扫描脚本固化下来,每次训练后自动跑。
4.3 后处理:去除小连通域
模型输出的掩码中,经常出现几个像素大小的噪点,这是小目标分割的常见现象。用cv2.connectedComponentsWithStats或scipy.ndimage把面积小于阈值的连通域删除,可以稳定提高几个点的Dice。气胸真实最小病灶通常不会小于50像素,建议阈值设在输入图面积的0.02%。
import cv2 import numpy as np def remove_small_regions(mask, min_area): n_lab, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8) out = np.zeros_like(mask) for i in range(1, n_lab): if stats[i, cv2.CC_STAT_AREA] >= min_area: out[labels == i] = 1 return out参数connectivity=8表示8邻域连通。min_area按输入尺寸缩放,比如512x512图取80像素左右。这一步对气胸分割的作用比遥感影像里强,因为气胸区域往往细长,单像素噪点更容易混入。
4.4 特征图空间注意力
轻度提升可以让解码端输出的特征做一次空间注意力加权,加强气胸边缘区域响应。以DeepLabV3为代表的现代分割模型都有类似结构,U-Net最小实现里可以直接加一个自注意力在最后一层:
class SpatialAttn(nn.Module): def __init__(self, channel): super().__init__() self.conv = nn.Conv2d(channel, 1, 1) self.sigmoid = nn.Sigmoid() def forward(self, x): attn = self.sigmoid(self.conv(x)) return x * attn这个模块开销很小,参数只有channel个,训练耗时增加不到2%。注意力图可以在推理时输出做可视化,判断模型关注区域是否符合胸片特征。
5. 气胸分割实验的进阶验证技巧
实验到最后,除了数值指标,还需要确认模型学的是气胸特征而不是数据集特有噪声。一个做法是拿同一批检查点的不同epoch预测同一张图,观察掩码是否逐轮趋于稳定。如果第5轮和第30轮预测差异巨大,多半是过拟合。实际落地上,更直接的做法是把预测结果做连通域级别的分布统计,和标注掩码的分布对比,能直观看出是否存在系统性过分割。
另一个值得做的是在推理阶段加入TTA(Test Time Augmentation),对输入做水平翻转,把两次预测取平均后再二值化。气胸在X光片上的左右位置都有出现,水平翻转不会破坏解剖结构,TTA的效果比简单增加模型容量更直接。代码示例:
with torch.no_grad(): logits_a = model(x) logits_b = torch.flip(model(torch.flip(x, dims=[3])), dims=[3]) prob = (torch.sigmoid(logits_a) + torch.sigmoid(logits_b)) / 2.0dims=[3]对应(B, C, H, W)的最后一维,即宽度方向翻转。
最后,模型训练完不要只看验证集Dice,要单独抽10张全零掩码的样本看预测的假阳性率。医学场景里负样本漏检比正样本误检更致命,全零图上出现任何大块预测区域都需要警惕。验证脚本里加一行统计全零图平均假阳性像素,如果超过阈值就要回查训练数据中负样本的mask是否干净,有时数据集标注会把胸膜阴影标成气胸,这类噪声直接拉高假阳性率。
本文还有配套的精品资源,点击获取