ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

3D-CT肺结节检测实战:两阶段网络与DICOM数据预处理全解析

3D-CT肺结节检测实战:两阶段网络与DICOM数据预处理全解析 简介基于Python的3D-CT影像肺结节检测算法项目主要面向计算机、人工智能、医学影像等方向的高校学生、毕业设计者及入门工程师可支撑课程设计、期末大作业或算法研究。代码经调试可直接运行覆盖数据预处理、候选区域检测、结节分类与测试等完整流程配套轻量数据集、标注文件与项目说明便于对照原理复现实验。资源包共53个文件以38个Python脚本为核心涵盖数据加载、网络结构、训练配置与检测推理等模块同时提供CSV标注、NumPy数据、示例图片、Jupyter Notebook演示及README文档总大小约9.6MB目录结构清晰便于分模块对照学习。目前已有124人学习下载对初学者而言是一条可运行的主线既可快速搭建3D检测基线也能通过调整参数或替换数据集深入理解算法细节对有毕设或课设需求者则提供了从数据处理到模型评估的完整代码骨架可作为改造和扩展的基础。1. 3D-CT影像肺结节检测先对齐数据姿势再谈网络拿一套胸部CT去检测几毫米的肺结节最磨人的不是网络结构而是数据姿势几百张DICOM切片堆成一个三维体积先得把体素间距对齐、把CT值转到合适的窗宽窗口模型才知道自己看到的是肺实质还是骨骼。这套基于Python的3D-CT影像肺结节检测算法包把检测网络、分类网络、预处理脚本、训练与推理代码、划线标注CSV和演示notebook全部打包在一起。适合做毕业设计、课程大作业以及想从二维深度学习切到三维医学影像的从业者。项目组织方式是两阶段管线先粗检候选、再精分类去假阳性复现路径清楚。2. 项目解剖从DICOM到训练样本先理清三条处理主线这套项目的目录结构第一眼看上去有点散既有main.py、master又有EG.png、EG1.png这种图片文件。但它本质上是一条完整的医学影像数据处理流水线。我不建议你先钻进某个.py文件逐行读而是先看文件分工把数据从原始DICOM到最终预测结果这一段路径走通。路径一旦清晰后面改网络、换数据集、调参数都不会抓瞎。2.1 模块划分preprocessing、detector、classifier 三条主线到底谁管谁从资源里的文件清单可以明显看到三组不同职责的代码。第一组是预处理dicom2raw.py、step1.py、full_prep.py它们负责把DICOM原始序列转成模型能吃的三维体数据labels_extract.py和prepare.py负责把CSV里的结节坐标转换成训练标签和样本块。第二组是网络主体net_detector.py、net_classifier.py、layers.py一个做候选检测一个做假阳性分类。第三组是训练与推理config_training.py、run_training.sh、test_detect.py、test_classifier.py、config_submit.py负责把模型跑起来并输出预测结果。文件定位输入 → 输出dicom2raw.pyDICOM解析原始dicom序列 → 三维体数据raw/npystep1.py数据初处理raw体数据 → 重采样/裁剪后的体数据full_prep.py完整预处理管道体数据 → 可直接送入网络的体积块labels_extract.py标签生成annotations.csv → 体素坐标标签prepare.py训练样本制作candidates.csv 体数据 → 候选块样本net_detector.py检测网络三维patch → 结节候选概率图net_classifier.py分类网络候选块 → 真结节/假阳性概率split_combine.py推理工具整图切块推理 → 拼接回全图概率图config_training.py训练配置训练参数集中管理config_submit.py推理配置模型路径、阈值、输出文件参数test_detect.py检测推理体数据 → 候选检测结果test_classifier.py分类推理候选 → 去掉假阳性的最终结果demo.ipynb演示可视化整个流程和检测结果为什么这个项目要拆成 detector 和 classifier 两个网络而不是直接拿一个大网络一次到位原因是真实CT里结节太小一个512×512、两三百层的3D影像里结节可能只占几十个像素块单阶段网络很容易被血管、支气管壁这些高密度结构干扰。常见做法是先让detector粗筛一遍确保所有可疑的地方都被捞出来哪怕假阳性多也没关系再用classifier逐个判断这些候选是不是真的结节。医学影像里的检测任务尤其是结节检测两阶段方案在显存占用和最终精度上都比硬上一体化网络好控制。2.2 dicom2raw 与 step1CT序列如何变成模型认识的体积数据CT检查不是一张图而是一个三维体积一个序列通常包含几十到几百张DICOM切片。dicom2raw.py干的事情就是把这个序列读进来按物理空间顺序排列输出成模型容易读取的三维数组。这里最关键的一步是切片排序。无数人踩过这个坑直接用文件名排序。有些厂商的DICOM文件名带序号看着顺序对但实际扫描时切片顺序和文件名并不一致。正确的做法是读DICOM头里的ImagePositionPatient按Z轴坐标排序# dicom2raw.py 核心逻辑简化 import pydicom from glob import glob import numpy as np def dicom_series_to_volume(dicom_dir): slices [pydicom.dcmread(f) for f in glob(f{dicom_dir}/*.dcm)] # 关键按病人坐标系Z轴排序而不是按文件名 slices.sort(keylambda s: float(s.ImagePositionPatient[2])) volume np.stack([s.pixel_array for s in slices], axis-1) return volume, slices这段代码里ImagePositionPatient[2]表示该切片在病人坐标系里的Z轴位置。不同扫描设备、不同扫描方向这个值是递增还是递减不一定所以只做一次sort不够还要确认你的顺序是从头到脚。np.stack把所有切片堆成一个三维数组形状是(height, width, num_slices)。排序之后还有一个隐藏问题pixel_array读出来通常不是真正的CT值。DICOM头里有RescaleSlope和RescaleIntercept两个字段真实CT值要用pixel * slope intercept换算。常见的值是 slope1、intercept-1024但不同设备不一样不能写死。转出来之后还要做窗位处理肺结节检测一般把CT值clip到[-1000, 400]这个肺窗范围内再归一化。这一步漏了整个网络看到的特征分布就是错的后面所有训练和推理都是在错误数据上消耗时间。step1.py在这个基础上做进一步处理重点是重采样。不同CT扫描的层厚和像素间距差别可能很大有0.5mm的也有1mm的。不统一的话同一个结节在不同扫描里占的体素数量完全不同网络等于在多个尺度之间来回横跳。常规做法是统一重采样到1mm各向同性分辨率让每个体素都代表1立方毫米。2.3 full_prep 与 labels_extract体积数据和划线标签怎么对齐full_prep.py把上面这些预处理串成一条完整管道输出的是能直接喂给网络的体积数据和配套信息。这里有一个医学影像任务特有的问题体积数据坐标系和标注坐标系要对齐。标注里的coordX/coordY/coordZ是毫米单位的世界坐标而模型操作的是体素坐标两者差了 origin 和 spacing 两个参数。labels_extract.py做的就是坐标转换。项目里的annotations.csv是公开数据集标准格式每一行包含seriesuid、coordX、coordY、coordZ、diameter_mm。要做的事情很直接找到对应序列的 origin 和 spacing把世界坐标减掉 origin 再除以 spacing得到体素坐标# labels_extract.py 坐标转换核心简化 import pandas as pd import numpy as np def world_to_voxel(world_coord, origin, spacing): # world_coord: [x, y, z] mm # origin: 体积原点坐标 mm # spacing: 每个体素代表的物理尺寸 mm voxel (world_coord - origin) / spacing return np.round(voxel).astype(int) annot pd.read_csv(annotations.csv) for _, row in annot.iterrows(): voxel_center world_to_voxel( np.array([row.coordX, row.coordY, row.coordZ]), origin, spacing ) # 结节半径 diameter_mm / 2 / spacing 对应体素数这段代码的注释里点明了一个关键spacing必须是和当前体积数据完全对应。如果origin和spacing从别的序列或别的预处理步骤里读出来坐标就会整体偏移几个体素。这种偏移很难靠肉眼发现因为训练的时候loss照样降但最终检测框总是偏的。我见过的多数调参失败案例最后查出来都是这里坐标系悄悄出了问题属于成本最高的隐蔽错误。labels.csv 和 candidates.csv 的分工也在这里梳理清楚。annotations.csv 是金标准告诉你哪些地方是真结节。candidates.csv 是候选池包含大量真假混杂的候选点其中假阳性数量远大于真结节。classifier 的训练样本就是从 candidates.csv 里按坐标裁剪出来的三维patchlabel 用和 annotations 匹配的结果。prepare.py 负责把这个过程自动化同时做好正负样本数量平衡不然分类器会全部预测成负样本。3. 核心代码解读检测网络、分类网络与切块推理的配合逻辑理解了数据流水线再来看网络代码。这个项目的核心是用两套三维卷积网络做接力第一棒是检测器负责高召回率地找候选第二棒是分类器负责精确剔除假阳性。这两段代码要配合着读单纯看任何一个文件都看不出整体设计意图。3.1 net_detector 与 layers三维卷积块和候选生成怎么搭先看layers.py它定义的是基础卷积块。因为输入是三维数据这里全部使用Conv3d而不是二维卷积。一个常见的卷积块是「3D卷积 批归一化 ReLU 随机丢弃」的组合# layers.py 风格3D卷积基础块简化示意 import torch.nn as nn class Conv3dBlock(nn.Module): def __init__(self, in_ch, out_ch, drop_rate0.2): super().__init__() self.block nn.Sequential( nn.Conv3d(in_ch, out_ch, kernel_size3, padding1), nn.BatchNorm3d(out_ch), nn.ReLU(inplaceTrue), nn.Dropout3d(drop_rate) ) def forward(self, x): return self.block(x)这里kernel_size3, padding1保证卷积不改变空间尺寸BatchNorm3d对三维数据做逐通道归一化让中间特征分布稳定Dropout3d按通道随机丢弃特征图防止网络对某些通道形成依赖。drop_rate 一般取0.1到0.3之间训练时开着推理时PyTorch会自动关闭。net_detector.py的主体是编码-解码结构下采样四到五次每次空间尺寸减半、通道数翻倍在最深层输出每个体素属于结节的概率。这一步本质上是在做前景分割把可能的结节区域从肺实质里分离出来。检测器的输出是一张概率热力图大于阈值的连通区域就构成候选结节再结合非极大值抑制去掉重复框。第一阶段的阈值不要设太高目标是把所有可疑点都捞出来把精度问题留给分类器解决。3.2 net_classifier小网络专门做假阳性约减检测器输出的候选里有大量血管截面、支气管壁、炎症灶它们形态上和结节相似但并不是结节。分类器的作用就是对每个候选做二分类。它输入的是以候选点为中心裁出的三维小块一般是32到48的立方块输出真结节和假阳性的概率。# net_classifier.py 简化示意3D小分类网络 import torch.nn as nn class NoduleClassifier3D(nn.Module): def __init__(self, in_ch1, n_class2): super().__init__() self.features nn.Sequential( nn.Conv3d(in_ch, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool3d(2), nn.Conv3d(32, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool3d(1) ) self.classifier nn.Linear(64, n_class) def forward(self, x): x self.features(x) x x.view(x.size(0), -1) return self.classifier(x)这个网络的参数量比检测器小一两个数量级推理速度快。AdaptiveAvgPool3d(1)把任意尺寸的特征图压成一个点这样做的好处是即使候选块大小不一致也能训练。实际上classifier的输入尺寸会在训练配置里固定所以这个设计更多是保留灵活性。使用分类器时有个经验训练分类器的正样本是标注结节中心裁块负样本从candidates.csv里拿大量假阳性候选比例一般控制在1:5到1:10。正负样本都从第一阶段的检测结果里采而不是从原始数据里采这样分类器看到的负样本分布和推理阶段真正遇到的分布接近效果比随机采背景块好得多。3.3 config_training 与 run_training三维训练资源消耗怎么控制三维卷积对显存是巨大考验。一个96×96×96的patchbatch size开4用常见的GTX 1080Ti或RTX 3060级别显卡就有溢出风险。config_training.py把这些参数集中管理便于调试# config_training.py 常见训练配置示意 TRAIN { gpu: 0, epochs: 100, batch_size: 4, base_lr: 1e-3, weight_decay: 1e-4, patch_size: [96, 96, 96], n_fold: 5, num_workers: 0, save_dir: ./checkpoints }patch_size决定了网络能看到的上下文范围。96mm的物理范围对肺结节足够了再往大里切显存成倍涨收益却很小。batch_size是显存溢出的第一个调节旋钮从4降到2能解决大部分OOM问题。n_fold为5说明项目默认用五折交叉验证这也是肺结节检测公开数据集的标准评估方式五折轮流训练和验证最终把所有折的预测合并起来算指标。run_training.sh把训练命令串成脚本方便批量执行#!/bin/bash # run_training.sh 典型执行顺序示意 export CUDA_VISIBLE_DEVICES0 python training/train_detector.py --config config_training.py --fold 0 python training/train_detector.py --config config_training.py --fold 1 python training/train_detector.py --config config_training.py --fold 2Linux下用sh run_training.sh跑Windows下就把export CUDA_VISIBLE_DEVICES0去掉代码里直接指定gpu序号。注意先训练detector拿到稳定候选之后再训练classifier不要并行训练两个网络因为classifier的训练数据依赖detector的输出分布。如果detector还在变classifier等于在追一个到处跑的靶子。3.4 split_combine一张大CT怎么切块推理再拼回实际推断的时候整个CT体积有512×512×300直接送进网络必然爆显存。split_combine.py用滑动窗口方式解决把大体积切成有重叠的小块逐块推理再把概率图拼回全图。重叠区域的概率做平均避免因边界信息缺失产生条带伪影。# split_combine.py 滑动窗口推理简化示意 import numpy as np def sliding_window_infer(volume, model, window96, stride48): D, H, W volume.shape prob np.zeros((D, H, W), dtypenp.float32) count np.zeros((D, H, W), dtypenp.float32) for z in range(0, D, stride): for y in range(0, H, stride): for x in range(0, W, stride): patch volume[z:zwindow, y:ywindow, x:xwindow] # 边界不足window时做padding推理后裁掉 pred model.predict(patch) prob[z:zwindow, y:ywindow, x:xwindow] pred count[z:zwindow, y:ywindow, x:xwindow] 1 return prob / np.maximum(count, 1)stride48且window96表示步长是窗口的一半重叠率50%。重叠区域越多拼接处越平滑但也更耗时。把stride提高到72推理速度能快不少精度略微下降。这个参数在不同机器上要根据显存和耗时权衡我一般先用50%重叠跑通流程确认结果没问题再逐步调大stride。count数组是保命设计边缘体素可能只被覆盖一次没有这个归一化概率图边缘会偏暗。4. 避坑排查跑CT肺结节检测的血泪记录与五个高频问题两阶段三维检测项目代码能跑通只是第一步跑出来的结果对不对才是关键。以下五个问题是我在实际运行这类项目时遇到最多、也最隐蔽的坑按现象到原因再到解决的顺序逐一说明。4.1 不做spacing重采样模型在多个尺度间横跳现象训练loss能正常下降但推理时同一个结节在A扫描里被检出在B扫描里漏检。可视化检测结果发现有些结节明显被放大或缩小了。原因不同CT扫描的层厚和像素间距差异很大0.5mm和1.0mm体素间距下6mm的结节分别占12个体素和6个体素。网络用固定patch_size训练相当于在训练集上看到了多种物理尺度特征分布被拉散。模型学到的是体素直径不是物理直径。解决预处理阶段统一做各向同性重采样到1mm。重采样之后spacing参数要保留并同步更新到标签转换里。这一步在step1.py里完成但要注意重采样顺序先重采样体积再用新的spacing计算标签坐标顺序反了坐标就全偏了。4.2 CT值没转HU且没裁剪窗位模型对血管和肺壁响应异常现象检测器的概率图对血管壁和骨骼响应非常高结节区域反而淹没在高亮里候选框大量落在支气管分叉处。原因DICOM的pixel_array是原始像素值不是CT值。没做pixel * slope intercept转换也没做窗位裁剪。极端值如金属伪影、骨骼边缘会把后续归一化彻底带偏整张图对比度异常。解决先转HU再clip到肺窗范围[-1000, 400]最后做归一化。clip区间可以微调但不要贪心把范围放太宽范围越大软组织对比度越低结节特征越不明显。这个顺序不能换先归一化再clip会让大于400的值先被压缩等于裁剪效果失效。4.3 显存溢出先降batch还是先降patch顺序很有讲究现象训练开始没多久就报CUDA out of memory或者一张CT推理要十几分钟。原因三维卷积中间特征图占显存极大96×96×96的patch配合batch size 4显存消耗并不线性。有些同学一看到OOM就把batch size降到1仍然溢出是因为patch本身才是显存消耗大头。解决先把patch_size从96降到64这是立竿见影的做法再考虑batch size从4降到2。我自己习惯优先保持patch尺寸不缩水因为上下文信息对结节检测太重要。推理阶段则调整split_combine里的stride从50%重叠降到25%速度提升一倍还多检测精度损失通常可以接受。4.4 世界坐标转体素坐标时origin和spacing串了序列现象训练集里正样本patch中心总是偏一点结节快要接近patch边缘分类精度卡在某个值上不去。原因annotations.csv里的坐标是世界坐标系下的毫米值转体素坐标要除以当前这个序列的spacing。当一批数据同时预处理时很容易从错误的样本里读取origin和spacing导致坐标偏移几个到十几个体素。标签错了网络照样能训练就是学不到干净特征。解决做一次坐标对齐自检。随便挑三个样本把标注中心换算成体素坐标把原始CT的对应层切片可视化判断结节是否在切片中心。项目里的EG.png和EG1.png就是这个用途自己复现时务必也跑一遍这个检查这是性价比最高的一步。4.5 预测结果格式与评估指标对不上有结果没分数现象test_detect.py跑完生成 prediction.csv拿去算指标要么为空要么分数低得离谱要么直接报字段不匹配。原因评估脚本通常要求世界坐标mm单位和特定列名而默认输出的是体素坐标或者列名大小写不一致。seriesuid的命名规则如果和原始CT对不上评估时会被完全忽略。解决读config_submit.py里定义的输出字段先把单个样本的预测坐标转回世界坐标和annotations.csv的金标准对比一下量级。坐标在几百到上千范围内是合理的如果输出是个位数到几十那肯定还是体素坐标没转换。我踩过一次这个坑之后习惯在提交前先做一个小脚本把预测点映射回原始CT层上可视化确认。5. 快速复现与验证用demo.ipynb跑通全流程并做一次坐标自检5.1 三条跑通路径按你的需求选一条第一只想看效果跑demo.ipynb。它会加载模型权重对示例CT做检测并把结果画出来适合先确认代码在这个环境下能正常运转。第二想完整复现训练流程执行dicom2raw.py → step1.py → full_prep.py → prepare.py → run_training.sh这条链路最后用test_detect.py和test_classifier.py出结果。第三想换成自己的数据保持annotations.csv和candidates.csv的格式把seriesuid替换成自己的CT序列名重新跑一遍预处理和标签生成后面的训练代码不用大改。完整复现时典型执行顺序如下# 一键复现顺序Windows逐条执行Linux用sh串联 python dicom2raw.py --input ./dicom --output ./raw python step1.py --data ./raw --out ./resampled python full_prep.py --data ./resampled --out ./processed python prepare.py --data ./processed --labels ./labels.csv bash run_training.sh python test_detect.py --config config_submit.py每一步的输入输出都是上一步的产物不能跳步。常见卡点有两个一是路径参数对不上二是依赖库版本问题。遇到ModuleNotFoundError时先看缺的是不是pydicom、SimpleITK、torch这类医学影像和深度学习基础库确认Python解释器和当前环境一致再谈其他。5.2 验证模型的一个硬习惯只看loss曲线的模型不值得信任我跑医学影像模型有一条强制习惯不管训练指标多漂亮必须拿一张真实样本的预测概率图叠加在原始CT切片上看。重点看检测框是不是落在软组织密度区域还是密集出现在气管分叉、血管截面这些位置。如果假阳性密集出现在气道壁说明分类器没学到结节和管壁的区别光调阈值治标不治本。坐标对齐自检也建议每次换数据集时都跑一遍。可视化三五个样本的标注点和预测点确认都在结节中心附近再开始调参。从那以后我每换一个数据集都会先花半小时做这两项检查发现过多次训练正常但实际结果不可用的问题省下的调参时间远超检查成本。希望这个习惯也能帮到你祝你一次跑通。本文还有配套的精品资源点击获取
返回列表