ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于YOLO12的肠息肉检测系统:从训练到PySide6桌面应用实战

基于YOLO12的肠息肉检测系统:从训练到PySide6桌面应用实战

结肠镜检查的痛点是镜检过程产生大量帧图像,单靠医生肉眼逐帧筛查很容易漏掉扁平型息肉,这类息肉后期恶变风险并不低。这就是我用深度学习做肠息肉检测的出发点。这段时间基于 YOLO12 训练了一版肠息肉检测模型,配套做了 PySide6 桌面客户端和一套完整训练代码,整体拉通下来效果不错,也踩了不少坑。

这篇内容是我个人在这个项目里的完整实操记录,覆盖选型思路、算法细节、训练流程、界面开发、打包发布和问题排查,适合想用 YOLO12 做医疗影像目标检测、以及想把模型落地成桌面工具的人参考。

1. 项目整体拆解与方案选型

1.1 为什么选 YOLO12 而不继续用 YOLOv8

YOLO 家族换代速度很快,做实际项目不能只看榜单指标,更要看部署成本、训练稳定性和生态成熟度。YOLOv8 很多同学已经很熟了,工程化范例多、文档全,但它的 C2f 模块在捕捉多尺度上下文信息时还是偏保守。YOLO12 引入了 Area Attention 机制,这算是我愿意迁移的重要理由——它在不显著增加计算量的前提下,有更好的全局上下文建模能力。

Area Attention 的思路是把特征图划分成多个区域,在每个区域内做注意力计算,再跨区域融合信息。相比全局注意力,它避免了整张特征图做矩阵运算的高开销;相比普通卷积的局部感受野,它的有效感受野又更大。对肠息肉这种目标大小变化很大的场景比较友好:同一帧图像里,有的息肉只有十几像素,有的息肉占了大半个画面。统一用固定卷积核去适配所有尺度,效果往往不够好,注意力机制能自适应调整关注范围。

另外,YOLO12 的 Anchor-Free 检测头在解码逻辑上更简洁,配合 TaskAligned Assigner 做标签分配,训练时正负样本划分更合理。我的训练数据里息肉形态差异很大,有隆起型、平坦型、带蒂型,统一标注为单一类别,这种类别内差异大的数据用 Anchor-Free 方式收敛起来更稳。

当然,迁移成本也要考虑。好在这一版模型结构改动虽然大,但训练配套组件——数据增强、EMA、学习率调度——基本都是成熟套路,不需要我从零造轮子。用一句话总结选型逻辑:既要更强的特征提取能力,又要保持推理速度足够跑实时视频流,YOLO12 是目前平衡得比较好的方案。

1.2 界面层为什么用 PySide6

模型训练好只是第一步,实际使用场景里,医生需要一个能快速打开、拖入图片或视频就能看的工具,而不是命令行。PySide6 是 Qt6 的 Python 绑定,界面控件丰富,QGraphicsView、QGraphicsScene 这套组合做图像展示非常顺手,支持缩放、拖拽,渲染效率也不错。

在方案对比阶段,我也试过用 OpenCV 自带的 HighGUI 做窗口,但它做多按钮、多面板布局太吃力,交互体验比较原始;也考虑过 Web 方案——Flask 后端 + 前端页面,优点是跨设备访问,但本地部署反而麻烦,还得起服务、管端口。桌面客户端配合 PySide6,对单机使用场景是更直接的选择。

PySide6 的 QThread 机制在视频流检测场景里是刚需。实时检测时,推理循环不能堵住主线程,否则界面会卡死,视频画面一卡一卡的,体验很差。我的设计是:摄像头采集和模型推理放在一个后台线程,检测结果通过 Signal 发回主线程刷新界面,主线程只负责绘制。这样实测在本地实时预览时,画面帧率能稳定在 25 到 30 FPS 左右。

1.3 系统整体架构与模块划分

整个系统拆成四个独立模块,目的是让训练、推理、界面、工具互不干扰:

第一个是数据模块,包含数据集整理脚本、标签格式转换、训练集验证集划分。第二个是训练模块,封装了模型定义、训练循环、验证循环、EMA、学习率调度、早停机制,以及训练过程可视化。第三个是推理模块,负责加载权重、预处理、模型 forward、后处理 NMS、结果绘制,这部分单独抽出来,方便后续统一给界面和测试脚本调用。第四个是界面模块,基于 PySide6 实现图片检测、视频检测、摄像头实时检测三条交互流程。

这四个模块各自独立,但依赖关系清晰。数据模块输出数据集路径和标签文件,训练模块消费数据并产出权重文件,推理模块加载权重文件输出检测结果,界面模块调用推理模块完成交互。任何一层替换都不影响其他层。比如后续想换检测头或者改模型结构,只需要动训练模块和推理模块,界面层完全不用动。

2. 核心细节解析与实操要点

2.1 预处理逻辑:归一化和自适应缩放

模型训练和推理阶段,预处理必须保持一致,否则训练时见到的数据分布和推理时不一样,效果会明显下降。YOLO12 的输入尺寸我固定为 640x640,预处理分三步。

第一步是自适应缩放。输入的原始图像可能是不同分辨率的长方形,比如内镜图像的常见分辨率是 1920x1080。直接拉伸成 640x640 会改变目标的长宽比,而息肉的长宽比本身是很有用的形态特征。正确做法是保持长宽比缩放到短边为 640,然后对不足的地方填充灰色像素,形成一个 640x640 的完整输入图。填充的灰度值选 114,这是检测领域的经验值,调成 0 或 255 效果反而差一些。

第二步是 BGR 转 RGB。训练框架里图像通道默认顺序是 RGB,但 OpenCV 读图得到的是 BGR,如果不转换,模型看到的颜色通道是错乱的,特征会混乱。

第三步是归一化。将像素值除以 255,转换到 0 到 1 区间。这一步可以直接放在网络 forward 之前做,也可以写进预处理函数里。我踩过的一个坑是训练时归一化、推理时忘归一化,导致检测置信度整体偏低,排查了好一阵才定位到是预处理不一致,所以现在直接包成一个函数,任何地方调用都走同一个入口。

2.2 推理模块的完整流程

推理流程看着简单,但每一步都有细节值得注意。输入图像经过预处理后得到 640x640 的张量,在 GPU 上做一次 forward,输出是一个大矩阵,形状是[1, 84, 8400]。84 的含义是 4 个边界框坐标加 1 个目标置信度加 80 个类别分数,8400 是所有特征图上的候选框总数。这个数字跟输入尺寸、特征图下采样倍数直接相关。

然后是解码和后处理,这是最容易出错的地方。YOLO12 的检测头输出不是直接的边界框坐标,而是相对于每个网格的偏移量,需要结合网格坐标和步长解码成真实框。紧接着是置信度过滤和 NMS。我写了一个non_max_suppression函数,主要逻辑是:先把置信度低于 0.25 的候选框过滤掉,然后按置信度排序,逐个剔除重叠度过高的框。NMS 的 IoU 阈值我设为 0.45,这是调参过程中对比了 0.35、0.45、0.5 三组值后选的折中方案——阈值太低容易重复框,阈值太高容易漏掉紧密相邻的息肉,整体测试下来 0.45 表现最均衡。

后处理阶段还需要把坐标从 640x640 的输入图映射回原始图像坐标。因为预处理时做了等比缩放,这里只要按缩放比例反向放缩即可,填充区域对应的坐标会被过滤掉,不会影响结果。

检测框画到原图上之后,我在框的左上角用cv2.putText标注类别名和置信度。置信度信息挺好的,它能在一定程度上反映这个区域是息肉的可能性,提醒医生重点关注低置信度区域——这类情况往往是形态不典型的息肉,反而有漏检风险。

2.3 训练模块的关键参数与监控指标

训练配置是实测多轮后定下来的,直接列出参考值:

输入尺寸 640,批次大小 16,初始学习率 0.001,带 warmup 的余弦退火调度。优化器选 AdamW,权重衰减设 0.0005。EMA 衰减系数 0.9998,用于平滑模型参数,提升验证精度。训练总轮次 150,早停 patience 20,也就是验证集 mAP 连续 20 轮不提升就停止。前 3 轮是 warmup 阶段,学习率从 0.0001 线性升到 0.001,目的是让模型在早期不因为学习率过大而震荡。

数据增强方面,我用了马赛克增强、随机翻转、随机色调抖动、随机缩放和随机平移。马赛克增强把四张图拼成一张送入训练,效果显著,相当于变相增大 batch size。但马赛克增强在训练后期应该逐步关闭,否则会增加收敛难度。我的做法是最后 10 轮关闭马赛克,让模型在接近真实数据分布上微调,提升验证精度。

训练期间重点盯着四个指标:train loss、box loss、cls loss、val mAP50 和 mAP50-95。mAP50 表示 IoU 阈值为 0.5 时的平均精度,mAP50-95 是 0.5 到 0.95 每隔 0.05 取一个阈值算平均,后者更严格,也更能反映模型定位精度。我的模型最终 mAP50 大约在 0.91,mAP50-95 大约在 0.68,在检测模型里算是不错的水平。如果你训练出来的 mAP50-95 偏低,而 mAP50 正常,说明定位框不够精准,可以针对性降低 NMS 阈值或者加大回归损失的权重。

3. 数据集准备与训练全流程实录

3.1 数据来源、标注格式与目录结构

数据是项目的基础,我直接用的是公开的结肠镜图像数据集,包含数万张内镜图像,标注为单类别 polyp。数据分布涵盖不同分辨率、不同光照条件和不同内镜设备拍摄的图像,这样的多样性能提升模型的泛化能力,避免在单一设备数据上过拟合。

标注格式转换是在数据准备阶段要解决的关键问题。我拿到的是标注文件,需要统一转成 YOLO 格式:每行一个目标,格式为class_id x_center y_center width height,坐标都是相对图像宽高的归一化值。转换脚本主要有两个功能:一是读取原始标注框的像素坐标,换算成归一化中心点坐标和宽高;二是做类别映射,把所有需要的类别统一对到一个 ID。

内存管理对训练效率也有影响。我的方案是在训练脚本里写了个数据加载器,用cache=True参数把图像和标签缓存到内存。但息肉图像数据量比较大,全量缓存不太现实,所以只缓存标签,图像用流式读取。这样既能大幅减少磁盘 I/O,又不会把内存吃到十几 GB。

数据集目录结构如下:

dataset/ images/ train/ img_001.jpg val/ img_102.jpg labels/ train/ img_001.txt val/ img_102.txt

目录层次简单,路径清晰,训练脚本里只需要传入data.yaml配置文件,配置文件里写好数据根路径、训练和验证图片目录、类别数,就可以直接启动训练,不需要在命令行里传一堆参数。

3.2 训练时的复现性与环境管理

训练的可复现性在实验阶段很重要,我为此做了一些配置上的约束。固定了随机种子,设置torch.backends.cudnn.deterministic = True,并且把torch.backends.cudnn.benchmark设置为 False。虽然 benchmark 模式在一些固定输入尺寸任务上能带来小幅加速,但它会引入不确定性的卷积算子选择,导致结果不稳定。输入尺寸固定时牺牲这点速度换取可复现的验证精度,是划算的。

环境管理方面,我用虚拟环境规格文件保存了一整套依赖版本,重点锁定了框架版本和推理库版本。模型训练用的框架版本和界面端推理时候的框架版本保持完全一致,避免因算子实现改动导致结果微妙漂移。别的依赖版本也尽量锁死,因为换一个版本,某些后处理函数的运行行为可能变化,比如置信度过滤逻辑或者 NMS 实现细节,这种隐形改动极难排查。

3.3 从零训练到收敛的完整记录

我完整跑过一次 150 轮的训练,把过程记录在这里,给还没跑通完整流程的人参考。

前 10 轮是快速下降期,train loss 从初始的 8 左右快速降到 2 附近,这说明模型正在学习基础特征——边缘、颜色、纹理等,定位框开始有雏形。第 10 到 30 轮,loss 下降速度明显变慢,mAP50 从 0.35 一路涨到 0.78,这个阶段是特征细化期,模型在分辨易混淆的黏膜纹理与真实息肉的边界。第 30 到 70 轮,mAP50 从 0.78 涨到 0.88,涨幅放缓,说明模型开始逼近当前结构的上限。第 70 到 120 轮,mAP50-95 缓慢爬升,从 0.55 涨到 0.65,这个阶段主要提升的是边界框精度,因为真实漂移的框越来越少。第 122 轮触发早停,验证 mAP50 不再显著提升,训练结束。

训练过程中的一个经验是,不要只看 mAP 指标,要定期在验证集上可视化推理结果。我每 20 轮保存一次验证集的检测结果图,肉眼观察漏检和误检情况。有时候 mAP 数字在涨,但漏检的是某些特定角度下的平坦型息肉,这种问题光看数字看不出来,必须看图像才能定位。有一次我发现模型对小目标息肉检测效果很差,后来检查发现是很多小目标在标注时被标得过大——标注框把周围的黏膜也包进去了,模型学到的边界就模糊了。这是标注质量直接影响模型能力的典型案例。

4. 界面开发与软件打包

4.1 PySide6 界面结构与交互流程

界面模块的核心是一个主窗口,顶部是菜单栏,左侧是功能面板,中间是图像显示区,右侧是结果信息区。功能面板提供三个核心入口:图片检测、视频检测、摄像头实时检测。

图片检测的交互流程是:点击按钮选择图片文件,程序调用推理模块处理单张图像,结果直接在图像显示区渲染,右侧列表展示检测数量、每个框的置信度,以及当前帧耗时。视频检测的交互流程是:选择视频文件,程序逐帧读取、逐帧检测、逐帧渲染,左上角显示当前帧率,用户可以在任意帧暂停,查看该帧的检测明细。摄像头实时检测流程类似,但读取源换成相机设备,需要额外处理分辨率设置、帧率设置、自动曝光参数等细节。

界面代码的组织上,我用了 Qt 的信号槽机制做线程通信。后台视频线程每一帧处理完成后,发出一个携带图像和检测结果的自定义信号,主线程的槽函数负责把QImage绘制到QLabel或者QGraphicsView上。这个设计避免了在子线程直接操作界面控件导致崩溃的问题。每次视频线程在循环里还要检查一个退出标志位,响应用户点击停止按钮的操作,否则线程无法正常终止,界面会残留后台进程。

4.2 视频流线程与界面刷新的性能优化

这里有一个性能关键点值得展开说。开始时我直接把推理放在主线程循环里跑,结果界面卡顿严重,视频播放完全不受控。后来把推理放到子线程,但每帧都往主线程发送信号,主线程频繁重绘,依然会掉帧严重——因为视频流 30 FPS,意味着每秒要重绘 30 次,每次重绘还要解码并显示一张大图,开销不小。

优化方案是队列 + 跳帧机制。子线程只负责推理,推理结果放到一个长度上限为 3 的队列里,超过长度就丢弃最旧的帧。主线程按自己的节奏从队列里取最新帧显示,宁可丢几帧,也要保证界面流畅。实际效果是界面稳定在 30 FPS 左右,推理端如果速度低于显示帧率也不会导致界面卡死。

结果信息区的更新用了一个小技巧:只在画面更新时刷新计数,而不做实时逐像素同步。比如检测到 4 个息肉,界面只更新“当前帧检测数:4”这一行文字,不会因为每帧都重复绘制整个表格而拖慢刷新速度。这个细节对长时间视频分析的流畅度影响很大。

5. 部署与打包中的坑

5.1 推理端部署与模型导出

模型训练完成后,并不能直接用训练出的权重文件做最终部署,中间需要做一步格式导出。我采用的最稳妥方案是导出为 ONNX 格式,再用 ONNX Runtime 做推理。这一步导出的核心问题是动态维度:如果不显式固定batch=1和输入尺寸,ONNX 导出的产物会带动态轴,推理时虽然方便,但性能有损失,而且一些端侧优化工具对动态轴支持不好。我直接导出固定尺寸的 ONNX,也就是(1, 3, 640, 640),速度最快最稳。

导出后做了一轮验证,把同一张图分别用训练流程图推理和 ONNX Runtime 推理,对比检测框坐标和置信度。坐标误差控制在 1 像素以内,置信度误差在 0.002 以内,基本可以忽略。这个验证别省略,很多同学做完导出不打对比,结果部署后发现问题再回头查,浪费大量时间。

5.2 打包成独立可执行文件的注意事项

打包桌面应用时,PyInstaller 是常见选择。打包有两类坑,一类是动态链接库缺失,另一类是资源文件路径问题。动态库方面,ONNX Runtime 的 DLL 依赖了部分 VC 运行库,打包环境里没有的话,目标机器上运行会报cudart64_*.dll not found之类的错误,需要手动把这些 DLL 拷进打包目录。处理方式是在 spec 文件的datas参数里显式列出这些依赖文件。

资源文件方面,界面里用到的模型文件、图标和配置项,在本地开发时直接写相对路径能跑,但打包成 exe 后,程序的工作目录可能会变,相对路径失效。我统一做了资源路径解析函数,优先读取绝对路径,同时在开发环境里测试所有相对路径是否可用。打包完成后,不要只在开发机上测试,需要换一台没有配置过环境的机器跑一次完整流程,基本能暴露大部分打包问题。

5.3 实际使用中的边界场景处理

医疗场景的软件和普通 demo 不一样,边界情况的处理直接关系到实用性。

第一个场景是用户选中了一张不包含息肉的正常图像。这时候不能什么都没发生,程序界面要明确显示“未检测到目标”这一状态。实现上就是在界面上保留检测区域为空状态,信息区显示零个检测结果,并且判断当前帧是否有目标,如果没有目标则显示提示文字。

第二个场景是视频中某一帧画面模糊、光照极差。模型可能输出很低的置信度,比如 0.1,这个阈值如果不够低,会漏检。我设了一个专门的提示逻辑:当连续多帧没有任何检测结果时,提示用户“检测到的目标过少,请检查图像清晰度或光线条件”。这种提示看起来很基础,但对实际操作的指导价值很高。

第三个场景是界面关闭时资源释放。关闭窗口、退出视频流线程、释放摄像头设备、销毁模型推理会话,一个都不能少。我遇到过摄像头设备没有正常释放导致下一次启动时设备占用报错的问题,后来统一写进closeEvent方法里,才算彻底解决。

6. 常见问题与排查技巧实录

6.1 训练阶段的典型问题

loss 下降缓慢甚至震荡。先检查学习率是否过高,再看数据增强是否过强。我遇到过马赛克增强比例过高导致 loss 震荡的情况,关掉马赛克后收敛立刻变稳。如果做过 warmup,再把 warmup 轮数增加一点,基本上能解决多数收敛问题。

验证集 mAP 上不去。最常见原因是训练/验证数据分布差异大。我重新梳理了数据划分逻辑,保证同一设备拍摄的图像不会全部落在训练集而验证集完全是新设备的图像,而是按场景和时间均匀打散分配。这个操作让 mAP50 提升了大概 3 到 4 个点。

显存不足。如果你训练卡显存只有 8GB 甚至更小,直接把 batch size 调到 8,同时开启梯度累积,每两个小批次累积一次梯度,等效于 16 的 batch size 效果。不要为了凑 batch size 硬撑,显存溢出会导致训练直接崩溃。

6.2 推理阶段的典型问题

检测框位置偏移但置信度很高。这通常是预处理不一致导致的——推理时有没有做与训练时相同的缩放和填充。我认真核对了一遍预处理函数,发现推理时忘了用训练时的填充参数,修正后框位置就准了。

NMS 后检测框有重复。把 IoU 阈值往下调低一点即可。如果还不行,检查是否对每一类单独做了 NMS,还是把所有类别混在一起做了全局 NMS——多分类模型这里容易出问题。

界面显示卡顿。先确认推理是不是在主线程。如果是,搬到子线程大概率解决。如果已经是子线程,那就是每一帧都触发了重绘,参考前面说的跳帧机制处理。

6.3 实用调试技巧合集

排查推理结果异常时用对比法非常好用。同过一张图、同一个模型文件,分别跑 Python 脚本和界面程序,把所有中间结果打印出来对比,找到第一个结果不一致的位置,问题范围瞬间缩小。

排查性能问题时用插桩打点法。在图像读取、预处理、模型推理、后处理、绘制这几个阶段分别记录耗时,用日志打印出来,就能看到瓶颈在哪。我之前发现预处理居然占了总耗时的一半,优化成批量预处理后,整体帧率直接翻倍。

7. 总结与个人体会

这个项目从数据准备到最终桌面应用上线,整个链路完整跑通。我个人最深的体会是:目标检测的难点不只是模型结构,从数据标注质量、预处理一致性、后处理参数调优到界面线程调度,每一环都影响最终效果。YOLO12 的注意力机制确实让模型对形态差异大的目标有更好的表征能力,但真正让它在实际场景里稳定可用的,是每个工程细节的不断打磨。

最后分享一个小经验:做医疗影像检测类项目,一定要在界面上把置信度保留显示出来,别单纯过滤掉低置信度目标。低置信度区域往往代表着形态不典型、容易被漏检的病灶,这些区域在辅助诊断场景里恰恰是最有价值的提示。把这套系统拿去用的时候,记得针对性收集你实际运行环境里的图像,再在训练集里补充一批类似场景的数据,效果会有很明显的提升。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进