简介:这份资源是面向计算机、人工智能、自动化等专业学生与从业者的3D-CT影像肺结节检测算法项目,源自个人毕业设计,答辩评审分达98分,代码经调试测试可稳定运行,适合作为期末大作业、课程设计或毕业设计的参考方案,也便于基础较好的学习者在此基础上修改扩展功能。压缩包共64个文件,约9.61MB,以38个Python源码文件为核心,辅以csv标注与候选数据、npy数组文件、png示意图、ipynb演示笔记及md说明文档,整体覆盖数据预处理、检测器与分类器网络、训练与测试脚本等模块,目录结构清晰。目前已有57人学习关注。读者可从中获得完整的肺结节检测流程实现,包括DICOM转raw预处理、候选提取、模型训练与推理评估等环节,并借助项目说明快速理解各模块职责,为复现实验、撰写论文或二次开发提供可借鉴的工程范例。
1. 从一份 98 分毕设拆起:3D-CT 肺结节检测这套源码到底能跑出什么
肺结节检测这个方向,很多人第一次接触都是在毕设选题表上看到「基于深度学习的医学影像检测」几个字,然后一头扎进 LUNA16 数据集,最后卡在「DICOM 读进来是一坨 HU 值,怎么变成网络能吃的张量」这一步。这套基于 Python 的 3D-CT 肺结节检测源码,恰好把这条链路完整走通了:从 DICOM 原始序列到预处理、从候选结节提取到假阳性分类,两个阶段串成一条可运行的流水线。它不是那种只丢一个模型权重的空壳,而是带了 detector 和 classifier 两套网络、配套的预处理脚本、训练配置和一份 demo notebook。适合谁?计算机、人工智能、自动化方向做期末大作业或毕业设计的同学,以及想找一个完整医学影像 3D 检测工程模板的从业者。下面我按「这套东西怎么组织、怎么跑起来、哪里会翻车」的顺序拆一遍。
2. 工程结构与两阶段检测原理:为什么是 detector + classifier
2.1 目录里藏着一条完整流水线
把压缩包解开,第一眼看到的是一堆.py和.zbak混在一起,容易懵。其实按功能分就三块:preprocessing/负责把 DICOM 转成统一尺度的体素数据,detector/是第一阶段候选结节检测,classifier/是第二阶段假阳性过滤。根目录下的main.py、config_training.py、run_training.sh是训练入口,test_detect.py、test_classifier.py、prediction.csv是推理和提交产物。
.zbak是编辑器或备份工具留下的副本,比如config_submit.py.zbak、net_detector_3.py这类,功能上和去掉后缀的文件重复。真正要跑的时候认准不带.zbak的版本,别改错文件——这是血泪经验,改了半天发现改的是备份。
| 目录/文件 | 作用 | 是否核心 |
|---|---|---|
preprocessing/ | DICOM 转 raw、肺部分割、重采样 | 核心 |
detector/ | 第一阶段 3D 候选检测网络 | 核心 |
classifier/ | 第二阶段假阳性分类网络 | 核心 |
config_training.py | 训练超参与路径配置 | 核心 |
run_training.sh | 训练启动脚本 | 核心 |
*.zbak | 备份副本 | 忽略 |
detection result demo.ipynb | 结果演示 | 参考 |
2.2 两阶段检测为什么比单阶段靠谱
肺结节在 CT 里是典型的「小目标 + 高假阳性」场景。一个扫描里可能有几十个候选,但真正结节往往只有一两个。如果直接用一个网络做端到端检测,召回率上去了假阳性会爆炸,医生根本没法用。所以主流做法是拆成两阶段:第一阶段用高召回策略把可疑位置全捞出来,宁可错杀;第二阶段用分类网络对每个候选做二分类,把假阳性压下去。
这套源码的net_detector.py和net_classifier.py就是这个思路。detector 负责在 3D 体素上滑窗或锚点生成候选,classifier 拿候选的小 patch 判断是不是真结节。常见做法是 detector 用类似 3D RPN 的结构,classifier 用 3D ResNet 做 patch 分类。这样拆的好处是每一阶段的目标单一,调参时知道该动哪一边——召回不够调 detector,误报多调 classifier。
2.3 3D 卷积和 2D 切片的取舍
有人会问,为什么不直接把 CT 当 2D 切片逐张检测?因为结节是立体的,一个结节在连续几层里都有体现,单看一层容易和血管断面混淆。3D 卷积能利用层间上下文,这是它相对 2D 方案的核心优势。代价是显存吃紧,所以源码里预处理阶段做了重采样和裁剪,把体素间距统一到各向同性,控制输入尺寸。这个取舍在full_prep.py和step1.py里能看出来。
3. 环境搭建与预处理:把 DICOM 变成网络能吃的体素
3.1 依赖安装与版本选择
这套代码是 Python 写的,深度学习部分依赖 PyTorch 或类似框架(看layers.py的写法)。医学影像读取离不开pydicom或SimpleITK,数值处理靠numpy,图像增强可能用到scipy。建议用 Python 3.8 建虚拟环境,太新的版本某些医学影像库轮子不全。
# 建虚拟环境,Python 3.8 兼容性最稳 python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate # 核心依赖,版本按实际报错微调 pip install numpy scipy pydicom SimpleITK pip install torch torchvision # 有 GPU 装对应 CUDA 版本 pip install pandas scikit-learn pip install jupyter # 要跑 demo notebook 才需要逻辑说明:先隔离环境避免污染全局。pydicom和SimpleITK二选一即可,前者轻量读单张,后者处理序列和几何信息更省心。torch的 CUDA 版本要和本机驱动匹配,装错了会在torch.cuda.is_available()返回 False,训练直接掉到 CPU 上慢到怀疑人生。
参数说明:numpy建议 1.21 以上,pydicom2.x 对压缩 DICOM 支持更好。如果pip install卡在编译,优先找预编译 wheel。
3.2 预处理三步走:转 raw、分割肺、重采样
preprocessing/下的dicom2raw.py、full_prep.py、step1.py是预处理主力。典型流程是:读 DICOM 序列 → 按 HU 值阈值分割肺部 → 裁剪到肺区域 → 重采样到统一间距 → 归一化存成 npy 或 raw。
import SimpleITK as sitk import numpy as np def load_and_resample(dicom_dir, target_spacing=(1.0, 1.0, 1.0)): # 读取 DICOM 序列,SimpleITK 会自动处理层间几何 reader = sitk.ImageSeriesReader() series_ids = reader.GetGDCMSeriesIDs(dicom_dir) files = reader.GetGDCMSeriesFileNames(dicom_dir, series_ids[0]) reader.SetFileNames(files) image = reader.Execute() # 重采样到各向同性间距,保证 3D 卷积输入尺度一致 original_spacing = image.GetSpacing() original_size = image.GetSize() new_size = [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(new_size) resampler.SetInterpolator(sitk.sitkLinear) resampled = resampler.Execute(image) # 转 HU 值数组,后续按阈值做肺分割 arr = sitk.GetArrayFromImage(resampled).astype(np.float32) return arr逻辑说明:GetGDCMSeriesIDs处理一个目录里可能有多组序列的情况,取第一组。重采样用线性插值,因为 CT 是连续灰度,线性比最近邻更平滑。GetArrayFromImage出来的维度顺序是 (z, y, x),和 DICOM 的 (x, y, z) 相反,后面写网络时要注意对齐。
参数说明:target_spacing设 (1,1,1) 是常见选择,间距越小分辨率越高但显存越吃紧。如果显存不够可以放宽到 (1.5,1.5,1.5)。插值方式在掩膜上要用最近邻,否则标签会被插出小数。
3.3 肺分割的阈值玄学
肺实质在 CT 里 HU 值大概在 -1000 到 -400 之间,空气是 -1000 左右,血管和结节偏正。常见做法是先阈值分割出肺,再做形态学闭运算填掉小结节造成的空洞,最后取最大连通域去掉背景。
from scipy import ndimage def segment_lung(arr, hu_threshold=-400): # 阈值分割:低于阈值的算肺实质候选 binary = arr < hu_threshold # 闭运算填补结节和血管造成的空洞 binary = ndimage.binary_closing(binary, structure=np.ones((3,3,3))) # 取最大连通域,去掉体外的空气区域 labeled, num = ndimage.label(binary) if num == 0: return binary sizes = ndimage.sum(binary, labeled, range(1, num + 1)) largest = np.argmax(sizes) + 1 return labeled == largest逻辑说明:阈值 -400 是经验值,能把大部分肺实质框进来。闭运算的结构元用 3x3x3 是为了在三个方向都做形态学处理,只用 2D 结构元会漏掉层间空洞。取最大连通域是关键一步,否则体外的空气会被误当肺。
参数说明:hu_threshold对不同设备可能要在 -450 到 -350 之间微调。结构元太大容易把肺门血管也吞进去,太小填不上空洞,3 是折中。
4. 训练与推理:detector 和 classifier 怎么分别调
4.1 配置文件里改什么
config_training.py是训练的总控,路径、batch size、学习率、epoch 数都在这里。第一次跑之前必须改的是数据路径和输出路径,其他超参可以先按默认跑通再说。
# config_training.py 里典型需要改的字段 config = { 'data_path': '/your/path/to/preprocessed', # 预处理后的数据目录 'output_path': '/your/path/to/output', # 权重和日志输出 'batch_size': 8, # 3D 网络显存吃紧,从小开始 'lr': 1e-3, # 初始学习率 'epochs': 100, # 训练轮数 'patch_size': (32, 32, 32), # classifier 的 patch 尺寸 'num_workers': 4, # 数据加载线程 }逻辑说明:batch_size在 3D 网络里是最容易翻车的参数,8 已经算激进,显存不够就降到 4 或 2。patch_size决定 classifier 看多大范围,太小看不到结节全貌,太大引入无关背景,32 是常见起点。
参数说明:num_workers在 Windows 上设太大会报多进程错误,设 0 或 2 更稳。lr用 1e-3 配 Adam 是常规组合,训练不收敛再降到 1e-4。
4.2 启动训练与日志观察
run_training.sh是 shell 入口,里面通常就是一行 python 调用。Linux 下直接跑,Windows 下把命令抠出来手动执行。
# Linux 下赋予执行权限后运行 chmod +x run_training.sh ./run_training.sh # 或者直接看脚本内容手动跑 python main.py --config config_training.py --mode train逻辑说明:先看run_training.sh里到底调了哪个入口、传了什么参数,别盲目执行。训练开始后重点盯 loss 曲线,detector 的 loss 通常包含分类损失和回归损失两部分,classifier 就是二分类交叉熵。
参数说明:如果 loss 一直不降,先检查数据路径对不对、标签有没有读进来。如果 loss 震荡剧烈,把学习率降一个数量级。如果显存 OOM,降 batch size 或 patch size。
4.3 推理与结果输出
test_detect.py和test_classifier.py分别对应两阶段的推理,最终结果汇总到prediction.csv。这个 csv 通常包含结节坐标、直径、置信度,是提交或评估用的。
# 先跑检测阶段生成候选 python test_detect.py --config config_submit.py # 再跑分类阶段过滤假阳性 python test_classifier.py --config config_submit.py逻辑说明:两阶段必须按顺序跑,classifier 的输入依赖 detector 的输出候选。config_submit.py是推理专用配置,和训练配置分开,别混用。
参数说明:推理时的置信度阈值决定最终输出多少结节,阈值高漏检多,阈值低误报多,要在验证集上卡一个平衡点。
5. 避坑与常见问题排查:那些让我重跑一整晚的坑
5.1 现象:训练 loss 是 nan,几轮后直接崩
原因:3D 医学影像的 HU 值范围跨度大(-1000 到 3000),如果没做归一化直接喂网络,梯度爆炸是必然的。另外学习率设太大也会导致 nan。
解决:在预处理阶段把 HU 值截断到 [-1000, 400] 再归一化到 [0,1] 或减均值除标准差。学习率从 1e-4 起步,确认稳定后再往上调。检查data_detector.py里的归一化逻辑有没有生效。
5.2 现象:显存 OOM,batch size 降到 1 还是爆
原因:3D 卷积的显存占用和 patch 尺寸是立方关系,32x32x32 看着不大,乘上通道数和 batch 就爆了。另外数据加载时如果一次性把整个 CT 读进内存也会爆。
解决:先把 patch 降到 24x24x24 试,或者用梯度累积模拟大 batch。数据加载改成按需读取,别在__init__里全load。num_workers调小也能省内存。
5.3 现象:DICOM 读出来方向反了,结节位置全错
原因:不同设备的 DICOM 坐标系和层间顺序不一致,SimpleITK 读出来的数组方向可能和预期相反。预处理时如果没统一方向,后面所有坐标都是错的。
解决:读完后用sitk.DICOMOrient统一到标准方向(如 LPS),或者在 numpy 层面做 flip 对齐。验证方法是拿一个已知结节的病例,看预处理后的坐标和原始标注能不能对上。
5.4 现象:classifier 准确率 99% 但实际全是假阳性
原因:候选样本里正负极度不平衡,真结节可能只占 1%,模型全预测成负样本也能有 99% 准确率。这是医学影像检测最经典的陷阱。
解决:看召回率和 AUC,别只看准确率。训练时用加权损失或重采样让正负平衡。data_classifier.py里如果有采样逻辑,确认它真的在起作用。
5.5 现象:.zbak文件和正式文件改混了
原因:目录里备份文件太多,config_training.py和config_training.py.zbak长得几乎一样,编辑器里切错标签就改错了。
解决:动手前先把所有.zbak挪到一个备份文件夹,眼不见心不烦。改配置前用git diff或文件修改时间确认改的是哪个。这个坑不致命但极耗时间。
6. 进阶玩法:把两阶段拆开单独调,以及怎么验证结果可信
跑通整条流水线只是第一步,真正要拿这份源码做点东西,得学会把两阶段拆开单独调。detector 阶段的核心指标是召回率,你希望它尽量别漏;classifier 阶段的核心指标是假阳性率,你希望它尽量别误报。这两个目标是对抗的,所以要分开评估。
一个实用技巧是先把 detector 的候选全导出来,人工看一批,统计漏检和误报的分布。如果漏检集中在某类形态的结节(比如贴着胸膜的),说明 detector 的锚点尺寸或数据增强需要针对这类样本加强。如果误报集中在血管断面,那是 classifier 的 patch 特征不够,可以加大 patch 或引入多尺度。
验证结果可信度有个笨办法但很有效:拿几个公开数据集里有标注的病例,把预测结果和标注做匹配,算每个结节的 IoU 或中心点距离。如果大部分预测都能和标注对上,说明流水线是通的;如果预测位置系统性偏移,八成是预处理的方向或间距没对齐。
import numpy as np def match_predictions(preds, gts, dist_threshold=5.0): # preds/gts 格式: [(z, y, x, diameter), ...] matched = 0 for gt in gts: gt_center = np.array(gt[:3]) dists = [np.linalg.norm(np.array(p[:3]) - gt_center) for p in preds] if dists and min(dists) < dist_threshold: matched += 1 recall = matched / len(gts) if gts else 0 return recall逻辑说明:用中心点欧氏距离做匹配,阈值 5mm 是常见设定,因为结节本身直径可能就 5-10mm。这个函数只算召回,精确率要反过来遍历预测。实际评估时两个都要看。
参数说明:dist_threshold根据结节大小调整,小结节用 3mm,大结节可以放宽到 8mm。坐标单位要统一,别一个用体素一个用毫米。
从那以后我每次拿到这类医学影像项目,都强制先跑一遍「读一张 DICOM → 打印形状和方向 → 可视化中间层」的检查,确认数据管道没问题再动模型。这个习惯帮我省了无数个通宵。希望帮到你。
本文还有配套的精品资源,点击获取