
简介面向医学影像分析与深度学习开发者提供基于胸部X光图像的肺部分割完整算法实现重点解决肺部区域自动提取难题为后续病理检测与分析提供可靠前置步骤。压缩包共包含19个文件体积约161.85MB文件类型覆盖Python脚本、Jupyter Notebook、模型权重、文本日志、Markdown说明与PNG结果图既有训练管线、模型定义与评估工具也附带了多版本U-Net预训练权重和对应的训练历史曲线。其中models.py、metrics.py、data.py构成核心代码main.ipynb与sandbox.ipynb提供交互式演示splits.pk保存数据划分readme.md与requirements.txt帮助快速搭建环境。算法基于卷积神经网络在U-Net架构上探索了不同深度配置并通过数据增强、Dice/交叉熵损失和正则化手段提升分割精度。目前已有65人学习下载项目结构清晰、注释到位从数据准备到推理评估均可直接复现适合医疗影像方向的学生、研究人员及开发者借鉴改进。1. 胸部X光肺部分割一个看着简单、实际翻车率很高的医学影像任务把胸部X光图像里的肺实质区域逐像素抠出来这是医学影像分割里最经典的入门任务也是公开集上最容易刷到高分的方向——不少人第一次跑通就能把Dice系数做到0.95以上。但同一个模型挪到混杂的真实场景分数经常直接掉到0.85以下问题基本都出在数据、标签和训练细节上而不是网络结构。这个标题指向的项目就是把「胸部X光图像 肺部分割算法 项目源码」打包成一整套可复现的工程适合正在做医学影像方向入门、毕业设计或论文复现的从业者。注意标题里的「病理分割」其实叫得不太严谨正规说法应该叫影像分割对应的是把器官或病灶从医学图像里分离出来的任务跟组织病理切片的分割是两条技术路线这一点我在后面细说。2. 为什么肺分割是医学影像落地的第一站任务边界和模型选型2.1 「病理分割」这个叫法错在哪病理分割在医学影像术语里有更严格的定义把活检组织切成几微米的薄片经过HE染色后放在显微镜下成像再把细胞核、腺体、肿瘤区域从这种高倍显微图里划出来。它面对的是全玻片扫描图像WSI分辨率动辄上亿像素颜色信息来自染色边界靠细胞形态判断。而胸部X光肺分割属于放射影像里的器官分割输入是一张灰度胸片输出是肺野的mask任务发生在宏观解剖尺度边界靠的是肋骨、心脏、膈肌围出来的相对位置。工程上会发现不少人把「病理分割」这个标签一股脑贴在所有医学图像分割项目上尤其在各源码分享站里取标题的人为了搜索流量经常混用。你拿到源码包后的第一件事应该是看它的输入通道这个项目吃的是单通道灰度图预处理围绕窗宽窗位、归一化和resize展开如果某个源码的预处理里出现了染色归一化或者超大图tile裁剪那它做的是另一种任务。两者从数据管线到模型设计都不同混着用会浪费大量时间。对做肺分割的人来说搞清楚这个区别的实际意义在于不要拿病理分割项目的预处理逻辑来套胸片也不要期待胸片分割的模型能直接迁移到病理任务上。标题这么叫不影响代码使用但读文档时心里要有个数。2.2 分割模型的选型U-Net是默认起点backbone决定上限肺分割这种单器官二分类任务U-Net是业界默认起点理由三条跳连接把encoder浅层的高分辨率特征直接搬到decoder边缘细节比FCN一系好得多参数量适中几百张图也训得动几乎所有深度学习框架都有U-Net的成熟实现改造成本极低。SegNet那种靠池化索引恢复细节的方式在小器官分割上表现明显不如U-Net的跳连接。backbone的选择更有讲究。同样一张胸片用VGG做编码器和用ResNet做编码器差距不在参数量而在梯度流动和预训练迁移效率。ResNet34是我在肺分割上用得最顺手的配置容量在几百张图的规模下刚好不会像ResNet50那样容易过拟合预训练权重迁移过来的收敛速度也明显更快。输入方面X光是天然单通道但多数框架默认吃三通道常见做法是把灰度图复制成三通道直接喂给ImageNet预训练权重省事且效果稳定。下表是几个常见选型在肺分割场景下的实际定位数据量参考的是几百张公开集规模模型数据量需求显存占用分割精度工程复杂度经典U-Net较低低中上最低U-Net ResNet34较低中高低DeepLabV3中等容易过拟合中高中等TransUNet / Swin-UNet高需要上千张高上限更高较高对于这个标题下的项目我的建议是拿到源码先看它用的是哪一种。如果默认就是U-Net加ResNet结构说明作者做过基础调研如果还在用FCN或者纯VGG编解码换数据后性能大概率撑不住直接替换成ResNet34 backone是投入产出比最高的一步。2.3 评价指标Dice系数和IoU到底在数什么肺分割论文里最常见的指标是Dice系数其次是IoU。Dice的公式是2|A∩B|/(|A||B|)IoU是|A∩B|/|A∪B|两者衡量的是同一件事但数值感受完全不同。IoU对漏检的惩罚更重同样的预测结果IoU数值通常比Dice低五到八个点。def dice_coef(y_pred, y_true, smooth1e-5): # y_pred 和 y_true 的形状都是 (B, 1, H, W) y_pred y_pred.contiguous().view(-1) y_true y_true.contiguous().view(-1) intersection (y_pred * y_true).sum() return (2.0 * intersection smooth) / (y_pred.sum() y_true.sum() smooth)smooth是平滑因子防止分子分母都为0时除零报错取1e-5这种量级就够了不要贪大否则指标会失真。医学影像分割惯例报告Dice是因为它对正样本区域占比不那么敏感更贴近「两块区域重合了多少」的直觉但Dice对边界锯齿、细小突起这类误差很不敏感两个模型的Dice可能只差0.002可视化效果却差很远。所以我一般要求项目里同时打印Dice和IoU并且每个epoch都抽几张预测图做叠加可视化。指标只能告诉你模型没崩不能告诉你模型好不好这是后面第5章要展开的坑。3. 从X光原图到训练集数据准备和预处理管线3.1 数据来源与前处理DICOM/JPEG转成模型能吃的灰度图以「胸部X光图像 肺部分割算法」为关键词去搜能稳定找到的公开数据集主要是JSRT和ChestX-ray Masks后者包含Montgomery County和Shenzhen Hospital两个子集mask由医生手工勾画质量整体不错。Montgomery的标注画得比较规整Shenzhen有一部分图左右肺不对称训练时增强参数要保守一些。拿到数据后第一步不是建模型而是把原始图像统一成模型能吃的格式。JPEG/PNG直接读就行DICOM要麻烦一些import pydicom import numpy as np import cv2 def load_dicom_to_gray(dcm_path, window_center-600, window_width1500): ds pydicom.dcmread(dcm_path) pix ds.pixel_array.astype(np.float32) # DICOM一般是16位有符号 # 胸片常看肺窗把窗宽窗位映射到 0~1 lo window_center - window_width / 2.0 hi window_center window_width / 2.0 pix np.clip((pix - lo) / (hi - lo), 0, 1) img (pix * 255).astype(np.uint8) # 转成单通道灰度 if len(img.shape) 3: # 极少数多帧存储取第一帧 img img[0] return img这段代码的核心是窗宽窗位映射。DICOM文件里存的是原始像素值直接转成8位图会得到一片灰白必须做窗宽窗位变换才能看到肺野。胸片分割默认用肺窗window_center-600、window_width1500是常用参数如果DICOM文件自带了WindowCenter和WindowWidth这两个tag优先读文件里的值代码里写死只是兜底。转成灰度后建议再统一做一次归一化常见做法是除以255后减0.5让输入落在[-0.5, 0.5]这个范围和预训练权重的分布更接近。3.2 mask处理和标签检查质量控制比模型重要mask的处理比原图更容易翻车。标注软件导出的mask常见三种形态0/255、0/1、偶尔混入127之类的中间值。不统一就训练会出现loss能降但预测结果诡异的情况。def normalize_mask(mask_path, target_size(512, 512)): mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 统一成 0/1 二值 mask (mask 127).astype(np.uint8) # mask 缩放永远用最近邻不能省 mask cv2.resize(mask, target_size, interpolationcv2.INTER_NEAREST) return mask阈值取127是因为很多标注工具保存时会做抗锯齿处理边缘像素落在0到255之间二值化阈值定在中间最稳。resize用最近邻插值是铁的纪律双线性插值会把0/1的mask插出0.7、0.3这类中间值后面再阈值化时整条边界会系统性向内或向外收缩一条边。这种事在训练集里每张图都发生一遍模型学到的就是「肺野比真实小一圈」。标签检查有个笨但有效的方法把原图和mask叠加画到一张图里肉眼抽查至少20张重点看边缘是否贴合、左右肺是否镜像、有没有标签错位的。mask是模型的天花板——标注错了网络再努力也只能学到一个错误的边界。3.3 数据增强的选择翻转、仿射与边界混淆胸片分割的数据增强比自然图像分割要克制。正常胸片左右肺对称水平翻转不改变解剖语义可以放心开。旋转和缩放要严格控制幅度因为肺在胸腔里的相对位置是稳定的大角度旋转会破坏模型对解剖结构的先验。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.ShiftScaleRotate(shift_limit0.05, scale_limit0.1, rotate_limit10, p0.5), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.3), A.GaussNoise(var_limit(10.0, 30.0), p0.2), ])ShiftScaleRotate里的scale_limit控制在0.1以内rotate_limit控制在10度以内。超过15度后模型开始把「斜着的肺」也学进去推理时对正常正位片反而犹豫。RandomBrightnessContrast是为了应对不同机器、不同曝光条件下拍的片子胸片来源杂的时候这个增强最值钱。GaussNoise模拟的是X光成像的噪声幅度不需要大。albumentations这个库比手写增强省心的地方在于它保证image和mask用同一套随机参数做变换。如果自己写增强很容易出现图转了mask没转的情况这种bug在训练里很难发现但会导致mask和原图错位Dice一路虚高。增强之后mask还要再做一次(mask 127)的二值化防止形变插值产生灰色边缘。4. 基于源码实操跑通项目结构、训练脚本与三个关键参数4.1 一个典型的肺分割项目长什么样拿到标题里说的这种项目源码包解压之后最常看到的组织结构是下面这样。这也是现在主流分割/检测项目源码的通用骨架和网上大量python实战源码包的模块化习惯基本一致chest_xray_seg/ ├── config.py # 超参数集中管理 ├── data/ │ ├── images/ # 原始X光图 │ └── masks/ # 对应肺野标注 ├── models/ │ └── unet_resnet.py # 分割网络定义 ├── utils/ │ ├── metrics.py # Dice/IoU │ └── vis.py # 叠加可视化 ├── train.py # 训练主脚本 └── predict.py # 推理脚本这种拆法有个好处改参数不动代码换模型不动数据管线加指标不动训练逻辑。最怕的是某些「一键训练」脚本把读数据、建模型、训练循环全塞在一个文件里前期跑起来快后期想换backbone或者调损失函数就得通读几百行代码。拿到源码别急着训练先把config.py里跟数据路径相关的字段改对然后跑一次predict.py看默认权重能不能出结果。如果这一步能通过说明环境、依赖、路径都没问题再进训练阶段。这个验证顺序能帮你区分「环境问题」和「模型问题」排查速度快很多。4.2 训练流程Dataset、混合损失函数与训练循环数据加载是训练流程里最容易埋bug的地方重点看Dataset的返回值格式和resize策略from torch.utils.data import Dataset import torch class LungDataset(Dataset): def __init__(self, image_paths, mask_paths, size512, transformNone): self.image_paths image_paths self.mask_paths mask_paths self.size size self.transform transform def __getitem__(self, idx): img cv2.imread(self.image_paths[idx], cv2.IMREAD_GRAYSCALE) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) img cv2.resize(img, (self.size, self.size)) mask cv2.resize(mask, (self.size, self.size), interpolationcv2.INTER_NEAREST) if self.transform: aug self.transform(imageimg, maskmask) img, mask aug[image], aug[mask] img (img.astype(np.float32) / 255.0 - 0.5) mask (mask 127).astype(np.float32) # 转成 0/1 img torch.from_numpy(img).unsqueeze(0) mask torch.from_numpy(mask).unsqueeze(0) return img, mask注意两点mask的resize必须用最近邻这个前面强调过mask的二值化必须在增强之后做因为增强过程中的插值会让mask出现中间值。图归一化到[-0.5, 0.5]mask归一化到[0, 1]两者量纲不要混。如果后续要加预训练backbone输入的三通道灰度图是在这里把单通道复制三遍还是在模型里处理取决于项目实现但一定要保持全流程一致。损失函数我几乎不用纯BCE或纯Dice而是混合import torch.nn.functional as F def mixed_loss(pred, target): # pred: 网络输出的 logits; target: 0/1 的 mask pred_prob torch.sigmoid(pred) bce F.binary_cross_entropy_with_logits(pred, target) inter (pred_prob * target).sum(dim(1, 2, 3)) union pred_prob.sum(dim(1, 2, 3)) target.sum(dim(1, 2, 3)) dice 1 - (2 * inter 1e-5) / (union 1e-5) return bce dice.mean()纯BCE的问题在于肺野占整张图的比例不高背景像素远多于前景模型容易偏向「全预测为背景」但只用Dice的话小区域上的梯度变化太剧烈loss曲线抖得厉害。两者相加BCE提供稳定的像素级梯度Dice直接优化区域重合度是医学分割的标配做法。训练循环本身不复杂每隔若干轮存一次checkpoint验证时同时记录val_dice和val_iou方便后续对比。4.3 三个影响结果的超参数整理一下我跑这类胸片分割项目时最常调的三组超参数超参数常用范围说明学习率1e-4Adam用ReduceLROnPlateaupatience10factor0.5batch size8~16尽量不低于8太小则BN不稳定输入分辨率512×512显存允许时试768但注意过采样学习率1e-4是Adam系优化器在医学分割里的稳定起点。新手最容易犯的错是直接套自然图像分类的3e-4甚至1e-3胸片数据集量小这个学习率会把loss顶成一个平台期看起来在降实际模型已经退化。配合ReduceLROnPlateau让它自己衰减比固定学习率硬跑到底效果好得多。输入分辨率对胸片分割的影响比想象中大。512×512在公开集上通常够用因为公开集的mask就是在这个量级下人工勾的没有更高频的细节可以学。但如果原始图是2000×2000的DICOM建议先做最长边缩放而不是直接resize变形否则肺的长宽比被压缩模型学到的形状特征会偏。epoch数在80到150之间配合early stopping监控val_dice连续15轮不升就停。源码包里给的默认epoch数往往偏大照着跑完纯属浪费时间。5. 常见问题与避坑从「跑通」到「好用」之间的五道坎5.1 mask和原图对不齐Dice却很高现象训练时Dice一路升到0.96loss也正常但把预测结果叠加到原图上一看mask整体比肺野小一圈或者某条边缘有系统性偏移。原因最常见的是resize时对mask用了双线性插值。0/1的mask插值后变成0.8、0.3这类灰色边缘再以0.5为阈值截断整条边界向内收缩一两个像素。每张图都缩一遍模型学到的就是「小一号的肺」。另一个可能原因是DICOM的像素间距信息没处理原图被resize变形而mask是按原始宽高比生成的。解决mask的resize永远用cv2.INTER_NEAREST训练前把原图和mask拼在一起肉眼过一遍mask读取统一走(mask 127)二值化。养成这个习惯后大多数「mask看起来不对」的问题会在训练前暴露而不是在训练后才发现。5.2 验证集Dice虚高换一批数据就崩现象同一批数据里训练验证分得Dice 0.95换个来源的胸片直接掉到0.83损失函数没变过。原因数据划分时没有按患者隔离。同一个患者的多次胸片如果被随机分到训练集和验证集模型相当于在验证集里「见过这个人」泛化能力被严重高估。这在公开集上尤其常见因为公开集文件名往往按序列编号而不是按患者编号。解决用sklearn.model_selection.GroupShuffleSplit按患者ID分组保证同一个人的所有片子只出现在训练或验证中的一边。处理DICOM序列时从文件tag里挖出PatientID来分组处理公开集时看文件名前缀通常一个前缀对应一个患者。按照患者隔离重新划分后Dice普遍会掉两到四个点这才是真实水平。5.3 模型把肋骨、锁骨和心脏区域也当成肺现象预测mask把整个胸腔的中上部分都框进去肋骨边缘、纵隔区域被大面积标成肺形态学上看起来像「整个胸腔」。原因一是标签本身的主观性。胸片是二维投影肺野边界在肋骨重叠区域本来就模糊不同医生勾的mask边界差异能到十几个像素有些数据集甚至把肺门软组织也画了进去。二是增强参数太激进旋转超过15度后模型开始混淆解剖位置关系把「软组织纹理」当成肺而不是把「肺野在胸腔中的位置」当成肺。解决训练前统一标签口径只保留肺野区域删掉纵隔和骨骼部分旋转角度压到10度以内推理时对预测结果取最大连通域并做孔洞填充。这三个措施组合起来基本能把「胸腔大礼包」式的误检压下去具体后处理代码在第6章。5.4 整张高分辨率图直接训练显存和收敛效率两头崩塌现象把1920×1920的胸片直接喂进模型batch size降到2才不OOM然后训练loss剧烈抖动模型怎么训都不收敛。原因高分辨率大幅裁掉了batch sizebatch太小导致BN层统计量不稳定每批数据shift很大loss自然抖。这和模型结构无关是训练配置的自相矛盾。解决训练阶段把最长边resize到1024或1536再random crop成512×512的块参与训练推理阶段用滑窗预测stride 256再把概率图拼回去重叠区域取平均。这样既保留了原始分辨率的信息又让batch size回到8以上的稳定区间。显存实在紧张就换InstanceNorm而不是硬扛BN。5.5 训练loss在降预测结果却全黑或全白现象loss曲线正常下降但模型预测的mask要么全0要么全1sigmoid输出完全饱和。原因十有八九是标签量纲没统一。mask读进来是uint8的0和255没做二值化就直接参与BCE计算255被当成目标值模型为了逼近255把logits推到极大输出全白。反过来如果目标值被归一化到[0,1]而模型输出没经过sigmoid就取阈值也会得到全黑。解决在Dataset里强制写mask (mask 127).float()不要依赖外部传入的mask已经是0/1。训练前打印一个batch里pred.min(), pred.max(), target.min(), target.max()确认输出和目标的量纲匹配这个习惯能省下大量排查时间。mixed_loss里的smooth因子不用太大1e-5到1e-6足够平滑除零问题设成0.1反而会拖慢小目标收敛。6. 进阶玩法注意力块、边缘后处理与可信评估6.1 在U-Net里加一个SE块改动最小、收益最直接不改整体结构的前提下给U-Net的decoder块加一个Squeeze-and-Excitation块是提升肺边界稳定性的性价比做法。SE块会自动学习每个特征通道的重要性对肺野和骨骼这类纹理相似但语义不同的区域有更好的区分度。import torch.nn as nn class SEBlock(nn.Module): def __init__(self, in_ch, r8): super().__init__() self.fc nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Conv2d(in_ch, in_ch // r, 1), nn.ReLU(inplaceTrue), nn.Conv2d(in_ch // r, in_ch, 1), nn.Sigmoid()) def forward(self, x): return x * self.fc(x)放在每个decoder块最后一层卷积之后、上采样之前最稳妥压缩比r取8既不会明显增加参数量又能让loss稳定下降。加了SE块之后训练收敛通常会变慢一两个epoch但val_dice一般能涨0.3到1个百分点在公开集上足够拉开差距。6.2 推理后处理最大连通域与孔洞填充模型输出的概率图直接阈值化会有很多细碎噪声尤其在肋骨边缘。正常胸片上肺野是面积最大的连续低密度区域用连通域过滤加孔洞填充就能把误检压掉大半import numpy as np import cv2 def postprocess(pred_prob, threshold0.5): mask (pred_prob threshold).astype(np.uint8) n, labels, stats, _ cv2.connectedComponentsWithStats(mask, 8) # 找面积最大的连通域滤掉肋骨/心脏误检产生的碎块 largest 1 np.argmax(stats[1:, cv2.CC_STAT_AREA]) mask (labels largest).astype(np.uint8) # 孔洞填充7x7 椭圆核闭运算 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) mask cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) return mask这个后处理只适用于「肺是最大目标」这类场景结节分割、骨折检测这些小目标任务不能套用会把真阳性一起过滤掉。核大小7×7在512×512尺度下够填细碎孔洞又不会把边界磨得太圆。6.3 验证方法别被一次Dice骗了小数据集上跑一次训练得到的Dice没有统计说服力。我会对数据集做3折交叉验证每折独立训练并记录val_dice最终报告写成「Dice 0.943±0.021」而不是「Dice 0.96」。样本量少于200张时用bootstrap重复采样1000次估计Dice的置信区间比单次评估可信得多。我第一次跑通这个项目时公开集Dice做到0.96以为可以收工了后来换到真实混杂数据一测直接掉到0.83排查了一周才发现问题不在网络而在数据划分和mask的resize方式。那之后我养成了一个习惯任何分割项目先画三张图——原图、标注、预测叠加再谈指标。希望帮到你。本文还有配套的精品资源点击获取