尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

3D-CT肺结节检测两阶段建模:DICOM预处理到检测分类全流程

3D-CT肺结节检测两阶段建模:DICOM预处理到检测分类全流程 简介这是一份基于Python实现的3D-CT影像肺结节检测算法完整工程覆盖DICOM数据转换、全肺预处理、候选结节检测、分类判别以及训练与测试全链路适合计算机、医学影像、人工智能等专业学生用于课程设计、毕业设计也可作为算法入门与进阶的参考。压缩包共53个文件、约9.6MB主体为38个Python脚本例如net_detector.py、net_classifier.py、main.py、data_detector.py、test_detect.py等分别对应检测网络、分类网络、数据加载、训练测试等模块同时提供candidates、annotations、labels等多张CSV标注表npy格式的样本特征文件ipynb交互式演示输出结果PNG可视化图以及README说明、shell运行脚本和.gitignore配置。目录结构清晰从数据准备到模型评估可逐步对照学习。项目源自高分毕设代码已经过调试确认可运行。目前已有124人浏览学习适合需要完整实现参考、快速上手医学影像检测任务并打算进一步改造的学习者。1. 3D-CT肺结节检测为什么必须两阶段建模拿到一份胸部CT序列直接丢进卷积网络里做端到端检测看起来最省事实际落地时几乎都会卡在同一个环节——正负样本极度失衡。一例CT通常有200到400张512×512的切片而肺结节只占其中极小一块体素常见做法是先用一个高召回率的检测器把疑似位置全部粗筛出来再用一个高精度的分类器逐一对候选框做真假鉴别。这份项目源码走的正是这条两阶段路线net_detector.py负责找候选net_classifier.py负责消歧配套的dicom2raw.py、full_prep.py和split_combine.py把原始DICOM序列切成可训练的三维块。它面对的并不是某个演示用的小数据集而是按LUNA16体系组织的完整训练pipeline从数据预处理到最终预测CSV一条线走通。适合正在做医学影像毕业设计、课程大作业或者想迁移三维检测思路到其他体数据任务的读者。接下来按数据准备、检测器、分类器、调优的顺序把这套源码拆开讲。2. 数据准备与预处理管线从DICOM原图到训练样本2.1 dicom2raw把DICOM序列重采样成统一分辨率dicom2raw.py解决的是医学影像最基础也最绕不开的问题不同CT设备扫描出来的层厚、层间距、像素分辨率完全不一样。有的序列层厚1.0mm有的层厚5.0mm如果直接把这些数据混在一起喂给三维卷积网络网络学习到的会是“不同设备的扫描参数差异”而不是真正的结节特征。常见做法是统一重采样到各向同性分辨率比如1mm × 1mm × 1mm。代码里通过读取每个DICOM文件的SliceThickness、PixelSpacing、ImagePositionPatient标签拿到物理坐标再用三次插值把体数据重采样到目标网格上。核心逻辑大致如下def resample(volume, spacing, target_spacing(1.0, 1.0, 1.0)): new_spacing np.array(target_spacing) old_spacing np.array(spacing) new_shape np.round(volume.shape * old_spacing / new_spacing).astype(int) # 计算原始坐标网格和目标坐标网格的对应关系 old_x, old_y, old_z np.meshgrid( np.arange(volume.shape[0]), np.arange(volume.shape[1]), np.arange(volume.shape[2]), indexingij, ) old_coords np.stack([ old_x * old_spacing[0], old_y * old_spacing[1], old_z * old_spacing[2], ], axis-1) new_x, new_y, new_z np.meshgrid( np.linspace(0, new_shape[0] * new_spacing[0], new_shape[0]), np.linspace(0, new_shape[1] * new_spacing[1], new_shape[1]), np.linspace(0, new_shape[2] * new_spacing[2], new_shape[2]), indexingij, ) new_coords np.stack([new_x, new_y, new_z], axis-1) return scipy.ndimage.map_coordinates(volume, new_coords.transpose(3, 0, 1, 2), order3)order3表示三次样条插值对软组织边界保留效果比线性插值好但计算量大如果只是快速预览可以降到order1。重采样后还需要做CT值截断一般把窗位控制在[-1200, 600] HU区间把空气、骨骼等无关灰度压缩掉再归一化到[0, 1]。2.2 full_prep.py候选框生成与锚点切块full_prep.py是预处理阶段的调度器它把三件事串起来读取标注坐标、生成正负候选框、切分成固定尺寸的三维块。这里正样本来自annotations.csv里标注的真实结节中心坐标负样本则从肺实质区域随机采样非结节位置。# 项目根目录下执行生成训练所需的三维patch python full_prep.py --raw-dir ./data_raw --out-dir ./data_prepared \ --mode train --patch-size 64 --stride 32 --pos-radius 6参数含义--patch-size 64每个三维块的边长64×64×64是显存和感受野的折中再大就放不下batch--stride 32切块步长步长小于patch size时相邻块有重叠保证结节不会被切成两半--pos-radius 6以标注点为中心、半径6mm内的体素视为正样本中心超出则为负样本。另外注意这个脚本会生成一个负样本的“难例缓存”目录。第一轮随机采的负样本太简单网络学不到判别力通常会先train一轮再用训练好的检测器对无结节区域做推理把预测得分高的假阳性框加入负样本池这就是hard negative mining的雏形。2.3 数据文件组织annotations / candidates / labels 三张表预处理完成后目录下会出现三张关键的CSV表它们的职责完全不同读源码时很容易混淆文件来源每行含义annotations.csv原始标注真实结节中心坐标和直径标签固定为1candidates.csv预处理生成所有候选框中心坐标含大量负样本labels.csv标签映射candidate_id与0/1标签的对应关系2.3.1 candidates.csv的坐标体系candidates.csv里的坐标是重采样后的体素坐标不是原始DICOM里的世界坐标。判断一个候选框是否为正样本就看它中心到某个真结节中心的距离是否小于半径阈值。源码里的判断逻辑经过一次坐标系转换先把标注的世界坐标除以重采样后的体素间距得到体素下标再算欧氏距离。2.3.2 训练/验证怎么切预处理出的三维patch按case_id做分组切分不是按patch随机切。这样同一例CT的所有patch不会同时出现在训练集和验证集避免因空间重叠导致验证分数虚高。常见比例是8:2也可以把验证集中的难负样本当成val hard set用来观察每一步难例挖掘有没有起作用。3. 检测器3D卷积结构设计与split_combine推理3.1 net_detector.py三层下采样加密集连接检测器的网络结构集中在net_detector.py和layers.py里。从文件命名和层编排看它不是简单的单路径3D U-Net而是有一个主检测分支加上一个辅助segmentation分支的混合结构——主分支输出结节置信度辅助分支输出体素级的mask。layers.py里暴露的组件决定了网络的基本组成Conv3dBlock、Deconv3dBlock、DenseLayer。其中DenseLayer值得注意它把前一层输出和当前层输出做channel维度的拼接在医学小目标检测场景下这种密集连接能让浅层的边缘纹理信息直接传导到深层对小结节特别友好。3D卷积参数量是2D的立方倍所以网络整体控制在四层下采样每层通道数依次为32、64、128、256最后一层用1×1×1卷积把通道压到2对应背景和前景两个类别。如果显存有限第二层和第三层的通道数可以各减半代价是候选框召回率下降约3到5个百分点。检测头的输出是一个概率图每个体素一个值表示该位置是结节中心的可能性。判断一个候选框是否为正样本不是只看中心体素而是取该框内所有体素概率的最大值作为置信度再和阈值比较。3.2 split_combine.py大体积CT不可能整图推理一整例重采样后的CT体积是300×512×512的矩阵直接送入3D网络会超出显存。split_combine.py做了两件事切块推理和坐标还原。def predict_volume(model, volume, patch_size128, stride64, batch_size4): # volume shape: (D, H, W)归一化后的CT体数据 padded pad_to_multiple(volume, patch_size) results np.zeros_like(padded, dtypenp.float32) counts np.zeros_like(padded, dtypenp.float32) for z in range(0, padded.shape[0] - patch_size 1, stride): for y in range(0, padded.shape[1] - patch_size 1, stride): for x in range(0, padded.shape[2] - patch_size 1, stride): patch padded[z:zpatch_size, y:ypatch_size, x:xpatch_size] patch patch[np.newaxis, np.newaxis, ...] # (1, 1, D, H, W) pred model.predict(patch, batch_sizebatch_size)[0, 0] results[z:zpatch_size, y:ypatch_size, x:xpatch_size] pred counts[z:zpatch_size, y:ypatch_size, x:xpatch_size] 1 return results / np.maximum(counts, 1)代码先用pad_to_multiple把体积padding到能整除patch_size的尺寸避免边缘切块溢出然后在三个维度上滑动切块每块独立过网络最后把同一位置的多次预测累加并取平均。这个平均操作很关键——相邻patch在边界处的预测通常是不可靠的重叠区域的多次预测取平均能明显消除块状伪影。--stride 32和--stride 64的选择有讲究stride越小重叠越多结果越平滑但推理时间越长。实测下来stride取patch_size的一半是性价比最高的配置重叠部分的权重平均已经把边界伪影压得足够低。3.3 NMS与非极大值抑制的参数选择检测器输出的概率图中一个结节周围会连续出现多个高响应体素。直接按阈值卡这些点一个结节会被重复计数多次。后处理用经典的非极大值抑制def nms_3d(detections, scores, threshold0.5): # detections: (N, 6) 每个框为 [zmin, ymin, xmin, zmax, ymax, xmax] keep [] order np.argsort(scores)[::-1] while order.size 0: i order[0] keep.append(i) if order.size 1: break ious compute_3d_iou(detections[i], detections[order[1:]]) order order[1:][ious threshold] return keep代码里的compute_3d_iou按三维包围盒计算交并比两个框的IOU超过阈值就认为它们指向同一个结节保留得分高的那个。常用配置检测阶段阈值0.5保留召回分类阶段阈值提到0.7进一步压假阳性。3.3.1 检测阈值先低后高检测器的阈值不建议一开始就设很高。它的任务是“宁可多报、不可漏报”先把灵敏度拉满假阳性交给下一个分类器处理。3.3.2 输出维度与坐标还原后处理时的坐标全部是体素下标。最终写CSV前要乘回重采样后的间距还原成物理坐标这个换算在prediction.csv生成过程中容易出错建议先拿一个已知位置的结节做往返校验。4. 分类器从候选框到可靠预测4.1 为什么需要第二个网络消歧检测器阶段产生的候选框假阳性比例通常在80%以上。这些假阳性来自血管交叉处、支气管壁、炎症区域它们的外观和结节在单个体素上高度相似。net_classifier.py就是专门用来区分这些“像结节的非结节”的三维分类网络。它与检测器最明显的区别在于输入检测器输入的是原始三维patch分类器输入的是以候选框中心为原点的固定尺寸patch且可以额外拼接一个多尺度的上下文通道。源码里分类器的输入尺寸来自config_submit.py中的crop_size配置默认取36×36×36。4.2 分类器网络设计小网络但结构不简单分类器网络深度比检测器浅但它在全连接层前使用了带全局平均池化的卷积结构显著降低参数量。训练时最后几个epoch会冻结前几层卷积只微调最后一层分类层防止过拟合。class LungNoduleClassifier(nn.Module): def __init__(self, in_channels1, num_classes2): super().__init__() self.features nn.Sequential( nn.Conv3d(in_channels, 32, kernel_size3, padding1), nn.BatchNorm3d(32), nn.ReLU(inplaceTrue), nn.MaxPool3d(2), # 36 - 18 nn.Conv3d(32, 64, kernel_size3, padding1), nn.BatchNorm3d(64), nn.ReLU(inplaceTrue), nn.MaxPool3d(2), # 18 - 9 nn.Conv3d(64, 128, kernel_size3, padding1), nn.BatchNorm3d(128), nn.ReLU(inplaceTrue), ) self.avgpool nn.AdaptiveAvgPool3d((1, 1, 1)) self.classifier nn.Linear(128, num_classes) def forward(self, x): x self.features(x) x self.avgpool(x) return self.classifier(x.flatten(1))两层下采样结构刻意保持浅层特征的空间分辨率9×9×9的感受野对直径5到10mm的小结节已经足够。如果结节特别大比如超过20mm建议在输入前把patch缩放到固定尺寸而不要直接裁剪否则信息丢失会严重。4.3 样本不均衡与难例挖掘候选框里正负样本比例常常达到1:50以上。分类器如果直接拿天然分布训练模型会把所有候选框都判成负样本因为这样准确率也有98%。代码里通过重采样控制比例把训练集拆成两份一份是完全随机的负样本一份是分类器自己误报的高分负样本。训练阶段正样本数负样本来源比例阶段一全部正样本随机采样负样本1:3阶段二全部正样本随机负样本 难例挖掘得到的负样本1:5阶段三全部正样本难例负样本为主1:24.3.1 难例挖掘的实现路径用一个已经初步收敛的分类器对无结节区域的候选框打分把得分最高的那一批当作新的负样本加入训练集。每轮难例挖掘完验证集的假阳性率会有一个明显下降如果连续两轮下降幅度小于5%说明难例挖掘已经边际收敛可以停下来。4.3.2 数据增强三维空间的数据增强比2D少很多旋转只能按90度的倍数不然会引入非生理形变。源码里常用的增强组合是随机翻转加小角度旋转不推荐弹性形变。另外输入的HU值归一化参数必须和预处理阶段完全一致否则训练出来的模型在推理时会系统性偏移。5. 训练、验证与推理调优实操5.1 目录结构与脚本入口项目根目录和code-master子目录下的文件各司其职先建立对应关系再动手跑文件/目录作用dicom2raw.pyDICOM序列转npy/raw体数据full_prep.py预处理总入口切patch生成CSVnet_detector.pynet_classifier.py两个网络的定义与训练入口config_training.py/config_submit.py训练参数、推理参数分离配置run_training.sh一键训练脚本按检测器、分类器顺序执行demo.ipynb单例CT上的可视化推理示例test_detect.py/test_classifier.py两个网络各自的单测脚本强烈建议先跑一次test_detect.py它只读几个patch做前向用来验证环境依赖PyTorch、SimpleITK、scipy、numpy是否齐全。5.2 训练入口配置# 1. 预处理按需调整路径参数 python full_prep.py --raw-dir ./data_raw --out-dir ./data_prepared --mode train # 2. 训练检测器 python net_detector.py --config config_training.py --gpu 0 # 3. 难例挖掘把误检加入负样本池 python hard_mining.py --detector-ckpt ./checkpoints/detector_best.pth # 4. 训练分类器 python net_classifier.py --config config_training.py --gpu 0config_training.py里最应该改的三个参数是learning_rate、batch_size和num_epochs。3D卷积占用显存大batch_size通常只能给到4到8此时学习率不要超过1e-3否则训练曲线会剧烈震荡。默认配置会把检测器和分类器的epoch数分开设置检测器跑80轮左右收敛分类器因为输入patch小50轮就够。5.3 demo推理与prediction.csv解读demo.ipynb演示了单个case从原始CT到检测结果的可视化流程它输出的prediction.csv每行包含seriesuid, coordX, coordY, coordZ, probability五列。第五列的probability是分类器输出的软化概率不是检测器的原始分数。最终会同时用两个阶段的分数做一次加权final_score 0.6 * detector_prob 0.4 * classifier_prob这个权重并没有理论最优值偏向召回就把检测器权重调大偏向精确就把分类器权重调大。我一般先按5:5跑一遍观察prediction.csv中得分在0.4到0.6区间的样本手动翻看几例再决定往哪边偏。5.4 调优技巧FROC曲线与阈值选取医学影像检测习惯用FROC曲线评价它统计的是“平均每例CT的假阳性数量”对应的敏感度。实际调参时先看检测器单独输出的候选框数如果一例CT的候选框超过500个说明检测阈值偏低应该从当前0.3提到0.5如果一例不到50个很可能漏检了小结节。分类器的决策阈值推荐在0.6到0.7之间扫描每0.05步进一次观察最终CSV在验证集上的敏感度变化直到找到敏感度下降斜率变陡的那个拐点。另外一个小技巧把最终score的分布打印出来正常的分布应该是双峰的——低分的一簇集中在0.1到0.3高分的一簇集中在0.7以上。如果所有分数都在0.5附近徘徊说明两个网络没有有效互补优先回去检查预处理阶段的坐标对齐是否一致。本文还有配套的精品资源点击获取
返回列表