简介:基于Python的3D-CT影像肺结节检测项目,源自个人毕设,答辩评分98分,完整涵盖源码、数据集与项目说明,面向计算机、通信、人工智能、自动化等专业的学生、老师或从业者,可作为期末大作业、课程设计或毕业设计的实战参考,也适合初学者从数据预处理到模型训练逐步进阶。项目按detector、classifier、training、preprocessing等模块分层组织,包含数据转换、训练验证、检测分类等完整流程;代码均已调试运行通过,并提供shell脚本与说明文档,便于复现和理解3D医学影像检测的关键环节。整套资源共64个文件,以38个Python源文件为主,辅以CSV标签/预测文件、NumPy数据、IPython演示笔记本和shell脚本等,压缩包约9.61MB,目录简洁、定位明确。目前已有56人学习浏览,可以为希望快速上手深度学习医学影像项目的开发者提供较高的借鉴与扩展价值,支持在此基础上二次调整实现更多功能。
1. 为什么自己搭3D-CT肺结节检测:从LUNA16榜单到本地复现
拿到这个项目时我第一反应是“又一个LUNA16复现”,但翻完文件列表后改了判断:这其实是一套完整的、能端到端跑通的检测+分类两阶段管线。detector目录下是3D FPN结构的候选结节生成网络,classifier目录下是独立的假阳性消减网络,再加上preprocessing里的dicom2raw.py和full_prep.py,覆盖了从原始CT序列到最终预测csv的全流程。这类项目对正在做医学影像课题、尤其是拿LUNA16做对比实验的学生来说,价值不在“能跑”,而在于它的模块边界清晰:数据预处理、检测、分类、训练四个环节彼此解耦,你可以单独替换其中任何一块而不影响其他部分。源码里保留了valSplit.npy、annotations.csv、candidates.csv,说明数据集侧已经按LUNA16的标准划分做好了,这在很多毕设项目里反而是最容易被忽略的工作。
适合什么人?如果你已经在用PyTorch做3D卷积,但没碰过医学影像的DICOM解析和滑窗推理,这个项目能帮你把“模型怎么接数据”这个环节补上;如果你只是想快速出一版完整流程作为课程设计,它同样够用。接下来按数据、检测、分类、训练四块拆开讲。
2. 预处理细节:DICOM转raw、体素间距归一化与数据对齐
2.1 为什么不能直接把DICOM喂给3D网络
CT影像和自然图像最大的区别在于:DICOM序列里每个切片的像素间距(Pixel Spacing)和层厚(Slice Thickness)是随扫描设备变化的。同一个结节在A设备上可能是10个体素直径,在B设备上可能是15个,如果不做体素间距归一化,网络学到的是“物理尺寸”还是“体素数量”就会变得不可控。这个项目里dicom2raw.py做的就是这件事:把DICOM序列读出来,重采样到统一的体素间距,再转成numpy的raw数组缓存到本地。
预处理管线里比较关键的是full_prep.py,我拆开看之后发现它把流程做成了step1.py和AddSegmentation两个阶段。step1负责DICOM读取、CT值裁剪、重采样;AddSegmentation额外做了肺实质分割掩膜,这一步对检测器的影响非常直接——如果不做掩膜约束,检测器会在胸腔外的空气区域、扫描床等位置产生大量候选框,后面分类器再强也得花不少样本去学“这些不是结节”。
# dicom2raw.py 核心逻辑(简化自项目源码) import dicom import numpy as np def load_scan(path): slices = [dicom.read_file(path + '/' + s) for s in os.listdir(path)] slices.sort(key=lambda x: float(x.ImagePositionPatient[2])) # 按切片位置排序,避免因文件名乱序导致z轴颠倒 return slices def resample(slices, new_spacing=[1.0, 1.0, 1.0]): # 原始spacing从DICOM头读取 spacing = np.array([slices[0].SliceThickness] + list(slices[0].PixelSpacing), dtype=np.float32) new_shape = np.round(np.array(slices[0].pixel_array.shape + (len(slices),)) * spacing / new_spacing) # 用scipy.ndimage.zoom做三线性插值重采样 image = np.zeros(new_shape, dtype=np.int16) for i, s in enumerate(slices): image[:, :, i] = s.pixel_array return zoom(image, new_shape / np.array(image.shape), order=3)这段代码里两个坑值得说明。第一,ImagePositionPatient[2]才是真正的z轴坐标,按文件名排序会在多层扫描重叠或方向不一致时出错;第二,zoom的order=3是三次样条插值,对边缘保持比线性插值好,但CT值本身是离散的HU值,过高的插值阶数反而会在肺实质与胸壁交界处产生过冲,我在自己的实验里改成order=2后分割掩膜质量更稳。
2.2 数据集目录结构与标签对齐方式
项目的数据集侧给了三个核心文件:annotations.csv、candidates.csv、label.csv。annotations.csv是LUNA16的结节标注,包含seriesuid(扫描ID)、coordX/Y/Z(世界坐标)、diameter_mm(直径);candidates.csv是从检测器输出的候选结节列表,每行带一个class字段,0表示非结节、1表示结节,这就是分类器的训练数据来源。
这里有个容易踩的坑:LUNA16的坐标是世界坐标系,单位是毫米,而网络输入是体素空间,单位是voxel。中间必须根据每个case的origin(origin即DICOM头的ImagePositionPatient)和重采样后的spacing做一次刚性变换。项目源码里这段变换是写在data_detector.py的get_ct_patch函数里的,具体的映射关系是:体素坐标 = (世界坐标 - origin) / spacing,三轴分别计算。
# data_detector.py 中候选结节坐标转换(关键片段) def world_to_voxel(coord_world, origin, spacing): # coord_world: [x, y, z] 单位mm # origin: DICOM ImagePositionPatient,单位mm # spacing: 重采样后的体素间距,单位mm/voxel coord_voxel = (np.array(coord_world) - np.array(origin)) / np.array(spacing) return coord_voxel.astype(np.int32)如果跳过这一步直接把毫米坐标当体素坐标用,最直观的症状是:训练时loss能降,但推理时检测结果和标注在CT横断面上对不上位置,敏感度和F1都异常低。我在帮人调这个项目时遇到过三次类似的错,全是坐标变换写反了方向。
2.3 全肺CT的存储策略与显存边界
预处理完的数据如果每个case都是512×512×300左右的float32数组,单个体积就是300MB,LUNA16共888个CT,全量落盘要260GB以上。项目用的是缓存raw数组的方式——prepare.py会把每个case重采样后存成case_X.npy,在训练时按需加载。这是单机实验最务实的做法,比h5py或LMDB少一层抽象,出问题时好排查。
要提一下的是预处理阶段的CT值裁剪范围:项目里是把HU值clip到[-1000, 400]。这个范围的选择原因是肺实质的CT值约在-900到-600之间,软组织在-100到100之间,钙化结节可以到400以上。clip到400会丢掉极高密度的钙化灶,但对绝大多数实性、亚实性结节已经够用;如果你用的是骨窗数据或需要检测钙化节结,建议把上界放宽到1000,代价是网络需要更多容量去适配更宽的数值分布。
3. 候选结节检测器:3D FPN、res18骨干与split_combine滑窗推理
3.1 检测器选型:为什么是FPN而不是单纯3D U-Net
项目的detector部分用的是res18.py+res_pool.py组合出的3D FPN结构,骨干是ResNet-18的3D变体,配合自顶向下的特征金字塔输出多尺度特征图,最后接一个1×1×1卷积把头输出分类和回归。这个选型比直接用3D U-Net更适合肺结节检测的原因在于:LUNA16的结节直径从3mm到30mm跨度很大,FPN天然覆盖多尺度,高层特征负责大结节、低层特征负责小结节(3mm以下),U-Net的skip connection虽然也有多尺度信息,但最后融合是在单尺度上做,抗尺度差异能力反而弱一些。
骨干网络具体的结构在res18.py里是标准ResNet的3D化版本:conv1是7×7×7步长2的卷积,后面接4个stage,每个stage包含若干BasicBlock,通道数分别为64、128、256、512。FPN部分对每个stage的输出做1×1×1卷积降维到256通道,再自顶向下通过3×3×3卷积上采样相加,最后在这4个尺度的融合特征上各接一个检测头。
# net_detector.py 中FPN多尺度特征融合的简化结构 class FPN3D(nn.Module): def __init__(self): super().__init__() self.backbone = resnet18_3d() # 4个stage输出C2-C5 self.toplayer = nn.Conv3d(512, 256, kernel_size=1) # 降维C5 self.lateral = nn.ModuleList([ nn.Conv3d(64, 256, 1), # C2 nn.Conv3d(128, 256, 1), # C3 nn.Conv3d(256, 256, 1), # C4 ]) self.smooth = nn.ModuleList([ nn.Conv3d(256, 256, 3, padding=1) for _ in range(3) ]) def forward(self, x): c2, c3, c4, c5 = self.backbone(x) p5 = self.toplayer(c5) p4 = self.smooth[2](p5 + self.lateral[2](c4)) p3 = self.smooth[1](p4 + self.lateral[1](c3)) p2 = self.smooth[0](p3 + self.lateral[0](c2)) return [p2, p3, p4, p5]FPN怎么生成候选框?每个尺度的特征图上的每个位置都对应原图上的一组预置anchor,项目里设了3组尺度:10mm、20mm、40mm(对应特征图步长),长宽比固定为1(肺结节基本是球形,长宽比变化不大)。回归头的目标是预测anchor和真实结节中心的偏移量,分类头是二分类(有结节/无结节)。这里有个很实际的现象:训练时正负样本比例可能到1:500以上,代码里用的是OHEM(在线难例挖掘)——每张patch只取loss最大的前128个负样本和全部正样本回传梯度,否则模型会快速收敛到“全部预测为负”的退化解。
3.2 split_combine.py的边界重叠策略
医学影像和自然图像检测还有一个显著差异:单张CT体积远超GPU显存能承受的输入尺寸。一个512×512×300的体积直接送进3D ResNet,光激活值就能把24G显存吃完。项目的做法是split_combine.py实现的滑窗推理:把整个CT按固定尺寸切块,每个块之间设置一定比例的重叠,推理完成后把每个块的结果合并回原图坐标。重叠比例为什么重要?因为3D卷积的感受野会跨越块边界,如果完全无重叠,边界处的结节会被切碎,检测置信度明显下降。
项目里的split_combine.py默认是stride = size/2的重叠策略:切块尺寸是64或96(取决于GPU显存),步长取一半。合并方式不是最大值抑制,而是加权平均——重叠区域的得分用距离边界远近做线性加权,越靠近块中心的预测权重越高,因为中心位置的卷积响应最可靠、边界位置的特征被padding污染的概率更大。
# split_combine.py 滑动窗口推理时的重叠合并权重逻辑 def combine_predictions(heatmaps, starts, block_size): # heatmaps: 每个滑窗块输出的结节概率图 # starts: 每个滑窗块的起始坐标 weight = np.ones_like(heatmaps[0]) # 边界区域线性衰减权重 margin = block_size // 8 for axis in range(3): w = np.linspace(0, 1, margin) tmp = np.ones(block_size) tmp[..., :margin] = w tmp[..., -margin:] = w[::-1] weight *= tmp # 累加所有块的概率图和权重图,最后归一化 return accumulated_prob / accumulated_weight这段的margin取块尺寸的1/8,就是说64尺寸的块,边界8个voxel的权重从0线性爬到1。alpha值越大,合并结果越平滑,但也会轻微抹掉小结节的局部高响应。推理阶段另一个决定性能的参数是置信度阈值,项目在test_detect.py里默认设0.3,低于这个值的候选全丢弃,只保留高置信区域再去重。
3.3 检测器训练的样本采样策略
detector训练时,data_detector.py从每个CT中抽取固定大小的块作为训练样本。采样策略很讲究:正样本以结节中心为中心裁块,负样本在非结节区域随机采。如果正样本和负样本数量悬殊,项目在trainval_detector.py里实现的方案是每个batch固定比例,比如size 64的batch里8个正、56个负,保证每轮迭代都能看到结节,同时负样本覆盖不同解剖位置。还有一点容易被忽略:裁块时对CT值做了一个随机偏移——每个样本统一加减一个随机HU值,模拟不同扫描设备间的重建核差异。这个方法比高级的domain adaptation实用得多,代价极小但能显著提高跨设备泛化能力。
4. 假阳性消减分类器:3D CNN怎么把“疑似结节”变“确诊结节”
4.1 两阶段设计的动机与数据构造
检测器的目标是把召回率做上去,宁可多报不可漏报。LUNA16这类数据集上,一个合格的检测器会产出平均每个CT 100到300个候选,但其中真正的结节平均只有1到2个。如果直接拿检测结果当最终结论,假阳性率没法看。所以项目里第二阶段的分类器出现得就有道理:对每个候选位置裁出固定大小的3D patch,用一个独立的3D CNN做二分类,把“像结节但不是结节”的候选(血管交叉、胸膜增厚、炎性假瘤等)过滤掉。
训练数据的构造重点在data_classifier.py里:从candidates.csv中读取所有候选位置,以每个候选为中心裁32×32×32(或48×48×48)的patch,label是0或1。关键问题是类别不平衡且负样本内部差异极大——血管断面、支气管壁、胸膜斑块看起来各不相同,随机采样负样本会让分类器过度关注易分类的简单负样本。项目源码里对负样本做了基于检测器得分(即检测阶段输出的置信度)的分层采样:检测器得分越高的负样本,抽中概率越大。因为这些是“最像结节”的难负样本,分类器把它们学透了,剩下的假阳性就基本是得分很低的,后处理就能滤掉。
4.2 net_classifier_3d.py的网络结构与训练技巧
分类器部分项目给了多个版本——net_classifier.py、net_classifier_3.py、net_classifier_4.py,差异主要在深度和数据流。核心结构是一个4层3D卷积加全连接分类头的小网络,配合BatchNorm3d和ReLU,最后输出一个logit。输入patch尺寸上,分类器比检测器更灵活,可以只处理小patch,因为候选位置已经是检测器给出的“有东西”的区域,不需要大感受野。这里有一个参数取舍:patch越大分类越准,但显存开销是按三次方增长的。32的patch在11G显存上可以跑batch size 64,48的patch就只能跑24左右。实际使用时通常先用32验证整体效果,再根据需要加大到40或48。
训练时的数据增强比我预想的更克制:只有随机翻转(沿三个坐标轴)和±15度的旋转。翻转对医学影像安全,因为左右肺对称;旋转要谨慎,过大的角度对小结节判别其实有害,因为结节在CT里的形态不应该因旋转而改变判断。
# net_classifier_3.py 简化版分类器结构 class NoduleClassifier3D(nn.Module): def __init__(self, in_channels=1, num_classes=2): super().__init__() self.features = nn.Sequential( nn.Conv3d(in_channels, 32, kernel_size=3, padding=1), nn.BatchNorm3d(32), nn.ReLU(inplace=True), nn.MaxPool3d(2), nn.Conv3d(32, 64, kernel_size=3, padding=1), nn.BatchNorm3d(64), nn.ReLU(inplace=True), nn.MaxPool3d(2), nn.Conv3d(64, 128, kernel_size=3, padding=1), nn.BatchNorm3d(128), nn.ReLU(inplace=True), nn.AdaptiveAvgPool3d((2, 2, 2)), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Linear(128 * 8, 64), nn.ReLU(inplace=True), nn.Dropout(0.5), nn.Linear(64, num_classes), )AdaptiveAvgPool3d在这里等价于全局平均池化的一个折中——输出2×2×2而不是1×1×1,保留少量空间信息,让分类器能区分“中心型高密度”和“边缘型高密度”这两种在池化后可能混淆的模式。Dropout加在第一个全连接后,对医学小数据集很关键,没有这个Dropout的版本我试过在训练集上能到99%,验证集上只有91%,典型的过拟合。
4.3 分类器的评估指标与阈值调整
二阶段评估不能只看准确率。准确率在假阳性率降到很低时会失真——如果负样本占95%以上,全预测负都能有95%准确率,显然没意义。项目里test_classifier.py输出的指标是FROC曲线所需的敏感度和FPs/scan(每扫描平均假阳性数),这两个指标才是LUNA16官方评估标准。敏感度的计算公式是:TP / (TP + FN),FPs/scan = FP数量 / 测试集CT数量。
阈值调整的逻辑也很直观:分类器输出的是每个patch的结节概率,默认取0.5作为判别边界。但实际使用中,如果检测器产出的候选很多且你更在意“不漏诊”,可以把阈值降到0.3,代价是假阳性多;如果更在意“减少医生阅读负担”,提到0.7,敏感度会掉。这个trade-off在test_detect.py里是通过一个循环对不同阈值分别计算指标来观察的。
5. 从valSplit.npy到稳定训练:数据划分、断点续训与常见失败模式
5.1 数据划分为什么必须固定
项目自带valSplit.npy,这是LUNA16官方的fold划分文件(10折中某一折的验证集索引),直接用它而不是重新随机划分的意义在于:LUNA16有多个case是同一患者的多次扫描(不同时间点、不同扫描仪),如果随机划分,同一个患者的扫描可能同时出现在训练集和验证集,相当于数据泄漏,评估出的指标虚高。valSplit.npy里的划分已经考虑了患者级别的隔离,我自己做实验时就吃过这个亏——自定义划分后FROC比官方fold高了将近0.05,不是模型变强了,是泄漏了。
训练时run_training.sh把配置和启动命令都写好了,核心参数包括学习率(默认1e-3)、weight decay(1e-4)、batch size、训练epoch数。训练医学影像模型时有个经验:如果数据量只有几百个CT,不要一上来就用cosine annealing,先用固定学习率跑前20个epoch,等loss平稳后再切到step decay,这样前期的loss曲线的震荡会小很多。
# run_training.sh 关键训练参数 python trainval_detector.py \ --lr 1e-3 \ --weight_decay 1e-4 \ --batch_size 16 \ --epochs 50 \ --val_fold 0 \ --snapshot checkpoints/detector_fold0.ckpt--val_fold 0对应valSplit.npy里的第0折;如果跑完整10折交叉验证,需要把0到9各跑一遍,最后把每折模型在各自验证集上的预测合并起来,再统一计算FROC。snapshot参数指定保存路径,代码会在每个epoch结束保存一次,方便断点续训——中断后重新启动时,通过加载.ckpt并把学习率按照已完成的epoch衰减到对应值,可以几乎无损接上。
5.2 三张图定位训练异常
训练时我习惯同时看三张曲线:training loss、validation loss、以及验证集上的检测召回(或分类AUC)。三张图各管一件事:loss下降说明模型在拟合,但val loss上升、train loss下降就是典型的过拟合信号;两个loss都在降但召回不涨,说明正样本的学习不足,多半是采样策略出了问题。
关于参数初始化毛病的排查路径大概是:先是NaN loss,看学习率是不是太大或数据里有异常值(比如重采样后出现inf);再是loss不降,拆解成输入数据是否有结节patch、正负比例是否失衡;最后是检测结果可视化里全是假阳性,看置信度阈值和NMS参数。一个有效的小技巧:在训练前先跑一遍test_detect.py的demo——它加载随机初始化的权重,跑完整个流程输出预测csv,虽然结果完全是噪声,但能验证数据流是否跑通。如果这一步报错,问题一定在数据预处理或模型输入输出尺寸不匹配,不用等训练浪费几个小时才排查。
5.3 训练完怎么验证自己的结果可信
最终验证分两层。第一层是把测试集CT的最终预测结果(test_detect.py输出的prediction.csv)和annotations.csv对比,关注每个结节的检测框中心坐标和真实标注中心的距离是否在半径范围内——LUNA16的匹配规则是预测中心与标注中心的欧氏距离小于结节半径才视为检测成功;第二层是换两个不同的CT扫描(最好是不同医院、不同设备扫描的)做外部验证,看看敏感度的下降幅度。如果内部测试FROC到了0.9,外部验证直接掉到0.6以下,基本可以确定模型学到了特定扫描仪的重建特征而不是结节本身,这时回查预处理、重采样参数和增强策略,优先检查CT值裁剪范围和spacing归一化。
本文还有配套的精品资源,点击获取