
简介这份资源面向医学影像分析与深度学习入门者聚焦肺部CT图像的肺结节自动检测与良恶性分类帮助读者理解如何用卷积神经网络完成病灶定位与分类任务。压缩包共22个文件约37.27MB以10个Python脚本为核心配合3个sh运行脚本、3个prototxt网络配置、1个ipynb实验笔记及config、md等辅助文件覆盖数据预处理、模型训练与推理流程。内容涉及U-Net、Faster R-CNN、YOLO等检测架构以及ResNet、Inception、DenseNet等分类模型与迁移学习思路并包含环境搭建说明和代码注释便于复现与二次开发。目前已有312人学习下载适合希望系统实践医疗影像深度学习项目、积累检测与分类经验的学习者参考。1. 肺结节检测与分类从CT切片到临床可用模型中间隔着多少坑拿到「基于深度学习的肺结节检测与分类.zip」这个标题很多人第一反应是找数据集、搭CNN、跑个准确率就完事。但真正做过医学影像项目的人知道从DICOM序列到能区分良恶性结节的模型中间至少隔着数据标注、坐标对齐、假阳性抑制三道坎。肺结节检测与分类在临床上属于典型的「小目标类别不平衡三维空间关联」问题LUNA16、LIDC-IDRI这些公开数据集虽然提供了CT影像和标注但原始标注是XML格式的结节坐标和直径不是直接可用的检测框。适合谁看如果你已经会PyTorch基础操作想把这个方向做成毕设、论文或产品原型这篇笔记能帮你省掉至少两周的试错时间。我踩过的坑包括把检测和分类拆成两个独立模型导致推理链路断裂、忽略肺实质分割导致大量胸壁假阳性、用2D切片训练丢失层间信息。下面按「数据准备→检测模型→分类模型→联合推理→避坑」的顺序展开。2. 数据准备从LUNA16原始CT到可训练张量的完整链路2.1 为什么不能直接拿DICOM喂给模型LUNA16数据集包含888个CT扫描每个扫描是一个.mhd文件加.raw文件标注文件annotations.csv里只有结节的世界坐标x, y, z和直径mm。直接读取DICOM或mhd会得到HU值范围在-1000到3000之间的三维数组如果直接归一化到0-1肺实质区域-1000到-500 HU和骨骼区域400 HU的对比度会被压缩模型很难学到结节特征。常见做法是先做肺实质分割把非肺区域置零再对肺内区域做窗宽窗位调整。我一般用-1000到400的窗宽归一化到0-1这样结节-800到100 HU和周围血管的边界更清晰。2.2 用Python读取mhd并生成肺实质掩膜import SimpleITK as sitk import numpy as np from skimage import morphology def load_mhd(mhd_path): 读取mhd文件返回HU值数组和空间信息 image sitk.ReadImage(mhd_path) spacing image.GetSpacing() # (x, y, z)方向像素间距 origin image.GetOrigin() arr sitk.GetArrayFromImage(image) # 形状为(z, y, x) return arr, spacing, origin def lung_mask(hu_arr, threshold-500): 基于阈值和形态学操作生成肺实质掩膜 # 第一步阈值分割肺实质HU值通常低于-500 binary hu_arr threshold # 第二步去除小连通区域气管、噪声 binary morphology.remove_small_objects(binary, min_size1000) # 第三步闭运算填充结节内部空洞 binary morphology.binary_closing(binary, morphology.ball(3)) # 第四步保留最大的两个连通区域左右肺 labeled morphology.label(binary) regions regionprops(labeled) regions sorted(regions, keylambda r: r.area, reverseTrue)[:2] mask np.zeros_like(binary) for r in regions: mask[labeled r.label] 1 return mask逻辑说明load_mhd返回的数组形状是(z, y, x)与常见的(x, y, z)相反后续做3D卷积时要注意维度顺序。lung_mask中阈值-500是经验值对大多数成人CT有效但遇到严重肺气肿患者可能需要调整到-400。闭运算的ball(3)半径对应约3mm能填充小结节内部的空洞但不会过度膨胀。参数说明min_size1000针对512×512×300的典型CT如果层厚较大2mm这个值要相应减小。2.3 从世界坐标到体素坐标的转换标注文件里的坐标是世界坐标mm需要根据origin和spacing转换成体素索引。转换公式voxel_x (world_x - origin[0]) / spacing[0]y和z同理。注意SimpleITK的GetOrigin返回的是(x, y, z)而数组索引是(z, y, x)所以转换后要反转顺序。我见过有人直接拿world坐标当索引用结果结节位置偏移了几十个体素模型完全学不到东西。转换后以结节中心为中心裁剪64×64×64的立方体作为正样本负样本从肺实质内随机采样保证正负比1:3左右。3. 检测模型3D CNN与假阳性抑制的工程取舍3.1 为什么选3D ResNet而不是2D检测器肺结节是三维结构有些结节在单层切片上看起来像血管断面只有连续多层才能区分。2D检测器如YOLO、Faster R-CNN在单层上做检测会引入大量假阳性。我试过用2D YOLOv5在切片上跑召回率能到0.9但每张切片平均产生15个假阳性后续分类模型根本扛不住。3D ResNet直接输入64×64×64的立方体输出是否结节的概率结构简单且能利用层间信息。常见做法是用3D ResNet-18或3D ResNet-34输入通道1最后一层全连接输出2类。训练时用Focal Loss解决正负样本不平衡γ2α0.25。3.2 3D ResNet的PyTorch实现与训练配置import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock3D(nn.Module): def __init__(self, in_planes, planes, stride1): super().__init__() self.conv1 nn.Conv3d(in_planes, planes, kernel_size3, stridestride, padding1, biasFalse) self.bn1 nn.BatchNorm3d(planes) self.conv2 nn.Conv3d(planes, planes, kernel_size3, stride1, padding1, biasFalse) self.bn2 nn.BatchNorm3d(planes) self.shortcut nn.Sequential() if stride ! 1 or in_planes ! planes: self.shortcut nn.Sequential( nn.Conv3d(in_planes, planes, kernel_size1, stridestride, biasFalse), nn.BatchNorm3d(planes) ) def forward(self, x): out F.relu(self.bn1(self.conv1(x))) out self.bn2(self.conv2(out)) out self.shortcut(x) return F.relu(out) class ResNet3D(nn.Module): def __init__(self, block, num_blocks, num_classes2): super().__init__() self.in_planes 64 self.conv1 nn.Conv3d(1, 64, kernel_size7, stride2, padding3, biasFalse) self.bn1 nn.BatchNorm3d(64) self.layer1 self._make_layer(block, 64, num_blocks[0], stride1) self.layer2 self._make_layer(block, 128, num_blocks[1], stride2) self.layer3 self._make_layer(block, 256, num_blocks[2], stride2) self.layer4 self._make_layer(block, 512, num_blocks[3], stride2) self.avgpool nn.AdaptiveAvgPool3d((1,1,1)) self.fc nn.Linear(512, num_classes) def _make_layer(self, block, planes, num_blocks, stride): strides [stride] [1]*(num_blocks-1) layers [] for s in strides: layers.append(block(self.in_planes, planes, s)) self.in_planes planes return nn.Sequential(*layers) def forward(self, x): x F.relu(self.bn1(self.conv1(x))) x self.layer1(x); x self.layer2(x) x self.layer3(x); x self.layer4(x) x self.avgpool(x) x x.view(x.size(0), -1) return self.fc(x) # 训练配置 model ResNet3D(BasicBlock3D, [2,2,2,2], num_classes2).cuda() optimizer torch.optim.Adam(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max50) criterion FocalLoss(alpha0.25, gamma2.0)逻辑说明BasicBlock3D的shortcut在通道数或步长变化时用1×1卷积调整维度。ResNet3D的conv1用7×7×7大核快速下采样减少显存占用。训练时batch_size设为16输入64×64×64显存约8GB。Focal Loss的alpha0.25给正样本更高权重gamma2.0让模型关注难分类样本。学习率用余弦退火初始1e-450个epoch降到接近0。参数说明weight_decay1e-5防止过拟合如果训练集小于500个样本可以加到1e-4。3.3 假阳性抑制用分类模型做二次过滤检测模型输出概率大于0.5的候选结节通常每个CT还有20-50个假阳性。常见做法是训练一个专门的假阳性抑制分类器输入是候选结节的64×64×64立方体输出是结节/非结节。这个分类器可以用更深的网络如3D ResNet-34训练时负样本从检测模型的假阳性中挖掘。我一般会做两轮第一轮用随机负样本训练第二轮用第一轮模型在验证集上的假阳性做hard negative mining。这样能把假阳性从每CT 30个降到3个以下同时保持召回率在0.95以上。4. 分类模型良恶性判别与多任务学习的参数调优4.1 良恶性分类的难点数据不平衡与标注噪声LIDC-IDRI数据集中良性结节远多于恶性比例大约4:1。如果直接训练模型会倾向于预测良性恶性结节的召回率很低。常见做法是用加权交叉熵良性权重1恶性权重4。另外LIDC的标注是4位放射科医生独立标注恶性程度评分1-5分通常取平均分≥3作为恶性。但不同医生的评分一致性只有0.6左右存在标注噪声。我一般会剔除评分标准差大于1的样本或者用软标签训练把4位医生的评分做softmax作为目标分布。4.2 多任务学习同时预测结节类型和恶性概率class MultiTaskNet(nn.Module): def __init__(self, backbone): super().__init__() self.backbone backbone # 去掉原fc层的3D ResNet self.fc_malignancy nn.Linear(512, 2) # 良恶性二分类 self.fc_type nn.Linear(512, 5) # 结节类型实性、部分实性、磨玻璃、钙化、其他 def forward(self, x): feat self.backbone(x) malignancy self.fc_malignancy(feat) nodule_type self.fc_type(feat) return malignancy, nodule_type # 损失函数加权和 def multitask_loss(malignancy_pred, type_pred, malign_label, type_label): loss_malign F.cross_entropy(malignancy_pred, malign_label, weighttorch.tensor([1.0, 4.0]).cuda()) loss_type F.cross_entropy(type_pred, type_label) return loss_malign 0.3 * loss_type逻辑说明共享backbone让两个任务互相正则化恶性分类学到的特征有助于结节类型判断反之亦然。fc_malignancy输出2类fc_type输出5类。损失函数中类型分类的权重0.3是经验值如果类型标注质量高可以调到0.5。参数说明backbone用3D ResNet-18去掉最后的fc层输出512维特征。训练时两个任务的batch交替进行每个batch先更新恶性分类头再更新类型分类头最后联合微调backbone。4.3 分类评估AUC比准确率更可靠医学影像分类中准确率会被类别不平衡误导。比如良性占80%模型全预测良性也有80%准确率但恶性召回率为0。我一般用AUCROC曲线下面积作为主要指标恶性分类的AUC目标在0.90以上。另外敏感度召回率和特异度要分开看临床上更关注高敏感度不漏诊所以阈值通常设在0.3左右而不是0.5。验证时用5折交叉验证按患者ID划分而不是按结节划分避免同一患者的结节同时出现在训练和验证集。5. 联合推理与部署从模型输出到临床可解释结果5.1 检测与分类的串联推理流程完整推理链路输入一个CT扫描mhd文件→ 肺实质分割 → 滑窗裁剪候选区域 → 检测模型筛选 → 假阳性抑制 → 分类模型判断良恶性 → 输出结节位置、直径、恶性概率。滑窗步长设为16个体素窗口64×64×64这样每个结节至少被覆盖一次。检测模型输出概率0.5的窗口用非极大值抑制NMS合并重叠窗口IoU阈值0.1因为结节很小IoU普遍偏低。NMS后每个CT保留最多10个候选送入分类模型。5.2 用ONNX导出模型并做推理加速import torch.onnx # 导出检测模型 dummy_input torch.randn(1, 1, 64, 64, 64).cuda() torch.onnx.export(model, dummy_input, detection_model.onnx, input_names[input], output_names[output], dynamic_axes{input: {0: batch_size}}, opset_version11) # ONNX Runtime推理 import onnxruntime as ort sess ort.InferenceSession(detection_model.onnx, providers[CUDAExecutionProvider]) input_name sess.get_inputs()[0].name output sess.run(None, {input_name: np.random.randn(1,1,64,64,64).astype(np.float32)})逻辑说明ONNX导出时dynamic_axes让batch维度可变方便批量推理。opset_version11兼容大多数ONNX Runtime版本。推理时用CUDAExecutionProvider比PyTorch直接推理快1.5-2倍。参数说明如果部署环境没有GPU可以用CPUExecutionProvider但推理时间从10ms增加到200ms。注意ONNX对3D卷积的支持在opset 11之后才完善低于11会报错。5.3 可解释性用Grad-CAM可视化结节关注区域from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image target_layers [model.layer4[-1]] cam GradCAM(modelmodel, target_layerstarget_layers) grayscale_cam cam(input_tensorinput_tensor) # 形状(1, 64, 64, 64) # 取中间层切片叠加到原始CT上 mid_slice grayscale_cam[0, :, :, :, 32]逻辑说明Grad-CAM对3D输入生成3D热力图取中间层切片叠加到原始CT上可以直观看到模型关注的是结节区域还是周围血管。如果热力图集中在结节边缘而非内部说明模型可能学到了伪影特征。参数说明target_layers选layer4的最后一个block分辨率最低但语义信息最强。如果热力图太粗糙可以换成layer3。6. 避坑与排查肺结节项目里最容易翻车的5个地方6.1 现象训练loss震荡不收敛验证集准确率卡在0.5原因数据归一化不一致。训练时用肺实质掩膜归一化验证时忘了做掩膜导致输入分布不同。解决把归一化逻辑封装成独立函数训练和验证调用同一个函数。检查方法打印训练和验证输入的均值和方差差异超过0.1就是有问题。6.2 现象检测模型在训练集上召回率0.99验证集只有0.6原因数据泄露。同一患者的多个结节被分到了训练集和验证集。解决按患者ID划分数据集用GroupKFold而不是KFold。检查方法统计训练集和验证集的患者ID交集必须为空。6.3 现象分类模型AUC 0.95但临床测试时恶性结节全漏诊原因阈值设错了。验证时用0.5阈值但临床要求高敏感度应该用0.3。解决在验证集上画ROC曲线找到敏感度0.95对应的阈值。另外恶性分类的权重可能不够把恶性权重从4调到8试试。6.4 现象推理时显存溢出batch_size1也报OOM原因滑窗推理时没有释放中间变量。每个窗口的梯度都保留在显存里。解决推理时加torch.no_grad()并且每处理10个窗口调用torch.cuda.empty_cache()。另外把模型设为eval模式关闭BatchNorm的running统计更新。6.5 现象ONNX推理结果和PyTorch不一致概率差0.2以上原因ONNX导出时没有把模型设为eval模式BatchNorm的running_mean和running_var还在更新。解决导出前调用model.eval()并且用torch.onnx.export的trainingtorch.onnx.TrainingMode.EVAL参数。检查方法用同一个输入分别跑PyTorch和ONNX输出差异应小于1e-5。7. 进阶技巧用半监督学习把标注成本砍掉一半标注一个CT的结节需要放射科医生花15-20分钟LUNA16只有888个CT远远不够训练一个泛化性好的模型。我后来用半监督学习先用标注数据训练一个教师模型再用教师模型对未标注CT生成伪标签筛选置信度0.9的伪标签加入训练集。具体做法教师模型在未标注CT上滑窗推理保留概率0.9的窗口作为正样本概率0.1的作为负样本中间概率的丢弃。然后用这些伪标签和原始标注一起训练学生模型。学生模型的结构和教师一样但训练时对伪标签样本用更小的学习率1e-5。一轮迭代后学生模型在验证集上的AUC从0.88提升到0.92而标注成本只增加了10%。注意伪标签的筛选阈值不能太低否则会引入太多噪声也不能太高否则伪标签数量不够。我一般从0.9开始每轮降低0.05直到0.7。另外伪标签样本的损失权重设为0.5让模型更信任真实标注。这个技巧在数据量少于1000个CT时特别有效但超过5000个CT后提升就不明显了。我自己的习惯是每拿到一批新数据先跑教师模型生成伪标签人工抽查10%的伪标签质量如果准确率低于0.85就调高阈值重新生成。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。