
简介基于Python的3D-CT影像肺结节检测项目源自个人毕设答辩评分98分完整涵盖源码、数据集与项目说明面向计算机、通信、人工智能、自动化等专业的学生、老师或从业者可作为期末大作业、课程设计或毕业设计的实战参考也适合初学者从数据预处理到模型训练逐步进阶。项目按detector、classifier、training、preprocessing等模块分层组织包含数据转换、训练验证、检测分类等完整流程代码均已调试运行通过并提供shell脚本与说明文档便于复现和理解3D医学影像检测的关键环节。整套资源共64个文件以38个Python源文件为主辅以CSV标签/预测文件、NumPy数据、IPython演示笔记本和shell脚本等压缩包约9.61MB目录简洁、定位明确。目前已有56人学习浏览可以为希望快速上手深度学习医学影像项目的开发者提供较高的借鉴与扩展价值支持在此基础上二次调整实现更多功能。1. 为什么自己搭3D-CT肺结节检测从LUNA16榜单到本地复现拿到这个项目时我第一反应是“又一个LUNA16复现”但翻完文件列表后改了判断这其实是一套完整的、能端到端跑通的检测分类两阶段管线。detector目录下是3D FPN结构的候选结节生成网络classifier目录下是独立的假阳性消减网络再加上preprocessing里的dicom2raw.py和full_prep.py覆盖了从原始CT序列到最终预测csv的全流程。这类项目对正在做医学影像课题、尤其是拿LUNA16做对比实验的学生来说价值不在“能跑”而在于它的模块边界清晰数据预处理、检测、分类、训练四个环节彼此解耦你可以单独替换其中任何一块而不影响其他部分。源码里保留了valSplit.npy、annotations.csv、candidates.csv说明数据集侧已经按LUNA16的标准划分做好了这在很多毕设项目里反而是最容易被忽略的工作。适合什么人如果你已经在用PyTorch做3D卷积但没碰过医学影像的DICOM解析和滑窗推理这个项目能帮你把“模型怎么接数据”这个环节补上如果你只是想快速出一版完整流程作为课程设计它同样够用。接下来按数据、检测、分类、训练四块拆开讲。2. 预处理细节DICOM转raw、体素间距归一化与数据对齐2.1 为什么不能直接把DICOM喂给3D网络CT影像和自然图像最大的区别在于DICOM序列里每个切片的像素间距Pixel Spacing和层厚Slice Thickness是随扫描设备变化的。同一个结节在A设备上可能是10个体素直径在B设备上可能是15个如果不做体素间距归一化网络学到的是“物理尺寸”还是“体素数量”就会变得不可控。这个项目里dicom2raw.py做的就是这件事把DICOM序列读出来重采样到统一的体素间距再转成numpy的raw数组缓存到本地。预处理管线里比较关键的是full_prep.py我拆开看之后发现它把流程做成了step1.py和AddSegmentation两个阶段。step1负责DICOM读取、CT值裁剪、重采样AddSegmentation额外做了肺实质分割掩膜这一步对检测器的影响非常直接——如果不做掩膜约束检测器会在胸腔外的空气区域、扫描床等位置产生大量候选框后面分类器再强也得花不少样本去学“这些不是结节”。# dicom2raw.py 核心逻辑简化自项目源码 import dicom import numpy as np def load_scan(path): slices [dicom.read_file(path / s) for s in os.listdir(path)] slices.sort(keylambda x: float(x.ImagePositionPatient[2])) # 按切片位置排序避免因文件名乱序导致z轴颠倒 return slices def resample(slices, new_spacing[1.0, 1.0, 1.0]): # 原始spacing从DICOM头读取 spacing np.array([slices[0].SliceThickness] list(slices[0].PixelSpacing), dtypenp.float32) new_shape np.round(np.array(slices[0].pixel_array.shape (len(slices),)) * spacing / new_spacing) # 用scipy.ndimage.zoom做三线性插值重采样 image np.zeros(new_shape, dtypenp.int16) for i, s in enumerate(slices): image[:, :, i] s.pixel_array return zoom(image, new_shape / np.array(image.shape), order3)这段代码里两个坑值得说明。第一ImagePositionPatient[2]才是真正的z轴坐标按文件名排序会在多层扫描重叠或方向不一致时出错第二zoom的order3是三次样条插值对边缘保持比线性插值好但CT值本身是离散的HU值过高的插值阶数反而会在肺实质与胸壁交界处产生过冲我在自己的实验里改成order2后分割掩膜质量更稳。2.2 数据集目录结构与标签对齐方式项目的数据集侧给了三个核心文件annotations.csv、candidates.csv、label.csv。annotations.csv是LUNA16的结节标注包含seriesuid扫描ID、coordX/Y/Z世界坐标、diameter_mm直径candidates.csv是从检测器输出的候选结节列表每行带一个class字段0表示非结节、1表示结节这就是分类器的训练数据来源。这里有个容易踩的坑LUNA16的坐标是世界坐标系单位是毫米而网络输入是体素空间单位是voxel。中间必须根据每个case的originorigin即DICOM头的ImagePositionPatient和重采样后的spacing做一次刚性变换。项目源码里这段变换是写在data_detector.py的get_ct_patch函数里的具体的映射关系是体素坐标 (世界坐标 - origin) / spacing三轴分别计算。# data_detector.py 中候选结节坐标转换关键片段 def world_to_voxel(coord_world, origin, spacing): # coord_world: [x, y, z] 单位mm # origin: DICOM ImagePositionPatient单位mm # spacing: 重采样后的体素间距单位mm/voxel coord_voxel (np.array(coord_world) - np.array(origin)) / np.array(spacing) return coord_voxel.astype(np.int32)如果跳过这一步直接把毫米坐标当体素坐标用最直观的症状是训练时loss能降但推理时检测结果和标注在CT横断面上对不上位置敏感度和F1都异常低。我在帮人调这个项目时遇到过三次类似的错全是坐标变换写反了方向。2.3 全肺CT的存储策略与显存边界预处理完的数据如果每个case都是512×512×300左右的float32数组单个体积就是300MBLUNA16共888个CT全量落盘要260GB以上。项目用的是缓存raw数组的方式——prepare.py会把每个case重采样后存成case_X.npy在训练时按需加载。这是单机实验最务实的做法比h5py或LMDB少一层抽象出问题时好排查。要提一下的是预处理阶段的CT值裁剪范围项目里是把HU值clip到[-1000, 400]。这个范围的选择原因是肺实质的CT值约在-900到-600之间软组织在-100到100之间钙化结节可以到400以上。clip到400会丢掉极高密度的钙化灶但对绝大多数实性、亚实性结节已经够用如果你用的是骨窗数据或需要检测钙化节结建议把上界放宽到1000代价是网络需要更多容量去适配更宽的数值分布。3. 候选结节检测器3D FPN、res18骨干与split_combine滑窗推理3.1 检测器选型为什么是FPN而不是单纯3D U-Net项目的detector部分用的是res18.pyres_pool.py组合出的3D FPN结构骨干是ResNet-18的3D变体配合自顶向下的特征金字塔输出多尺度特征图最后接一个1×1×1卷积把头输出分类和回归。这个选型比直接用3D U-Net更适合肺结节检测的原因在于LUNA16的结节直径从3mm到30mm跨度很大FPN天然覆盖多尺度高层特征负责大结节、低层特征负责小结节3mm以下U-Net的skip connection虽然也有多尺度信息但最后融合是在单尺度上做抗尺度差异能力反而弱一些。骨干网络具体的结构在res18.py里是标准ResNet的3D化版本conv1是7×7×7步长2的卷积后面接4个stage每个stage包含若干BasicBlock通道数分别为64、128、256、512。FPN部分对每个stage的输出做1×1×1卷积降维到256通道再自顶向下通过3×3×3卷积上采样相加最后在这4个尺度的融合特征上各接一个检测头。# net_detector.py 中FPN多尺度特征融合的简化结构 class FPN3D(nn.Module): def __init__(self): super().__init__() self.backbone resnet18_3d() # 4个stage输出C2-C5 self.toplayer nn.Conv3d(512, 256, kernel_size1) # 降维C5 self.lateral nn.ModuleList([ nn.Conv3d(64, 256, 1), # C2 nn.Conv3d(128, 256, 1), # C3 nn.Conv3d(256, 256, 1), # C4 ]) self.smooth nn.ModuleList([ nn.Conv3d(256, 256, 3, padding1) for _ in range(3) ]) def forward(self, x): c2, c3, c4, c5 self.backbone(x) p5 self.toplayer(c5) p4 self.smooth[2](p5 self.lateral[2](c4)) p3 self.smooth[1](p4 self.lateral[1](c3)) p2 self.smooth[0](p3 self.lateral[0](c2)) return [p2, p3, p4, p5]FPN怎么生成候选框每个尺度的特征图上的每个位置都对应原图上的一组预置anchor项目里设了3组尺度10mm、20mm、40mm对应特征图步长长宽比固定为1肺结节基本是球形长宽比变化不大。回归头的目标是预测anchor和真实结节中心的偏移量分类头是二分类有结节/无结节。这里有个很实际的现象训练时正负样本比例可能到1:500以上代码里用的是OHEM在线难例挖掘——每张patch只取loss最大的前128个负样本和全部正样本回传梯度否则模型会快速收敛到“全部预测为负”的退化解。3.2 split_combine.py的边界重叠策略医学影像和自然图像检测还有一个显著差异单张CT体积远超GPU显存能承受的输入尺寸。一个512×512×300的体积直接送进3D ResNet光激活值就能把24G显存吃完。项目的做法是split_combine.py实现的滑窗推理把整个CT按固定尺寸切块每个块之间设置一定比例的重叠推理完成后把每个块的结果合并回原图坐标。重叠比例为什么重要因为3D卷积的感受野会跨越块边界如果完全无重叠边界处的结节会被切碎检测置信度明显下降。项目里的split_combine.py默认是stride size/2的重叠策略切块尺寸是64或96取决于GPU显存步长取一半。合并方式不是最大值抑制而是加权平均——重叠区域的得分用距离边界远近做线性加权越靠近块中心的预测权重越高因为中心位置的卷积响应最可靠、边界位置的特征被padding污染的概率更大。# split_combine.py 滑动窗口推理时的重叠合并权重逻辑 def combine_predictions(heatmaps, starts, block_size): # heatmaps: 每个滑窗块输出的结节概率图 # starts: 每个滑窗块的起始坐标 weight np.ones_like(heatmaps[0]) # 边界区域线性衰减权重 margin block_size // 8 for axis in range(3): w np.linspace(0, 1, margin) tmp np.ones(block_size) tmp[..., :margin] w tmp[..., -margin:] w[::-1] weight * tmp # 累加所有块的概率图和权重图最后归一化 return accumulated_prob / accumulated_weight这段的margin取块尺寸的1/8就是说64尺寸的块边界8个voxel的权重从0线性爬到1。alpha值越大合并结果越平滑但也会轻微抹掉小结节的局部高响应。推理阶段另一个决定性能的参数是置信度阈值项目在test_detect.py里默认设0.3低于这个值的候选全丢弃只保留高置信区域再去重。3.3 检测器训练的样本采样策略detector训练时data_detector.py从每个CT中抽取固定大小的块作为训练样本。采样策略很讲究正样本以结节中心为中心裁块负样本在非结节区域随机采。如果正样本和负样本数量悬殊项目在trainval_detector.py里实现的方案是每个batch固定比例比如size 64的batch里8个正、56个负保证每轮迭代都能看到结节同时负样本覆盖不同解剖位置。还有一点容易被忽略裁块时对CT值做了一个随机偏移——每个样本统一加减一个随机HU值模拟不同扫描设备间的重建核差异。这个方法比高级的domain adaptation实用得多代价极小但能显著提高跨设备泛化能力。4. 假阳性消减分类器3D CNN怎么把“疑似结节”变“确诊结节”4.1 两阶段设计的动机与数据构造检测器的目标是把召回率做上去宁可多报不可漏报。LUNA16这类数据集上一个合格的检测器会产出平均每个CT 100到300个候选但其中真正的结节平均只有1到2个。如果直接拿检测结果当最终结论假阳性率没法看。所以项目里第二阶段的分类器出现得就有道理对每个候选位置裁出固定大小的3D patch用一个独立的3D CNN做二分类把“像结节但不是结节”的候选血管交叉、胸膜增厚、炎性假瘤等过滤掉。训练数据的构造重点在data_classifier.py里从candidates.csv中读取所有候选位置以每个候选为中心裁32×32×32或48×48×48的patchlabel是0或1。关键问题是类别不平衡且负样本内部差异极大——血管断面、支气管壁、胸膜斑块看起来各不相同随机采样负样本会让分类器过度关注易分类的简单负样本。项目源码里对负样本做了基于检测器得分即检测阶段输出的置信度的分层采样检测器得分越高的负样本抽中概率越大。因为这些是“最像结节”的难负样本分类器把它们学透了剩下的假阳性就基本是得分很低的后处理就能滤掉。4.2 net_classifier_3d.py的网络结构与训练技巧分类器部分项目给了多个版本——net_classifier.py、net_classifier_3.py、net_classifier_4.py差异主要在深度和数据流。核心结构是一个4层3D卷积加全连接分类头的小网络配合BatchNorm3d和ReLU最后输出一个logit。输入patch尺寸上分类器比检测器更灵活可以只处理小patch因为候选位置已经是检测器给出的“有东西”的区域不需要大感受野。这里有一个参数取舍patch越大分类越准但显存开销是按三次方增长的。32的patch在11G显存上可以跑batch size 6448的patch就只能跑24左右。实际使用时通常先用32验证整体效果再根据需要加大到40或48。训练时的数据增强比我预想的更克制只有随机翻转沿三个坐标轴和±15度的旋转。翻转对医学影像安全因为左右肺对称旋转要谨慎过大的角度对小结节判别其实有害因为结节在CT里的形态不应该因旋转而改变判断。# net_classifier_3.py 简化版分类器结构 class NoduleClassifier3D(nn.Module): def __init__(self, in_channels1, num_classes2): super().__init__() self.features nn.Sequential( nn.Conv3d(in_channels, 32, kernel_size3, padding1), nn.BatchNorm3d(32), nn.ReLU(inplaceTrue), nn.MaxPool3d(2), nn.Conv3d(32, 64, kernel_size3, padding1), nn.BatchNorm3d(64), nn.ReLU(inplaceTrue), nn.MaxPool3d(2), nn.Conv3d(64, 128, kernel_size3, padding1), nn.BatchNorm3d(128), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool3d((2, 2, 2)), ) self.classifier nn.Sequential( nn.Flatten(), nn.Linear(128 * 8, 64), nn.ReLU(inplaceTrue), nn.Dropout(0.5), nn.Linear(64, num_classes), )AdaptiveAvgPool3d在这里等价于全局平均池化的一个折中——输出2×2×2而不是1×1×1保留少量空间信息让分类器能区分“中心型高密度”和“边缘型高密度”这两种在池化后可能混淆的模式。Dropout加在第一个全连接后对医学小数据集很关键没有这个Dropout的版本我试过在训练集上能到99%验证集上只有91%典型的过拟合。4.3 分类器的评估指标与阈值调整二阶段评估不能只看准确率。准确率在假阳性率降到很低时会失真——如果负样本占95%以上全预测负都能有95%准确率显然没意义。项目里test_classifier.py输出的指标是FROC曲线所需的敏感度和FPs/scan每扫描平均假阳性数这两个指标才是LUNA16官方评估标准。敏感度的计算公式是TP / (TP FN)FPs/scan FP数量 / 测试集CT数量。阈值调整的逻辑也很直观分类器输出的是每个patch的结节概率默认取0.5作为判别边界。但实际使用中如果检测器产出的候选很多且你更在意“不漏诊”可以把阈值降到0.3代价是假阳性多如果更在意“减少医生阅读负担”提到0.7敏感度会掉。这个trade-off在test_detect.py里是通过一个循环对不同阈值分别计算指标来观察的。5. 从valSplit.npy到稳定训练数据划分、断点续训与常见失败模式5.1 数据划分为什么必须固定项目自带valSplit.npy这是LUNA16官方的fold划分文件10折中某一折的验证集索引直接用它而不是重新随机划分的意义在于LUNA16有多个case是同一患者的多次扫描不同时间点、不同扫描仪如果随机划分同一个患者的扫描可能同时出现在训练集和验证集相当于数据泄漏评估出的指标虚高。valSplit.npy里的划分已经考虑了患者级别的隔离我自己做实验时就吃过这个亏——自定义划分后FROC比官方fold高了将近0.05不是模型变强了是泄漏了。训练时run_training.sh把配置和启动命令都写好了核心参数包括学习率默认1e-3、weight decay1e-4、batch size、训练epoch数。训练医学影像模型时有个经验如果数据量只有几百个CT不要一上来就用cosine annealing先用固定学习率跑前20个epoch等loss平稳后再切到step decay这样前期的loss曲线的震荡会小很多。# run_training.sh 关键训练参数 python trainval_detector.py \ --lr 1e-3 \ --weight_decay 1e-4 \ --batch_size 16 \ --epochs 50 \ --val_fold 0 \ --snapshot checkpoints/detector_fold0.ckpt--val_fold 0对应valSplit.npy里的第0折如果跑完整10折交叉验证需要把0到9各跑一遍最后把每折模型在各自验证集上的预测合并起来再统一计算FROC。snapshot参数指定保存路径代码会在每个epoch结束保存一次方便断点续训——中断后重新启动时通过加载.ckpt并把学习率按照已完成的epoch衰减到对应值可以几乎无损接上。5.2 三张图定位训练异常训练时我习惯同时看三张曲线training loss、validation loss、以及验证集上的检测召回或分类AUC。三张图各管一件事loss下降说明模型在拟合但val loss上升、train loss下降就是典型的过拟合信号两个loss都在降但召回不涨说明正样本的学习不足多半是采样策略出了问题。关于参数初始化毛病的排查路径大概是先是NaN loss看学习率是不是太大或数据里有异常值比如重采样后出现inf再是loss不降拆解成输入数据是否有结节patch、正负比例是否失衡最后是检测结果可视化里全是假阳性看置信度阈值和NMS参数。一个有效的小技巧在训练前先跑一遍test_detect.py的demo——它加载随机初始化的权重跑完整个流程输出预测csv虽然结果完全是噪声但能验证数据流是否跑通。如果这一步报错问题一定在数据预处理或模型输入输出尺寸不匹配不用等训练浪费几个小时才排查。5.3 训练完怎么验证自己的结果可信最终验证分两层。第一层是把测试集CT的最终预测结果test_detect.py输出的prediction.csv和annotations.csv对比关注每个结节的检测框中心坐标和真实标注中心的距离是否在半径范围内——LUNA16的匹配规则是预测中心与标注中心的欧氏距离小于结节半径才视为检测成功第二层是换两个不同的CT扫描最好是不同医院、不同设备扫描的做外部验证看看敏感度的下降幅度。如果内部测试FROC到了0.9外部验证直接掉到0.6以下基本可以确定模型学到了特定扫描仪的重建特征而不是结节本身这时回查预处理、重采样参数和增强策略优先检查CT值裁剪范围和spacing归一化。本文还有配套的精品资源点击获取