简介:这是一套面向计算机、人工智能、自动化等专业学生与从业者的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_max=400): """肺窗截断并归一化到 [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, connectivity=8 ) # 按面积排序,保留最大的两个连通域(左右肺) 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, iterations=2 ) return lung_mask这里按切片逐层做连通域是有意为之:3D 连通域在肺门区域容易把左右肺连成一片,逐层处理再叠加反而更稳。iterations=2的闭运算能填补直径 6 像素以内的空洞,再大就可能把结节和血管壁粘连处误填。如果数据集层厚较大(>2mm),建议先重采样再做分割,否则 Z 轴方向的连通性判断会失真。
3. 检测网络选型与训练:3D U-Net 还是 3D RPN
3.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, padding=1, bias=False), nn.BatchNorm3d(out_ch), nn.ReLU(inplace=True), nn.Conv3d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm3d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.block(x) class UNet3D(nn.Module): def __init__(self, in_ch=1, base_ch=16): 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, stride=2) self.dec4 = ConvBlock3D(base_ch * 16, base_ch * 8) self.up3 = nn.ConvTranspose3d(base_ch * 8, base_ch * 4, 2, stride=2) self.dec3 = ConvBlock3D(base_ch * 8, base_ch * 4) self.up2 = nn.ConvTranspose3d(base_ch * 4, base_ch * 2, 2, stride=2) self.dec2 = ConvBlock3D(base_ch * 4, base_ch * 2) self.up1 = nn.ConvTranspose3d(base_ch * 2, base_ch, 2, stride=2) 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], 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)base_ch=16是显存和精度的平衡点。在 16GB 显存的卡上,输入 64×64×64 的 patch,batch size 可以到 8;如果base_ch提到 32,batch size 得降到 2,BatchNorm 的统计量会不稳定。损失函数用 Dice + BCE 加权:loss = 0.5 * dice_loss + 0.5 * bce_loss,Dice 负责解决正负样本极度不平衡(结节体素占比通常不到 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_ch=1): super().__init__() self.features = nn.Sequential( nn.Conv3d(in_ch, 16, 3, padding=1), nn.BatchNorm3d(16), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(16, 32, 3, padding=1), nn.BatchNorm3d(32), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(32, 64, 3, padding=1), 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,用 FROC(Free-response ROC)。横轴是平均每扫描的假阳性数(FP/scan),纵轴是召回率。常用报告点是 FP/scan = 0.125、0.25、0.5、1、2、4、8 时的召回率,再取平均得到 CPM(Competition Performance Metric)。计算时要注意匹配规则:预测结节和真值结节的中心距离小于结节半径才算命中,否则算假阳性。
| 指标 | 含义 | 典型基线值 |
|---|---|---|
| 召回率 @ FP=0.5 | 每扫描 0.5 个假阳性时的检出率 | 0.75~0.85 |
| 召回率 @ FP=2 | 每扫描 2 个假阳性时的检出率 | 0.88~0.93 |
| CPM | 7 个报告点召回率的平均 | 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_ch=32时,单样本前向传播就要 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, overlap=0.5) prob_maps.append(prob_map * w) fused = np.sum(prob_maps, axis=0) return fusedsliding_window_inference需要自己实现,核心是记录每个体素被覆盖的次数,最后做归一化。overlap=0.5是经验值,再高推理时间翻倍但收益递减。权重[0.3, 0.4, 0.3]不是固定的,我在不同数据集上试过,小结节为主的数据集把 48 的权重提到 0.4 更好,大结节为主则 96 的权重提到 0.4。这个技巧的代价是推理时间变成三倍,但换来的是召回率在 FP=0.5 时从 0.76 提到 0.84,假阳性抑制模块的压力也小了很多。
另一个容易被忽略的点是后处理里的「结节聚合」。同一个结节可能在相邻切片上被检出多次,如果直接按连通域输出,会得到一堆重叠的候选。我一般用 3D NMS,IoU 阈值设 0.1——比 2D 检测的 0.5 低得多,因为结节的 3D 框重叠度天然就高。NMS 之后再接假阳性分类器,整体流程的 CPM 能再涨 1 个点。
这套流程我前后调了三个月,最大的教训是:别在网络结构上反复横跳,把数据管线的每个环节可视化确认一遍,比换模型管用得多。每次改预处理参数后,一定用同一份验证集跑完整评估,不要只看 loss 曲线。希望帮到你。
本文还有配套的精品资源,点击获取