ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

基于2000张胸片的气胸语义分割实战:从U-Net到SegFormer

基于2000张胸片的气胸语义分割实战:从U-Net到SegFormer 简介本资源面向医学图像分割方向的研究人员、算法工程师与相关专业学生提供气胸Pneumothorax语义分割的胸部X光数据集可用于训练和评估病灶区域识别模型帮助解决气胸范围与严重程度自动判读的问题。压缩包共约2000个文件以1998张png图像及对应标签为主另含1个txt类别说明与1个py脚本整体约183.48MB图像已做对比度拉伸与resize等增广处理并预先划分训练集与验证集。目前已有648人学习下载。读者可据此直接搭建分割实验流程结合类别文件确认标签体系参考作者提供的网络分割资料快速复现与调参省去数据采集与清洗成本适合作为医学图像分割入门与进阶的实战素材。1. 气胸图像语义分割数据集2000 张胸片能训练出什么拿到一份标注好的气胸 Chest X-Ray 语义分割数据集第一反应往往不是兴奋而是怀疑——2000 多张够不够胸片里的气胸区域边界模糊、和肋骨重叠、和肺纹理混在一起标注质量参差不齐直接拿去训 U-Net 大概率翻车。但换个角度想气胸分割在临床上本来就是辅助定位任务不是替代放射科医生做诊断模型只需要把可疑的胸膜线区域圈出来帮医生快速定位这个目标下 2000 张标注数据是完全能跑出可用结果的。这份数据集的核心价值在于它把气胸这个细分场景的像素级标注做成了可直接训练的结构省掉了从 DICOM 原始影像到标注掩码的整条预处理链路。适合谁用一是做医学图像分割入门、想找一个真实临床场景练手的人二是已经在做胸片分类、想加一个分割头做可解释性输出的人三是需要快速验证某个新分割架构在医学小数据集上表现的研究者。不适合谁指望直接拿去上临床、或者想用 2000 张覆盖所有气胸亚型的人——数据规模决定了它只能做单场景验证不能做泛化部署。2. 气胸语义分割的数据组织与预处理链路2.1 胸片语义分割的标注格式与目录结构拿到数据集先别急着写 DataLoader第一步是搞清楚它的组织方式。常见的医学分割数据集有两种布局一种是图像和掩码分目录存放文件名一一对应另一种是图像和掩码同目录、用后缀区分。气胸数据集通常采用前者因为胸片原始格式可能是 PNG 或 DICOM 转出的灰度图掩码是单通道 8 位 PNG像素值 0 表示背景、255 表示气胸区域。先跑一段脚本把目录结构和文件对应关系摸清楚import os from pathlib import Path from PIL import Image import numpy as np root Path(pneumothorax_seg) # 数据集根目录 img_dir root / images mask_dir root / masks # 检查图像与掩码是否一一对应 img_files sorted([f.stem for f in img_dir.glob(*.png)]) mask_files sorted([f.stem for f in mask_dir.glob(*.png)]) print(f图像数: {len(img_files)}, 掩码数: {len(mask_files)}) missing set(img_files) - set(mask_files) if missing: print(f缺失掩码: {list(missing)[:5]}) # 抽查一张看尺寸和像素分布 sample img_files[0] img np.array(Image.open(img_dir / f{sample}.png).convert(L)) mask np.array(Image.open(mask_dir / f{sample}.png).convert(L)) print(f图像 shape: {img.shape}, dtype: {img.dtype}, 灰度范围: {img.min()}-{img.max()}) print(f掩码 shape: {mask.shape}, 唯一值: {np.unique(mask)}) print(f气胸像素占比: {(mask 127).sum() / mask.size:.4f})这段脚本做三件事核对图像和掩码的文件名是否严格对应避免训练时读到空掩码检查图像是否为单通道灰度、位深是否一致统计掩码的像素值分布确认标注是二值还是多类。如果掩码唯一值出现 1、2、3 这种说明是多类标注需要做类别映射如果只有 0 和 255就是标准二值分割。参数上要注意convert(L)强制转灰度因为部分胸片可能存成 RGB 三通道但实际是灰度内容不转的话后面归一化会出问题。mask 127是二值化阈值如果掩码边缘有抗锯齿产生的中间值用 127 做阈值比用 0 更稳。2.2 胸片预处理的四个关键参数胸片和自然图像最大的区别是动态范围大、对比度低直接除以 255 会丢失大量细节。我一般会做三步先做 CLAHE 限制对比度自适应直方图均衡再归一化到 [0,1]最后按数据集统计做标准化。import cv2 import numpy as np def preprocess_chest_xray(img_path, clip_limit2.0, tile_size8): clip_limit: CLAHE 对比度限制胸片常用 2.0-3.0 tile_size: 网格大小8x8 适合 512 以上分辨率 img cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE) # CLAHE 增强局部对比度气胸的胸膜线在低对比区域 clahe cv2.createCLAHE(clipLimitclip_limit, tileGridSize(tile_size, tile_size)) img_eq clahe.apply(img) # 归一化到 [0,1] img_norm img_eq.astype(np.float32) / 255.0 # 标准化用数据集全局均值和标准差这里给的是胸片经验值 mean, std 0.505, 0.252 img_norm (img_norm - mean) / std return img_norm # 验证预处理效果 sample_img preprocess_chest_xray(img_dir / f{sample}.png) print(f预处理后范围: {sample_img.min():.3f} ~ {sample_img.max():.3f}, 均值: {sample_img.mean():.3f})CLAHE 的clipLimit控制对比度增强上限设太高会把噪声也放大气胸区域反而被淹没tileGridSize决定局部统计的粒度8x8 在 512×512 以上分辨率下比较均衡。标准化用的均值和标准差最好自己从训练集统计一遍不要直接套 ImageNet 的 0.485/0.456胸片分布和自然图像差很远。2.3 数据增强气胸分割不能用的增强方式医学分割的数据增强和自然图像有本质区别。水平翻转可以用因为胸片左右对称结构不影响气胸的病理特征但垂直翻转绝对不能用倒过来的胸片解剖结构完全错乱模型会学到错误的空间先验。旋转要限制在 ±15 度以内大角度旋转会让肋骨和肺野的相对位置失真。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.Rotate(limit15, p0.5, border_modecv2.BORDER_CONSTANT), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), A.ElasticTransform(alpha1, sigma50, p0.2), # 模拟组织形变 A.Resize(512, 512), ]) val_transform A.Compose([A.Resize(512, 512)])ElasticTransform是医学分割里少数值得用的形变增强它模拟软组织挤压效果对气胸边界这种不规则形状有正则作用。但alpha和sigma要调小alpha1、sigma50 是保守值再大就会把胸膜线扭曲成不真实的形状。RandomBrightnessContrast的幅度也要压住胸片本身对比度就低增强过头会让气胸区域和正常肺组织混在一起。3. 从 U-Net 到 SegFormer气胸分割模型选型与训练3.1 小数据集上 U-Net 和 Transformer 分割头的取舍2000 张数据在分割任务里属于小样本模型选型的第一原则是参数量不能太大。U-Net 原始版本约 31M 参数在 2000 张上从头训容易过拟合但配合强增强和早停还能跑。SegFormer 的 MiT-B0 主干约 3.7M 参数B1 约 13MB0 在小数据集上反而比 U-Net 更稳因为 Transformer 的归纳偏置弱对数据分布偏移的敏感度低。我的建议是如果只有 2000 张且不做预训练优先用 U-Net ResNet34 编码器约 24MImageNet 预训练权重能显著加速收敛。如果有条件做自监督预训练或者用医学预训练权重SegFormer-B1 是更好的选择。下面给一个 U-Net 的训练配置这是最稳的基线。import torch import torch.nn as nn from torch.utils.data import DataLoader import segmentation_models_pytorch as smp # 用 smp 库快速搭 U-Net编码器用 ResNet34 预训练 model smp.Unet( encoder_nameresnet34, encoder_weightsimagenet, in_channels1, # 胸片是单通道 classes1, # 二值分割输出 1 通道 activationNone, # 损失函数里带 sigmoid ) # 损失Dice BCE 组合气胸区域小纯 BCE 会被背景淹没 dice_loss smp.losses.DiceLoss(modebinary) bce_loss nn.BCEWithLogitsLoss(pos_weighttorch.tensor([5.0])) # 正样本加权 def criterion(pred, target): return dice_loss(pred, target) bce_loss(pred, target) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50)pos_weight5.0是气胸分割的关键参数。气胸区域通常只占整张胸片的 2%-8%正负样本极度不平衡不加权的话模型会倾向于全预测背景Dice 看着不低但实际什么都没分出来。5.0 是经验起点如果验证集上召回率偏低就往上调到 8-10如果误报太多就降到 3。3.2 训练循环里必须监控的三个指标气胸分割不能只看 lossloss 下降不代表模型学到了有用特征。必须同时盯 Dice、IoU 和召回率。Dice 和 IoU 反映整体重叠度召回率反映漏检情况——临床上漏检比误报严重得多。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for imgs, masks in loader: imgs imgs.to(device).float() masks masks.to(device).float().unsqueeze(1) # (B,1,H,W) optimizer.zero_grad() preds model(imgs) loss criterion(preds, masks) loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step() total_loss loss.item() return total_loss / len(loader) torch.no_grad() def evaluate(model, loader, device, threshold0.5): model.eval() dice_sum, iou_sum, recall_sum, n 0, 0, 0, 0 for imgs, masks in loader: imgs imgs.to(device).float() masks masks.to(device).float().unsqueeze(1) preds torch.sigmoid(model(imgs)) pred_bin (preds threshold).float() # 逐样本算避免 batch 内大区域主导 for p, m in zip(pred_bin, masks): inter (p * m).sum() union p.sum() m.sum() - inter dice_sum (2 * inter / (p.sum() m.sum() 1e-6)).item() iou_sum (inter / (union 1e-6)).item() recall_sum (inter / (m.sum() 1e-6)).item() n 1 return dice_sum/n, iou_sum/n, recall_sum/nclip_grad_norm_的 max_norm1.0 是防止气胸小区域梯度爆炸的保险医学分割里正样本少单 batch 梯度方差大不裁剪容易训崩。评估时逐样本算指标而不是整个 batch 汇总因为不同胸片的气胸面积差异大batch 汇总会被大区域样本主导掩盖小区域漏检。3.3 学习率与 batch size 的搭配经验2000 张数据、512×512 分辨率单卡 8G 显存下 batch size 一般能到 8-12。学习率用 1e-4 配合 CosineAnnealing 是稳的但如果用预训练编码器编码器部分的学习率应该设成解码器的 1/10避免预训练特征被快速破坏。# 分层学习率编码器 1e-5解码器 1e-4 encoder_params list(model.encoder.parameters()) decoder_params [p for n, p in model.named_parameters() if not n.startswith(encoder)] optimizer torch.optim.AdamW([ {params: encoder_params, lr: 1e-5}, {params: decoder_params, lr: 1e-4}, ], weight_decay1e-4)训练轮数上2000 张数据一般 40-60 epoch 收敛配合早停验证 Dice 连续 10 epoch 不升就停。如果 20 epoch 内验证 Dice 还在 0.3 以下大概率是预处理或标注有问题先回去查数据别急着调模型。4. 气胸分割的评估、可视化与常见翻车点4.1 避坑气胸分割训练中最容易踩的五个坑现象一训练 loss 正常下降但验证 Dice 始终在 0.2 以下。原因通常是掩码和图像没对齐或者掩码被当成了图像读入。解决用 2.1 的脚本逐张核对把掩码叠加到原图上可视化确认标注区域确实在胸膜线位置。现象二模型把所有像素预测为背景Dice 却显示 0.85。这是正负样本极度不平衡下的指标假象。原因是没有加 pos_weight 或 Dice loss 权重太低。解决把 BCE 的 pos_weight 提到 8-10同时确认 Dice loss 用的是 batch 内逐样本平均而不是全局汇总。现象三验证集上 Dice 波动超过 0.15。原因通常是验证集太小或者数据增强用在了验证集上。解决验证集至少留 200 张且验证时只做 resize 不做任何随机增强。另外检查 DataLoader 的 shuffle 在验证时是否关掉。现象四训练到 30 epoch 后 Dice 突然掉到 0.1。这是学习率过大导致的后期震荡CosineAnnealing 在末期学习率接近 0 不应该出现这种情况。原因可能是梯度裁剪的 max_norm 设太大或者某批数据里有异常样本比如全黑图。解决把 max_norm 降到 0.5同时在 Dataset 里加异常样本过滤图像均值低于 0.05 或高于 0.95 的直接跳过。现象五换一台机器重新训练结果差很多。原因通常是随机种子没固定或者 CUDA 版本差异导致某些算子行为不一致。解决在训练脚本开头固定 torch、numpy、random 的种子并设置torch.backends.cudnn.deterministic True。注意这会让训练慢 10%-20%但可复现性值得。4.2 用 Grad-CAM 验证模型是否真的在看气胸区域Dice 高不代表模型学到了正确的特征可能只是学到了“胸片左上角通常是背景”这种捷径。用 Grad-CAM 把模型注意力可视化出来确认高响应区域落在气胸标注附近。from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image # 取 U-Net 最后一个上采样层的输出作为目标层 target_layers [model.decoder.blocks[-1]] cam GradCAM(modelmodel, target_layerstarget_layers) # 对单张图生成热力图 input_tensor torch.from_numpy(sample_img).unsqueeze(0).unsqueeze(0).float().to(device) grayscale_cam cam(input_tensorinput_tensor)[0] # 叠加到原图 visualization show_cam_on_image(sample_img, grayscale_cam, use_rgbFalse)如果热力图集中在肺野边缘、肋骨交界处说明模型在学解剖结构这是好信号。如果热力图集中在图像边框或某个固定角落说明模型在走捷径需要检查数据里是否有位置偏置——比如所有气胸都标注在右肺模型就会直接学“右边就是气胸”。4.3 后处理把分割掩码变成可用的气胸区域模型输出的概率图直接二值化会有很多小噪点临床上需要的是连通区域。用形态学开运算去掉孤立像素再取最大连通域最后算区域面积和位置。from scipy import ndimage def postprocess_mask(prob_map, threshold0.5, min_area100): binary (prob_map threshold).astype(np.uint8) # 开运算去噪 kernel np.ones((3, 3), np.uint8) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) # 连通域分析 labeled, num ndimage.label(binary) if num 0: return np.zeros_like(binary) # 取最大连通域 sizes ndimage.sum(binary, labeled, range(1, num 1)) largest np.argmax(sizes) 1 result (labeled largest).astype(np.uint8) # 面积过滤 if result.sum() min_area: return np.zeros_like(binary) return resultmin_area100是 512×512 下的经验值小于这个面积的连通域基本是噪声。如果原始分辨率更高按比例放大。后处理之后可以算气胸区域的边界框和面积占比这些数值可以直接输出给下游的临床报告系统。5. 把 2000 张气胸数据用到极致迁移与半监督2000 张标注数据在医学分割里不算多但可以通过迁移学习把它变成起点而不是终点。我的习惯是先用这份数据训一个基线 U-NetDice 到 0.75 左右之后把编码器冻结用未标注的胸片做自监督预训练——具体做法是对未标注图像做随机遮挡让模型重建被遮挡区域这样编码器能学到胸片的通用纹理特征。然后再解冻微调通常能再涨 3-5 个 Dice 点。另一个方向是跨数据集迁移。公开的 Chest X-Ray 数据集里有很多带肺野分割标注的先用那些数据预训练一个肺野分割模型再把编码器迁移到气胸分割上。肺野和气胸在解剖上是包含关系气胸一定在肺野内部这个先验能帮模型快速定位搜索区域。我试过用 JSRT 数据集的肺野标注做预训练气胸分割的收敛速度能快一倍最终 Dice 也能高 2 个点左右。验证方法上除了看 Dice我还会做一件事把模型在验证集上的预测按气胸面积分成小、中、大三档分别统计 Dice。如果小面积气胸的 Dice 明显低于大面积说明模型对小目标不敏感需要调整 pos_weight 或者加一个多尺度输出头。这个分档统计比看总体 Dice 有用得多能直接告诉你模型在哪个区间不行。最后说一个我踩过的坑不要用测试集调任何参数包括后处理的阈值。我见过有人把测试集 Dice 调到 0.9结果换一批数据直接掉到 0.5。测试集只能用一次调参全部在验证集上做。这个习惯比任何模型结构都重要。希望帮到你。本文还有配套的精品资源点击获取
返回列表