简介这是一套面向计算机、人工智能、自动化等专业学生与从业者的3D-CT影像肺结节检测项目源码源自个人毕业设计答辩评审分达98分代码经调试测试可稳定运行适合作为毕业设计、期末大作业或课程设计参考也便于基础较好的学习者在此基础上修改扩展功能。资源包共64个文件约9.61MB以38个Python脚本为核心覆盖数据预处理、检测器与分类器网络构建、训练与测试流程另含CSV标注与候选数据、npy验证集划分、ipynb演示笔记、png示意图及项目说明文档目录按detector、classifier、preprocessing等模块划分结构清晰。目前已有57人学习下载。读者可借此掌握DICOM转raw、候选结节提取、模型训练与推理的完整链路理解检测与分类两阶段协同思路并参考配置脚本与运行脚本快速复现实验具备较高的学习借鉴价值。1. 从一份 3D-CT 肺结节检测源码说起它到底解决了什么拿到「基于 Python 的 3D-CT 影像肺结节检测算法源码 数据集 项目说明」这类项目时多数人第一反应是解压、装依赖、跑train.py然后被显存爆掉或者 mAP 低到怀疑人生。这个方向真正要解决的不是「写个卷积网络」而是把一套三维体数据从 DICOM 序列变成可训练的张量再让模型在几十立方厘米的肺实质里定位直径可能只有 3 毫米的球状病灶。它适合两类人一类是医学影像方向的在读学生需要一份能跑通、能改、能写进论文的基线另一类是工程侧想切入 AI 辅助诊断的开发者需要理解 3D 数据和 2D 图像在预处理、标注、评估上的根本差异。肺结节检测的难点从来不在网络结构本身而在数据管线和假阳性抑制——这两块做好了哪怕用最朴素的 3D U-Net 也能出可用的结果。2. 3D-CT 数据管线从 DICOM 到可训练张量的完整链路2.1 为什么不能直接把 DICOM 丢给模型CT 影像和普通 RGB 图像最大的区别在于它是各向异性的体数据。一次胸部 CT 扫描通常有 200 到 400 层切片层内分辨率约 0.6~0.8 毫米层间距却在 1.0~2.5 毫米之间。这意味着体素在 Z 轴方向被「拉长」了如果直接按原始尺寸送入网络模型学到的结节形状是扭曲的。常见做法是先做各向同性重采样把体素间距统一到 1mm×1mm×1mm这样结节的球状形态才成立。另一个坑是 HU 值范围。CT 的原始像素是 Hounsfield 单位空气约 -1000肺实质约 -500骨头可以到 1000 以上。直接归一化到 [0,1] 会把肺实质的对比度压扁。我一般用肺窗截断把 HU 值裁剪到 [-1000, 400]再线性映射到 [0,1]。这个区间覆盖了肺实质、血管和大部分实性结节钙化和胸壁的高密度区域被截掉反而减少了干扰。2.2 用 Python 读取 DICOM 序列并重采样import pydicom import numpy as np import SimpleITK as sitk from pathlib import Path def load_dicom_series(series_dir): 读取一个 DICOM 序列目录返回 3D 数组和元信息 reader sitk.ImageSeriesReader() dicom_names reader.GetGDCMSeriesFileNames(str(series_dir)) reader.SetFileNames(dicom_names) image reader.Execute() # SimpleITK 默认 (x, y, z)转成 numpy 后是 (z, y, x) volume sitk.GetArrayFromImage(image) spacing image.GetSpacing() # (sx, sy, sz) origin image.GetOrigin() return volume, spacing, origin def resample_isotropic(volume, spacing, target_spacing(1.0, 1.0, 1.0)): 将体数据重采样到各向同性 image sitk.GetImageFromArray(volume) image.SetSpacing(spacing) original_size image.GetSize() original_spacing image.GetSpacing() new_size [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetSize(new_size) resampler.SetOutputSpacing(target_spacing) resampler.SetOutputOrigin(image.GetOrigin()) resampler.SetOutputDirection(image.GetDirection()) resampler.SetInterpolator(sitk.sitkLinear) resampled resampler.Execute(image) return sitk.GetArrayFromImage(resampled) def apply_lung_window(volume, hu_min-1000, hu_max400): 肺窗截断并归一化到 [0,1] volume np.clip(volume, hu_min, hu_max) volume (volume - hu_min) / (hu_max - hu_min) return volume.astype(np.float32)load_dicom_series用 SimpleITK 的ImageSeriesReader自动按 DICOM 头信息排序比手动按文件名排序可靠得多——很多扫描仪的实例编号并不连续。resample_isotropic里new_size的计算必须用round而不是整除否则会累积尺寸误差。插值器选sitkLinear是折中方案sitkNearestNeighbor会引入阶梯伪影sitkBSpline在肺边界容易产生过冲。apply_lung_window的hu_min和hu_max是经验值如果数据集里结节钙化比例高可以把上限提到 600但要注意归一化后整体对比度会下降。2.3 肺实质分割把无关区域先剔掉整张 CT 里肺实质只占约 20% 的体积剩下的纵隔、胸壁、床板都是干扰。常见做法是用阈值 连通域做粗分割先按 -500 HU 阈值二值化取最大的两个连通域左右肺再做形态学闭运算填补血管造成的空洞。这一步不需要深度学习OpenCV 的connectedComponentsWithStats就够用。import cv2 def segment_lung_mask(volume, hu_threshold-500): 基于阈值的肺实质粗分割 mask (volume hu_threshold).astype(np.uint8) lung_mask np.zeros_like(mask) for z in range(mask.shape[0]): slice_mask mask[z] num_labels, labels, stats, _ cv2.connectedComponentsWithStats( slice_mask, connectivity8 ) # 按面积排序保留最大的两个连通域左右肺 areas stats[1:, cv2.CC_STAT_AREA] if len(areas) 2: continue top2 np.argsort(areas)[-2:] 1 for label_id in top2: lung_mask[z][labels label_id] 1 # 形态学闭运算填补血管空洞 kernel np.ones((3, 3), np.uint8) for z in range(lung_mask.shape[0]): lung_mask[z] cv2.morphologyEx( lung_mask[z], cv2.MORPH_CLOSE, kernel, iterations2 ) return lung_mask这里按切片逐层做连通域是有意为之3D 连通域在肺门区域容易把左右肺连成一片逐层处理再叠加反而更稳。iterations2的闭运算能填补直径 6 像素以内的空洞再大就可能把结节和血管壁粘连处误填。如果数据集层厚较大2mm建议先重采样再做分割否则 Z 轴方向的连通性判断会失真。3. 检测网络选型与训练3D U-Net 还是 3D RPN3.1 两种主流路线的取舍肺结节检测在学术上分两条路一是分割式用 3D U-Net 输出体素级概率图再对概率图做连通域分析得到候选结节二是检测式用 3D RPN 或 3D RetinaNet 直接回归边界框。分割式对小结节更友好因为体素级监督信号密集3mm 的结节在 64×64×64 的 patch 里也有几百个体素检测式对边界框标注质量敏感而医学影像的框标注往往不一致——不同医生对同一个结节的框可能差好几个像素。我一般推荐分割式起步原因很实际3D U-Net 的结构简单调参空间小而且分割掩码可以复用做假阳性抑制。检测式虽然推理快但 3D 锚框的设计需要针对结节尺寸分布做聚类调起来更玄学。3.2 3D U-Net 的最小实现与训练配置import torch import torch.nn as nn class ConvBlock3D(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block nn.Sequential( nn.Conv3d(in_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm3d(out_ch), nn.ReLU(inplaceTrue), nn.Conv3d(out_ch, out_ch, 3, padding1, biasFalse), nn.BatchNorm3d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.block(x) class UNet3D(nn.Module): def __init__(self, in_ch1, base_ch16): super().__init__() # 编码器4 次下采样 self.enc1 ConvBlock3D(in_ch, base_ch) self.enc2 ConvBlock3D(base_ch, base_ch * 2) self.enc3 ConvBlock3D(base_ch * 2, base_ch * 4) self.enc4 ConvBlock3D(base_ch * 4, base_ch * 8) self.pool nn.MaxPool3d(2) # 瓶颈层 self.bottleneck ConvBlock3D(base_ch * 8, base_ch * 16) # 解码器转置卷积 跳跃连接 self.up4 nn.ConvTranspose3d(base_ch * 16, base_ch * 8, 2, stride2) self.dec4 ConvBlock3D(base_ch * 16, base_ch * 8) self.up3 nn.ConvTranspose3d(base_ch * 8, base_ch * 4, 2, stride2) self.dec3 ConvBlock3D(base_ch * 8, base_ch * 4) self.up2 nn.ConvTranspose3d(base_ch * 4, base_ch * 2, 2, stride2) self.dec2 ConvBlock3D(base_ch * 4, base_ch * 2) self.up1 nn.ConvTranspose3d(base_ch * 2, base_ch, 2, stride2) self.dec1 ConvBlock3D(base_ch * 2, base_ch) self.out nn.Conv3d(base_ch, 1, 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], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)base_ch16是显存和精度的平衡点。在 16GB 显存的卡上输入 64×64×64 的 patchbatch size 可以到 8如果base_ch提到 32batch size 得降到 2BatchNorm 的统计量会不稳定。损失函数用 Dice BCE 加权loss 0.5 * dice_loss 0.5 * bce_lossDice 负责解决正负样本极度不平衡结节体素占比通常不到 0.1%BCE 提供稳定的梯度。优化器用 AdamW初始学习率 1e-3余弦退火到 1e-5训练 200 个 epoch 左右。3.3 数据增强3D 场景下哪些增强真正有效2D 图像那套翻转、旋转、色彩抖动在 3D CT 上要重新审视。随机翻转和 90 度旋转是安全的因为肺结节没有方向性。但弹性形变要慎用——在 3D 体数据上做弹性形变计算量大而且容易把小结节的形状破坏掉。我常用的增强组合是随机翻转三个轴独立、随机 90 度旋转、随机缩放0.9~1.1、随机亮度偏移±0.1。缩放增强要注意同步调整标签掩码否则分割目标会对不上。提示如果数据集里结节尺寸分布跨度大3mm 到 30mm建议在 patch 采样时做尺寸分层保证每个 batch 里既有小结节也有大结节否则模型会偏向大目标。4. 假阳性抑制与评估让模型输出真正可用的结果4.1 为什么原始概率图不能直接用3D U-Net 输出的概率图经过阈值二值化后除了真结节还会在血管分叉、胸膜粘连处产生大量假阳性。一个典型扫描可能检出 50 个候选其中真结节只有 1~2 个。假阳性抑制的常见做法是训练一个 3D 分类网络对每个候选 patch 做二分类。这个分类网络不需要很深3~4 层卷积加全局平均池化就够输入是 32×32×32 的候选区域。class FPClassifier3D(nn.Module): def __init__(self, in_ch1): super().__init__() self.features nn.Sequential( nn.Conv3d(in_ch, 16, 3, padding1), nn.BatchNorm3d(16), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(16, 32, 3, padding1), nn.BatchNorm3d(32), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(32, 64, 3, padding1), nn.BatchNorm3d(64), nn.ReLU(), nn.AdaptiveAvgPool3d(1), ) self.fc nn.Linear(64, 1) def forward(self, x): x self.features(x).flatten(1) return self.fc(x)训练这个分类器时正样本是真结节候选负样本从假阳性候选里随机采样正负比控制在 1:3 左右。推理时把分类概率低于 0.5 的候选直接丢弃剩下的按概率排序输出。4.2 评估指标FROC 曲线怎么算肺结节检测不用 mAP用 FROCFree-response ROC。横轴是平均每扫描的假阳性数FP/scan纵轴是召回率。常用报告点是 FP/scan 0.125、0.25、0.5、1、2、4、8 时的召回率再取平均得到 CPMCompetition Performance Metric。计算时要注意匹配规则预测结节和真值结节的中心距离小于结节半径才算命中否则算假阳性。指标含义典型基线值召回率 FP0.5每扫描 0.5 个假阳性时的检出率0.75~0.85召回率 FP2每扫描 2 个假阳性时的检出率0.88~0.93CPM7 个报告点召回率的平均0.80~0.88平均假阳性/扫描固定召回率 0.9 时的 FP 数3~8如果 CPM 低于 0.75先检查预处理是否统一了体素间距再检查训练集里小结节5mm的样本比例是否过低。很多高分项目的差距不在网络而在数据清洗和候选匹配逻辑。5. 避坑与排查跑这份源码时最容易翻车的 5 个地方现象一训练 loss 正常下降但验证集召回率始终为 0。原因通常是标签掩码和输入图像的空间对应关系错了。DICOM 读取后 SimpleITK 返回的数组是 (z, y, x)而标注文件如果是 (x, y, z) 顺序直接叠加就会错位。解决方法是统一在 numpy 层面用np.transpose把标注转到和图像一致的轴序并在可视化时用matplotlib逐层叠加确认。现象二显存溢出batch size 降到 1 还是 OOM。3D 卷积的显存占用和输入尺寸的立方成正比。64×64×64 的 patch 在base_ch32时单样本前向传播就要 4GB 以上。解决方法是把base_ch降到 16或者用梯度累积模拟大 batch。另一个隐藏问题是数据加载器里的num_workers设太大每个 worker 都缓存了一份完整数据集内存先爆。设成 2~4 就够。现象三推理时同一份数据两次运行结果不一致。检查模型是否忘了model.eval()以及是否有未固定的随机种子。3D U-Net 里的 Dropout 和 BatchNorm 在训练和推理模式下的行为不同。另外如果用了torch.backends.cudnn.benchmark True卷积算法会自动选择不同运行可能选到不同算法导致微小数值差异。对复现性要求高的场景设benchmark False。现象四假阳性抑制后召回率掉得厉害。分类器的正负样本比例失衡或者候选 patch 的提取中心偏了。检查候选提取时用的连通域质心是否和真值结节中心对齐——如果分割概率图在结节边缘响应弱质心会偏移到血管上。解决方法是提取候选时用概率加权质心而不是二值化后的几何质心。现象五换一个数据集后性能断崖式下跌。不同数据集的扫描协议、层厚、重建核不同HU 分布和噪声水平差异很大。解决方法是做强度归一化时用数据集的统计量均值和标准差而不是固定用肺窗的 [-1000, 400]。另外如果目标数据集的结节尺寸分布和训练集差异大需要在 patch 采样时重新做尺寸分层。注意医学影像数据涉及隐私公开数据集的使用要遵守对应的数据协议。自己采集的数据做实验时记得在预处理阶段就把患者标识信息剥离。6. 把 CPM 从 0.78 推到 0.85一个被低估的技巧多数人把精力花在换网络结构上从 U-Net 换到 nnU-Net 再换到 Swin UNETR涨点往往不到 2 个百分百。真正能把 CPM 从 0.78 推到 0.85 的是候选提取阶段的「多尺度概率融合」。具体做法对同一个 CT 体数据用三个不同输入尺寸的 patch 分别推理——48×48×48、64×64×64、96×96×96每个尺寸对应不同的感受野。小结节在 48 的 patch 里占比更大大结节在 96 的 patch 里上下文更完整。把三组概率图重采样回原始尺寸后做加权平均权重按验证集上各尺寸的召回率分配。def multi_scale_fusion(model, volume, scales[48, 64, 96], weights[0.3, 0.4, 0.3]): 多尺度推理融合volume 为预处理后的 3D 数组 model.eval() prob_maps [] for scale, w in zip(scales, weights): # 滑窗推理步长为 scale 的一半 prob_map sliding_window_inference(volume, model, scale, overlap0.5) prob_maps.append(prob_map * w) fused np.sum(prob_maps, axis0) return fusedsliding_window_inference需要自己实现核心是记录每个体素被覆盖的次数最后做归一化。overlap0.5是经验值再高推理时间翻倍但收益递减。权重[0.3, 0.4, 0.3]不是固定的我在不同数据集上试过小结节为主的数据集把 48 的权重提到 0.4 更好大结节为主则 96 的权重提到 0.4。这个技巧的代价是推理时间变成三倍但换来的是召回率在 FP0.5 时从 0.76 提到 0.84假阳性抑制模块的压力也小了很多。另一个容易被忽略的点是后处理里的「结节聚合」。同一个结节可能在相邻切片上被检出多次如果直接按连通域输出会得到一堆重叠的候选。我一般用 3D NMSIoU 阈值设 0.1——比 2D 检测的 0.5 低得多因为结节的 3D 框重叠度天然就高。NMS 之后再接假阳性分类器整体流程的 CPM 能再涨 1 个点。这套流程我前后调了三个月最大的教训是别在网络结构上反复横跳把数据管线的每个环节可视化确认一遍比换模型管用得多。每次改预处理参数后一定用同一份验证集跑完整评估不要只看 loss 曲线。希望帮到你。本文还有配套的精品资源点击获取