简介:这份资源是一套基于Python实现的肺结节分割完整项目源码,面向计算机、人工智能及相关专业的学生与开发者,可用于毕业设计、课程设计、项目开发或算法竞赛等场景,帮助解决医学影像中肺结节自动分割这一典型任务。压缩包共26个文件,约541KB,以17个Python脚本为核心,覆盖数据预处理、2D U-Net模型构建、训练与验证、结节检测及预测提交等环节,另含3个CSV结果文件、2个Markdown说明文档及缓存与配置文件,目录结构清晰,便于按流程阅读与复现。目前已有119人学习下载。项目源码经过严格测试,读者可据此理解肺结节分割的完整实现思路,掌握从训练集预处理、模型训练到验证预测的工程流程,并在此基础上进行模块替换、参数调优或功能延伸,适合作为学习与二次开发的参考。
1. 肺结节分割这套源码,到底能帮你省下多少重复劳动
带过几届毕设之后我发现一个规律:选医学图像分割题目的同学,十有八九卡在同一个地方——不是算法不会,而是数据管线搭不起来。CT 影像的窗宽窗位怎么调、掩膜和原图怎么对齐、Dice 算出来为什么和别人的差一截,这些问题在通用语义分割教程里根本找不到答案。基于 Python 开发的肺结节分割项目,核心价值就在于把这条链路完整跑通了:从 LIDC-IDRI 或本地脱敏 CT 数据读入,到预处理、模型训练、推理输出掩膜,再到指标评估和可视化,每一步都有可运行的代码兜底。
这套东西适合三类人:赶毕业设计需要快速搭出可演示系统的、课程设计想理解医学分割完整流程的、打比赛需要一份能改的 baseline 的。它不解决“发顶会”的问题,但能让你在两周内拥有一个能跑通、能出图、能答辩的完整工程。下面我按实际落地的顺序,把选型理由、代码骨架、参数设置和踩坑记录拆开讲。
2. 数据管线怎么搭:从 DICOM 到可训练张量的完整路径
2.1 为什么医学图像预处理比自然图像麻烦三倍
自然图像做分割,读进来是 RGB 三通道,归一化到 [0,1] 就能送进网络。肺结节 CT 完全不是这个逻辑。原始 DICOM 文件存的是 HU 值(Hounsfield Unit),范围大概在 -1024 到 3071 之间,直接归一化会把肺实质和结节的对比度压没。常见做法是先做窗宽窗位截断,把感兴趣区域拉出来,再归一化。
肺结节检测常用的窗是肺窗,窗宽 1500、窗位 -600,这个设置能把肺实质和结节都保留在可视范围内。如果你做的是纵隔窗或者骨窗,参数完全不同,别混用。截断之后再做 z-score 归一化,比简单除以最大值稳定得多。
另一个麻烦是掩膜对齐。LIDC-IDRI 的标注是 XML 格式,四个放射科医生各自勾了结节轮廓,你需要决定用谁的、怎么融合。常见做法是取交集或者多数投票,但这一步没有标准答案,取决于你的任务定义。如果只是做课程设计,直接用官方提供的掩膜 PNG 最省事。
2.2 用 Python 把 DICOM 序列转成 npy 训练集
下面这段代码是我一般会用的预处理骨架,基于 pydicom 和 numpy,不依赖框架,方便你嵌入任何训练流程。
import pydicom import numpy as np import os from pathlib import Path def load_dicom_series(series_dir): """读取一个 DICOM 序列,按 InstanceNumber 排序后堆叠成 3D 数组""" slices = [] for f in Path(series_dir).glob('*.dcm'): ds = pydicom.dcmread(str(f)) slices.append(ds) # 按层号排序,否则 z 轴会乱 slices.sort(key=lambda s: int(s.InstanceNumber)) # 堆叠前统一尺寸,不同层可能因为重建参数有 1px 差异 target_shape = slices[0].pixel_array.shape volume = np.stack([s.pixel_array for s in slices if s.pixel_array.shape == target_shape]) return volume, slices[0] def apply_lung_window(volume, wc=-600, ww=1500): """肺窗截断,把 HU 值映射到 [0,1]""" low = wc - ww // 2 high = wc + ww // 2 volume = np.clip(volume, low, high) volume = (volume - low) / (high - low) return volume.astype(np.float32) def normalize_zscore(volume): """按体积做 z-score,比 min-max 更抗异常值""" mean = volume.mean() std = volume.std() if std < 1e-6: return volume - mean return (volume - mean) / std def preprocess_pipeline(series_dir, output_path): volume, ref = load_dicom_series(series_dir) volume = apply_lung_window(volume) volume = normalize_zscore(volume) # 保存为 npy,训练时直接内存映射,比每次读 DICOM 快 20 倍以上 np.save(output_path, volume) return volume.shape if __name__ == '__main__': shape = preprocess_pipeline('./data/patient_001', './processed/patient_001.npy') print(f'预处理完成,体积尺寸: {shape}')这段代码的逻辑分三层:读取层负责把散落的 DICOM 文件按物理顺序拼成 3D 体积,排序那一步不能省,否则 z 轴错位会让结节形状完全变形;窗变换层把 HU 值压到网络能吃的范围,窗宽窗位两个参数直接决定结节边缘是否清晰;归一化层用 z-score 而不是 min-max,是因为 CT 体积里常有金属伪影或异常高值,min-max 会被这些离群点带偏。
参数方面,wc=-600, ww=1500是肺窗的经典值,如果你发现结节在图像上偏暗或偏亮,优先调窗位而不是窗宽。InstanceNumber排序在大多数设备上可靠,但少数机器这个字段会重复,稳妥做法是同时参考SliceLocation。输出用.npy而不是.png,是为了保留浮点精度,中间转 PNG 再读回来会引入量化误差。
2.3 掩膜生成与数据增强的边界
掩膜这块,如果你用的是 LIDC-IDRI 的 XML 标注,需要自己写解析脚本把轮廓点转成二值掩膜。常见做法是用matplotlib.path或者cv2.fillPoly,但要注意坐标系:DICOM 的像素坐标原点和图像数组的索引原点可能差一个翻转。我一般会先画一张叠加图肉眼确认,再批量处理。
数据增强在肺结节任务里要克制。水平翻转、±15 度旋转、弹性形变是安全的,但垂直翻转要慎用——肺的解剖结构上下不对称,翻了之后模型可能学到错误的空间先验。亮度对比度扰动可以做,但幅度别超过 10%,否则窗变换的意义就被抵消了。
3. 模型选型与训练:U-Net 还是 nnU-Net,参数怎么定
3.1 为什么我建议从 U-Net 改起而不是直接上 Transformer
肺结节分割的数据量通常不大,LIDC-IDRI 里真正有明确结节的病例也就一千出头,切完 patch 之后正样本更少。Transformer 类模型在这种数据规模下容易过拟合,而且训练成本高,调参周期长。U-Net 的编码器-解码器结构加跳跃连接,在医学分割里被验证了无数次,收敛稳定,改起来也直观。
如果你追求开箱即用的最强 baseline,nnU-Net 确实是当前医学分割的默认答案,它会自动配置预处理、网络结构和训练策略。但毕设场景下我不推荐直接用 nnU-Net,因为它的自动化程度太高,答辩时老师问“你这个网络为什么这么设计”你答不上来。从 U-Net 手写一遍,把每个模块的作用搞清楚,再对比 nnU-Net 的结果,反而是更好的技术叙事。
3.2 一个可训练的 U-Net 实现与关键参数
下面是一个精简版 U-Net,基于 PyTorch,输入是 2D 切片,适合快速验证。
import torch 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.enc1 = DoubleConv(in_ch, base) self.enc2 = DoubleConv(base, base*2) self.enc3 = DoubleConv(base*2, base*4) self.enc4 = DoubleConv(base*4, base*8) self.pool = nn.MaxPool2d(2) # 瓶颈层 self.bottleneck = DoubleConv(base*8, base*16) # 解码器:转置卷积上采样后拼接对应编码层 self.up4 = nn.ConvTranspose2d(base*16, base*8, 2, stride=2) self.dec4 = DoubleConv(base*16, base*8) self.up3 = nn.ConvTranspose2d(base*8, base*4, 2, stride=2) self.dec3 = DoubleConv(base*8, base*4) self.up2 = nn.ConvTranspose2d(base*4, base*2, 2, stride=2) self.dec2 = DoubleConv(base*4, base*2) self.up1 = nn.ConvTranspose2d(base*2, base, 2, stride=2) self.dec1 = DoubleConv(base*2, base) self.out = nn.Conv2d(base, out_ch, 1) def forward(self, x): e1 = self.enc1(x) e2 = self.enc2(self.pool(e1)) e3 = self.enc3(self.pool(e2)) e4 = self.enc4(self.pool(e3)) b = self.bottleneck(self.pool(e4)) d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1)) d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1)) d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1)) d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1)) return self.out(d1) # 训练时的损失函数组合:Dice + BCE,医学分割里比单用 BCE 收敛快 class DiceBCELoss(nn.Module): def __init__(self): super().__init__() self.bce = nn.BCEWithLogitsLoss() def forward(self, pred, target): bce_loss = self.bce(pred, target) pred_sig = torch.sigmoid(pred) intersection = (pred_sig * target).sum() dice_loss = 1 - (2 * intersection + 1e-6) / (pred_sig.sum() + target.sum() + 1e-6) return bce_loss + dice_loss网络结构上,base=32是通道基数,显存不够就降到 16,但别低于 8,否则特征表达能力不够。编码器每层用两个 3x3 卷积加 BN 和 ReLU,这是 U-Net 的标准配置,BN 在 batch size 大于 4 时稳定,如果 batch size 只有 2,换成 GroupNorm 更靠谱。
损失函数用 Dice + BCE 的组合,是因为肺结节正负样本极度不平衡,单用 BCE 模型会倾向于全预测背景。Dice 项直接优化重叠率,BCE 项提供稳定的梯度,两者加权 1:1 在多数场景下够用。如果你发现模型对小结节不敏感,把 Dice 的权重提到 2。
学习率我一般从 1e-3 开始,用 CosineAnnealing 衰减到 1e-5,优化器选 AdamW,weight decay 设 1e-4。batch size 根据显存来,8 或 16 都行,但要注意 BN 的统计量在 batch size 太小时不准。
3.3 训练循环里必须监控的三个量
训练时别只看 loss。第一个要盯的是验证集 Dice,如果训练 loss 降但验证 Dice 不涨,说明过拟合了,加 dropout 或者减网络宽度。第二个是学习率,CosineAnnealing 下学习率曲线应该是平滑下降的,如果出现平台期,可能需要 warmup。第三个是正样本比例,每个 batch 里前景像素占比如果低于 1%,梯度会被背景主导,考虑用加权采样或者 Focal Loss。
# 训练循环骨架 model = UNet(in_ch=1, out_ch=1, base=32).cuda() criterion = DiceBCELoss() optimizer = torch.optim.AdamW(model.parameters(), lr=1e-3, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50) for epoch in range(50): model.train() for img, mask in train_loader: img, mask = img.cuda(), mask.cuda() pred = model(img) loss = criterion(pred, mask) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step() # 验证阶段 model.eval() with torch.no_grad(): val_dice = evaluate(model, val_loader) print(f'Epoch {epoch}, Loss {loss.item():.4f}, Val Dice {val_dice:.4f}')这段循环里,T_max=50对应总 epoch 数,如果你训练 100 轮就改成 100。验证阶段记得切model.eval()并关掉梯度,否则 BN 的统计量会被验证数据污染,Dice 会虚高。
4. 推理、后处理与评估:让分割结果真正能用
4.1 滑窗推理与重叠拼接
CT 体积往往比显存能容纳的尺寸大,常见做法是切 patch 推理再拼回去。滑窗的步长设置很关键:步长等于 patch 尺寸时没有重叠,拼接处会有明显接缝;步长设为 patch 尺寸的一半,重叠区域用高斯加权融合,接缝基本消失。
def sliding_window_inference(model, volume, patch_size=256, stride=128): """对 3D 体积做滑窗推理,重叠区域高斯加权""" model.eval() _, H, W = volume.shape output = np.zeros((H, W), dtype=np.float32) weight = np.zeros((H, W), dtype=np.float32) # 生成高斯权重核,中心高边缘低 gauss = np.outer(np.hanning(patch_size), np.hanning(patch_size)) with torch.no_grad(): for y in range(0, H - patch_size + 1, stride): for x in range(0, W - patch_size + 1, stride): patch = volume[:, y:y+patch_size, x:x+patch_size] patch_t = torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).cuda() pred = torch.sigmoid(model(patch_t)).squeeze().cpu().numpy() output[y:y+patch_size, x:x+patch_size] += pred * gauss weight[y:y+patch_size, x:x+patch_size] += gauss return output / np.maximum(weight, 1e-6)patch_size=256是显存和感受野的折中,结节通常不超过 64 像素,256 的窗口足够覆盖上下文。stride=128即 50% 重叠,再小会增加推理时间但收益递减。高斯核用np.hanning生成,比手动写高斯函数省事,效果一样。
4.2 Dice、IoU 和 Hausdorff 距离该看哪个
Dice 是最常用的指标,但它对小结节不敏感——一个 5 像素的结节预测对了 Dice 可能只有 0.6,预测错了 Dice 也就掉到 0.4,区分度不够。IoU 和 Dice 单调相关,看一个就行。真正能反映边界质量的是 Hausdorff 距离,它衡量预测边界和真实边界的最远距离,对结节分割这种边界模糊的任务更有参考价值。
我一般三个都算,但答辩时重点讲 Dice 和 Hausdorff。如果 Dice 高但 Hausdorff 也高,说明内部填得对但边界飘了,这时候要检查后处理有没有做形态学平滑。
4.3 后处理:连通域过滤与形态学操作
模型输出的概率图直接二值化,往往会有零散的小假阳性。用连通域分析过滤掉面积小于阈值的区域,能显著提升视觉效果。阈值设多少取决于你的数据,我一般从 10 像素开始试,看验证集上的 Dice 变化。
import cv2 import numpy as np def postprocess(pred_prob, threshold=0.5, min_area=10): """二值化后用连通域过滤小区域""" binary = (pred_prob > threshold).astype(np.uint8) num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8) clean = np.zeros_like(binary) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] >= min_area: clean[labels == i] = 1 # 闭运算填补小孔洞 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) clean = cv2.morphologyEx(clean, cv2.MORPH_CLOSE, kernel) return cleanmin_area=10是经验值,如果你的数据分辨率高,这个值要相应调大。闭运算的核用 3x3 椭圆,比矩形更符合结节的圆形形态。注意形态学操作要在连通域过滤之后做,顺序反了会把小区域连成大区域,过滤就失效了。
5. 避坑与排查:那些让我熬夜的翻车现场
5.1 现象:训练 loss 正常下降但 Dice 始终在 0.3 以下
原因:最常见的是掩膜和图像没对齐。DICOM 的像素数组可能经过 RescaleSlope 和 RescaleIntercept 变换,如果你直接读 pixel_array 而没做 HU 转换,图像和掩膜的对应关系就错了。另一个可能是掩膜的标签值不是 0/1 而是 0/255,二值化时阈值设 0.5 会把所有前景判成背景。
解决:先可视化叠加图确认对齐,再检查掩膜的 unique 值。如果是 0/255,训练前除以 255。
5.2 现象:验证集 Dice 比训练集高很多
原因:验证集太小或者分布和训练集差异大。LIDC-IDRI 里不同设备的扫描参数差异明显,如果验证集恰好都是同一台机器的数据,Dice 会虚高。另一个可能是验证时忘了切 eval 模式,BN 用了训练集的统计量,反而在验证集上表现更好。
解决:确保验证集覆盖不同来源的数据,训练循环里显式调用 model.eval()。
5.3 现象:推理结果全是背景或全是前景
原因:学习率太大导致模型崩溃,或者损失函数权重失衡。如果 Dice 项权重过高而 BCE 项过低,模型可能陷入全预测前景的局部最优。另外,如果输入归一化用了错误的均值方差,模型看到的分布和训练时不一致,也会输出无意义的结果。
解决:降低学习率到 1e-4 重训,检查损失函数的两项权重,确认推理时的预处理和训练时完全一致。
5.4 现象:显存溢出,batch size 降到 1 还是报错
原因:输入 patch 尺寸太大,或者网络通道基数太高。256x256 的 patch 配 base=32 的 U-Net,显存占用大概 4GB,如果显卡只有 6GB,加上数据加载的缓存就容易爆。另一个可能是忘了用torch.no_grad()包住验证阶段,验证时也建了计算图。
解决:把 patch 降到 128,base 降到 16,验证阶段加 no_grad。如果还不够,用梯度累积模拟大 batch。
5.5 现象:Dice 在 0.7 左右卡住不涨
原因:数据增强太弱或者太强。太弱模型没见过足够的变化,太强则图像失真严重,模型学不到有效特征。另一个可能是结节太小,下采样四次后特征图上的结节只剩一两个像素,解码器恢复不出来。
解决:检查增强后的图像是否还能肉眼辨认结节,如果不能就减弱增强。对于小结节,减少一次下采样或者用空洞卷积替代。
6. 把 Dice 从 0.75 推到 0.85 的三个具体动作
第一个动作是换损失函数。Dice + BCE 在 0.75 左右会遇到瓶颈,换成 Tversky Loss 或者 Focal Tversky Loss,通过调整 alpha 和 beta 控制假阳性和假阴性的权重。肺结节分割里假阴性代价更高,把 beta 设得比 alpha 大,让模型更关注漏检的结节。我一般从 alpha=0.3, beta=0.7 开始试,在验证集上看 Dice 和召回率的变化。
第二个动作是加注意力机制。在 U-Net 的跳跃连接上加 Attention Gate,让解码器聚焦在结节区域而不是背景。Attention Gate 的实现不复杂,几行代码的事,但对小结节的提升明显。注意加的位置,我一般加在编码器到解码器的拼接之前,加在解码器内部效果反而差。
第三个动作是测试时增强(TTA)。推理时对同一张图做水平翻转、旋转,分别预测后取平均。这个技巧不增加训练成本,推理时间翻几倍但通常能涨 1-2 个点。对于毕设答辩来说,这 1-2 个点可能就是“优秀”和“良好”的差距。
def tta_inference(model, image): """测试时增强:原图 + 水平翻转 + 垂直翻转,取平均""" preds = [] # 原图 preds.append(torch.sigmoid(model(image))) # 水平翻转 preds.append(torch.flip(torch.sigmoid(model(torch.flip(image, [3]))), [3])) # 垂直翻转 preds.append(torch.flip(torch.sigmoid(model(torch.flip(image, [2]))), [2])) return torch.stack(preds).mean(dim=0)TTA 的翻转维度要对准图像的空间维度,[3]是宽度方向,[2]是高度方向,别搞反了。另外 TTA 只适合推理阶段,训练时用反而会拖慢收敛。
最后说一个我自己的习惯:每次改完参数,先在小数据集上跑 5 个 epoch 看趋势,趋势对了再上全量。全量训练一次几个小时,盲目试错的时间成本太高。这套源码的价值不在于它用了多新的网络,而在于它把数据、训练、推理、评估的每个环节都串起来了,你可以在任何一个环节上做改进,而不需要从零搭管线。希望帮到你。
本文还有配套的精品资源,点击获取