简介:这套基于LUNA16数据集的3D-CT肺结节检测工程代码包,面向医学影像分析、深度学习和计算机辅助诊断方向的研究者与开发者,可帮助读者快速上手肺部结节自动检测的完整流程。压缩包共54个文件,以38个Python脚本为主,涵盖DICOM数据预处理、特征提取、检测与分类网络构建、训练测试及提交预测等环节;同时配有4个CSV标注文件、2个NPY数据文件、效果图PNG和Markdown说明文档,整体大小仅9.6MB,结构紧凑。已有1481人学习下载,是学习LUNA16任务的高热度参考工程。资源提供了从原始CT影像转换、结节标注提取到模型推理与结果输出的完整管线,并附有demo.ipynb和prediction.csv,可直接运行体验或在此基础上二次开发,能帮助读者理解3D医学影像检测的关键技术细节与实用基线方案。
1. 项目概述:LUNA16 到底在解决什么问题
医学影像AI这几年看起来很热,但如果你真的动手做过一次肺结节检测,会发现这个方向并没有网上说的那么“无脑”。肺结节检测的核心任务,简单说就是给定一套3D胸部CT影像,让算法自动标出每一个可疑结节的中心位置和大小。而LUNA16数据集,就是这类任务里被用得最多、也最能检验基本功的一套数据。
先简单交代一下LUNA16的背景。它来自LUNA(LUng Nodule Analysis)挑战赛的16年版本,数据源自LIDC/IDRI公共数据库。官方筛选出888套完整的CT扫描,并且只保留了直径大于等于3mm的结节标注。每个结节由多名放射科医生标注,最后融合成10个annotation文件。数据集还额外提供了一个candidates.csv,里面包含了大约55万个候选结节位置,每个候选点标注了是否是结节,这其实是为“假阳性消除”阶段准备的,也是我后面会重点讲到的一个设计思路。
这个项目适合谁?如果你正在入门医学影像深度学习,或者想基于3D卷积网络做一个完整的检测流程,那LUNA16是最好的练手项目之一。它不像很多工业数据集那样规模巨大,但足够你把“数据预处理—模型训练—后处理—评估指标”整条链路走通。它也能帮你理解医学影像任务和普通自然图像任务之间的本质差异:坐标体系不同、数据是体素的、正负样本极度不平衡、评价指标是FROC而不是简单准确率。
2. 整体方案设计:为什么我选择两阶段检测链路
2.1 两阶段方案 vs 端到端方案
做肺结节检测,主流路线大概分两种:端到端目标检测和两阶段检测。
端到端方案类似自然图像里的YOLO或FCOS,直接把整幅3D CT输入网络,输出结节的位置和大小,常见做法是把3D版SSD或FCOS用在候选区域生成上。这个思路的优势是流程简洁、推理速度快,但问题也很明显:结节的尺寸很小(几毫米到几十毫米),在一整幅CT里只占极少体素,正负样本比例极度失衡,端到端模型很容易训练不稳定,特别是在数据量只有888例的情况下,小目标漏检率会很高。
两阶段方案更像传统的“先粗筛、后精排”思路。第一阶段(Candidate Generation)用高召回率的网络找出所有可能像结节的候选区域,第二阶段(False Positive Reduction)再对这些候选点逐一分类,筛掉血管截面、炎症、疤痕等假阳性。LUNA16本身就专门为这种思路设计了candidates.csv,所以做两阶段方案在数据上非常顺。我在这个项目里就是走的这条路线,因为它在每一步都能单独调优,也更容易定位问题。
2.2 整条工程链路的结构
我把整条检测链路分成五个模块,每个模块各司其职:
- 数据预处理:CT重采样、HU值裁剪归一化、坐标转换,所有后续步骤的地基。
- 候选结节生成:用3D分割或Anchor-based检测网络生成高召回候选点,目标是“宁可多检,不能漏检”。
- 假阳性消除:对候选点裁剪小patch,用3D分类网络精判是否为结节,目标是“宁缺毋滥”。
- 后处理:NMS去重、阈值调优、按FROC评估指标输出灵敏度。
- 评估与交叉验证:按数据集的fold划分训练,计算CPM得分。
每个模块都有各自容易踩坑的地方。下面我按这条链路,把关键细节和实操经验拆开讲。
3. 预处理与核心实现细节
3.1 重采样和坐标系:最容易出错的一环
CT影像有一个和自然图像很大的不同点——不同扫描仪的层厚、像素间距都不一样。有的CT层厚是1mm,有的是2.5mm甚至5mm;像素间距可能是0.6mm,也可能是0.8mm。如果把这些数据直接塞给网络,网络会看到同一结节在不同样本里的“物理尺寸”完全不同,模型根本学不出稳定的特征。
因此,预处理第一步就是把所有CT重采样到统一的体素间距。我一般统一到1mm×1mm×1mm,这也是LUNA16官方推荐的做法。重采样要用三线性插值,不能用最近邻,否则边缘会严重锯齿化。这里有一个实现细节:重采样不是直接对CT数组插值,而是要先用一个重采样因子算出新数组的shape,再用scipy.ndimage.zoom或SimpleITK的Resample完成。我遇到过不少人在这一步搞反了坐标轴的顺序,导致后来所有结节位置都错位,痛苦排查了一整天。
更隐蔽的问题是坐标系转换。LUNA16的标注文件里,每个结节的坐标是世界坐标系(World Coordinate,单位是mm),而你在Python里读出来的CT数组是体素坐标系(Voxel Coordinate,即第几行第几列第几层)。你必须用一个4×4的仿射矩阵(通常来自NIfTI文件头或SimpleITK的GetDirection、GetOrigin、GetSpacing)把世界坐标转成体素坐标,才能在数组里准确裁剪出结节patch。这个转换公式不复杂:voxel_index = inv(affine) × world_coord。但我建议你写好之后,务必挑几个结节可视化验证一下,确认patch中心确实落在结节内部,再继续往下走。否则你训练的数据可能就是错的。
3.2 HU值裁剪和归一化:为什么不能直接喂原始数据
CT影像里的原始数值是HU(Hounsfield Unit),它代表组织对X射线的衰减程度。空气大约是-1000,肺实质在-500到-900之间,软组织和血液在0到100左右,骨骼可以到几百甚至上千。如果把整个HU范围(一般是-1024到3071)直接输入网络,绝大部分体素的数值范围差异太大,网络训练会非常不稳定,而且肺结节这个目标在HU分布里占比太小,容易被淹没。
我的做法是:先做肺窗裁剪。肺结节检测最关心的是肺实质区域,所以一般把HU值限制在[-1000, 500]这个区间,低于-1000的按-1000算,高于500的按500算。这个范围大致对应于肺窗的窗位-600、窗宽1500,能把肺结节、血管、胸膜都保留下来,同时压缩掉骨骼等高密度干扰。裁剪完再做min-max归一化,把数值映射到0到1之间。归一化是必须的,因为3D卷积网络对输入尺度很敏感,统一到[0,1]后不同样本之间才有可比性。
3.3 训练样本的裁剪策略和数据增强
候选生成和假阳性消除这两个阶段都需要裁剪patch。以假阳性消除阶段为例,我会以每个候选点为中心,在重采样后的CT数组里裁剪出一个64×64×32的patch(前面两个维度对应冠状面/矢状面,最后一个维度对应轴向层数)。为什么要用非对称尺寸?因为CT的层间分辨率虽然已经重采样到1mm,但轴向信息量还是略少于层面内,用稍小的轴向尺寸可以节省显存,同时不影响判别效果。
数据增强方面,我常用的组合是随机翻转(三个轴)、90度旋转、小幅缩放和随机平移。需要特别提醒的是:训练时的缩放和平移范围不能太大,否则会改变结节的形态学信息,反而让模型学到错误的先验。我在实践里会把缩放控制在0.85到1.15之间,平移不超过3个体素。增强的目的不是制造无限多的假样本,而是让模型对微小变化不敏感,这是医学小数据集训练的核心哲学。
4. 模型选型与训练调参实战
4.1 候选生成网络:用3D U-Net还是3D FPN
候选生成阶段的目标是“高召回”,也就是把所有可能像结节的位置都找出来,哪怕误检率高一点也没关系。我在这类任务里最常用的结构是3D U-Net的变体。
为什么选3D U-Net而不是2D网络?最直接的原因是结节的形态是三维的,很多结节在单张2D切片上和血管截面几乎无法区分,但放到三维空间里看,血管是细长的管状结构,结节则是类球形的团块。2D网络天然缺失这个判别信息。3D U-Net通过编码器逐层下采样、解码器恢复分辨率的对称结构,能在多尺度上捕获上下文信息,这对大小差异很大的结节尤其重要(小到3mm,大到几十毫米)。
输出层我习惯用两个分支:一个分支做体素级分割,预测每个体素是结节的概率;另一个分支做距离回归,预测体素到最近结节中心的距离。这在医学检测里叫“Center-ness”思想,后续只需要对分割结果取局部极大值,就能得到一组候选点。如果你不想这么复杂,也可以在3D U-Net后面接一个3D Anchor-based检测头(类似3D RPN),直接回归每个anchor的结节概率和坐标偏移。两条路我都试过,分割+中心点回归的稳定性更好,尤其适合LUNA16这种正样本极其稀疏的任务。
4.2 损失函数和样本采样:正负样本严重失衡怎么破
肺结节检测里最大的训练障碍就是正负样本失衡。以体素分割为例,一幅CT有上千万体素,但结节区域可能只有几十到几百个连通区域,正样本体素占比经常不到0.1%。如果直接用普通的交叉熵损失,模型会一路学到“全部预测为背景”的退化解。
我的第一层防护是混合损失:Dice loss和Focal loss叠加。Dice loss直接优化分割区域和真实区域的overlap,对类别不平衡有天然鲁棒性。Focal loss通过调整难易样本的权重,让模型更关注那些难以分类的体素。两者结合的实际效果比单独用任何一种都好。Focal loss的gamma我一般取2,alpha取0.25,这是检测任务里比较通用的起始值,但建议在你的验证集上做个小网格搜索,因为不同数据分布差异很大。
第二层防护是训练时的样本采样策略。我不用全图的所有体素,而是限制每一batch里的负样本比例。具体做法是:每个batch内的patch,正样本(含结节中心)占比控制在一半左右,另外一半从背景区域随机采样。这能保证每个step模型都能看到足够的正样本,梯度更新不偏。对于假阳性消除阶段,候选点里真结节和假阳性的比例天然就是极端的,所以我还会额外做在线难例挖掘——每轮迭代结束后,把分类错误概率最高的假阳性样本挑出来,丢回训练集下一轮继续强化。
4.3 假阳性消除:一个轻量但关键的3D分类器
候选生成之后,你会得到大量候选点,其中绝大多数是假阳性。LUNA16里的难点就在这:模型要把“不是结节但看起来非常像结节”的候选点筛掉。
我在这个阶段用一个比检测网络轻量得多的3D分类网络,输入就是前面提到的64×64×32 patch,网络结构可以简单一点,比如四五个3D卷积+池化层,最后接两个全连接层输出二分类概率。轻量设计是有意为之,因为这里的候选点数量很大,如果分类器太重,推理时间会翻好几倍。输入的尺寸选择也影响很大,我试过32×32×16和96×96×48,前者信息不足,后者噪声太大,最后64×64×32是精度和速度的平衡点。
另一个容易被忽略的细节是patch的实际大小要包含足够的上下文。结节的直径可能只有3mm,但如果你只裁剪出刚好包裹结节的一小块,分类器因为看不到周围的血管、胸膜关系,很难判断形态是否可疑。所以我的patch尺寸设计为64×64×32,在1mm间距下对应64mm×64mm×32mm的物理范围,周围留出足够的上下文,效果明显好于只裁剪20mm见方的小patch。
4.4 推理与NMS后处理
推理阶段,对每幅CT应用训练好的网络得到候选点后,还需要合并重叠的检测结果。因为候选生成网络会把同一个结节在多个位置都产生高响应,如果不做NMS,最终会输出大量重复框,而且FROC指标的假阳性数量也会虚高。
我做NMS的标准是3D IoU大于0.1就合并,这个阈值比自然图像检测里常用的0.5要低得多。原因在于,肺结节检测里的候选框是“点+半径”的形式,尺度小,对IoU极度敏感。如果用0.5,很多同一个结节的重复检测会躲过合并,导致最终灵敏度虚高、但不可复现。NMS合并时对每个簇保留置信度最高的那个预测,置信度用于后面的阈值调优。
调阈值的方法同样关键。先确定你期望的假阳性数(比如每幅CT平均1个、2个、4个、8个),然后在验证集上扫一遍置信度阈值,找到对应的灵敏度。LUNA16的官方评估指标FROC,就是把这几个假阳性率下的灵敏度取平均,通常叫CPM评分。这个指标很值得玩味,它不追求某一点做到极致,而是要求模型在低假阳性率到高假阳性率的全区间里都保持稳定。
5. 常见问题与排查技巧实录
5.1 坐标转换错误,导致所有预测位置偏移
这是我在实战中遇到的第一个大坑。症状表现:训练时loss正常下降,但可视化验证时发现,每一个预测结节都比真实位置偏了固定方向几个体素。排查半天才发现,是重采样后没有同步更新仿射矩阵,导致体素坐标和世界坐标的换算基准不一致。
这里的规律是:只要你对CT数组做了重采样、裁剪等几何变换,仿射矩阵必须同步修改。更稳妥的做法是,在代码里写一个函数专门维护“体素坐标↔世界坐标↔标注坐标”的转换,所有模块统一调用,而不是到处散落scipy.ndimage.zoom。我后来把每个fold里随机抽5个结节可视化输出,跑完整个数据流确认坐标正确,才敢开始训练。
5.2 显存不足:patch太大,batch设不上去
3D卷积对显存的消耗是出了名的“吃显卡”。我第一次训练3D U-Net时,输入尺寸设成128×128×64,batch size设为2,结果一张12GB的显卡直接OOM,连模型都加载不进去。
后来我用了三个招组合解决:第一,把输入裁剪改小一点,候选生成阶段用96×96×48,分类阶段用64×64×32,信息量基本不受影响。第二,开启混合精度训练,在PyTorch里用AMP,显存和训练时间都下降约40%。第三,用梯度累积,实际等效batch size保持足够大,但每次显存只占一小部分。这三个招组合下来,一张16GB的卡能跑绝大部分阶段。如果还不够,还可以用滑窗推理替代整图推理,把大CT切块预测,最后拼回去。
5.3 FROC评分始终为0:检查测试集标注和NMS
如果训练了很久,FROC评估还是得0,那说明不是模型没学会,而是评估流程本身出了问题。我见过两种典型情况:一是测试时把坐标预测转到了错误的空间,导致检测位置和标注的ground truth永远对不上,IoU为0;二是NMS的参数不对,重复框太多,FROC统计时把同一个结节算作多个检测,不但灵敏度没提升,假阳性数还爆炸式增长。
针对第一种情况,建议先做一个冒烟测试:把标注的ground truth结节位置直接当作预测结果输进去评估,如果评分不是满分,说明评估代码或坐标转换有bug,跟模型无关。这个操作能帮你快速排除问题。针对第二种情况,把NMS的IoU阈值调低是一个方向,另外可以检查重叠框之间的置信度排序是否合理,确保保留的是最高置信度的检测。
5.4 过拟合:数据量只有888例怎么办
LUNA16的训练数据只有888套CT,做深度学习明显偏少。如果前期就发现验证集loss和训练集loss差异越来越大,基本可以判断是过拟合。
除了常用的数据增强,我还会用Dropout和权重衰减。3D网络参数多,Dropout加在最后的全连接层之前,概率设0.3左右比较合适。权重衰减方面我习惯设1e-4到5e-4,太小起不到约束作用,太大会影响收敛。最有效的还是交叉验证:LUNA16官方给了10个fold的划分,我开发阶段只用前5个fold训练,后5个fold做验证;等所有超参调完,再用全部数据训练最终模型。这样能最大程度避免“在验证集上调参,把验证集过拟合了”的隐性错误。
6. 项目落地与扩展思考
LUNA16这套流程跑通之后,我发现它带来的能力迁移性很强。肺结节检测本质上是一个三维医学影像中的小目标检测问题,而同一套“预处理—候选生成—假阳性消除—后处理”的框架,可以直接迁移到其他医学影像任务上,比如脑出血检测、肝肿瘤定位、肺栓塞识别等。区别主要在于HU窗宽窗位、目标尺寸、解剖结构先验这些细节,整体工程架构基本不用动。
在实际部署层面,有一个点值得提醒:训练时的性能跑分和真实临床场景会有不小差距。LUNA16的数据集相对“干净”,多数CT都是规整的轴位扫描。但你如果拿到不同扫描协议、不同品牌机器的数据,可能要在预处理阶段做更多归一化,甚至用域自适应方法来弥合数据分布差异。我在把一个基于LUNA16训练的模型迁移到院内CT数据上时,就发现召回率明显下降,后来补了不少目标域样本做微调才恢复性能。这说明医学AI项目真正的工作量,往往不在模型结构上,而在数据工程和泛化适配这些“看不见”的地方。
最后分享一个我自己的经验:做这类项目时,一定要从一开始就把评估代码和可视化工具写好。医学影像模型的调参周期很长,如果每一步都靠肉眼看结果,效率非常低。把FROC计算、坐标转换校验、patch可视化、训练指标监控做成几条固定的shell命令,循环跑实验会轻松很多。我个人现在接手任何医学影像检测项目,第一件事永远是先写一个最小可复现脚本,确保从原始数据到最终指标整条链路是通的,再开始碰模型。这个习惯帮我省下了大量排错时间,也推荐给你试试。
本文还有配套的精品资源,点击获取