2026/10/11 10:04:26

基于YOLO11的肺结节检测系统:LUNA16数据集到PyQt5图形界面实战

基于YOLO11的肺结节检测系统:LUNA16数据集到PyQt5图形界面实战 简介基于YOLOv11的肺结节检测系统完整项目包面向高校课程设计、毕业设计及医学影像入门研究者解决从LUNA16数据集处理到模型训练、评估与部署的全流程需求。系统解析1186张2D切片内置YOLOv5、YOLOv8及YOLOv11的nano、small共4组已训练模型支持一键训练/测试和图形化、Web双界面便于快速上手和对比效果。资源共2000个文件、约599MB以txt标注与说明、md文档、py脚本、yaml配置、png图表为主并附结构图、PPT模板及3份PDF参考整体目录清晰可直接用于实验复现与报告撰写。已有1461人学习下载适合需要完整方案和现成模型进行肺结节检测实践的学生与开发者。1. 基于yolo11的肺结节检测系统为什么值得自己做一遍一个医学影像小组手上有几百套胸部CT每套几百张切片医生逐层找肺结节快也要三五分钟。用yolo11做一个肺结节检测系统喂进去切片、标出疑似结节的位置和置信度大概一秒左右就能出一版粗筛结果医生的精力可以放到复核上。这里用LUNA16数据集来训练模型数据是公开的标注是毫米级坐标跑通一次就能把医学影像落地的整套路数摸熟。做这件事的代价是一块8G显存的显卡和两个周末收益是以后遇到CT、B超这类灰度影像检测都可以复用这条管线。这个方向适合两类人一是做医学影像算法、想把YOLO11迁移到新数据上的工程师二是研究生阶段拿LUNA16做实验、需要一个能跑通还能演示的图形化界面的人。系统不复杂核心就三件事把LUNA16的CT数据转成能被yolo11吃进去的格式训练出一个肺结节检测模型再用pyqt5做一个能开图的图形化界面。看着不难但里面有几个坑只要你踩一次就知道为什么医学影像检测项目翻车率这么高了。2. 先啃LUNA16CT数据集转YOLO格式前要过的三道关2.1 LUNA16是什么、为什么拿它当肺结节检测的基准LUNA16是LUng Nodule Analysis 2016挑战赛的缩写提供了一批低剂量胸部CT影像和对应的肺结节标注。比赛的原始设定是在888个CT扫描样本里找出所有直径不小于3毫米的结节但实际训练时也可以把小于3毫米的样本保留下来做丰富背景只是评测时按官方口径走。这套数据之所以被当成基准是因为它公开早、标注规范、论文里可对比的结果多YOLOv5和v8时代就有人拿它跑检测到了yolo11改进阶段大家依然拿它来衡量医学影像检测的上限。拿到数据之后先别急着写训练脚本把文件结构看清楚。LUNA16的影像文件不是一堆png而是.mhd和.raw成对出现的医学格式前者是头部信息后者是像素体数据。标注放在annotations.csv里一行一个结节。我第一次做的时候不知道这一点直接拿普通图片预处理去读结果.OpenCV报了一堆错。医学影像预处理的第一步永远是先读.mhd头搞清楚这个CT的体积、像素间距和原点再谈转图片。标注文件里存的不是像素坐标而是空间坐标系里的毫米坐标字段是coordX、coordY、coordZ外加一个diameter_mm字段表示结节的直径。这个坐标和CT扫描时病人的摆位有关和你把CT切出来的那张图片没有任何直接关系。所以转YOLO格式之前必须先从.mhd头里读出体素间距和原点把毫米坐标换算成体素索引再算成像素坐标多一步都不能省。2.2 把CT窗口化和切片导出为什么不能直接存彩色图CT影像是16位的灰度图数值表达的是衰减系数换算出来的HU值空气大约是-1000水是0骨骼能到几百上千。如果直接把16位数据转成8位存成图片相当于把整个灰度范围硬压进去软组织的对比度全丢肺结节在切片上几乎看不见。标准做法是窗口化只保留一个兴趣的HU区间然后线性映射到0到255。肺实质一般用窗位-600、窗宽1500也就是把-1350到150这个区间的灰阶拉开低于下限全黑高于上限全白。窗口化的代码大概是这样。先用SimpleITK读取.mhd和.raw取出像素数组再按窗宽窗位增强。一个CT扫描是三维的体数据第三维通常有几十到几百个切片做二维检测的话需要逐层处理只保留肺实质内、并且存在结节的层。import SimpleITK as sitk import numpy as np import cv2 # 读取LUNA16的.mhd文件SimpleITK会自动带上对应的.raw itk_img sitk.ReadImage(/path/to/subset0/1.3.6.1.4.1.14519.5.2.1.6279.6001.298806137288633456128771857665073.mhd) ct_array sitk.GetArrayFromImage(itk_img) print(体数据形状:, ct_array.shape) # (Z, H, W)通常是(切片数, 512, 512) spacing itk_img.GetSpacing() origin itk_img.GetOrigin() def window_ct(volume, window_width1500, window_level-600): lower window_level - window_width / 2.0 upper window_level window_width / 2.0 volume np.clip(volume, lower, upper) volume (volume - lower) / (upper - lower) * 255.0 return volume.astype(np.uint8) ct_windowed window_ct(ct_array) for z in range(ct_windowed.shape[0]): slice_8bit ct_windowed[z] # 每个窗口化后的切片可以存成png供后续训练 cv2.imwrite(f./luna16_png/{patient_id}_z{z:04d}.png, slice_8bit)这段代码里最关键的两个参数是window_width和window_level。肺结节的典型CT值范围在-500到100附近用肺窗能把结节和血管的灰度差异拉开如果换到腹部场景就要把窗位提高到40、窗宽调到400。另一个骨架参数是spacing它决定了同一张切片在物理空间里对应的实际大小后面换算坐标时要用它做尺度还原。内存也会在这里第一次翻车一个512乘以512再乘以几百层的体数据直接全量载入需要几百MB建议按文件名切片后释放内存。2.3 把LUNA16的毫米坐标换算成YOLO格式的归一化框LINN16标注给的是世界坐标我们要的是特定切片上、像素坐标系里的边界框。换算分三步先用间距和原点把毫米坐标转成体素坐标再取出结节的z层值作为切片序号最后用直径算像素半径。这一步不做对后面画出来的框全偏在角落训练直接报废。import pandas as pd import numpy as np csv_path ./CSVFILES/annotations.csv df pd.read_csv(csv_path) spacing_x, spacing_y, spacing_z spacing # 每个体素在X/Y/Z方向上的毫米大小 origin_x, origin_y, origin_z origin def world_to_voxel(world_x, world_y, world_z): # 毫米坐标减去原点再除以spacing得到体素索引 vx (world_x - origin_x) / spacing_x vy (world_y - origin_y) / spacing_y vz (world_z - origin_z) / spacing_z return int(round(vx)), int(round(vy)), int(round(vz)) def box_to_yolo(center_x_voxel, center_y_voxel, diameter_mm): # 直径(mm)换算成像素半径再用图片宽高归一化 radius_pixel (diameter_mm / spacing_y) / 2.0 # 取y方向间距假设像素近各向同性 img_w, img_h 512, 512 x_center (center_x_voxel 0.5) / img_w y_center (center_y_voxel 0.5) / img_h box_w (radius_pixel * 2) / img_w box_h (radius_pixel * 2) / img_h return x_center, y_center, box_w, box_h for idx, row in df.iterrows(): vx, vy, vz world_to_voxel(row[coordX], row[coordY], row[coordZ]) if vx 0 or vy 0 or vz 0: continue # 越界的坐标直接放弃 xc, yc, bw, bh box_to_yolo(vx, vy, row[diameter_mm]) label_line f0 {xc:.6f} {yc:.6f} {bw:.6f} {bh:.6f}\n # 写入与切片png同名的txtYOLO格式一行一个目标 with open(f./luna16_png/{patient_id}_z{vz:04d}.txt, a) as f: f.write(label_line)代码里的radius_pixel用除以spacing_y而不是spacing_x是因为CT体素在横截面方向上不一定是正方形LUNA16里z轴间距通常是1毫米左右而横截面是0.7毫米左右。你没看错切片的像素宽高和对切片外框其实不一致这时以y方向为准会导致框略微变形但检测任务里影响不大风吹草动不至于丢目标。更值得注意的坑是z轴层数可能和标注的vz相差一个切片原因是CT重采样和标注采样的层不完全对齐。3. 用YOLO11训肺结节检测网络结构、训练命令与参数调优3.1 为什么主力选YOLO11s而不是n或更大YOLO11是目前Ultralytics系列里较新的检测模型官方变体从n到x都有。n模型虽然推理速度快但特征图较浅对LUNA16里大量10像素以下的小结节很不友好漏检率会明显上升。x模型精度高但显存、训练时长都翻倍对于二分类单类别场景性价比太低。LUNA16只有一类目标背景又是结构固定的CT切片用一个中等规模的模型足够容纳特征的复杂度所以主力选s变体。如果显存连8G都没有可以把n作为起点用知识蒸馏或者取消mosaic作为补救。YOLO11的网络结构和v8相比做了改动用C3k2块替换了部分C2f还在主干里保留了SPPF这样的空间金字塔池化模块来扩大感受野。这些改动体现在实际使用上最直观是同样的batch下推理速度变快FLOPs下降但精度不会因为轻量化掉太多。有一个需要留意的细节v8里常见的anchor base设计在YOLO11里被去掉了推理结果直接解码成边界框和置信度后处理少一步不需要调anchor参数。这对肺结节这种尺度分布极不均匀的任务反而省事。3.2 一份能落地的data.yaml和训练命令训练前要先把数据划分好。我的习惯是把病人级别做划分同一个CT的切片全放进同一个集合避免训练集和验证集出现同一病人的切片那样mAP会虚高得不像话。目录结构直接按YOLO规范组织# data.yaml train: ./datasets/luna16_yolo/train val: ./datasets/luna16_yolo/val nc: 1 names: 0: nodule训练命令一行就够了但参数要盯紧。我用的是s模型输入分辨率设为640batch取16。如果显存紧batch降到8也可以配合梯度累积把有效batch拉回去。第2行注释里写了几个调参入口后面细讲。yolo detect train \ modelyolo11s.pt \ data./luna16_yolo/data.yaml \ epochs120 \ imgsz640 \ batch16 \ device0 \ mosaic0.0 \ hsv_h0.015 \ hsv_s0.5 \ hsv_v0.3 \ degrees0.0 \ fliplr0.5 \ label_smoothing0.1这里的mosaic0.0需要解释。YOLO系列默认训练会做mosaic增强把四张图拼一起对于通常的目标检测是有益的但肺结节尺寸小拼接时极容易把结节裁到拼缝附近而且四个切片拼一张图会强行改变CT灰度的统计分布。我实际跑出来的结果是开mosaic时loss降得慢关闭后收敛更快。degrees0.0也好理解CT切片的解剖方向是固定的旋转90度或45度会让模型学到错误的方向先验所以不做角度增强。fliplr保留0.5做水平翻转左右肺镜像后结节语义不变是一分钱不用花的数据扩充。3.3 增强与lossmosaic要不要关、假阳怎么压关闭mosaic之后训练数据量会下降不少这时可以用hsv颜色增强来补充。hsv_h设0.015只做很小幅度的色相扰动hsv_s和hsv_v的扰动幅度可以稍微大一点因为窗口化后的CT切片本身是灰度图但导入训练代码时会被转成三通道轻微的对比度扰动相当于做了灰度抖动让模型不至于死记某一套窗宽窗位的输出。假阳性的压力主要在loss层面。LUNA16里每个切片最多一两个结节背景占比极高模型很容易出现“全图都是背景也能把loss压得很低”的情况。我一般会在训练命令里加cls和box的loss权重把边界框回归的权重稍微调高一点分类分支用focal产物来自动压制易分类负样本。# 通过修改yolo11s.yaml中的loss权重项而不是改训练命令 # scale: 0.5, 1.0, 1.5分别对应box、cls、dfl的折算比例 # 建议手动把cls从0.5调到0.4让box分支用力一些验证阶段看两组数据一组是val集合的mAP50一组是每类别的PR曲线。LUNA16的阈值定在3毫米以上也就是直径最小的结节像素尺寸大约是4.2像素如果你的mAP50高于0.85但假阳率异常高优先降低conf_thres而不是重新训练。第三节那个GUI里的置信度滑块就是要干这个用的它把threshold调节从训练阶段挪到了推理阶段实际操作时最顺手。4. 图形化界面用PyQt5把YOLO11模型包成可点选的工具4.1 选型PyQt5、Tkinter还是Web界面做一个单机演示用的图形化界面可以在tkinter、pyqt5和web界面之间选。tkinter最轻不需要额外装包但控件样式原始做滑动条和图片缩放时开发效率低。web界面用Flask或FastAPI做后端在前端调接口效果最现代但要同时维护前后端两套代码。我的选择是pyqt5它是python图形化界面开发工具里最主流的方案控件成熟能直接复用QThread做异步推理本地跑起来不会卡。界面布局按医生使用习惯来左边一个大的图像显示区右边是“打开文件”“开始检测”按钮和置信度阈值滑动条。检测结果显示在原图上框的颜色用红色医生扫一眼就知道哪些区域被标记为疑似。窗体本身不用复杂一个QMainWindow加几个widget就够了窗口大小控在1400x900左右保证一台普通工作站上能完整显示。4.2 一个最小可跑的GUI骨架加载模型、选图、画框核心代码不依赖任何界面美化库用pyqt5的QLabel显示图片用QFileDialog选文件用YOLO的predict方法做推理。加载模型一次就行不要每次点按钮都重新加载那会很慢。import sys from PyQt5.QtWidgets import QApplication, QMainWindow, QLabel, QPushButton, QSlider, QVBoxLayout, QWidget, QFileDialog from PyQt5.QtCore import Qt from PyQt5.QtGui import QPixmap, QImage from ultralytics import YOLO class DetectorWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(基于yolo11的肺结节检测) self.model YOLO(best.pt) # 训练好的yolo11权重提前加载 self.conf_thres 0.25 image_label QLabel(点击左下角按钮打开CT切片) image_label.setAlignment(Qt.AlignCenter) self.image_label image_label open_btn QPushButton(打开文件) open_btn.clicked.connect(self.open_file) detect_btn QPushButton(开始检测) detect_btn.clicked.connect(self.run_detect) slider QSlider(Qt.Horizontal) slider.setRange(1, 99) slider.setValue(25) slider.valueChanged.connect(self.on_conf_changed) self.slider slider layout QVBoxLayout() layout.addWidget(self.image_label) layout.addWidget(slider) layout.addWidget(open_btn) layout.addWidget(detect_btn) container QWidget() container.setLayout(layout) self.setCentralWidget(container) def open_file(self): path, _ QFileDialog.getOpenFileName(self, 选择切片, , PNG files (*.png)) if path: self.current_path path pixmap QPixmap(path) self.image_label.setPixmap(pixmap.scaled(800, 600, Qt.KeepAspectRatio)) def run_detect(self): # 传入切片和置信度阈值返回绘制后的结果图 results self.model.predict( sourceself.current_path, confself.conf_thres, iou0.45, verboseFalse, ) plotted results[0].plot() # 已经是带框的BGR图片 h, w, ch plotted.shape bytes_per_line ch * w qimage QImage(plotted.data, w, h, bytes_per_line, QImage.Format_RGB888).rgbSwapped() self.image_label.setPixmap(QPixmap.fromImage(qimage).scaled(800, 600, Qt.KeepAspectRatio)) def on_conf_changed(self, value): # 拖动滑块时即时更新阈值点击检测后生效 self.conf_thres value / 100.0run_detect方法里有一行是plot()它是YOLO自带的绘制函数能在原图上把检测框、置信度数字全画出来省去手写cv2.rectangle的麻烦。这里closure的结果图是BGR顺序而QImage按RGB解释所以最后用rgbSwapped()把顺序换回来不然在界面里看到的颜色会偏蓝。滑块把置信度阈值暴露给操作者这个设计比重新训练模型务实得多因为不同医生的接受程度不同有的宁可多看几个假阳也不想漏诊有的则要求高阈值、只要高置信度候选。4.3 推理放子线程别把界面卡成假死一个典型的翻车现场是界面点完“开始检测”后窗口标题栏显示“未响应”悬停鼠标光标变成转圈过几秒又恢复。原因是predict被放在了主线程里GUI线程被推理过程占用Qt无法重绘界面就被系统判定为假死。解决方法是把推理放进QThread。from PyQt5.QtCore import QThread, pyqtSignal class DetectWorker(QThread): finished pyqtSignal(object) def __init__(self, model, path, conf): super().__init__() self.model model self.path path self.conf conf def run(self): result self.model.predict(sourceself.path, confself.conf, iou0.45, verboseFalse) self.finished.emit(result)在窗口类里把run_detect改成创建线程、连接finished信号结果绘制逻辑放到槽函数里。线程结束之后记得deleteLater否则每点一次按钮就多一条僵尸线程。这个改动只需要十几行但对实际使用的体验提升非常明显尤其是模型跑在CPU上的时候一次推理几百毫秒不做线程隔离根本没法给别人演示。5. 肺结节检测常见翻车点从LUNA16坐标到假阳性排查5.1 画出来的框全偏左上角现象验证集里预览每个切片都画了一个框但位置全堆在角落或者框和结节完全不重叠。原因把LUNA16标注的毫米坐标直接当成了像素坐标没有除以spacing、没有减origin结果是坐标被放大了几十倍归一化后全部挤到图像边缘。解决按第2.3节的换算流程先转体素坐标再归一化。调试时先在原图上画框对比不要直接开训练一个肉眼可见的框比任何指标都直观。5.2 模型loss降不下去或震荡现象训练跑到第20个epochloss纹丝不动甚至往回涨。原因CT原图是16位灰度直接resize到输入尺寸时如果没做窗口化整个图像的对比度极低模型要从几乎全灰的输入里学特征优化器很难稳定下降。解决重新检查数据预处理确认切片已经做了窗宽窗位映射到0到255千万别直接丢png出来。另外看一下数据目录里有没有不小心混入了空白的纯黑切片那种样本会干扰BN层的统计。5.3 mAP很高但医生没法用现象val的mAP50过0.9但实际在整片CT上测试时一个切片能标出几十个假阳血管截面、肺纹理、小片纤维化全被报成结节。原因LUNA16的切片级验证里正负样本比例相对温和但整片CT的背景复杂度远高于验证集模型泛化不够硬。解决把推理时的conf从0.25往上提到0.4到0.5假阳会成倍下降或者训练时加入更多无结节的纯背景切片作为负样本。也可以给直径设下限过滤掉小于3毫米的检测框对齐LUNA16的评测口径。5.4 GUI点检测就转圈白屏现象界面打开正常一按“开始检测”就卡住不动标题栏出现“未响应”。原因推理直接跑在Qt主线程里predict期间无法处理重绘事件被OS判定为假死。解决把predict封装进QThread结果用信号槽机制丢回主线程显示参考第4.3节的写法。注意QThread里的model实例不用复制YOLO在推理时会自己加锁不会因为多线程调用出冲突。5.5 装了CUDA还是CPU推理现象GUI运行正常但检测速度只有一两帧每秒明显走了CPU。原因YOLO在Ultralytics里默认会自动选设备但PyQt5加载了多线程之后如果环境变量CUDA_VISIBLE_DEVICES没设置或者PyTorch装的是CPU版本模型就会落到CPU上。解决在加载模型前打印model.device确认是cuda。如果装了GPU版但没生效检查torch.cuda.is_available()最常见原因是换了CUDA版本但pip装torch时没带对应的cu后缀我当时重装一次torch就解决了。6. 从mAP到界面响应验证、加速和滑窗推理的最后一公里训练完成后除了看val的mAP我建议手动过一遍混淆矩阵和PR曲线再交付给同事。混淆矩阵能直接看出假阳最常出现在哪一类背景里PR曲线的拐点能帮你决定GUI里置信度的默认值。我用YOLO的val接口导出过一次预测结果再把置信度从0.1踩到0.9逐点画PR曲线发现LUNA16的曲线在0.4到0.5之间有个明显转折过了这个点假阳减少但召回掉得厉害最后把GUI里的默认阈值定在0.4。推理速度不够时先把模型导出成onnx或tensorrt。yolo11s在GPU上跑640输入大概几十毫秒但CPU上就要几百毫秒导出成onnx配合int8量化往往能跑进100毫秒附近。导出命令一行就行yolo export modelbest.pt formatonnx opset12另一个更实用的技巧是滑窗推理。CT切片有时候不止512乘以512界面里直接塞进yolo会缩放失真小结节在缩放后只有几个像素检测等于盲猜。我一般把原图切成640乘以640的patch步长取320重叠区域用NMS去重最后再把盒子映射回原图坐标。配合第4章的GUI滑窗推理放在线程里做医生选完大图后一两秒出结果。说到底完整跑通这套系统最大的收获不是那份权重文件而是建立起对医学影像检测的直觉坐标要先验证图要先窗口化阈值要让使用者能调。我最早调LUNA16坐标花了两个周末才想明白毫米和像素的关系现在无论换哪个数据集第一步永远是拿标注画一次框确认坐标没有偏才敢继续往下走。这个习惯帮我省掉过好几轮无效训练。希望这个方案能让你少走一段同样的弯路做完之后回头看这套流程顺手也耐磨。本文还有配套的精品资源点击获取