ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

LUNA16肺结节检测全流程实战:3D-CT预处理、两段式检测与FROC评估

LUNA16肺结节检测全流程实战:3D-CT预处理、两段式检测与FROC评估 简介面向医学影像分析研究者和深度学习入门者的3D-CT肺结节检测完整项目采用LUNA16公共数据集聚焦于计算机辅助诊断中的关键任务提供从原始图像预处理到结节检测、分类的端到端代码实现。压缩包共54个文件包含38个Python脚本覆盖数据转换、预处理、检测器与分类器、4个CSV标注文件如annotations、candidates、2个numpy数组、2张示例图像、2个Markdown说明文档及配置文件等整体仅约9.6MB。已有1481人浏览学习。项目内提供了net_detector.py、net_classifier.py、run_training.sh、demo.ipynb等关键模块以及训练、测试、预处理分步骤脚本读者可通过运行示例快速复现肺结节检测流程也可基于标准标注和自己数据进一步调优适合算法对比与科研起步。此外利用附带的标注文件可开展候选结节提取实验修改配置文件还能调整训练参数对学生课程设计和研究者入门都非常友好。 我以前整理过一套用LUNA16做肺结节检测的完整代码打包成一个zip文件发给不少搞医学影像分析的朋友。这个包里从3D-CT原始数据预处理到候选结节生成、假阳性抑制、FROC评估整套流程都有。今天这篇就把这套东西拆开讲透它解决的问题是什么、为什么数据要那样处理、模型怎么搭、训练里有哪些坑、最终怎么评估。不管你是刚接触医学影像的小白还是已经跑过几个2D检测项目的工程师只要打算在3D-CT肺结节检测上走一遍这篇文章应该都能帮你省下大量试错时间。1. LUNA16到底牛在哪数据、标注和评估口径LUNA16的全称是LUng Nodule Analysis 2016一个面向肺结节检测的公开竞赛。它从更大的LIDC/IDRI数据集里筛出888例胸部CT筛掉的是层厚过大或采集参数差异太大的扫描。每个病例由四位放射科医生在LIDC中独立标注再经融合规则生成最终结节列表只有直径大于等于3mm的结节才进入评测。正因为标注过程相对干净又有统一的CPM打分机制LUNA16才成为医学影像分析里被引用最多的benchmark之一。在选择它之前我也犹豫过要不要用自建数据。但自建数据的麻烦在于标注一致性很难保证结节大小分布极不均匀更别提隐私合规。LUNA16的好处就是你能在论文上找到一个公平对比的锚点几乎所有肺结节检测模型都会报自己在这个数据集上的CPM你复现的模型到底行不行一比便知。1.1 3D数据规模对工程选型的影响因为这是3D数据输入尺寸不再是HxW而是DxHxW。一张CT通常有200到500层每层512x512体素间距在0.5到1.5mm之间。直接全图输入网络在显存上不现实所以几乎所有方案都会先把体积切patch。LUNA16的结节又特别小平均直径大概7到8mm在一个1mm分辨率的体素空间里也就是七八个体素的大小。这决定了模型输入patch不能太大也不能太小后面会专门讲。另外一个现实问题是训练周期。LUNA16官方会给出十折交叉验证的划分方式但我不建议一开始就十折全跑。比较好的节奏是先用一折或者两折做快速迭代把预处理、模型、评估这一整条链路都跑通之后再用全部数据做正式训练。因为888例3D数据全量训练在单卡上往往要好几天如果链路没通就全量跑等于把时间白白烧掉了。1.2 标注坐标体系是最容易埋雷的地方LUNA16的标注文件CSV里给的坐标是物理坐标系下的x、y、z单位mm同时给出结节直径。这个细节极其容易踩坑SimpleITK读出来的数组是[z,y,x]顺序而spacing是(x,y,z)顺序重采样之后同样的物理坐标在新的体素坐标里要重新换算。预处理阶段如果不管坐标体系后面训练时标签全错位模型大概率学不到东西。我习惯是预处理时就把标注换算成体素坐标和重采样后的数组一并保存后面所有环节都用体素坐标操作不来回切换这样能少踩很多坑。2. 预处理三件套HU换算、窗宽窗位、各向同性重采样3D-CT影像预处理是整个管线里最朴实但最重要的部分它直接影响模型能不能正常收敛。很多复现失败的项目最终定位到的问题都在预处理而不是模型结构。2.1 从mhd/raw里读出HU值LUNA16的数据格式是mhd加raw。mhd是文本头部raw是二进制体素数据。我一般用SimpleITK几行代码就能读出来import SimpleITK as sitk itk_img sitk.ReadImage(image.mhd) img sitk.GetArrayFromImage(itk_img) # 得到 z, y, x 顺序 spacing itk_img.GetSpacing() # 得到 (x, y, z) 方向间距注意这里数组顺序和spacing顺序不是对应的。我用的时候会把spacing翻转成(z,y,x)避免后面处理混乱。mhd头部里有RescaleSlope和RescaleIntercept两个字段CT原始存储值需要做一次线性换算才是真正意义的HUHounsfield Unit。很多开源代码默认slope1、intercept-1024但在严谨复现时我建议还是读元数据自己换算因为不同设备的值可能不一样。HU值对肺结节检测的意义在于它把CT值标准化到以水为0、空气为-1000的刻度。空气、肺实质、软组织、钙化结节的CT值范围有明显区别这正是后续做窗宽窗位和阈值分割的基础。2.2 窗宽窗位把注意力集中在肺结节上窗宽窗位并不只是给医生看片用的在深度学习里我们会在预处理阶段直接用一个固定的HU范围做clamp相当于把输入灰度分布限定在医生读片最关注的窗口内。肺结节通常看肺窗我常用窗宽1500HU、窗位-600HU也就是把HU clamp到[-1350, 150]附近。另一种常见做法是直接map到[-1000, 500]再归一化到[-1,1]或[0,1]。为什么不做完整动态范围的归一化因为完整CT值范围可能从负一千多跨越到三千以上肺结节的软组织对比度会被严重压缩。clamp之后背景空气都变成同样的最小值肺实质和结节之间的对比就凸显出来了这对3D卷积网络学习结节特征很有帮助。我早期试过不做窗宽窗位直接归一化网络收敛速度明显慢CPM也掉不少。这个细节千万别省。2.3 各向同性重采样原始CT在不同扫描里z轴层厚差异很大有的0.5mm有的2.5mm直接混训网络很吃力。常规做法是把所有数据重采样到各向同性体素比如1x1x1mm。这样不管原始z轴多厚网络看到的物理尺度是一致的。重采样用SimpleITK的ResampleImageFilter。需要先根据原始size和spacing计算新的size再设置新的spacing和插值器new_spacing [1.0, 1.0, 1.0] old_spacing itk_img.GetSpacing() old_size itk_img.GetSize() new_size [int(round(old_size[i] * old_spacing[i] / new_spacing[i])) for i in range(3)]插值我推荐线性插值保留灰度信息不要用最近邻边界会很难看。重采样完的数据建议先保存成npy或h5而不是训练时实时重采样否则IO和CPU开销会拖垮GPU利用率。为了省时间可以先用2mm分辨率训练候选阶段的粗模型再用1mm分辨率训练精分类模型。但推理时一定要统一到训练时的预处理配置否则性能会下降。3. 两段式检测管线粗分割找候选3D CNN防假阳性LUNA16的肺结节检测不是一步到位的。直接用3D检测器做端到端检测当然也可以但它对显存、数据量、调参能力的要求都更高。我自己走通的是业界更经典的两段式方案第一阶段尽量找出所有可能是结节的候选点第二阶段再对这些候选做精细分类把假阳性压下去。3.1 第一阶段用3D U-Net做粗分割生成候选第一阶段的思路是“宁可错杀不可放过”。我训练了一个3D U-Net输入的是重采样并归一化后的CT patch输出是一个实时概率图标记每个体素是不是结节的一部分。推理时对整个CT做滑窗或者直接整图切patch得到一张概率图之后用阈值比如0.5转成二值掩膜再通过连通域分析提取每个连通区域每个区域取概率最大的体素作为候选结节中心点。这里有个工程要点用于候选生成的网络不需要太精细。我通常会先把CT下采样到2mm各向同性分辨率patch大小128x128x64这样一块区域就能覆盖较大的范围显存占用也可控。粗模型的定位误差可以通过候选生成时的阈值放宽来弥补——只要候选框和真实结节有重叠后续精细分类还有机会修正。候选阶段的目标是召回率不是精度。理想情况下真实结节要100%进入候选列表哪怕因此引入大量假阳性比如几千个/scan也没关系那是第二阶段的任务。3.2 第二阶段3D CNN做假阳性抑制第二阶段对每个候选点在原分辨率CT上裁剪一个固定大小的patch比如32x32x32送入一个3D卷积分类网络输出是“结节/非结节”的二分类概率。因为候选点来自第一阶段正样本太少负样本太多训练时不能直接全部拿来做交叉熵。我常用的做法是控制正负样本比例在1:10到1:20之间并且在第一轮分类器训练完之后把预测错的高置信度负样本作为hard negative再补进训练集重新finetune一轮。这个技巧对降低假阳性率非常有效。分类网络的结构不需要花哨几组3D卷积加一个全局池化输出标量就够5层左右的小网络在1080Ti上跑得非常快。两个阶段串起来之后完整推理流程是读CT - 预处理 - 粗分割 - 连通域取候选 - 逐个裁剪patch - 分类器打分 - 输出坐标、直径和置信度。后处理可以用3D NMS去掉重叠的候选但结节本身很小重叠情况不多我一般按置信度阈值直接过滤再根据候选概率图的连通域大小估算直径。3.3 为什么不用一步到位的3D目标检测我也试过用3D Faster R-CNN类似结构做检测效果不是不行但工程复杂度高了不少anchor的尺寸和长宽比需要针对结节直径分布调训练时正负样本的IoU阈值对结果影响极大而且3D的RoI Pooling实现起来也不省心。相比之下粗分割加分类器的方案每一步都可以单独验证出现问题容易定位。对个人复现来说我更推荐先把两段式跑通再去折腾端到端检测器。4. 训练3D模型时最容易翻车的四个环节这一章我想专门说踩坑。这些坑我在做这个项目时几乎都碰过一遍很多是从代码仓的issue里翻出来的。4.1 显存不够用patch训练别贪大3D卷积的显存消耗随输入体积是立方级增长的。一块1080Ti显卡直接塞整个512x512x300的CT肯定爆。唯一可行的就是patch训练。候选阶段的U-Net我输入是128x128x64batch size设4分类网络输入32x32x32batch size设64。这个组合可以把显存占满但不会溢出。如果显存还是不够可以考虑混合精度训练。torch.cuda.amp在3D卷积上同样能省下不少显存速度也有提升。但要留意BN层在混合精度下可能出现数值不稳所以我一般在最后几个epoch切回全精度或者用固定batch size避免BN统计漂移。4.2 数据加载成为训练瓶颈一开始我偷懒训练时实时从mhd里切patch结果GPU利用率长期在30%以下。后来我把预处理和重采样全部离线做好存成npy或h5文件并在DataLoader里用多进程预读。即使这样3D数据量也很大每次随机读取几十个体素patch仍然会有大量随机IO。我最终的做法是每个epoch开始前随机选一批npy文件把选中的整个CT读进内存后续从这些驻留内存的volume里切patch。这样IO开销小了很多8个data loader worker就可以基本喂饱GPU。4.3 正负样本比例失衡候选生成阶段一个扫描里可能有几百个假阳性候选真实结节可能就一两个。如果直接拿全部样本训练分类器网络会倾向于把一切预测成非结节因为这样loss已经很小了。我的办法是在训练时限制负样本数量保持正负比在1:10左右使用Focal Loss或者给正样本更高的权重第一轮训练后收集误判的负样本加入hard negative集合重新finetune。这三步叠加以后分类器的FP/scan能明显降下来对CPM的提升往往比换网络结构还要明显。4.4 训练集和验证集划分不当同一个患者的CT可能出现多个patch如果随机打乱后把同患者的patch同时分到训练集和验证集就是典型的数据泄漏验证结果会虚高。我的做法是严格按scan或patient id划分保证同一个CT的所有patch只出现在一侧。这个原则在医学影像任务里必须无条件遵守。另外训练U-Net时我很注意ground truth的坐标变换。重采样会改变数组的索引和物理坐标的对应关系标注必须跟着重采样的参数同步更新。我遇到过一种情况网络训练得很正常loss也在降但评估时发现预测点和真实结节全部错开最后排查发现是重采样后只重采样了CT忘了把标注坐标乘以spacing缩放系数。这种错位问题在二维里不容易犯三维空间里就特别容易出现因为多了一个维度更容易疏忽。5. FROC和CPM别再用mAP评估医学影像模型很多做普通目标检测的工程师刚接触肺结节项目第一反应是算mAP。这个习惯在LUNA16上不合适因为评估需求不一样医学筛查场景里宁可多报几个假阳性也不想漏掉一个真结节。FROC曲线和CPM就是专门为这种“在卡控误报数量的前提下追求高召回”的场景设计的。5.1 FROC曲线是怎么算出来的FROC的横轴是FP/scan纵轴是灵敏度。它的计算过程是这样的把每个预测点按置信度从高到低排序从最高分往下逐步把分数阈值放宽预测点会越来越多TP数量和FP数量都增加在每个阈值下统计灵敏度 TP数 / 总真实结节数以及FP/scan FP总数 / 扫描总数把这些点连成曲线就是FROC。有一个匹配规则要特别注意一个真实结节只能被一个预测点匹配一次。如果两个预测点落在同一个真实结节附近通常只算第一个是TP第二个要么算FP要么被忽略。官方评估脚本用的是“预测点在结节标注半径范围内即视为匹配”的规则。所以后处理阶段一定要做NMS或距离去重否则同一个结节被重复预测会被当成多个假阳性FP/scan虚高。5.2 CP M是七个点上的平均灵敏度CPM全称Competition Performance Metric其实就是FROC在FP/scan等于1/8、1/4、1/2、1、2、4、8这七个离散点上的灵敏度取平均。这三个点覆盖了从极严格到极宽松的实际使用场景。医学筛查场景通常希望FP/scan保持在1甚至0.5以下但FP/scan偏高时灵敏度也会上升。CPM是一个折中它不会因为你在某个特定误报率上表现好就给高分而是要求整条曲线都尽量高也就是检测器需要在不同误报容忍度下都有稳定表现。5.3 跑评估时常见的三个坑第一不要在同一个case上重复预测。第二预测坐标的单位要统一。我见过有人模型输出的是体素坐标评估时却用物理坐标和标注比结果自然完全对不上。第三确认match radius与官方一致。如果你用的匹配半径比官方宽会得出虚高的假CPM。官方默认用的是标注结节半径。最后说一个经验我在复现时先用官方提供的evaluation script跑一遍确认输出格式、坐标单位都正确再开始做模型改进。否则辛辛苦苦训练几天最后发现评估脚本里坐标轴顺序错了那才是真正的浪费时间。6. 打包输出一个能直接跑的zip目录、依赖和权重当初那个zip被问最多的一个问题就是“我解压之后怎么跑起来”。如果只是丢一堆train.py和test.py不交代环境、数据路径、权重文件别人拿到手大概率还是跑不起来。现在我自己整理这类项目zip会强制包含以下几样东西。6.1 目录结构要清晰一个合理的项目压缩包通常长这样LUNA16_nodule_detection/ ├── README.md ├── requirements.txt ├── config.yaml ├── src/ │ ├── preprocess.py │ ├── generate_candidates.py │ ├── train_candidate_unet.py │ ├── train_classifier.py │ ├── inference.py │ └── evaluate_froc.py ├── weights/ │ ├── candidate_unet.pth │ └── nodule_classifier.pth └── demo/ └── demo_inference.py代码目录和权重目录分开配置中心化README写清楚每一步怎么跑。尤其是README我见过太多项目代码质量不错就是README只写了两行最后只能靠猜。6.2 环境和依赖清单要固定3D医学影像训练依赖的包不算多但版本敏感。我的requirements.txt一般固定Python 3.8、PyTorch 1.10、SimpleITK、numpy、scipy、pandas、h5py、PyYAML。最好在README里写清GPU要求我这套代码默认单卡8GB以上显存即可完成推理训练建议16GB以上。还有一个容易被忽略的地方随机种子。我会把random、numpy、torch的种子都在入口脚本固定下来否则别人复现时结果永远对不上。6.3 提供能直接跑的demo压缩包里最好塞一个demo脚本输入一个CT路径输出检测结果的可视化二维切片或者候选列表CSV。用户不需要先复现训练就能先看到模型效果。不要在没有权重的情况下只给训练代码那样对于大部分使用者来说zip的价值就砍了一半。这是真实经验很多人下载这种zip就是想先跑通、看效果确认有效才会自己训练。所以权重文件和推理demo是打包时优先级最高的条目远远高于训练代码的完整性。最后一个小建议在压缩包说明里明确标注数据集的获取方式和许可要求。LUNA16数据需要从官网申请我不应该把原始数据塞进zip里但一定要在README里给出准确的数据下载链接和目录放置规则否则用户半天找不到数据第一步就卡住。本文还有配套的精品资源点击获取
返回列表