☰
基于YOLO11与LUNA16的肺结节检测系统:数据预处理到GUI实现
2026/10/11 10:03:41 网站建设 项目流程

简介:面向医学影像与深度学习交叉方向的肺结节检测实战资源,以YOLOv11为核心,基于LUNA16数据集构建,适配课程设计、毕业设计及入门研究者,是覆盖数据预处理、模型训练到推理展示的可运行项目。资源包共2000个文件、约599MB,类型涵盖txt说明、md文档、Python训练测试脚本、png可视化结果、yaml模型配置及cpp推理源码,便于按模块查阅和二次开发。内置YOLOv5、YOLOv8与YOLOv11的nano、small四组预训练模型,支持一键训练、测试及图形化/Web双界面,明显降低环境配置和复现门槛。另附系统结构图、报告文档、PPT模板与配置视频指引,可缩短验收和答辩准备时间。已有1461人浏览学习,适合医疗影像课题和需要对比实验参考的读者。

1. 基于 YOLO11 的肺结节检测系统:这门大作业的资源其实卡在数据管线上

很多人拿到肺结节检测这个大作业、课程设计题目时,第一反应是找个 YOLO 模型,下载点 CT 图片,开训。结果往往是:训练 loss 正常下降,mAP 却始终是 0,或者模型把肺外组织都框出来了。原因很简单——LUNA16 给的是三维 CT 体数据和毫米坐标标注,而 YOLO11 吃的是二维图像和归一化文本框。这套基于 YOLO11 的肺结节检测资源,把 LUNA16 数据集、标注转换脚本、训练好的权重和图形化界面串成了一条完整链路,拿到手能直接从数据预处理跑到 GUI 演示。适合正在做课程设计、毕设,或者想用目标检测快速落一个医学影像方向 demo 的人。它解决的问题不是“模型多强”,而是“怎么让 YOLO11 在医学 CT 上真正跑通”,这一步跨过去,后面所有东西都顺了。

2. LUNA16 数据集处理:窗宽窗位、坐标换算与 YOLO 标注生成

2.1 为什么选 LUNA16,而不是自己标数据

肺结节检测这个方向,自己标注 CT 数据几乎不可行。一份胸部 CT 通常有 200~500 张轴向切片,结节直径只有几毫米到十几毫米,在 512×512 的图像上往往只占几十个像素,非放射科医生根本分不清血管截面和结节。LUNA16 是公开的肺结节检测数据集,来自 LIDC/IDRI 的筛选结果,提供 888 例带结节标注的 CT 扫描。它给你的是三样东西:CT 原始体数据(DICOM 或 mhd/raw 格式)、结节中心的三维世界坐标、结节直径(mm)。标注文件是一个 CSV,字段大概是 seriesuid、coordX、coordY、coordZ、diameter_mm,其中 seriesuid 对应某个 CT 序列,coord 是病人坐标系下的坐标,而不是图像像素坐标。

选 LUNA16 做课程设计有很强的现实理由:数据公开、标注权威、论文里可以引用、而且不用自己拉标注团队。但代价是格式转换工作量集中在坐标系换算上。它不是那种下载下来就能直接丢给 YOLO 的数据集。

2.2 DICOM 转 PNG:窗宽窗位决定结节能不能看见

CT 数据的存储值单位是亨氏单位(HU),范围从 -1000(空气)到 +1000 以上(骨骼),但 YOLO 训练需要的是 8bit 的图像,直接把原始值映射到 0~255 会导致结节几乎不可见。肺结节的对比度集中在软组织密度,如果不做窗宽窗位处理,肺窗里的结节会被压缩成一片灰色。常见做法是采用肺窗,窗宽 1500 HU、窗位 -600 HU,把 [-600-750, -600+750] 即 [-1350, 150] HU 范围映射到 0~255。

这一步我建议用 pydicom 读取 DICOM 序列,按 z 方向排序后逐切片做窗宽窗位转换。代码示例如下:

import pydicom import numpy as np from pathlib import Path from PIL import Image def load_series_slices(dcm_dir): """读取一个目录下的全部 DICOM 切片,按扫描位置排序""" slices = [pydicom.dcmread(str(p)) for p in Path(dcm_dir).glob("*.dcm")] slices.sort(key=lambda s: float(s.ImagePositionPatient[2])) return slices def hu_to_8bit(hu_array, wl=-600, ww=1500): """窗宽窗位映射:将 HU 值线性映射到 0~255""" lower = wl - ww / 2 # -1350 upper = wl + ww / 2 # 150 arr = (hu_array - lower) / (upper - lower) * 255.0 return np.clip(arr, 0, 255).astype(np.uint8) slices = load_series_slices("subset0/1.3.6.4.1.14519.5.2.1.6279.6001.100398073456018269056860234535") for idx, s in enumerate(slices): img_8bit = hu_to_8bit(s.pixel_array.astype(np.float32)) Image.fromarray(img_8bit).save(f"imgs/scan_{idx:03d}.png")

参数说明:wl是窗位,控制亮度中心,ww是窗宽,控制对比度范围。肺窗wl=-600, ww=1500是最常用组合;如果是想突出纵隔淋巴结,可以换成wl=40, ww=400。实际做的时候我会把整份 CT 都转成 PNG 保存,而不是用的时候再转换,省得重复读 DICOM。注意pixel_array里可能有RescaleSlope和RescaleIntercept,标准做法是先乘 slope 再加 intercept 转成真实 HU 值再窗宽窗位,LUNA16 里这两个值一般很标准,但写脚本时最好兼容。

2.3 把毫米坐标换算成 YOLO 标注,并处理负样本切片

YOLO 的标注格式是归一化的类别和框坐标:class cx cy w h,其中 cx、cy、w、h 都是相对图像宽高的比例。LUNA16 给的是结节中心的世界坐标和毫米直径,需要先转成像素坐标,再归一化。换算要用到 DICOM 里的ImagePositionPatient(原点世界坐标)和PixelSpacing(像素间距)。轴向切片的 row 和 col 方向对应世界坐标的 X、Y,而 Z 方向要先在切片序列里找最近的切片索引。

下面是我在项目里实际用过的转换脚本骨架:

import pandas as pd import numpy as np def world_to_image_coords(coord, origin, spacing): """世界坐标转像素坐标:origin 是 DICOM 原点,spacing 是 (row_spacing, col_spacing)""" col = (coord[0] - origin[0]) / spacing[1] row = (coord[1] - origin[1]) / spacing[0] return row, col def annotation_to_yolo(series_path, ann_row, slices): origin = np.array(slices[0].ImagePositionPatient, dtype=np.float64) spacing = np.array(slices[0].PixelSpacing, dtype=np.float64) # [row, col] # 找到离结节 z 方向最近的切片 target_z = ann_row["coordZ"] z_list = np.array([s.ImagePositionPatient[2] for s in slices]) slice_idx = int(np.argmin(np.abs(z_list - target_z))) s = slices[slice_idx] row, col = world_to_image_coords( [ann_row["coordX"], ann_row["coordY"]], origin, spacing ) # 直径 mm 转像素直径,取两个方向的均值 diameter_px = ann_row["diameter_mm"] / float(np.mean(spacing)) h = w = diameter_px img_h, img_w = s.pixel_array.shape cx, cy = col / img_w, row / img_h nw, nh = w / img_w, h / img_h # 边界截断,防止归一化越界导致训练时 loss 变成 nan nw = min(nw, 1.0 - cx) nh = min(nh, 1.0 - cy) return slice_idx, f"0 {cx:.6f} {cy:.6f} {nw:.6f} {nh:.6f}\n" # 使用示例 # slices = load_series_slices(series_dir) # for _, ann in annotations_df.iterrows(): # idx, yolo_line = annotation_to_yolo(series_dir, ann, slices) # with open(f"labels/scan_{idx:03d}.txt", "a") as f: # f.write(yolo_line)

参数说明:spacing的单位是 mm/像素,通常 row 和 col 方向相同;coordZ是用三维坐标表示的 Z 值,对应ImagePositionPatient列表里的第三个分量。这里有两个容易被坑的点。第一,DICOM 的ImagePositionPatient在不同切片间可能 Z 值递减,所以一定要按 Z 排序再找最近切片,不能直接拿文件名下标当切片索引。第二,结节直径转像素时要用spacing的均值,因为单个结节的横截面通常不是正圆,但 LUNA16 给的直径本身是个近似值,取均值就能满足检测框的召回需求。

负样本切片怎么处理,我一般这样理解:一个 300 张切片的 CT,可能只有 2~5 张切片上有结节。正例切片太少,模型学不到“没有结节的肺长什么样”。常见做法是保留结节所在切片上下各 3~5 张作为一个序列,同时从每个无结节的 CT 里随机抽样 20~30 张纯负样本切片,放进训练集里让模型见过足够的背景。这步不做,训练的 recall 会出问题。

3. YOLO11 训练配置与流程:从预训练权重到自己的肺结节模型

3.1 YOLO11 网络结构与模型选型:小目标场景选哪一档

YOLO11 是 Ultralytics 在 YOLOv8 之后更新的检测模型,主干里用了 C3k2 模块替换掉一部分 C2f,检测头保持解耦设计,整体结构在轻量化和精度之间做了重新平衡。它提供了 n/s/m/l/x 从轻到重五档规格。肺结节检测有两个特殊性:目标小(十几像素到几十像素)、正样本稀(一个 CT 里就几个结节)。n 档模型参数量小、显存友好,但小目标特征提取能力偏弱,容易漏检。m 档在这个任务里通常是性价比最高的选择,l 档效果更好但对显存和训练时间的要求明显上升。课程设计场景,我建议从 yolo11m 的预训练权重开始迁移学习,不要用 yolo11n 硬扛,也不建议一开始就上 yolo11x。很多人做这个题目一心想着“yolo11 改进发小论文”,但课程设计先把基线跑通,模型选型不要追求最大。

预训练权重的用处很大,虽然 COCO 上没有肺结节,但网络前几层学到的纹理和边缘特征可以迁移到医学图像上。从零训练一个 YOLO11 在几百张切片上极容易过拟合,预训练权重 + 冻结前 10 层训练 50 个 epoch,再解冻微调,效果好很多。

3.2 数据目录与 data.yaml:按病人维度划分,防止数据泄漏

训练开始前要先把目录结构搭好,Ultralytics 的 YOLO 默认按 images 和 labels 并列的方式组织。我的目录习惯是这样:

datasets/luna16_yolo/ ├── images/ │ ├── train/ │ └── val/ ├── labels/ │ ├── train/ │ └── val/ └── data.yaml

注意:划分 train/val 时一定要按 seriesuid(也就是病人的一次扫描)维度划分,而不是按切片划分。同一个 CT 的相邻切片高度相似,如果同一个 CT 的切片同时出现在训练集和验证集,验证指标会虚高,换成新病人直接失灵。这是这个项目里最严重的坑。我一般按 8:2 或者 9:1 按病人划分,对同一个病人所有切片全部归入同一侧,代码上先 list 所有 seriesuid,随机 split 后再复制对应文件。

data.yaml 写起来很直接:

path: datasets/luna16_yolo train: images/train val: images/val names: 0: nodule

参数说明:names里类别 id 必须是从 0 开始的连续数字,YOLO 内部要求类别 id 从 0 开始,如果你把标注写成1 cx cy w h,训练时模型会认为存在一个 id=1 的类别而 data.yaml 只定义了 0,轻则训练混乱,重则 mAP 恒为 0。课程设计里我见过大量这种低级但代价高的错误。

3.3 训练脚本与关键超参数:从 100 epoch 起步,别迷信大模型

我用 Ultralytics 的命令行训练,一条命令跑完整流程:

yolo detect train \ data=datasets/luna16_yolo/data.yaml \ model=yolo11m.pt \ epochs=100 \ imgsz=640 \ batch=16 \ optimizer=AdamW \ lr0=0.0005 \ patience=15 \ device=0

参数说明:imgsz=640是输入分辨率,肺结节原图是 512×512,640 会先缩放到 640 再训练,不需要担心信息丢失,但也不建议拉到 1280,显存和训练时间翻倍,mAP 提升有限;batch=16是显存相关的关键参数,8GB 显存跑 yolo11m 时 batch 8 更稳;patience=15表示 15 个 epoch 验证指标不涨就早停,医学小数据集后段容易震荡,早停能帮你省时间;lr0=0.0005比 YOLO 默认的 0.01 低,因为医学数据量小,学习率太高会直接发散。

如果习惯用 Python 脚本控制,直接这样写也行:

from ultralytics import YOLO model = YOLO("yolo11m.pt") results = model.train( data="datasets/luna16_yolo/data.yaml", epochs=100, imgsz=640, batch=16, optimizer="AdamW", lr0=0.0005, patience=15, device=0, )

训练结果的权重会在runs/detect/train/weights/best.pt和last.pt两个文件里,best 是验证集效果最好的,last 是最后一轮的。下载资源里给的模型权重通常就是跑完这套流程得到的 best.pt。判断训练是否正常,看训练日志里的box_loss和cls_loss是否稳步下降,mAP 是否逐步抬升,如果 loss 降了但 mAP 纹丝不动,回到第 5 章查标注问题。

4. 模型验证与指标判读:mAP、recall 与医学场景的取舍

4.1 val 命令与指标读取:mAP50 和 mAP50-95 怎么评

训练完不要只盯着训练日志,要在验证集上独立跑一次指标。命令和读取方式如下:

yolo detect val \ model=runs/detect/train/weights/best.pt \ data=datasets/luna16_yolo/data.yaml

跑完会在终端打印一组指标,核心是mAP50和mAP50-95。mAP50 是 IoU 阈值 0.5 下的平均精度,mAP50-95 是在 0.5 到 0.95 之间每隔 0.05 取一个阈值的平均结果。两者差异能说明问题:如果 mAP50 很高但 mAP50-95 很低,说明模型框的位置不够准,可能有偏移;如果两者都低,说明模型漏检严重或者类别学习失败。用 Python 拿指标更直观:

from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") metrics = model.val(data="datasets/luna16_yolo/data.yaml") print("mAP50:", metrics.box.map50) print("mAP50-95:", metrics.box.map) print("precision:", metrics.box.mp) print("recall:", metrics.box.mr)

参数说明:metrics.box.map50对应 mAP50,metrics.box.map对应 mAP50-95,mp是整体 precision,mr是整体 recall。在肺结节这个场景下,我通常更关注 recall。医学筛查场景漏掉一个真结节比多框一个假阳性严重得多,所以只要 recall 高,mAP 略低可以接受。如果你想把阈值压低换取召回,可以在 val 时指定conf=0.1看指标变化,但训练完报告里一般写默认 conf 下的数值更规范。

4.2 可视化推理结果:单图预测与结节定位核对

指标是数字,但最终演示需要图片。用 best.pt 对单张切片做推理并保存可视化结果,一行命令:

yolo detect predict \ model=runs/detect/train/weights/best.pt \ source=datasets/luna16_yolo/images/val/scan_012.png \ conf=0.25 \ save=True

也可以一次性验证整个文件夹,结果会存到runs/detect/predict/目录下,带预测框的图片文件名与源文件对应。肉眼核对时重点看三件事:结节区域是否真的被框住;框的中心是否在疑似结节上而不是血管交叉处;多个结节同片时是否漏框。这种可视化验证对课程设计答辩尤其重要,评委一般不看 mAP 数字,看图片。

如果发现窗口可视化效果很差,比如框是对的但图片太黑或太白,说明推理阶段的窗宽窗位和数据预处理阶段不一致。推理时读 DICOM 转图一定要复用训练时的hu_to_8bit函数,不要换了工具链就换映射参数。这就是典型的前后不一致问题,后面避坑章节会专门说。

5. 肺结节检测避坑指南:数据、训练与推理阶段的典型翻车

5.1 训练 mAP 值为 0:标注类别 id 和归一化越界

现象:loss 正常下降,val 也能跑完,但 mAP 始终是 0,precision 和 recall 也是 0。

原因:标注文件里类别 id 写成 1,而 data.yaml 只有 0 这一类;或者归一化坐标越界,比如 cx 写成大于 1 的像素值,模型标注读取阶段直接丢弃这些样本。

解决:打开 labels 下的 txt 文件,检查第一列是不是 0,cx cy w h 是否都在 [0,1] 区间。写一个小脚本扫描全部标注,一行行校验坐标范围,如果有越界就用np.clip截断并重新保存。另外检查 images 和 labels 是否同名同目录,Ultralytics 要求同名文件分别放在 images 和 labels 目录下,后缀不同但文件名一致。

5.2 结节在图上完全看不见:窗宽窗位没做对

现象:转出来的 PNG 整张发灰或发黑,肺纹理清晰但结节和血管混在一起无法区分。

原因:直接用 DICOM 的原始 pixel_array 映射成 8bit,没有做 HU 单位的窗宽窗位处理。CT 原始值是 16bit 的 HU 数据,范围动辄上千,直接归一化到 0~255 会把软组织细节压缩没。

解决:统一用肺窗wl=-600, ww=1500处理后再保存训练图像。推理和训练必须用同一个映射函数,最好把hu_to_8bit单独放到一个工具模块里,两边共用。我还遇到过 RescaleSlope 不为 1 的奇葩 DICOM,那种要先执行hu = pixel * slope + intercept再做窗宽窗位,否则结节会整体偏移几百 HU。

5.3 验证集 mAP 很高,换新数据完全不能用:按病人维度划分没做

现象:训练时 mAP50 高达 0.9 以上,但用新病人 CT 测试时召回率暴跌。

原因:划分数据集时按切片随机打散,同一个 CT 的相邻切片被同时分进 train 和 val。相邻 5 张切片视觉特征极其相似,模型在训练阶段已经见过验证图像内容,验证指标是虚假的。

解决:划分必须发生在 seriesuid 层级。先收集所有 seriesuid,按 8:2 随机划分,再根据每个 seriesuid 复制对应切片到 train 或 val。做数据划分的脚本里强制加一个断言,保证train_series和val_series两个集合没有任何交集。从那以后我每次做数据集划分都强制走一遍病人级去重,这个习惯救了我很多次。

5.4 显存不足或训练中断:imgsz、batch、workers 三件套没适配

现象:训练开始几分钟就报 CUDA out of memory,或者训练到一半进程被杀。

原因:yolo11m 搭配 imgsz=640、batch=32 在 8GB 显存上必然爆掉。课程设计常用显卡是 6GB~8GB 的 3060/4060 系列,需要按显存容量倒推参数。

解决:一个稳妥组合是 8GB 显存跑 yolo11m、imgsz=640、batch=8;如果还想降,把模型降到 yolo11s、batch=16。同时在训练命令里加workers=2,减少 Dataloader 内存占用。另外把cache=True参数关掉,医学图像总量不大,硬盘读取即可,缓存到内存反而容易占满内存导致进程被杀。出现 OOM 时先砍 batch,再砍 imgsz,最后才砍模型规模。

5.5 推理时每张切片都跑一遍,演示卡成 PPT:没有按切片步长抽样

现象:图形化界面里选一个 CT,程序把 300 多张切片全部送入模型,单张推理几十毫秒但累计起来要十几秒,用户拖动滑块卡顿明显。

原因:推理逻辑没有做步长控制和结果缓存。演示场景不需要全部切片都检测,结节在一个方向上是连续出现的,跳 2~5 张切片完全不影响演示效果。

解决:推理时设置step=3,只处理 1、4、7…号切片,同时把第一次推理结果缓存到内存字典里,切片索引作为 key,用户拖动滑块时直接读缓存结果而不是重复推理。界面上的显示方式是“切到某张就显示该张的检测结果”,缓存的命中率很高。

6. 图形化界面:把模型封装成能演示的肺结节检测工具,并缓存推理结果

这章说一个具体技巧:图形化界面里最影响体验的不是检测精度,而是交互速度。下载资源里带 GUI 模块,核心逻辑一般是 Tkinter 或 PySide6 写的,我都试过,课程设计演示用 Tkinter 就够,代码量小、环境不容易出问题。下面是一个可用的推理封装骨架:

import tkinter as tk from tkinter import filedialog, Label from ultralytics import YOLO model = YOLO("best.pt") cache = {} # 切片索引 -> 推理结果 def predict_slice(slice_idx, img_8bit): if slice_idx in cache: return cache[slice_idx] result = model.predict(img_8bit, conf=0.25, verbose=False)[0] cache[slice_idx] = result return result def open_ct(): dcm_dir = filedialog.askdirectory(title="选择 DICOM 文件夹") slices = load_series_slices(dcm_dir) # 复用第 2 章的加载函数 for i in range(0, len(slices), 3): # 步长 3,减少演示卡顿 img_8bit = hu_to_8bit(slices[i].pixel_array.astype(np.float32)) predict_slice(i, img_8bit)

逻辑说明:cache是核心,第一个 CT 打开时预计算所有步长为 3 的切片,之后滑动滑块直接命中缓存,响应时间从几百毫秒降到几十毫秒。step=3是血泪教训换来的参数——一开始我用 step=1 全量跑,第一版界面被老师吐槽像幻灯片。另外 GUI 里把置信度阈值做成可调滑块很有用,演示时对着一张难判的图把 conf 从 0.25 降到 0.15,模型能多框出几个低置信候选,容易给评委留下“可交互”的印象。

还可以再加一个报告导出功能,把当前 CT 里所有检测框按切片位置排列导出成 CSV,包含结节像素坐标、置信度、直径像素值。这个功能在答辩时是加分项,因为它把模型输出变成了可解释的结果文件。从那以后我每做一个检测项目,GUI 里都强制带缓存和结果导出这两个东西,演示顺畅,别人拿过去也能直接验收。希望帮到你。

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

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

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

立即咨询