医学图像肺结节检测源码拆解:从DICOM到U-Net分割与ResNet分类全链路
2026/9/24 18:11:47 网站建设 项目流程

简介:这份资源面向医学图像处理与深度学习方向的学习者和研究者,聚焦肺部CT影像中的肺结节自动检测问题,可用于早期肺癌辅助诊断的算法实践与课程设计。压缩包共26个文件,约5.7MB,以Python脚本为主体,包含U-Net、Inception、VGG、ResNet、DenseNet等多种分割与分类模型实现,并配有数据预处理、训练配置、可视化工具等模块;另有4个Jupyter Notebook用于分步实验,8张PNG图示展示网络结构与预处理效果,以及说明文档和依赖清单,便于快速复现与二次开发。目前已有230人学习下载。读者可借此掌握从CT图像灰度化、归一化、降噪到模型训练、预测与评估的完整流程,理解ROC、AUC、平均IOU等指标的使用,并参考目录结构组织自己的医学影像项目,适合具备一定机器学习基础、希望深入医疗AI应用的中高级学习者。

1. 肺结节检测这套源码拆开看:从 CT 切片到分割掩码的完整链路

肺结节检测这件事,真正上手做过的人都知道,难点从来不在模型本身,而在从 DICOM 到训练张量这一段。这套「基于医学图像处理的肺结节检测」源码包,把预处理、分割、分类、可视化拆成了独立脚本,preprocess.py管数据清洗,generators.py管批次生成,model_UNet.pymodel_ResNet.py覆盖了分割和分类两条线,train_segmentation.pytrain_classification.py分别对应两个训练入口。它适合已经跑通过基础 CNN、想切进医学影像这个方向的工程师,也适合手里有 LUNA16 或类似数据集、缺一套能直接改的工程骨架的人。下面我按自己拆包的习惯,把这份资源从目录结构到训练参数逐层过一遍,重点讲清楚每个文件在链路里的位置、参数怎么调、哪里容易翻车。

2. 目录结构与模块职责:先搞清楚每个文件在链路里的位置

2.1 从文件清单反推数据流

拿到一个压缩包,我第一件事不是急着跑train_classification.py,而是先把文件按数据流排一遍。这套源码的链路其实很清晰:原始 CT 数据经过preprocess.py做重采样和归一化,generators.py负责在训练时按批次喂数据,model_UNet.py做肺实质分割或结节分割,model_ResNet.pymodel_VGG.pymodel_DenseNet.pymodel_Inception.py做结节分类,train_segmentation.pytrain_classification.py是两个训练入口,visual_utils.py负责把中间结果画出来,config.py集中管理路径和超参数。

这里有个容易忽略的点:assets目录下的VGG.pngInception.pngResNet.pngDenseNet.png是网络结构示意图,small-tumor.pngsegmentation.pngpreprocess-cover-ratio.pngpreprocess-diameter-mm.png是预处理和分割效果的参考图。这些图不是装饰,它们对应的是预处理阶段两个关键参数——覆盖率和结节直径分布,后面调参时会反复用到。

train_ipynbs目录下的train-UNet.ipynbtrain_DenseNet.ipynbtrain_VGG.ipynbpreprocess.ipynb是交互式版本,适合先在小样本上验证流程,再切到.py脚本跑全量。我一般建议先用 notebook 跑通一个 batch,确认张量形状和标签对齐,再动正式训练脚本。

2.2 配置文件与依赖的落地检查

config.py是整套代码的枢纽,路径、图像尺寸、批次大小、学习率这些都在里面。requirements.txt列了依赖,但医学影像项目常见的坑是 SimpleITK 和 OpenCV 的版本冲突,以及 pydicom 读取压缩 DICOM 时的解码问题。我一般会先建一个干净虚拟环境,按requirements.txt装完后再单独确认这几个包的版本。

python -m venv venv_lung source venv_lung/bin/activate # Windows 用 venv_lung\Scripts\activate pip install -r requirements.txt # 单独确认医学影像相关依赖 python -c "import SimpleITK, pydicom, cv2; print(SimpleITK.__version__, pydicom.__version__, cv2.__version__)"

这段命令的逻辑是先隔离环境再装依赖,最后单独验证三个核心库能否正常导入。参数上,虚拟环境名可以随意改,但建议不要和系统 Python 混用,因为医学影像项目经常需要特定版本的 GDCM 或 ITK 编译支持。如果pydicom导入报错,多半是缺pydicom[all]里的解码后端,补装即可。

config.py里通常会有DATA_ROOTIMG_SIZEBATCH_SIZELREPOCHS这几项。我拿到手会先把DATA_ROOT改成自己的数据路径,IMG_SIZE保持 512×512 或 256×256 先跑通,BATCH_SIZE根据显存从 8 或 16 起步。不要一上来就改学习率,先让损失能正常下降再说。

3. 预处理与数据生成:preprocess.pygenerators.py怎么配合

3.1 预处理脚本的关键参数

preprocess.py干的事通常包括:读取 DICOM 序列、按体素间距重采样到各向同性、把 HU 值截断到肺窗范围、归一化到 [0,1] 或 [-1,1]、保存为 npy 或 png。assets里的preprocess-cover-ratio.pngpreprocess-diameter-mm.png说明这套代码在预处理阶段统计了结节覆盖率和直径分布,这两个指标直接决定后面分割任务好不好做。

覆盖率指的是结节在切片上的像素占比,直径分布则影响重采样时的目标间距选择。如果直径分布集中在 3-10mm,重采样到 1mm 各向同性比较合适;如果大部分结节小于 3mm,重采样太粗会直接丢信息。我一般会先跑一遍统计,再定target_spacing

import SimpleITK as sitk import numpy as np def resample_image(image, target_spacing=(1.0, 1.0, 1.0), interpolator=sitk.sitkLinear): original_spacing = image.GetSpacing() original_size = image.GetSize() target_size = [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(target_size) resampler.SetOutputDirection(image.GetDirection()) resampler.SetOutputOrigin(image.GetOrigin()) resampler.SetInterpolator(interpolator) return resampler.Execute(image) def hu_window_normalize(image, window_level=-600, window_width=1500): arr = sitk.GetArrayFromImage(image).astype(np.float32) lower = window_level - window_width // 2 upper = window_level + window_width // 2 arr = np.clip(arr, lower, upper) arr = (arr - lower) / (upper - lower) return arr

这段代码做了两件事:重采样和肺窗归一化。target_spacing控制输出体素间距,肺结节检测常用 1mm 各向同性;interpolator在重采样掩码时要换成sitk.sitkNearestNeighbor,否则标签会被插值成小数。window_levelwindow_width是肺窗参数,-600/1500 是常见组合,但不同数据集可能不同,调之前先看preprocess-cover-ratio.png里的分布。

3.2 数据生成器的批次逻辑

generators.py负责把预处理后的数据按批次喂给模型。医学影像的生成器比普通图像分类复杂,因为要同时处理图像和掩码,还要做数据增强。常见做法是用tf.keras.utils.Sequence或 PyTorch 的Dataset+DataLoader,在__getitem__里做随机翻转、旋转、弹性形变。

import numpy as np from tensorflow.keras.utils import Sequence class LungPatchGenerator(Sequence): def __init__(self, image_paths, mask_paths, batch_size=8, dim=(256, 256), augment=True): self.image_paths = image_paths self.mask_paths = mask_paths self.batch_size = batch_size self.dim = dim self.augment = augment def __len__(self): return int(np.ceil(len(self.image_paths) / self.batch_size)) def __getitem__(self, idx): batch_images = self.image_paths[idx * self.batch_size:(idx + 1) * self.batch_size] batch_masks = self.mask_paths[idx * self.batch_size:(idx + 1) * self.batch_size] X = np.zeros((len(batch_images), *self.dim, 1), dtype=np.float32) Y = np.zeros((len(batch_masks), *self.dim, 1), dtype=np.float32) for i, (img_path, mask_path) in enumerate(zip(batch_images, batch_masks)): img = np.load(img_path) mask = np.load(mask_path) X[i] = img[..., np.newaxis] Y[i] = mask[..., np.newaxis] if self.augment: X, Y = self._random_flip(X, Y) return X, Y def _random_flip(self, X, Y): for i in range(X.shape[0]): if np.random.rand() > 0.5: X[i] = np.flip(X[i], axis=1) Y[i] = np.flip(Y[i], axis=1) return X, Y

生成器的核心是__getitem__返回一个批次的图像和掩码,形状是(batch, H, W, 1)augment控制是否做随机翻转,医学影像里翻转要谨慎,左右翻转通常可以,上下翻转要看解剖结构是否合理。dim要和config.py里的IMG_SIZE一致,不一致会在模型输入层报形状错误。如果训练时 loss 一直是 nan,先检查np.load出来的数据范围是不是在 [0,1],很多预处理脚本忘了归一化就会出这个问题。

4. 模型选型与训练:U-Net 分割和 ResNet 分类怎么落地

4.1 U-Net 分割的跳跃连接与损失函数

model_UNet.py是这套代码里分割任务的核心。U-Net 的结构是编码器-解码器加跳跃连接,编码器逐层下采样提取特征,解码器逐层上采样恢复分辨率,跳跃连接把编码器的高分辨率特征直接拼到解码器对应层,这样分割边界会准很多。肺结节分割里,结节边界模糊、尺寸小,跳跃连接的作用尤其明显。

import tensorflow as tf from tensorflow.keras import layers, Model def unet_model(input_shape=(256, 256, 1), base_filters=32): inputs = layers.Input(shape=input_shape) # 编码器 c1 = layers.Conv2D(base_filters, 3, activation='relu', padding='same')(inputs) c1 = layers.Conv2D(base_filters, 3, activation='relu', padding='same')(c1) p1 = layers.MaxPooling2D(2)(c1) c2 = layers.Conv2D(base_filters * 2, 3, activation='relu', padding='same')(p1) c2 = layers.Conv2D(base_filters * 2, 3, activation='relu', padding='same')(c2) p2 = layers.MaxPooling2D(2)(c2) # 瓶颈层 c3 = layers.Conv2D(base_filters * 4, 3, activation='relu', padding='same')(p2) c3 = layers.Conv2D(base_filters * 4, 3, activation='relu', padding='same')(c3) # 解码器 u4 = layers.Conv2DTranspose(base_filters * 2, 2, strides=2, padding='same')(c3) u4 = layers.concatenate([u4, c2]) c4 = layers.Conv2D(base_filters * 2, 3, activation='relu', padding='same')(u4) u5 = layers.Conv2DTranspose(base_filters, 2, strides=2, padding='same')(c4) u5 = layers.concatenate([u5, c1]) c5 = layers.Conv2D(base_filters, 3, activation='relu', padding='same')(u5) outputs = layers.Conv2D(1, 1, activation='sigmoid')(c5) return Model(inputs, outputs)

这段代码搭了一个简化版 U-Net,base_filters控制第一层卷积核数量,显存不够就降到 16。Conv2DTranspose做上采样,concatenate实现跳跃连接。输出层用sigmoid是因为分割是二分类,如果做多类分割要换成softmax并把输出通道数改成类别数。损失函数常见做法是 Dice loss 加二元交叉熵的混合,单纯用交叉熵在小结节上会严重偏向背景。

4.2 分类模型与训练脚本的参数

model_ResNet.pymodel_VGG.pymodel_DenseNet.pymodel_Inception.py是四个分类骨干,train_classification.py是分类训练入口。分类任务通常是判断一个候选结节是良性还是恶性,输入是裁剪后的结节 patch,输出是概率。assets里的VGG.pngInception.pngResNet.pngDenseNet.png是结构参考,实际代码里可能做了简化,比如把全连接层换成全局平均池化。

import tensorflow as tf from tensorflow.keras import layers, Model def resnet_block(x, filters, stride=1): shortcut = x x = layers.Conv2D(filters, 3, strides=stride, padding='same')(x) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = layers.Conv2D(filters, 3, padding='same')(x) x = layers.BatchNormalization()(x) if stride != 1 or shortcut.shape[-1] != filters: shortcut = layers.Conv2D(filters, 1, strides=stride, padding='same')(shortcut) shortcut = layers.BatchNormalization()(shortcut) x = layers.Add()([x, shortcut]) return layers.ReLU()(x) def build_resnet_classifier(input_shape=(64, 64, 1), num_classes=2): inputs = layers.Input(shape=input_shape) x = layers.Conv2D(32, 3, padding='same')(inputs) x = layers.BatchNormalization()(x) x = layers.ReLU()(x) x = resnet_block(x, 32) x = resnet_block(x, 64, stride=2) x = resnet_block(x, 128, stride=2) x = layers.GlobalAveragePooling2D()(x) outputs = layers.Dense(num_classes, activation='softmax')(x) return Model(inputs, outputs)

分类模型的输入尺寸通常比分割小,64×64 或 32×32 就够,因为结节 patch 本身不大。num_classes=2对应良恶性二分类,如果做多级分类要改。GlobalAveragePooling2D替代全连接层可以减少参数量,降低过拟合。训练时train_classification.py里一般会用Adam优化器,学习率从 1e-3 起步,配合ReduceLROnPlateau回调。如果验证集 AUC 一直上不去,先检查正负样本是否极度不平衡,医学影像里阴性样本远多于阳性,需要加权损失或重采样。

5. 避坑与排查:这套代码跑起来最容易翻车的五个地方

5.1 现象:预处理后图像全黑或全白

原因:HU 值截断范围设错,或者归一化时用了错误的窗宽窗位。肺窗通常是 level=-600、width=1500,但有些数据集已经做过预处理,再截断就会丢信息。

解决:先用visual_utils.py里的函数把原始 HU 直方图画出来,确认肺实质的 HU 范围,再定截断参数。如果图像全黑,多半是 lower 设太高;全白是 upper 设太低。

5.2 现象:训练 loss 不下降或震荡严重

原因:学习率太大、批次太小、或者数据标签没对齐。医学影像里图像和掩码的文件名对应关系容易搞错,尤其是用glob排序时。

解决:先把BATCH_SIZE调到 4 或 8,学习率降到 1e-4,跑 10 个 epoch 看 loss 趋势。然后用visual_utils.py把图像和掩码叠在一起画出来,确认标签没串位。

5.3 现象:显存溢出(OOM)

原因:IMG_SIZE设太大、base_filters太多、或者生成器里一次性加载了全部数据。

解决:把IMG_SIZE从 512 降到 256,base_filters从 64 降到 32,生成器改成按需读取而不是预加载。如果还不行,用梯度累积模拟大批次。

5.4 现象:验证集 Dice 或 AUC 很高但实际预测很差

原因:数据泄漏。同一个病人的切片可能同时出现在训练集和验证集里,导致模型记住了病人特征而不是结节特征。

解决:按病人 ID 划分数据集,不要按切片随机划分。LUNA16 有官方的 10 折交叉验证划分,直接用那个最稳。

5.5 现象:pydicom读取报错或图像方向不对

原因:DICOM 文件有压缩格式,或者ImageOrientationPatientImagePositionPatient没正确处理,导致重建的 3D 体数据方向错乱。

解决:用pydicom.dcmread时加force=True,压缩格式需要额外装pylibjpeggdcm。重建 3D 体数据时按ImagePositionPatient的 z 轴排序,不要依赖文件名排序。

6. 可视化验证与进阶技巧:用visual_utils.py把黑匣子打开

visual_utils.py这套代码里最容易被低估的文件。训练时 loss 和 AUC 只是数字,真正能帮你判断模型有没有学到东西的,是把中间结果画出来。我一般会在训练脚本里加一个回调,每几个 epoch 存一张预测掩码和原图的叠加图,跑完一轮后翻一遍,比看曲线有用得多。

import matplotlib.pyplot as plt import numpy as np def plot_image_mask_pred(image, mask, pred, save_path=None): fig, axes = plt.subplots(1, 3, figsize=(12, 4)) axes[0].imshow(image, cmap='gray') axes[0].set_title('CT Slice') axes[1].imshow(mask, cmap='gray') axes[1].set_title('Ground Truth') axes[2].imshow(pred, cmap='gray') axes[2].set_title('Prediction') for ax in axes: ax.axis('off') if save_path: plt.savefig(save_path, bbox_inches='tight', dpi=150) plt.close() def overlay_contour(image, mask, color='red', alpha=0.5): import cv2 img = (image * 255).astype(np.uint8) img = cv2.cvtColor(img, cv2.COLOR_GRAY2RGB) contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cv2.drawContours(img, contours, -1, (255, 0, 0), 1) return cv2.addWeighted(img, 1 - alpha, np.zeros_like(img), alpha, 0)

plot_image_mask_pred把原图、真值掩码和预测掩码并排画出来,overlay_contour把预测轮廓叠在原图上。这两个函数配合用,能快速看出模型是漏检、误检还是边界不准。如果预测掩码全是背景,先查损失函数权重;如果边界比真值大一圈,多半是 Dice loss 的平滑项设太小。

进阶用法上,我习惯在config.py里加一个DEBUG_MODE开关,打开时只跑 20 个 batch 并保存可视化结果,关闭时跑全量。这样调参时不用等完整训练。另外,train_ipynbs里的 notebook 适合做消融实验,比如把 U-Net 的跳跃连接去掉看 Dice 掉多少,或者把 ResNet 换成 VGG 看 AUC 变化。这些对比结果比单次训练的数字更有说服力。

从那以后我每次拿到新的医学影像代码包,都强制先跑一遍小样本可视化,确认图像、掩码、预测三者的空间对齐没问题,再动全量训练。这个习惯帮我省了至少两次通宵排查数据泄漏的时间。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询