基于Python的3D-CT肺结节检测算法复现:从数据到FROC的完整实战

发布时间:2026/10/18 11:30:03
基于Python的3D-CT肺结节检测算法复现:从数据到FROC的完整实战
简介这是一套面向计算机、人工智能、自动化等专业学生与从业者的3D-CT影像肺结节检测项目源码源自个人毕业设计答辩评审分达98分代码经调试测试可稳定运行适合作为期末大作业、课程设计或毕业设计的参考范例也便于基础较好的学习者在此基础上二次开发。资源包共64个文件约9.61MB以38个Python脚本为核心覆盖数据预处理、检测器与分类器网络、训练与测试流程另含csv标注与候选数据、npy切分文件、ipynb演示笔记、png示意图及md说明文档目录按detector、classifier、preprocessing等模块划分结构清晰。目前已有57人学习。读者可借此掌握DICOM转raw、候选结节提取、模型训练与推理的完整链路理解检测与分类两阶段设计思路并参考配置脚本与运行脚本快速复现实验具备较高的学习借鉴价值。1. 从一份3D-CT影像肺结节检测项目说起为什么值得动手复现拿到「基于Python的3D-CT影像肺结节检测算法源码数据集项目说明」这个标题很多人第一反应是去搜现成的检测网络结果翻了一圈发现2D检测跑得挺欢一换到3D-CT就各种翻车。原因不复杂——CT影像是三维体数据肺结节在轴位、冠状位、矢状位上形态差异大直径从3毫米到30毫米都有还常常贴着血管或胸膜2D切片模型看到的只是冰山一角。这个方向真正要解决的是把一整卷CT通常512×512×N喂进去输出每个结节的中心坐标、直径和置信度。适合谁做有一定Python和PyTorch基础、想找一个完整医学影像落地项目的开发者也适合做课程设计或毕设、需要一套能跑通全流程源码和数据集的同学。下面我按自己复现这类项目的顺序把数据、模型、训练、推理和踩坑一条条拆开讲。2. 3D-CT数据怎么读、怎么裁、怎么喂给网络2.1 先搞清楚数据长什么样体素间距与HU值CT数据常见两种格式DICOM序列和打包好的NIfTI.nii/.nii.gz。DICOM是一层一个文件NIfTI是一卷一个文件。不管哪种读进来之后你拿到的是一个三维数组每个体素的值是HUHounsfield Unit。这里有个新手最容易忽略的点体素间距spacing。不同设备的层厚可能从0.5mm到5mm不等同一个结节在层厚5mm的数据里可能只占两三层在0.5mm的数据里占二三十层。如果不做重采样统一间距模型学到的“大小”概念就是乱的。常见做法是把所有数据重采样到统一间距比如1mm×1mm×1mm。用SimpleITK或nibabel都能做。下面是我一般会用的读取和重采样代码import SimpleITK as sitk import numpy as np def load_ct(path): 读取NIfTI格式CT返回图像数组和原始间距 img sitk.ReadImage(path) arr sitk.GetArrayFromImage(img) # 形状为 (z, y, x) spacing img.GetSpacing() # (x, y, z) 顺序 origin img.GetOrigin() return arr, spacing, origin def resample_to_spacing(arr, spacing, target(1.0, 1.0, 1.0)): 把体数据重采样到目标间距使用线性插值 img sitk.GetImageFromArray(arr) img.SetSpacing(spacing) new_size [ int(round(arr.shape[2] * spacing[0] / target[0])), int(round(arr.shape[1] * spacing[1] / target[1])), int(round(arr.shape[0] * spacing[2] / target[2])), ] resampler sitk.ResampleImageFilter() resampler.SetSize(new_size) resampler.SetOutputSpacing(target) resampler.SetInterpolator(sitk.sitkLinear) out resampler.Execute(img) return sitk.GetArrayFromImage(out)逻辑说明load_ct把NIfTI读成numpy数组注意SimpleITK返回的数组轴顺序是(z, y, x)而spacing是(x, y, z)后面做坐标换算时别搞反。resample_to_spacing按目标间距算新尺寸用线性插值重采样。参数上target一般设(1.0, 1.0, 1.0)如果显存吃紧可以放宽到(1.5, 1.5, 1.5)但别超过2mm否则小结节会被抹掉。2.2 肺实质分割把无关区域先砍掉整卷CT里肺只占一部分直接拿全图训练负样本太多模型会学偏。常规流程是先做肺实质分割把肺区域抠出来再在肺内找结节。阈值法是最省事的HU小于-400的基本是空气和肺大于-1000排除体外空气再做连通域保留最大的两个肺叶。from scipy import ndimage def lung_mask(ct_arr, hu_thresh-400): 基于HU阈值的简易肺实质掩膜 mask ct_arr hu_thresh # 保留最大的两个连通域左右肺 labeled, num ndimage.label(mask) if num 0: return mask sizes ndimage.sum(mask, labeled, range(1, num 1)) top2 np.argsort(sizes)[-2:] 1 lung np.isin(labeled, top2) # 填洞去掉肺内血管造成的小孔 lung ndimage.binary_fill_holes(lung) return lung逻辑说明先按HU阈值二值化再用连通域分析保留最大的两个区域左右肺最后填洞。参数hu_thresh默认-400如果数据噪声大可以调到-350。这个掩膜不是最终结果只是用来裁剪候选区域减少后续计算量。2.3 候选结节提取与3D patch裁剪有了肺掩膜接下来在肺内找疑似结节的位置。传统做法是用形态学或阈值找圆形高密度区域作为候选点。每个候选点裁一个固定大小的3D patch比如32×32×32或48×48×48送进分类网络判断是不是结节。def extract_patches(ct_arr, candidates, patch_size(32, 32, 32)): 按候选中心裁3D patch边缘用0填充 patches [] half [p // 2 for p in patch_size] for (z, y, x) in candidates: z0, z1 z - half[0], z half[0] y0, y1 y - half[1], y half[1] x0, x1 x - half[2], x half[2] patch np.zeros(patch_size, dtypenp.float32) # 计算有效区域防止越界 zs, ze max(0, z0), min(ct_arr.shape[0], z1) ys, ye max(0, y0), min(ct_arr.shape[1], y1) xs, xe max(0, x0), min(ct_arr.shape[2], x1) patch[zs-z0:ze-z0, ys-y0:ye-y0, xs-x0:xe-x0] \ ct_arr[zs:ze, ys:ye, xs:xe] patches.append(patch) return np.stack(patches)逻辑说明candidates是候选中心坐标列表patch_size决定感受野。32³适合小结节48³覆盖大结节但显存翻倍。边缘越界用0填充因为肺外本来就是空气HU接近-1000填0在归一化后影响不大。这里要注意坐标顺序必须和数组轴一致否则裁出来的patch是错位的。3. 检测网络怎么选、怎么搭、怎么训3.1 3D检测的两条路线锚框法 vs 无锚框法3D肺结节检测主流分两派。一派是锚框法比如3D RPN在特征图上预设不同尺度的3D锚框回归偏移量另一派是无锚框法比如3D CenterNet直接预测每个体素的中心热力图和尺寸。锚框法对小目标更稳但锚框尺寸要针对结节直径分布调无锚框法结构简单但密集小结节容易合并。我一般会选锚框法做基线因为肺结节尺寸跨度大锚框能显式覆盖。锚框尺寸怎么定先统计训练集里结节直径分布取25%、50%、75%分位数作为三档锚框边长比如8mm、16mm、32mm每档再配2~3个长宽比。这样召回率比较稳。3.2 一个可跑的3D检测网络骨架下面是一个简化的3D检测网络主干用几层3D卷积下采样再接两个分支分类分支输出每个锚框是结节的概率回归分支输出中心偏移和尺寸。import torch import torch.nn as nn class ConvBlock3D(nn.Module): def __init__(self, in_ch, out_ch, stride1): super().__init__() self.conv nn.Conv3d(in_ch, out_ch, 3, stride, 1) self.bn nn.BatchNorm3d(out_ch) self.relu nn.ReLU(inplaceTrue) def forward(self, x): return self.relu(self.bn(self.conv(x))) class Detector3D(nn.Module): def __init__(self, in_ch1, base16, num_anchors9): super().__init__() self.stem nn.Sequential( ConvBlock3D(in_ch, base, stride2), ConvBlock3D(base, base * 2, stride2), ConvBlock3D(base * 2, base * 4, stride2), ConvBlock3D(base * 4, base * 4), ) self.cls_head nn.Conv3d(base * 4, num_anchors, 1) self.reg_head nn.Conv3d(base * 4, num_anchors * 6, 1) def forward(self, x): feat self.stem(x) cls self.cls_head(feat) # (B, A, D, H, W) reg self.reg_head(feat) # (B, A*6, D, H, W) return cls, reg逻辑说明主干三次stride2下采样特征图尺寸变成输入的1/8感受野足够覆盖小结节。cls_head每个锚框输出一个logitreg_head每个锚框输出6个值中心偏移3个尺寸3个。参数base控制通道数16起步显存够可以加到32。num_anchors要和锚框配置一致比如3档尺寸×3个长宽比9。3.3 损失函数与正负样本分配检测训练的核心是正负样本怎么分。锚框和真实结节框的IoU大于0.5算正样本小于0.1算负样本中间忽略。分类用focal loss缓解正负不平衡回归用smooth L1。import torch.nn.functional as F def focal_loss(pred, target, alpha0.25, gamma2.0): 二分类focal losspred为logit p torch.sigmoid(pred) ce F.binary_cross_entropy_with_logits(pred, target, reductionnone) p_t p * target (1 - p) * (1 - target) loss ce * ((1 - p_t) ** gamma) if alpha 0: alpha_t alpha * target (1 - alpha) * (1 - target) loss alpha_t * loss return loss.mean() def smooth_l1(pred, target, beta1.0): diff torch.abs(pred - target) loss torch.where(diff beta, 0.5 * diff ** 2 / beta, diff - 0.5 * beta) return loss.mean()逻辑说明focal_loss里alpha平衡正负样本gamma压低易分样本权重肺结节检测里正样本可能只占千分之一alpha设0.25、gamma设2.0是常见起点。smooth_l1的beta控制二次和线性区切换1.0适合归一化后的坐标。训练时总损失是分类损失加回归损失回归只对正样本算。3.4 训练参数怎么设参数建议值说明输入patch32×32×32小结节够用显存友好batch size8~16看显存3D卷积吃显存初始学习率1e-3Adam跑不动降到3e-4学习率策略Cosine比step更平滑训练轮数50~100看验证集mAP早停正负样本比1:3采样后控制别全用训练时我一般会先冻结主干跑几轮分类头再解冻全网络微调。验证指标用FROCFree-response ROC看平均每卷假阳性1、2、4、8个时的召回率比单纯mAP更贴近临床需求。4. 推理、后处理与评估从热力图到结节列表4.1 滑窗推理与重叠合并整卷CT太大没法一次喂进网络常规做法是滑窗。窗口大小和训练patch一致步长取窗口的1/2或1/3保证重叠。每个窗口输出一批检测框最后用NMS合并。def nms_3d(boxes, scores, iou_thresh0.3): 3D NMSboxes格式为 (z, y, x, d, h, w) order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) if order.size 1: break # 计算当前框和剩余框的IoU z1 np.maximum(boxes[i, 0], boxes[order[1:], 0]) y1 np.maximum(boxes[i, 1], boxes[order[1:], 1]) x1 np.maximum(boxes[i, 2], boxes[order[1:], 2]) z2 np.minimum(boxes[i, 0] boxes[i, 3], boxes[order[1:], 0] boxes[order[1:], 3]) y2 np.minimum(boxes[i, 1] boxes[i, 4], boxes[order[1:], 1] boxes[order[1:], 4]) x2 np.minimum(boxes[i, 2] boxes[i, 5], boxes[order[1:], 2] boxes[order[1:], 5]) inter np.maximum(0, z2 - z1) * np.maximum(0, y2 - y1) * np.maximum(0, x2 - x1) area_i boxes[i, 3] * boxes[i, 4] * boxes[i, 5] area_j boxes[order[1:], 3] * boxes[order[1:], 4] * boxes[order[1:], 5] iou inter / (area_i area_j - inter 1e-6) order order[1:][iou iou_thresh] return keep逻辑说明nms_3d按分数从高到低遍历保留当前最高分框剔除和它IoU超过阈值的框。iou_thresh设0.3比较稳设太高小结节会被误删设太低同一结节会重复报。滑窗步长建议取窗口的1/2重叠太少边缘结节容易漏。4.2 假阳性抑制用肺掩膜和尺寸过滤NMS之后还有一堆假阳性常见来源是血管交叉点和胸膜。两个后处理很有效一是用肺掩膜过滤检测框中心不在肺内的直接丢二是尺寸过滤直径小于3mm或大于30mm的按经验剔除。def filter_detections(boxes, scores, lung_mask, min_d3, max_d30): 按肺掩膜和直径范围过滤检测框 keep [] for i, (z, y, x, d, h, w) in enumerate(boxes): diameter max(d, h, w) if diameter min_d or diameter max_d: continue zi, yi, xi int(round(z)), int(round(y)), int(round(x)) if 0 zi lung_mask.shape[0] and \ 0 yi lung_mask.shape[1] and \ 0 xi lung_mask.shape[2] and lung_mask[zi, yi, xi]: keep.append(i) return keep逻辑说明min_d和max_d按临床经验设3mm以下多数是噪声30mm以上可能是肿块而非结节。肺掩膜判断中心点是否在肺内能砍掉胸膜外的假阳性。这两个过滤组合起来假阳性通常能降三到五成。4.3 评估指标FROC怎么算FROC横轴是平均每卷假阳性个数纵轴是召回率。算的时候把所有检测按分数排序逐个累加每遇到一个真阳性就更新召回同时记录当前假阳性数。最后在1、2、4、8这几个点上插值取平均。def froc_score(detections, ground_truth, fp_points(1, 2, 4, 8)): 简化版FROCdetections为(score, is_tp)列表 detections sorted(detections, keylambda x: -x[0]) total_gt len(ground_truth) recalls, fps [], [] tp, fp 0, 0 for score, is_tp in detections: if is_tp: tp 1 else: fp 1 recalls.append(tp / total_gt) fps.append(fp) # 在每个假阳性点上找最大召回 scores [] for pt in fp_points: r max([recalls[i] for i in range(len(fps)) if fps[i] pt] [0]) scores.append(r) return sum(scores) / len(scores)逻辑说明detections里每个元素是分数和是否真阳性按分数降序排列后累加。fp_points是评估点一般取1、2、4、8。这个简化版假设每卷独立实际评估要按卷分组再平均。FROC比mAP更贴近临床因为医生关心的是每卷少报几个假阳性时能抓到多少真结节。5. 避坑与排查复现时最容易翻车的五个地方5.1 坐标轴顺序搞反patch全错位现象训练loss不降推理结果框全在奇怪位置。原因SimpleITK读出来是(z, y, x)spacing是(x, y, z)标注文件里的坐标可能是(x, y, z)三者混用就错位。解决在代码里统一约定读数据后立刻把spacing转成(z, y, x)顺序标注也统一转写个注释标清楚。5.2 HU值没归一化网络学不动现象输入CT原始HU范围-1000到3000网络第一层就饱和。原因没做窗宽窗位或归一化。解决肺结节常用肺窗窗位-600、窗宽1500截断后归一化到[0,1]或[-1,1]。别直接除以3000那样肺内对比度全丢了。5.3 正负样本极度不平衡模型全预测负现象训练几轮后分类输出全是负召回为0。原因肺内候选点里结节占比可能不到0.1%。解决用focal loss训练时对负样本采样正负比控制在1:3左右。另外锚框匹配时IoU阈值别设太高0.5不行就降到0.4。5.4 数据增强用错结节被切掉现象增强后训练集召回高验证集崩。原因3D旋转或缩放时把小结节转出patch边界了。解决增强以中心为原点做小角度旋转±15度和小比例缩放0.9~1.1别用大平移。翻转可以用但左右翻转要注意左右肺对称性一般没问题。5.5 推理滑窗步长太大边缘结节漏检现象小结节在验证集上时有时无。原因滑窗步长等于窗口大小没有重叠结节正好落在窗口边缘。解决步长取窗口的1/2或1/3保证重叠。重叠多了NMS压力大但召回更稳。我一般用1/2步长NMS阈值0.3。6. 把FROC再往上推一档两个我常用的进阶技巧第一个技巧是多尺度测试。训练时用32³的patch推理时分别用24³、32³、48³三个尺度跑一遍把检测结果合并再做NMS。小结节在24³尺度下响应更强大结节在48³下更完整。代价是推理时间翻三倍但FROC在假阳性4个的点上通常能涨2~4个点。实现上就是把滑窗和推理包一层循环每个尺度单独NMS后再合并。第二个技巧是模型集成。训三到五个不同初始化的模型推理时把它们的分类分数平均回归框取加权平均。别小看这个单个模型可能在某几卷上漏检集成后互补。权重按验证集FROC分配表现好的模型权重大。集成后NMS阈值可以稍微调高到0.35因为平均后框更准。验证方法上我习惯留一个独立的测试集不参与任何调参。训练集、验证集、测试集按7:1:2分且按病人分别让同一个病人的不同卷跨集。评估时除了FROC还看每卷的检测时间临床场景下超过30秒一卷就偏慢。最后说个血泪经验别在验证集上反复调后处理阈值调多了就是过拟合测试集一跑就现原形。我一般只在验证集上定一次参数之后不再动。希望帮到你。本文还有配套的精品资源点击获取