基于深度学习的肺结节检测与分类:从CT切片到可用模型的工程实践

发布时间:2026/10/11 19:45:50
基于深度学习的肺结节检测与分类:从CT切片到可用模型的工程实践
简介这份资源面向医学影像分析与深度学习入门者提供肺结节检测与分类的完整项目代码帮助读者理解如何用卷积神经网络在肺部CT图像中定位结节并区分良恶性。压缩包共22个文件约37.27MB以Python脚本为主辅以Shell脚本、Caffe网络配置、Jupyter Notebook、动图与说明文档覆盖数据预处理、模型训练、批量生成与工具函数等环节。项目基于Caffe与TensorFlow双后端包含U-Net、Faster R-CNN、YOLO等检测思路以及ResNet、DenseNet等分类模型的实践参考并配有环境搭建教程与详细代码注释便于复现和二次开发。目前已有312人学习下载适合希望掌握医疗影像分析流程、积累深度学习项目经验的学生与开发者也可为早期肺癌筛查研究提供技术参考。1. 肺结节检测与分类从CT切片到可用模型一条能跑通的工程路径拿到「基于深度学习的肺结节检测与分类.zip」这个标题很多人第一反应是去搜论文、找开源仓库结果被一堆LUNA16、FROC、mAP之类的指标绕晕代码还没跑起来就先放弃了。这个方向真正要解决的问题很具体给定一套胸部CT的DICOM序列先定位出疑似肺结节的候选区域再判断它是良性还是恶性。它适合两类人——一类是想把深度学习落地到医学影像的算法工程师另一类是有标注数据、想把模型跑起来做辅助筛查的研究者。难点不在模型本身而在数据预处理、假阳性抑制和评估指标这三块。下面按「数据怎么准备 → 检测怎么做 → 分类怎么接 → 坑在哪 → 怎么验证」的顺序把这条链路拆开讲清楚。2. 数据准备从DICOM到模型能吃的张量2.1 为什么肺结节数据不能直接丢进网络CT原始数据是DICOM格式每个病人的扫描是一个三维体数据层厚从0.6mm到5mm不等像素值范围也不统一。直接读进来喂给网络模型学到的全是伪影和噪声。常见做法是先把DICOM转成HU值再做肺实质分割最后重采样到统一间距。这一步不做后面检测的召回率会低得离谱。肺结节在CT上的表现差异极大实性结节、磨玻璃结节、部分实性结节直径从3mm到30mm都有。3mm的结节在512×512的切片上可能只占几个像素如果不做重采样和窗宽窗位调整模型根本看不到。我一般会把所有数据重采样到1mm×1mm×1mm的等间距这样结节的物理尺寸和像素尺寸对应关系就固定了。import pydicom import numpy as np from scipy.ndimage import zoom def load_dicom_series(dicom_dir): 读取DICOM序列并转为HU值 slices [pydicom.dcmread(f) for f in sorted(dicom_dir.glob(*.dcm))] # 按InstanceNumber排序保证层序正确 slices.sort(keylambda s: int(s.InstanceNumber)) # 转HU像素值 * RescaleSlope RescaleIntercept volume np.stack([s.pixel_array * s.RescaleSlope s.RescaleIntercept for s in slices]) # 获取原始层厚和像素间距 spacing (float(slices[0].SliceThickness), float(slices[0].PixelSpacing[0]), float(slices[0].PixelSpacing[1])) return volume, spacing def resample_volume(volume, spacing, target(1.0, 1.0, 1.0)): 重采样到目标间距 factors [s / t for s, t in zip(spacing, target)] return zoom(volume, factors, order1) # 三线性插值这段代码的关键在RescaleSlope和RescaleIntercept不同设备的这两个值不同不处理的话HU值全是错的。重采样用order1而不是order3是因为三次插值在肺结节边缘会产生过冲反而让边界模糊。参数target(1.0, 1.0, 1.0)是经验值LUNA16和大多数公开数据集都按这个间距处理方便对齐。2.2 肺实质分割把无关区域先砍掉CT切片里肺结节只占很小一块大部分是胸壁、纵隔、气管。如果不做肺实质分割检测网络会把大量计算浪费在无关区域。常见做法是用阈值法加形态学操作先按HU值小于-400提取肺部区域再用连通域分析去掉气管和背景最后做闭运算填补空洞。from skimage import measure, morphology def segment_lung(volume): 基于HU阈值的肺实质分割 # 肺部HU值通常在-1000到-400之间 binary volume -400 # 去掉与边界相连的连通域背景和气管 labels measure.label(binary) for region in measure.regionprops(labels): if region.bbox[0] 0 or region.bbox[1] 0: binary[labels region.label] False # 闭运算填补血管造成的空洞 binary morphology.binary_closing(binary, morphology.ball(3)) return binary阈值-400是分界线高于这个值的基本是软组织或骨骼。morphology.ball(3)的半径3是经验值太小填不上血管空洞太大容易把胸膜附近的结节吞掉。这一步做完后续检测只在肺实质mask内进行计算量能降一半以上。2.3 数据增强肺结节场景下哪些增强不能用通用图像增强里水平翻转、旋转、缩放都能用但有几类增强在肺结节上会翻车。第一是颜色抖动CT是灰度图调亮度对比度会改变HU值的物理意义。第二是随机裁剪结节可能被裁掉一半标签对不上。第三是弹性形变虽然能增加多样性但会把小结节的形状扭曲成不真实的形态。我一般用这几类随机旋转±15度、随机缩放0.9到1.1倍、随机平移±10像素、随机翻转。增强的强度不能太大因为结节的形态学特征毛刺、分叶是分类的重要依据过度增强会破坏这些特征。3. 检测网络从候选框到假阳性抑制3.1 为什么选3D CNN而不是2D切片检测2D检测把每层切片单独处理问题是结节是三维结构单层切片上可能只看到一个模糊的圆点相邻层的信息完全丢失。3D CNN能同时利用空间上下文对磨玻璃结节尤其重要。但3D网络显存占用大常见做法是用3D Faster R-CNN或者3D U-Net做候选区域提取再用一个小的3D CNN做假阳性抑制。我一般会先用3D U-Net做分割式检测输出每个体素的结节概率再对概率图做连通域分析得到候选框。这样做的好处是不需要预设锚框尺寸对小结节更友好。缺点是后处理复杂需要调连通域的阈值。import torch import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv3d(in_ch, out_ch, 3, padding1), nn.BatchNorm3d(out_ch), nn.ReLU(inplaceTrue), nn.Conv3d(out_ch, out_ch, 3, padding1), nn.BatchNorm3d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet3D(nn.Module): def __init__(self, in_ch1, base16): super().__init__() # 编码器3次下采样 self.enc1 ConvBlock(in_ch, base) self.enc2 ConvBlock(base, base*2) self.enc3 ConvBlock(base*2, base*4) self.pool nn.MaxPool3d(2) # 解码器3次上采样 self.up2 nn.ConvTranspose3d(base*4, base*2, 2, stride2) self.dec2 ConvBlock(base*4, base*2) self.up1 nn.ConvTranspose3d(base*2, base, 2, stride2) self.dec1 ConvBlock(base*2, base) self.out nn.Conv3d(base, 1, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) d2 self.dec2(torch.cat([self.up2(e3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return torch.sigmoid(self.out(d1))base16是显存和精度的折中8G显存能跑batch size 4。如果显存够base32效果更好但训练时间翻倍。输出用sigmoid是因为做的是逐体素二分类配合BCEWithLogitsLoss训练。注意输入patch大小建议64×64×64太小看不到结节全貌太大显存扛不住。3.2 假阳性抑制检测之后必须做的一步3D U-Net输出的概率图里血管交叉、胸膜增厚、炎症都会产生高响应这些就是假阳性。不做抑制的话每个扫描能出几十个候选医生根本看不过来。常见做法是训练一个3D CNN分类器输入是候选框裁剪出的小patch输出是结节/非结节。class FPClassifier(nn.Module): def __init__(self): super().__init__() self.features nn.Sequential( nn.Conv3d(1, 32, 3, padding1), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(32, 64, 3, padding1), nn.ReLU(), nn.MaxPool3d(2), nn.Conv3d(64, 128, 3, padding1), nn.ReLU(), nn.AdaptiveAvgPool3d(1) ) self.fc nn.Linear(128, 2) def forward(self, x): return self.fc(self.features(x).flatten(1))这个分类器的输入patch大小建议32×32×32正负样本比例控制在1:3左右。负样本要从检测高响应但实际不是结节的位置采样这样分类器才能学到真正的区分边界。训练时用Focal Loss比交叉熵好因为难负样本占比高。3.3 检测评估FROC曲线怎么看肺结节检测不用mAP用FROCFree-response ROC。横轴是平均每扫描的假阳性数纵轴是召回率。常见指标是CPMCompetition Performance Metric取7个假阳性点0.125, 0.25, 0.5, 1, 2, 4, 8的召回率平均值。LUNA16上好的模型CPM能到0.9以上但要注意这是在公开数据集上的结果自己数据上通常低10到15个百分点。评估时有个坑结节匹配标准。预测框和真值框的IoU大于0.1就算命中这个阈值很低因为结节本身很小IoU稍微偏一点就掉到0.1以下。我一般会同时看IoU 0.1和0.3两个标准后者更严格能反映定位精度。4. 分类网络良恶性判断的关键参数4.1 分类和检测的区别为什么不能共用一个网络检测网络学的是「哪里有结节」分类网络学的是「这个结节是良性还是恶性」。两者的输入尺度不同检测需要大patch看上下文分类需要小patch看细节。共用一个网络会导致检测召回高但分类准确率低因为网络把容量都花在定位上了。我一般会分开训练检测用64×64×64的patch分类用32×32×32的patch分类网络的输入直接从检测框裁剪不做额外缩放。分类网络可以比检测网络深因为输入小显存压力小。4.2 良恶性分类的3个必调参数第一个是类别权重。良性结节数量通常是恶性的3到5倍不加权的话模型全预测良性也能到80%准确率但恶性召回率极低。常见做法是用WeightedRandomSampler或者给损失函数加pos_weight。from torch.utils.data import WeightedRandomSampler def make_sampler(labels): labels: 0为良性1为恶性 class_counts np.bincount(labels) weights 1.0 / class_counts sample_weights weights[labels] return WeightedRandomSampler(sample_weights, len(labels))weights 1.0 / class_counts让少数类样本被采样的概率更高。如果恶性样本太少还可以用数据增强对恶性样本过采样但要注意增强强度不能太大否则过拟合。第二个是输入patch的边界扩展。结节本身可能只有10mm但恶性结节的毛刺和胸膜牵拉在结节外5mm范围内更明显。我一般会把检测框向外扩5mm再裁剪这样分类网络能看到结节边缘和周围组织的关系。第三个是学习率策略。分类网络比检测网络容易过拟合因为输入小、参数多。用余弦退火加warmup比固定学习率好warmup设5个epoch初始学习率1e-4最低降到1e-6。4.3 分类评估AUC之外还要看什么AUC是常用指标但肺结节分类里AUC高不代表临床可用。因为恶性结节只占少数AUC对类别不平衡不敏感。我一般会同时看敏感度、特异度和F1分数。敏感度要求高因为漏诊恶性结节后果严重特异度可以适当放宽假阳性可以通过随访排除。还有个指标是校准曲线。模型输出的概率是不是可靠的如果模型说80%恶性实际恶性比例是不是80%校准不好的模型在临床决策时会有问题。常见做法是用Platt Scaling或者Isotonic Regression做后校准。5. 避坑与排查肺结节项目里最容易翻车的5个地方5.1 现象训练loss正常下降但验证集召回率极低原因数据泄漏。同一个病人的不同切片被分到了训练集和验证集模型记住了病人特征而不是结节特征。肺结节数据里同一个病人有多层切片如果按切片随机划分几乎必然泄漏。解决按病人ID划分数据集训练集和验证集的病人完全不重叠。如果数据量少用交叉验证但每一折都要保证病人不跨折。5.2 现象检测模型在公开数据集上CPM 0.9自己数据上只有0.6原因域偏移。公开数据集如LUNA16的扫描参数、层厚、设备型号和自己数据不同模型学到的特征不通用。另外自己数据的标注标准可能和公开数据集不一致比如结节最小尺寸定义不同。解决先在自己数据上微调冻结编码器只训练解码器学习率设小一点1e-5。如果数据量够直接从头训练。标注标准要对齐至少保证最小结节尺寸和公开数据集一致。5.3 现象分类网络训练准确率99%但测试AUC只有0.6原因过拟合加类别不平衡。模型记住了训练集的噪声测试时泛化差。另外如果测试集里恶性样本极少AUC波动会很大。解决加正则化Dropout、Weight Decay用早停Early Stopping监控验证集AUC而不是准确率。测试集要保证有足够的恶性样本至少50例以上否则AUC置信区间太宽。5.4 现象推理时显存溢出batch size降到1还是OOM原因3D网络的显存占用和patch大小的三次方成正比。64×64×64的patchbase32的U-Net推理时显存占用可能超过8G。另外如果没加torch.no_grad()推理时还会存梯度。解决推理时用with torch.no_grad():patch大小降到48×48×48或者用滑动窗口加重叠。如果还不行把模型转成ONNX或者TensorRT推理显存能降一半。5.5 现象DICOM读取时HU值异常肺部区域全是高亮原因RescaleSlope和RescaleIntercept没处理或者DICOM文件里这两个值缺失。有些设备用PixelRepresentation标记有无符号没处理的话负HU值会变成大正数。解决读DICOM时先检查RescaleSlope和RescaleIntercept是否存在不存在就默认slope1、intercept0。同时检查PixelRepresentation如果是1有符号像素值要按有符号整数解析。6. 验证与进阶怎么确认模型真的能用6.1 用留出集做一次完整的临床模拟评估训练完模型后不要只看AUC和CPM。找一个完全没参与训练的留出集模拟临床流程模型输出所有候选结节和恶性概率然后按概率排序看前10个候选里有多少是真结节。这个指标叫Precision10比AUC更接近实际使用场景。我一般会做一张表列出每个病例的检测结果和分类结果然后逐例分析漏诊和误诊的原因。漏诊的结节是尺寸太小、位置太偏、还是密度太低误诊的假阳性是血管交叉、胸膜增厚、还是炎症这些分析比调参更有价值。病例编号真结节数检出数恶性真值恶性预测漏诊原因0012211-002101-结节直径3mm低于检测阈值0033301炎性假瘤误判为恶性6.2 模型集成简单但有效的提升手段单个模型的效果有限集成能稳定提升2到3个百分点。常见做法是训练3到5个不同初始化的模型推理时对概率图取平均。如果显存够还可以做多尺度集成同一模型输入不同尺度的patch输出取平均。def ensemble_predict(models, volume): 多模型集成推理 probs [] for model in models: model.eval() with torch.no_grad(): # 滑动窗口推理窗口大小64重叠32 prob sliding_window_inference(volume, model, window_size(64,64,64), overlap0.5) probs.append(prob) return torch.stack(probs).mean(dim0)overlap0.5是经验值重叠太少边界会有拼接痕迹重叠太多推理时间翻倍。集成时要注意所有模型的预处理必须一致否则概率图对不齐。6.3 一个我踩过的坑别在预处理上偷懒早期做这个方向时我觉得预处理不重要直接把DICOM转成numpy就喂给网络。结果模型在训练集上loss降到0.1验证集上完全不收敛。排查了一周才发现不同病人的像素间距不同同一个结节在不同病人身上的像素尺寸差了好几倍网络根本学不到尺度不变的特征。后来老老实实做重采样和HU转换验证集召回率直接从0.3跳到0.75。这件事之后我养成了一个习惯拿到任何医学影像数据先花半天时间做数据探查看像素间距分布、HU值范围、层厚分布把这些统计量画出来。预处理做扎实了后面调模型才有意义。希望帮到你。本文还有配套的精品资源点击获取