3D-CT肺结节检测源码拆解:从DICOM预处理到双阶段网络训练与推理 简介本资源面向医学影像分析与深度学习方向的本科生、研究生及算法初学者提供一套基于Python的3D-CT影像肺结节检测完整实现方案可用于毕业设计、期末大作业与课程设计等场景。压缩包共53个文件约9.6MB以38个Python源码文件为核心覆盖数据预处理、候选结节提取、检测网络与分类网络构建、训练与测试等模块另含CSV标注与候选数据、NPY特征文件、PNG示意图、Jupyter演示笔记及README说明文档结构清晰便于按流程学习。项目源码经本地编译验证可运行评审分达98分难度适中内容经助教审定。已有109人学习下载。读者可借此掌握DICOM转RAW、肺实质分割、结节检测与假阳性过滤等关键环节理解3D CNN在医学影像中的落地思路并直接复用脚本与数据集完成实验复现与二次开发。1. 从一份能跑通的 3D-CT 肺结节检测源码说起如果你正在找一份能直接跑起来的医学影像分析项目尤其是 3D-CT 肺结节检测方向那这份基于 Python 的源码包值得花时间拆一遍。它不是那种只丢几个.py文件、跑起来满屏报错的“半成品”而是把数据预处理、候选结节提取、检测网络、分类网络、训练脚本、推理脚本、结果提交这一整条链路都铺齐了。目录里能看到data_detector.py、net_detector.py、data_classifier.py、net_classifier.py、full_prep.py、dicom2raw.py、prepare.py、run_training.sh这些文件基本覆盖了从 DICOM 原始影像到最终prediction.csv的完整流程。适合谁做毕业设计、期末大作业、课程设计的学生以及想快速摸清肺结节检测工程结构的初级算法工程师。它最大的价值不是算法多新而是把“怎么把 3D CT 数据喂进网络、怎么把检测和分类串起来、怎么产出可提交结果”这条工程路径讲清楚了。下面我按实际拆包和复现的顺序把这份资源从环境到训练再到避坑一层层拆开。2. 环境搭建与数据准备把 DICOM 变成网络能吃的 3D 数组2.1 依赖安装与 Python 版本选择这份源码是纯 Python 技术栈核心依赖绕不开 NumPy、SimpleITK 或 pydicom、PyTorch 或 TensorFlow、scikit-image、pandas。从dicom2raw.py和full_prep.py的命名来看预处理阶段要读 DICOM 序列并转成原始体素数组所以医学影像 IO 库是必须的。我一般会先建一个干净虚拟环境避免和系统里已有的包打架。# 创建虚拟环境Python 3.8 兼容性最稳 python -m venv venv_lung source venv_lung/bin/activate # Windows 用 venv_lung\Scripts\activate # 核心依赖版本不要追新 pip install numpy1.23.5 pip install SimpleITK2.2.1 pip install pydicom2.3.1 pip install scikit-image0.19.3 pip install pandas1.5.3 pip install opencv-python4.7.0.72 pip install torch1.13.1 torchvision0.14.1这里把 NumPy 锁在 1.23.x 是因为部分医学影像库对 NumPy 2.x 的 ABI 还没完全跟上追新容易在import SimpleITK时直接报_ARRAY_API not found。PyTorch 选 1.13.1 是兼顾 CUDA 11.7 和旧显卡驱动如果你机器新可以换 2.x但net_detector.py里如果有自定义层要留意torch.nn.functional的接口变动。opencv-python主要给split_combine.py做图像块拼接和可视化用不是核心推理依赖。提示如果你用pip install装 SimpleITK 失败先确认 pip 本身是不是太旧python -m pip install --upgrade pip走一遍再装。2.2 数据目录结构与 DICOM 转 raw 的预处理链路从文件列表看数据相关文件有labels.csv、annotations.csv、candidates.csv还有data_detector.py、data_classifier.py两个数据加载模块。典型 LUNA16 风格的数据组织是原始 DICOM 按病人 ID 分文件夹annotations.csv存结节坐标和直径candidates.csv存候选点labels.csv存候选点的真假标签。预处理第一步是dicom2raw.py把每个病人的 DICOM 序列读成一个 3D 数组同时记录原点和 spacing因为后面要把世界坐标转成体素坐标。# dicom2raw.py 核心逻辑示意 import SimpleITK as sitk import numpy as np import os def series_to_raw(series_dir, out_path): reader sitk.ImageSeriesReader() dicom_names reader.GetGDCMSeriesFileNames(series_dir) reader.SetFileNames(dicom_names) image reader.Execute() arr sitk.GetArrayFromImage(image) # 形状 (z, y, x) origin image.GetOrigin() spacing image.GetSpacing() np.save(out_path _raw.npy, arr.astype(np.int16)) np.save(out_path _meta.npy, np.array([origin, spacing], dtypeobject)) return arr.shape, spacing这段代码的关键点有三个GetGDCMSeriesFileNames会自动按层厚排序别自己用os.listdir排DICOM 文件名顺序和实际切片顺序经常不一致这是血泪经验GetArrayFromImage出来的轴顺序是(z, y, x)而后面网络里如果按(x, y, z)写卷积核就会翻车spacing一定要存因为肺结节直径是以毫米为单位的转体素时要除以 spacing。full_prep.py和step1.py大概率是在做肺实质分割和归一化把 HU 值截断到[-1000, 400]再缩放到[0, 1]这是肺 CT 的常规操作。2.3 候选结节提取与标签对齐prepare.py和labels_extract.py负责把annotations.csv里的结节坐标转成体素索引再和candidates.csv做匹配。常见做法是对每个候选点计算它到最近真结节的距离小于结节半径就算正样本否则算负样本。这一步的坑在于坐标顺序annotations.csv里通常是(x, y, z)世界坐标而 NumPy 数组是(z, y, x)转换时要把顺序倒过来。# labels_extract.py 坐标转换与标签生成 import pandas as pd import numpy as np ann pd.read_csv(annotations.csv) cand pd.read_csv(candidates.csv) def world_to_voxel(coord, origin, spacing): return np.round((coord - origin) / spacing).astype(int) labels [] for _, row in cand.iterrows(): c np.array([row[coordX], row[coordY], row[coordZ]]) v world_to_voxel(c, origin, spacing) v v[::-1] # 转成 (z, y, x) # 与 ann 中同 series 的结节比距离 same ann[ann[seriesuid] row[seriesuid]] if len(same) 0: labels.append(0) continue dists np.linalg.norm(same[[coordX,coordY,coordZ]].values - c, axis1) labels.append(1 if dists.min() 5 else 0) cand[label] labels cand.to_csv(candidates_labeled.csv, indexFalse)参数上5这个阈值是体素距离不是毫米实际项目里应该用结节直径换算。如果annotations.csv里没有直径列就按经验取 3 到 5 个体素。这一步做完candidates_labeled.csv就是检测网络训练的正负样本来源。split_combine.py则是把大体积 CT 切成 48×48×48 或 64×64×64 的小块方便送进 3D 卷积网络切块时的重叠步长直接影响召回率步长太大漏结节步长太小显存爆炸。3. 检测网络与分类网络双阶段结构怎么串起来3.1 net_detector.py 的 3D CNN 结构与候选框生成net_detector.py是第一阶段任务是在全肺 CT 里找“可能是结节”的候选位置。从文件名和常见实现看它大概率是一个 3D U-Net 或 3D RPN 结构输入是切好的体素块输出是每个体素属于结节的概率图。data_detector.py负责按块加载数据test_detect.py负责推理并输出候选点。# net_detector.py 简化结构 import torch import torch.nn as nn class Detector3D(nn.Module): def __init__(self): super().__init__() self.enc1 nn.Sequential( nn.Conv3d(1, 32, 3, padding1), nn.BatchNorm3d(32), nn.ReLU(), nn.Conv3d(32, 32, 3, padding1), nn.BatchNorm3d(32), nn.ReLU() ) self.pool1 nn.MaxPool3d(2) self.enc2 nn.Sequential( nn.Conv3d(32, 64, 3, padding1), nn.BatchNorm3d(64), nn.ReLU(), nn.Conv3d(64, 64, 3, padding1), nn.BatchNorm3d(64), nn.ReLU() ) self.up1 nn.ConvTranspose3d(64, 32, 2, stride2) self.out nn.Conv3d(32, 1, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool1(e1)) d1 self.up1(e2) return torch.sigmoid(self.out(d1 e1))这个结构里Conv3d的通道数从 32 到 64 是保守配置显存 8GB 以内能跑。padding1保证尺寸不变MaxPool3d(2)降一次分辨率再ConvTranspose3d升回来最后1×1卷积压成单通道概率图。训练时损失函数一般用BCEWithLogitsLoss或 Dice Loss但代码里用了sigmoid输出所以损失要用BCELoss别混用。test_detect.py推理时会用滑动窗口遍历整个 CT窗口大小和训练时一致步长通常取窗口的 1/2 或 1/3然后对概率图做阈值化加非极大值抑制得到候选点列表。3.2 net_classifier.py 的假阳性过滤与特征输入第一阶段检测召回率高但假阳性也多所以第二阶段net_classifier.py要对每个候选点做二分类。data_classifier.py负责按候选点坐标抠出固定大小的体素块通常比检测窗口小比如 32×32×32然后送进一个更深的 3D CNN 或带残差的结构。# net_classifier.py 分类网络示意 class Classifier3D(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv3d(1, 32, 3, padding1), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(64, 128, 3, padding1), nn.ReLU(), nn.AdaptiveAvgPool3d(1) ) self.fc nn.Linear(128, 2) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.fc(x)AdaptiveAvgPool3d(1)把任意空间尺寸压成 1避免全连接层输入维度写死。训练时正负样本比例要控制通常负样本远多于正样本所以data_classifier.py里会有随机下采样或加权采样。test_classifier.py对每个候选点输出概率概率低于阈值的丢掉剩下的就是最终检测结果。config_training.py和config_submit.py分别管训练超参和提交格式prediction.csv就是最终产物一般包含seriesuid、coordX、coordY、coordZ、probability五列。3.3 训练脚本 run_training.sh 与两阶段串联run_training.sh是整条训练链路的入口里面大概率按顺序调prepare.py、full_prep.py、data_detector.py训练、data_classifier.py训练。我一般会先单独跑预处理确认.npy文件生成正常再跑检测训练最后跑分类训练。#!/bin/bash set -e # 任何一步失败就停别让错误滚下去 python prepare.py --data_dir ./data --out_dir ./prepared python full_prep.py --input ./prepared --output ./prepared_full python -m detector.train --config config_training.py --stage detector python -m classifier.train --config config_training.py --stage classifier python test_detect.py --model ./weights/detector.pth --out candidates_pred.csv python test_classifier.py --model ./weights/classifier.pth --in candidates_pred.csv --out prediction.csvset -e是必须的否则中间某步报错后面继续跑最后得到一个空prediction.csv你还以为模型没学好。--stage参数用来区分训练阶段config_training.py里会定义学习率、batch size、epoch 数。检测阶段学习率一般 1e-3 起步分类阶段 1e-4因为分类网络更深学习率大了容易震荡。两阶段串联时检测输出的候选点要保留概率值分类阶段可以把它当额外特征拼进去但这份源码里大概率是纯图像输入没做特征融合。4. 避坑与排查复现时最容易翻车的五个点4.1 现象预处理后.npy文件全是 0 或全是一个值原因通常是 DICOM 读取时没做 HU 值截断或者截断范围写错。肺 CT 的 HU 值范围很宽空气约 -1000骨头能到 1000 以上如果不截断直接归一化肺实质的对比度会被压没。解决方法是确认full_prep.py里有np.clip(arr, -1000, 400)这一步然后再做(arr 1000) / 1400缩放到[0, 1]。如果截断后还是全 0检查dicom2raw.py里GetArrayFromImage的返回值是不是被意外转成了uint8int16才够存 HU 值。4.2 现象训练 loss 不降或者降了但验证集召回率为 0先看标签对齐。candidates.csv里的坐标是世界坐标转体素时如果没减 origin 或没除 spacing候选点会全部偏到肺外面网络学到的全是背景。用labels_extract.py生成标签后随机抽几个正样本把对应体素块可视化出来确认块中心确实在结节上。另一个常见原因是正负样本比例失衡负样本占 99% 以上网络直接全预测负类也能到 99% 准确率但召回率为 0。解决是在data_classifier.py里做加权采样或 focal loss。4.3 现象推理时显存溢出batch size 降到 1 还是爆3D 卷积的显存占用和输入尺寸是立方关系。检测阶段如果滑动窗口取 64×64×64batch size 设 88GB 显存基本必爆。先把窗口降到 48×48×48batch size 设 2用torch.cuda.amp混合精度再省一半。如果还爆检查test_detect.py里是不是把整个 CT 一次性送进网络了正确做法是滑动窗口逐块推理每块推理完把结果写回概率图再清缓存。4.4 现象prediction.csv提交后评分极低或格式报错格式问题占一半。config_submit.py里通常定义了列名和顺序prediction.csv必须严格对齐多一列少一列都会报错。另一半是坐标顺序提交要求(x, y, z)世界坐标而网络输出的是体素坐标要用origin voxel * spacing转回去并且把(z, y, x)倒回(x, y, z)。我一般会写个check_submit.py读prediction.csv和annotations.csv算一下预测点与真结节的距离确认转换没反。4.5 现象run_training.sh跑一半报ModuleNotFoundError相对导入问题。detector和classifier目录下如果没有__init__.pypython -m detector.train会找不到模块。确认每个子目录都有__init__.py并且run_training.sh是在项目根目录执行的。如果还报错检查sys.path里有没有把项目根目录加进去临时方案是在脚本开头加export PYTHONPATH$PYTHONPATH:$(pwd)。5. 进阶技巧用 demo.ipynb 做单例可视化与阈值调优demo.ipynb是这份资源里最容易被忽略但最实用的文件。它通常加载一个训练好的模型对单个病人的 CT 做推理并把检测到的结节在切片上画框。我习惯用它做两件事一是验证模型在具体病人上的表现二是调分类阈值。检测阶段输出的是概率图阈值取 0.5 还是 0.3直接决定召回率和假阳性的平衡。在demo.ipynb里可以交互式地改阈值看框的变化。# demo.ipynb 里常用的阈值扫描片段 import numpy as np import pandas as pd candidates pd.read_csv(candidates_pred.csv) for th in [0.3, 0.4, 0.5, 0.6, 0.7]: kept candidates[candidates[probability] th] print(fthreshold{th}, kept{len(kept)})这个扫描能让你直观看到阈值和候选点数量的关系。如果 0.5 时只剩几个点说明模型欠拟合或分类网络太激进如果 0.3 时还有几百个点说明假阳性没压住要回去看分类网络的训练数据。另一个技巧是把demo.ipynb里的可视化结果和annotations.csv叠加算每个真结节有没有被框到漏掉的结节长什么样是靠近血管还是胸膜这些边界 case 才是提分的关键。调优方向具体操作预期影响检测窗口步长从 1/2 窗口降到 1/3召回率升推理时间增分类阈值从 0.5 降到 0.35召回率升假阳性增正负采样比从 1:10 调到 1:3分类器更敏感但可能过拟合数据增强加随机旋转和翻转泛化提升训练时间增我一般会先把检测步长调密保证召回再用分类阈值压假阳性最后看prediction.csv在验证集上的 FROC 曲线。demo.ipynb里如果已经有画 FROC 的代码直接复用没有就自己加一段用sklearn.metrics算不同阈值下的敏感度和平均假阳性数。从那以后我每次拿到新的医学影像检测项目都强制先跑一遍单例可视化确认坐标转换和阈值逻辑没问题再开大规模训练。希望这份拆解能帮到你少走几个我踩过的坑。本文还有配套的精品资源点击获取