简介:面向医学图像处理与深度学习开发者,这份资源提供了一套完整的肺结节检测项目,聚焦于利用卷积神经网络对肺部CT图像进行自动识别与定位。压缩包共26个文件,约5.7MB,以11个Python脚本和4个Jupyter Notebook为主,另含8张效果图、1份说明文档及配置文件;脚本覆盖数据生成、预处理、模型构建(U-Net、Inception、VGG、ResNet)和可视化,Notebook对应各网络的训练流程。预处理部分包含灰度化、归一化、降噪等操作,训练阶段采用交叉熵损失与Adam/SGD优化器,并支持数据增强,评估环节给出ROC曲线、AUC和IOU指标。项目已有230人学习,读者可据此理解CT图像处理与分割模型实现细节,快速搭建实验并复现结果,对早期肺癌辅助诊断研究具有实用参考价值。
1. 为什么“基于医学图像处理的肺结节检测”值得自己动手复现
看到这个 zip 包名,第一反应是它大概率不是一套能直接拿去发论文的完整系统,而是某个课程设计、开源项目或竞赛 Baseline 的打包。里面通常放着预处理脚本、模型权重或训练代码、少量样本数据,以及一份写得不怎么仔细的 README。但恰恰是这类项目,最适合用来把“医学图像处理”到“肺结节检测”这条链路上的所有环节一次性跑通。
肺结节检测之所以是医学影像 AI 里最常被拿来练手的题目,是因为它的任务边界非常清晰:输入是胸部 CT 断层序列,输出是每个可疑结节的坐标、直径和良恶性概率。它同时牵涉 DICOM 解析、窗宽窗位调整、HU 值归一化、2D/3D 卷积网络、锚框或候选框生成、假阳性消减等一系列技术点。一个 zip 包如果能把这套流程完整走通,基本就等于把医学图像处理里最核心的工程能力过了一遍。
这篇博文不假设你已经拿到了那份 zip 的源码,而是站在“如果我自己要复现一个同名项目”的角度,把数据怎么处理、模型怎么搭、参数怎么调、踩坑怎么避讲清楚。5 年以上经验的人可以直接跳读第 3 章和第 5 章,新手建议按顺序跟下来。
2. 肺结节检测的数据面:从 DICOM 到可训练的张量
2.1 为什么输入必须是 CT 断层序列而不是单张 X 光片
肺结节检测的常规输入是胸部 CT 的薄层扫描序列,层厚通常在 1mm 到 2.5mm 之间。CT 图像的本质是人体组织对 X 射线的衰减系数映射,存储时用的是 Hounsfield Unit(HU)标度,空气约 -1000,水为 0,骨骼通常在 400 以上。肺结节在 CT 上的典型表现是肺窗下可见的软组织密度团块影,HU 值范围大致在 -600 到 +200 之间,但这个范围会因结节类型(实性、磨玻璃、部分实性)而明显波动。
处理医学图像和普通自然图像的第一个关键区别就在这里:不能直接把 DICOM 文件当作普通图片读入。DICOM 文件里除了像素矩阵,还包含了患者信息、扫描参数、像素间距(Pixel Spacing)、层厚(Slice Thickness)、截距(Rescale Intercept)和斜率(Rescale Slope)。其中 Rescale Intercept 和 Slope 用于把原始存储值转换为真实 HU 值:
import pydicom import numpy as np dcm = pydicom.dcmread("CT_0001.dcm") raw = dcm.pixel_array.astype(np.float32) intercept = float(dcm.RescaleIntercept) slope = float(dcm.RescaleSlope) hu = raw * slope + intercept # 裁剪到肺窗常用范围,抑制无关组织 hu_clipped = np.clip(hu, -1200, 600)这段代码里,slope和intercept是每个序列内统一的,但不同扫描设备、不同协议下它们的值可能不同。很多初学项目直接对像素值做归一化,忽略了这两个参数,导致同一个结节在不同序列里的数值分布不一致,模型泛化能力就会大打折扣。np.clip的上下界也值得注意:-1200 以下主要是空气和背景噪声,600 以上是骨骼和钙化灶,保留这个范围对检测实性结节和钙化结节都有意义。
2.2 从序列到三维体数据:层厚、间距与重采样
CT 序列是一组二维切片,按扫描顺序堆叠后构成三维体数据。这里的“顺序”不能简单地按文件名排序,因为文件名可能是按扫描顺序编号的,也可能是按位置编号的。最可靠的做法是读取每个 DICOM 文件里的ImagePositionPatient标签,按空间坐标排序:
import os import pydicom def load_series(dicom_dir): slices = [] for f in os.listdir(dicom_dir): if not f.endswith(".dcm"): continue ds = pydicom.dcmread(os.path.join(dicom_dir, f)) slices.append(ds) slices.sort(key=lambda s: float(s.ImagePositionPatient[2])) return slices排序之后,还要检查相邻切片的间距是否一致。常见的坑是序列中间混入了几张定位像或不同层厚的扫描,导致某些切片之间的ImagePositionPatient差值不等于平均层厚。这种异常切片会让后续的重采样结果产生锯齿伪影,检测模型很容易把层间跳变误判为结节边缘。
重采样是医学图像处理里绕不开的一步。不同 CT 设备的层厚和像素间距不同,如果不统一,模型学到的“结节大小”就不是物理尺寸而是像素尺寸。常规做法是把所有体数据重采样到各向同性分辨率,比如 1mm × 1mm × 1mm:
import SimpleITK as sitk img = sitk.ReadImage("series.nii.gz") original_spacing = img.GetSpacing() original_size = img.GetSize() new_spacing = (1.0, 1.0, 1.0) new_size = [ int(round(orig_sz * orig_spc / new_spc)) for orig_sz, orig_spc, new_spc in zip(original_size, original_spacing, new_spacing) ] resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(new_spacing) resampler.SetSize(new_size) resampler.SetOutputDirection(img.GetDirection()) resampler.SetOutputOrigin(img.GetOrigin()) resampler.SetInterpolator(sitk.sitkLinear) resampled = resampler.Execute(img)重采样插值方式的选择有讲究。CT 值是连续的物理量,线性插值基本够用;但如果后续要做结节的体积测量,可以考虑sitk.sitkBSpline来保留更平滑的边缘。不过 B-spline 会平滑掉微小钙化点,对检测任务未必是好事,我一般默认用线性插值,只有在分割任务里才切换。
2.3 标注格式和候选区域:LIDC-IDRI 是绕不开的标尺
绝大多数肺结节检测项目使用 LIDC-IDRI 数据集作为训练和评测基准。这个数据集包含 1018 例胸部 CT 扫描,每例由 4 位放射科医生独立标注,标注内容包括结节的位置、直径、边缘特征和恶性程度评分。实际使用中常见的处理方式是:
- 只保留直径 >= 3mm 的结节(小于 3mm 的微结节通常不纳入检测目标)
- 对 4 位医生的标注取并集或取众数坐标
- 把结节的中心坐标从 DICOM 坐标系映射到重采样后的体素坐标系
坐标映射是新手最容易出错的地方。DICOM 的坐标系以患者左侧为 X 正方向、背部为 Y 正方向、头部为 Z 正方向,而重采样后的数组索引是从左上角开始的体素坐标。两者之间需要经过 Origin、Spacing 和 Direction 的组合变换。很多项目直接用(coord - origin) / spacing计算,忽略了 Direction 矩阵,结果在旋转扫描上全部偏掉。稳妥的做法是使用 SimpleITK 把标注点也变成图像空间中的物理坐标来变换:
def world_to_voxel(img, world_coord): # img 是 SimpleITK Image,world_coord 是物理坐标 [x, y, z] index = img.TransformPhysicalPointToIndex(world_coord) return list(index)3. 检测算法怎么选:2D 检测、3D 检测还是两阶段串联
3.1 自然图像检测模型直接搬来用,可行但不优雅
把 CT 体数据切成一层层的 2D 切片后,用 Faster R-CNN、YOLO 或 SSD 这类自然图像检测器去跑,是最容易上手也最容易复现的方案。每个切片独立检测,再把跨切片的检测框通过 IoU 关联合并成三维结节坐标。
这个做法的优点是工程复杂度低、预训练权重丰富、训练速度快。缺点也很明显:CT 的层内信息是 2D 的,但结节在相邻切片之间是连续变化的,单张切片上看不到结节的完整形态。一个 8mm 的结节在 1mm 层厚下横跨约 8 层,只看其中一层,容易把血管截面误判为结节,也容易把磨玻璃结节的微弱信号漏掉。
我见过不少课程设计项目在 2D 检测器上做到了不错的 FROC 分数,原因是 LIDC-IDRI 里大于 5mm 的实性结节特征相当明显,2D 模型在单层上也能识别。但到了真实临床数据、尤其是亚实性结节上,2D 方案的漏检率会明显上升。
3.2 3D 卷积的优势与显存代价
3D CNN 直接把整个体数据的 patch 作为输入,在空间三个维度上做卷积和池化。对于肺结节这种目标体积小、但需要空间上下文来判断良恶性的任务,3D 模型天然更合适。常见的结构包括 3D U-Net 作为候选区域生成网络,以及基于 3D ResNet 或 3D DenseNet 的分类网络。
import torch import torch.nn as nn class Conv3dBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv3d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm3d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class NoduleDetector3D(nn.Module): def __init__(self): super().__init__() self.enc1 = Conv3dBlock(1, 32) self.enc2 = Conv3dBlock(32, 64) self.pool = nn.MaxPool3d(2) self.fc = nn.Sequential( nn.AdaptiveAvgPool3d((4, 4, 4)), nn.Flatten(), nn.Linear(64 * 4 * 4 * 4, 256), nn.ReLU(), nn.Dropout(0.5), nn.Linear(256, 2) # 结节 / 非结节 ) def forward(self, x): x = self.enc1(x) x = self.pool(x) x = self.enc2(x) x = self.fc(x) return x3D 模型的显存占用是主要瓶颈。输入 patch 大小通常取 32×32×32 到 64×64×64 左右,一个 batch 的大小限制在 8 到 16 之间。显存不够时可以尝试混合精度训练或梯度累积。另外 3D 数据增强的开销也比 2D 大很多,随机翻转和随机旋转都要在三个维度上进行,实现时要注意体数据的坐标方向不能反转错位。
3.3 两阶段方案:候选生成加假阳性消减,工程上的折中
完整的肺结节检测系统几乎都是两阶段结构。第一阶段用高召回率的方法生成候选区域,第二段用高精度的分类器把假阳性筛掉。第一阶段可以是不太深的 3D U-Net,也可以是经典的形态学方法(如基于 HU 阈值和连通域分析),第二阶段则是一个 3D 分类网络。
两阶段的好处是每个阶段的目标单一,训练更容易收敛。第一阶段的评价指标是召回率,允许有大量假阳性;第二阶段的评价指标是精确率,专门负责把假阳性压下去。这个解耦思路和你处理其他检测任务时遇到“单模型精度上不去”的问题是一样的逻辑:把任务拆开,让每个模型做简单事,整体效果往往优于一个复杂模型硬扛。
表:两阶段方案的参数配置示例
| 阶段 | 输入 patch 尺寸 | 通道数 | Batch Size | 优化器 | 初始学习率 |
|---|---|---|---|---|---|
| 候选生成(3D U-Net) | 64×64×64 | 32/64/128 | 8 | Adam | 1e-3 |
| 假阳性消减(3D ResNet) | 32×32×32 | 32/64 | 32 | SGD | 1e-2 |
候选生成网络不需要像分割任务那样输出精细的 mask,只要输出粗略的置信度图,在置信度高于阈值的区域提取连通域作为候选。通常每例 CT 生成 300 到 1000 个候选框,其中大部分是血管和支气管截面,后续分类器的主力工作就是把这些结构区分掉。
4. 从 zip 到能跑的检测流程:训练、验证与推理的完整骨架
4.1 数据划分:按病人分而不是按切片分
医学图像和自然图像在数据集划分上有一个重要区别:同一病人的多张切片(即使是不同位置的切片)高度相关,如果按切片划分训练集和测试集,模型等于“见过”同一个人的不同部位,评估结果会虚高。正确的做法是按病人 ID 划分,确保训练集和测试集中的病人完全没有交集。
import json import random patient_ids = list(range(1, 1019)) # LIDC-IDRI 共 1018 例,这里示意 random.seed(42) random.shuffle(patient_ids) train_split = patient_ids[:800] val_split = patient_ids[800:900] test_split = patient_ids[900:] split_map = { "train": train_split, "val": val_split, "test": test_split, } with open("data_split.json", "w") as f: json.dump(split_map, f, indent=2)训练时从train中采样结节 patch 和阴性 patch 的比例一般控制在 1:3 到 1:5 之间。阴性 patch 采样要特别注意不要全从肺尖和肺底取,那里解剖结构简单,模型学不到足够的背景多样性。我一般先在肺分割 mask 的约束下做均匀采样,再额外补充血管密集区域的阴性样本。
4.2 训练循环里容易被忽略的两个细节
第一个细节是类别不均衡的处理。肺结节检测的候选区域里,真结节占比极低,典型的正负比可能在 1:200 以上。除了采样均衡之外,损失函数上常用 Focal Loss 代替交叉熵:
class FocalLoss(nn.Module): def __init__(self, alpha=0.75, gamma=2.0): super().__init__() self.alpha = alpha self.gamma = gamma def forward(self, logits, targets): ce_loss = nn.functional.cross_entropy(logits, targets, reduction="none") pt = torch.exp(-ce_loss) focal_loss = (1 - pt) ** self.gamma * ce_loss if self.alpha is not None: alpha_t = self.alpha * targets + (1 - self.alpha) * (1 - targets) focal_loss = alpha_t * focal_loss return focal_loss.mean()alpha控制正负样本的权重比例,gamma控制难易样本的权重缩放。gamma=2.0是 Focal Loss 论文里的默认值,实际项目中我通常从 1.5 开始调,如果假阳性压不下去就提高到 2.5,反而过拟合了再降回来。
第二个细节是验证指标的选择。检测任务不能用简单准确率,因为在假阳性极多时准确率依然很高。医学影像领域最常用的指标是 FROC 曲线,横轴是平均每例假阳性次数(FPs/scan),纵轴是召回率。竞赛里通常报告 FPs/scan 为 1/8、1/4、1/2、1、2、4、8 七个点的平均召回率(CPM 得分)。
4.3 推理代码:从体数据到检测结果列表
推理阶段的输入是一整例重采样后的 CT 体数据,输出是结节坐标和置信度。常规实现方式是用滑窗把体数据切成 patch 逐批送入 3D 模型,再通过非极大值抑制合并重叠的检测结果:
import numpy as np import torch def inference(volume, model, patch_size=64, stride=32, batch_size=16): model.eval() device = next(model.parameters()).device depth, height, width = volume.shape detections = [] with torch.no_grad(): for z in range(0, depth - patch_size + 1, stride): for y in range(0, height - patch_size + 1, stride): for x in range(0, width - patch_size + 1, stride): patch = volume[z:z+patch_size, y:y+patch_size, x:x+patch_size] patch_tensor = torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).float() patch_tensor = patch_tensor.to(device) prob = torch.softmax(model(patch_tensor), dim=1)[0, 1].item() if prob > 0.5: center_z = z + patch_size // 2 center_y = y + patch_size // 2 center_x = x + patch_size // 2 detections.append({ "coord": (center_z, center_y, center_x), "score": prob }) merged = nms_3d(detections, iou_threshold=0.3) return merged滑动步长stride直接决定了推理速度和召回率的平衡。stride=32在 64 的 patch 下有 50% 的重叠,通常够用;追求更高召回率可以改成stride=16,但推理时间会翻两倍以上。NMS 的 IoU 阈值也要注意,结节检测阈值设置在 0.3 左右比较合适,因为同一结节的不同 patch 检测框中心偏移在几个体素以内,比自然图像的检测框小得多。
5. 排错、边界条件和落地验证:把检测结果送进真实场景前要做的事
拿到一个肺结节检测 zip 包,你最先要验证的不是模型精度,而是坐标变换的闭合环路是否正确。常见做法是随机取几个训练样本,读原始 DICOM 里的结节标注坐标,变换到重采样后的体素空间,再变换回来,看坐标是否回到原始位置。偏一个体素可能只是插值误差,偏三五个体素以上一定是变换逻辑写错了。
另一个值得检查的边界条件是肺分割的质量。如果模型允许在肺实质之外检测结节,胸壁、纵隔和气管内的强回声结构会产生大量假阳性。实践中可以先跑一个简单的肺分割(阈值加连通域分析就够),把分割结果作为模型的输入约束,相当于告诉模型“只在这些区域里找结节”。这种领域先验的注入比单纯堆训练数据有效得多。
线上推理和离线测试还有一点不同:真实 CT 序列的层厚可能只有 5mm,这种厚层数据里小于 5mm 的结节几乎不可见,模型大概率测不出来。如果你的系统要部署在真实医院环境,必须有“层厚检查”和“剂量检查”两个前置模块,条件不满足时直接拒绝分析并提示重新扫描。这是医学图像处理落地时最容易出问题的环节,技术栈本身没问题,数据质量先杀死了系统。
最后提一个实用的调试技巧:把模型的中间层输出可视化,比如候选生成网络的置信度热力图叠加在原图上,直接看模型聚焦的区域和标注结节的区域是否重合。如果热力图一片模糊,大概率是梯度在浅层就消失了,考虑加深 BN 层或换更深的预训练骨干;如果热力图只在边缘响应,说明数据增强的强度不够,模型在走特征捷径。这类可视化排错在线性代码里做起来很快,但对判断模型行为是否合理帮助极大。
本文还有配套的精品资源,点击获取