工业相机取流与YOLO推理:PyQt报警状态机工程落地 把相机接上能出图把 YOLO 跑起来能画框这两件事单独拆开做都不算难。真正让人抓头的是中间那一段——采到的一帧图像怎么稳稳当当地交给推理推理吐出来的框怎么变成一条“这次真的要报警”的判断判断出来了又怎么让界面不卡、日志不丢、产线不跟着乱叫。我在几个视觉检测的现场反复踩过这条链路最典型的一次是离线看模型效果挺好一上产线报警灯闪成跑马灯产线师傅直接把线停了让我先把“它到底在叫什么”解释清楚。后来才发现问题根本不在模型而在报警逻辑——单帧命中就触发检测框还带抖动一个零件被重复数了三次。这篇内容讲的就是这套东西以某国产 GigE 工业相机为图像来源用 YOLO 做目标检测用 PyQt 搭界面然后把“检测结果”翻译成“报警动作”的完整落地方法。关键词是工业相机取流、YOLO 推理、PyQt 界面、报警逻辑状态机这四个东西怎么对接。适合已经能把模型跑通、但卡在“工程化”这一步的读者也适合做设备集成的朋友想搞清楚视觉侧到底该往外吐什么信号。代码我尽量写成抽象接口的形式换个品牌的 SDK 只要改最外面那一层中间的骨架不用动。1. GigE 相机的取流链路从回调到一帧可推理的图像工业相机和普通 USB 摄像头最大的区别不在分辨率而在“它被设计成需要被正确配置”。USB 摄像头插上就能跑工业相机插上以后网卡、IP、包长、触发模式、像素格式、心跳超时任何一项没配好表现出来的都是同一个症状连上了但没图或者图断断续续。我见过不少人卡在这一步然后在代码里疯狂加重连逻辑其实是网络层没调优。1.1 设备枚举与连接初始化顺序比代码本身更重要枚举设备这一步本身没什么技术含量SDK 一般提供“按接口类型搜索”和“按 IP 搜索”两种方式。真正容易翻车的是多网卡环境。工控机上有三块网卡——一块接办公网两块接相机——如果代码里不显式指定网卡SDK 很可能默认走第一块网卡去发广播结果就是“相机明明亮着灯但搜不到”。我的做法是永远显式绑定网卡 IP把“用哪块网卡找相机”写进配置文件而不是依赖自动发现。连接的顺序我固定成这样实测最稳打开网卡句柄设置包长Jumbo Frame为 9000 字节同时确认交换机也开了巨帧搜索设备拿到设备列表按序列号而不是 IP 匹配目标相机IP 可能被现场工程师改过打开设备立即设置心跳超时时间为 3000ms 左右设置采集模式为连续采集关闭自动曝光和自动增益工业检测要可重复性自动参数是灾难设置像素格式注册回调最后才开始取流。注意顺序错了会出现“设置参数时相机已在出流”的情况部分型号会直接返回错误码而错误码又不好好说明原因排查起来非常耗时。另外巨帧这个事值得单独说一句。网卡上没开巨帧相机那边设了 9000后果不是“慢一点”而是大量丢包。表现是帧率看着还行但偶尔报“取流超时”而且越大的分辨率越明显。这个坑我踩过两次第一次查了两个小时以为是代码问题。1.2 触发模式怎么选连续采集适合调试硬触发适合产线调试阶段我一般用连续采集方便随时看画面、调焦、对光。但上产线基本都要切到硬触发由光电开关或者 PLC 给一个上升沿相机曝光一次。为什么必须触发因为检测的语义是“这个工位上、这一刻、这一个零件”连续采集拿到的是时间上连续的一堆帧你无法把某一帧和某一个零件严格对应起来。硬触发把这个对应关系锁死了一个触发信号对应一帧图一帧图对应一个检测结论。触发模式下有一个参数经常被忽略就是曝光时间与运动模糊的关系。计算很简单允许的像素位移 曝光时间 × 运动速度 ÷ 像素当量举例线速度 200mm/s像素当量 0.1mm/pixel要求位移不超过 1 个像素那么曝光时间上限就是 0.1mm ÷ 200mm/s 0.5ms。超过这个值边缘就会糊掉模型在小目标上的召回会明显下降。我见过不少现场是“模型不好使”最后发现是曝光给到了 5ms。提示曝光时间降不下来的时候优先加光源亮度而不是去调模型的置信度阈值。光源是物理层阈值是补丁层补丁打多了会越打越乱。1.3 回调线程里只做三件事拷贝、打时间戳、入队SDK 的回调运行在它自己的线程里这个线程的职责是“尽快把数据搬走”一旦你在里面做耗时操作相机侧的缓冲区就会积压最终表现为丢帧甚至断流。我在回调里只做三件事深拷贝一份图像数据很多 SDK 的缓冲区是复用的不拷贝就等着数据被踩打上单调时钟时间戳和设备帧号塞进一个有界队列队满则丢弃最旧的一帧并计数。推理、绘制、写日志、发信号统统不在回调里干。这个边界划清楚以后“跑一晚上丢几十帧”这类问题基本消失。有界队列的容量我一般设 2 到 4。设大了没意义因为图像处理是实时的积压超过 4 帧说明消费端已经跟不上与其排长队不如主动丢帧并让上层知道“我在丢帧”。丢帧计数是个非常有用的健康指标建议直接显示在界面上。1.4 像素格式转换Mono8 和 BGR8 的处理路径不一样工业相机常见的像素格式有 Mono8、Mono12、BayerRG8、RGB8、BGR8 几种。OpenCV 吃的是 BGR 三通道或者单通道灰度所以中间要做转换。Mono8直接np.frombuffer之后 reshape 成 (H, W)不用转YOLO 也能吃灰度图训练时用灰度就完事。这条路最省 CPU。Mono12 / Mono10注意数据是按 16 位存的要右移或者按比例压到 8 位直接 cast 会全白或者全黑。Bayer 格式需要做去马赛克SDK 一般提供转换函数也可以用 OpenCV 的cvtColor指定对应的 Bayer 模式。这一步比想象中吃 CPU能选 BGR8 直出就直出。RGB8 转 BGR8一个cv2.cvtColor的事但注意别在主线程做。我的建议是在相机端就把格式设成下游最需要的让相机做转换而不是让 CPU 做。相机内部的转换是流水线硬件几乎不占主机资源。2. 把 YOLO 喂饱预处理、推理节奏与结果结构相机这一段搞定以后很多人会立刻写一个while True取一帧、推理一帧、画个框、更新界面。写到第三天就会发现界面越来越卡内存越来越高然后开始怀疑是 PyQt 的问题。其实问题在于“取流”和“推理”被塞进了同一个节奏里而它们的速率根本不匹配。2.1 letterbox 缩放与坐标反算别在这里丢精度YOLO 的输入尺寸是固定的比如 640×640而相机出来可能是 2448×2048。这里必须做等比缩放加灰边填充letterbox不能直接拉伸否则宽高比变了模型学到的形状先验就失效了。缩放之后模型输出的框坐标是在 640×640 空间里的要反算回原图坐标公式是x_orig (x_640 - pad_x) / scale y_orig (y_640 - pad_y) / scalescale是缩放比例pad_x/pad_y是填充的偏移量。这两个值必须和预处理时完全一致最好封装成一个PreprocessMeta结构体跟着结果一起走别在反算的时候重新算一遍——重算就容易出现“四舍五入方式不一致”导致框偏一两个像素虽然不影响判断但在精密测量场景会被当成 bug 追。提示如果你的检测目标很小比如小于 20 像素把输入尺寸从 640 提到 960 或 1280 通常比调阈值有效得多代价是推理时间上升。这是个明确的性价比权衡先算清楚时间预算再决定。2.2 追帧还是追新队列策略决定了延迟的形态消费端拿帧有两种策略我称它们为“追帧”和“追新”。追帧是严格按队列顺序消费好处是不丢任何一帧坏处是当推理速度跟不上采集速度时延迟会持续累积跑十分钟以后界面显示的画面可能是十秒前的。这在产线上是致命的因为操作员看到的是历史。追新是每次消费都取队列里最新的那一帧中间的丢掉。好处是延迟恒定在一帧推理时间左右坏处是漏检可能发生——某个零件的那一帧刚好被跳过了。我的做法是默认追新但对被跳过的帧做计数和上报。如果丢帧率超过 5%说明推理速度与产线节拍不匹配这时候要么降分辨率、要么换更小的模型、要么上加速硬件而不是继续硬撑。把一个“性能不足”的事实藏着最后一定会在客户现场以“漏检”的形式爆发。2.3 结果结构设计让下游只依赖数据结构不依赖模型这一条是我做过几个项目之后最想强调的。报警逻辑千万不要直接读 YOLO 的原始输出张量。你今天用 YOLOv8明天换 v11后天换成别的检测框架输出结构全变了报警逻辑要跟着重写。正确做法是定义一个中间层数据结构from dataclasses import dataclass, field from typing import List, Tuple dataclass class Detection: cls_id: int cls_name: str conf: float bbox: Tuple[int, int, int, int] # x1, y1, x2, y2原图坐标系 center: Tuple[float, float] area: int dataclass class FrameResult: frame_id: int ts: float image_shape: Tuple[int, int] detections: List[Detection] field(default_factorylist) cost_ms: float 0.0报警逻辑只认FrameResult和Detection模型换不换无所谓。这一层抽象花不了半小时但能省下后面无数次返工。顺带说一句frame_id和ts一定要带因为后面做“连续 N 帧确认”的时候要靠frame_id判断连续性——丢帧和重复帧都会让“连续”这个语义失真。2.4 置信度阈值与 NMS这两个参数不要在现场随手调置信度阈值和 NMS 的 IoU 阈值是现场最容易被随手改的两个参数。改完当天可能效果好一点但一周以后没人记得改过什么问题复现不了。我的习惯是这两个参数写死在配置文件里界面上只读不可写需要改就走变更流程并记录。真要现场调也提供一个“调试模式”调试模式下的修改只在当前会话生效退出即恢复同时在日志里留痕。另外置信度阈值不建议卡得太高。如果你发现必须把阈值提到 0.8 才能不误报那大概率是训练数据里负样本不够而不是阈值该调。把这类“产线背景”的图收集一批做负样本比调参数管用得多。3. 报警逻辑这个项目真正值钱的部分到这一节才算进入正题。前面两节都是“怎么把数据搬来搬去”而这一节是“怎么把数据变成决策”。我见过太多项目在这里偷懒——判断条件写成if len(detections) 0: alarm()然后被现场的各种抖动折磨到怀疑人生。3.1 从“单帧有目标”到“这次真的该报”状态机的四个状态单帧命中就报警本质上是在做“瞬时判断”而工业现场的检测需求几乎都是“事件判断”。这两者的差别就像“门口有人影”和“有人闯进来了”的差别。我用的状态机有四个状态状态含义进入条件退出条件IDLE无目标初始状态 / 恢复条件满足命中计数达到候选门槛CANDIDATE疑似单帧命中但未达确认条件累计确认帧数达标 → ALARM窗口内未达标 → IDLEALARM报警中确认条件满足目标消失并稳定 N 帧 → COOLDOWNCOOLDOWN冷却报警结束冷却时间到 → IDLE关键在于CANDIDATE这个中间态。它存在的意义是给“不确定”一个位置。没有这个状态你要么误报要么漏报没有第三条路。有了它低置信度的命中可以先进候选区待着攒够证据再升级攒不够就自然衰减掉。3.2 抖动抑制的四个参数连续帧、滑动窗口、滞回、冷却具体怎么攒证据我一般用四个参数配合连续命中帧数 N连续 N 帧都检测到同一类别才认为目标真实存在。N 取 2 到 5取决于帧率。帧率高可以取大一点。滑动窗口 M/K最近 M 帧里有 K 帧命中就确认。比“连续”更宽容适合目标偶尔被遮挡的场景。滞回进入报警的阈值比退出报警的阈值高。比如置信度 0.6 才报警但要跌到 0.35 以下才算消失。这一条专门对付“在阈值附近疯狂跳变”的情况效果立竿见影。冷却时间一次报警结束后强制静默一段时间比如 3 到 5 秒防止同一个持续存在的目标反复触发。举个具体配置可以当起点用alarm: confirm_frames: 3 # 连续确认帧数 window_size: 10 # 滑动窗口 window_hits: 5 # 窗口内命中数 enter_conf: 0.60 # 进入报警的置信度 exit_conf: 0.35 # 退出报警的置信度滞回 cooldown_sec: 5.0 # 冷却时间 recover_frames: 10 # 目标消失后稳定多少帧才认为结束这套参数不是拍脑袋来的是从“最小可辨识目标持续时间”倒推的。假设产线节拍是 2 秒一个零件你绝不可能对“持续 100ms 的目标”报警因为那必然是噪声。反过来如果目标正常会持续 1.5 秒那确认帧数就可以给到 5 帧以上。3.3 区域过滤与类别优先级别让背景把人吓一跳工业现场的画面里永远有你不关心的东西走动的工人、晃动的线缆、反光的金属件。如果只按“有没有目标”判断这些东西会把你坑惨。区域过滤是性价比最高的一招。给每类目标配一个多边形 ROI通常就是工位的那块矩形只有检测框的中心点落在 ROI 内才参与判断。实现上用cv2.pointPolygonTest就够几十个框也就微秒级。ROI 我在界面上做成可拖拽的矩形现场工程师自己就能框比让他改配置文件友好一百倍。类别优先级也必须有。当同一帧里同时出现“划痕”和“脏污”而这两个的处理方式完全不同时一个要停线一个只记录必须定好优先级。我的做法是给每个类别配一个severity字段取值 1 到 3报警时取当前帧里 severity 最高的那个类别作为这次报警的类型其余的作为附注写进日志。def pick_primary(detections, roi_poly, class_table): best None for d in detections: if not inside(d.center, roi_poly): continue rule class_table.get(d.cls_name) if rule is None or not rule.enabled: continue if d.conf rule.enter_conf: continue if best is None or rule.severity class_table[best.cls_name].severity: best d return best这段代码看起来平淡但它把“过滤、启用、阈值、优先级”四件事收敛到一处后面加新类别只要往class_table里加一行不用改逻辑。这种设计在项目后期价值极大。3.4 报警动作分级界面、日志、IO、外部通知各管一段“报警”不是一个动作是一组动作。我一般分四级界面级画面框变红、顶部状态条变色、可选蜂鸣。给人看要求是立刻可见但不阻塞。日志级写一条结构化记录包含时间戳、帧号、类别、置信度、框坐标、当时的参数快照可选存一张裁剪图。给事后追溯用。IO 级通过 IO 卡或者 PLC 输出一个脉冲。这是给产线设备看的要特别注意脉冲宽度和默认电平。脉冲太窄比如 10ms某些 PLC 采样不到我一般给 100 到 200ms默认电平要设计成“掉电即安全”的那种避免上位机崩溃时产线无保护。外部通知级发一条消息到内部系统。这一级必须异步且可失败绝不能因为网络抖动把整个检测循环卡住。这四级里第 3 级是最容易被低估的。我见过一个项目IO 输出写成了电平翻转而没有复位结果第一次报警之后信号一直是高电平产线那边以为“一直在报警”直接把整条线停了。这种问题排查起来极其耗时因为视觉侧日志显示一切正常。注意IO 输出一定要做成“脉冲超时复位”的双保险。即发出脉冲后启动一个定时器超过设定时间强制拉回默认电平防止逻辑异常导致信号粘死。4. PyQt 界面与后台线程的边界PyQt 在这个项目里的角色是“监控窗口”不是“计算节点”。任何在 UI 线程里做的耗时操作都会以“界面卡死”的形式反馈给用户而且这种卡死很容易被误判成程序崩溃。分清边界比写好看的界面重要得多。4.1 跨线程刷新信号槽用对了就不会崩基本事实Qt 的界面对象只能在主线程操作。后台的取流线程和推理线程要把数据传给界面必须通过信号槽并且连接方式要是Qt.QueuedConnection跨线程默认就是队列连接但显式写出来更保险。一个常见的错误是在后台线程里直接label.setPixmap(...)。小概率不崩大概率在某些平台上偶发崩溃而且崩溃位置离真正的原因十万八千里。我的做法是定义一个轻量的信号载体class UiBridge(QObject): frame_ready pyqtSignal(object) # np.ndarray(BGR) 已缩放 result_ready pyqtSignal(object) # FrameResult alarm_fired pyqtSignal(object) # AlarmEvent stat_updated pyqtSignal(dict) # fps / 丢帧 / 耗时后台只负责emit界面只负责slot里更新控件。两边谁也不等谁。要注意frame_ready传的图像必须已经在后台缩放好。如果你传的是 2448×2048 的原图然后在setPixmap之前主线程再resize那么每次刷新的开销都在主线程1080P 的显示区照样能把你拖到 15fps。缩放放在后台代价是几毫秒收益是主线程几乎零负担。4.2 三种图像显示方案的取舍方案上手难度高帧率表现适用场景QLabel QPixmap最低一般超过 30fps 开始吃力展示为主分辨率不高QGraphicsView QGraphicsPixmapItem中等较好支持缩放平移需要人工查看细节、标注 ROIQOpenGLWidget 纹理较高最好高分辨率高帧率或需要叠加大量图形大部分检测工位用 QGraphicsView 就够了因为操作员需要放大看细节而 QLabel 方案做缩放平移要自己写一堆事件处理。QOpenGLWidget 我一般只在 4K 以上或者 60fps 的场景才上调试成本明显高一截。不管用哪种画框不要用cv2.rectangle画在图像上再显示虽然能跑因为那样框的颜色和线宽会被缩放拉变形看着很脏。正确做法是把框作为图元叠在图像上或者至少在后台按显示尺寸画好。我个人偏向后者简单直接。4.3 界面卡死的三个高频原因排查界面卡顿我一般先看这三个地方主线程里做推理。有人图省事把model.predict()写在按钮的槽函数里点一下卡三秒。改成后台线程 信号问题立刻消失。日志控件无限追加。QTextEdit.append()调用一万次试试必卡。必须限制最大行数超过就删头部或者用环形缓冲。paintEvent里做重活。在重写的paintEvent里做cv2.resize或者cvtColor帧率掉到个位数都不奇怪。所有变换提前做好paintEvent里只做绘制。这三条覆盖了我遇到过的八成卡顿。剩下的两成通常是信号发得太密——比如每帧发三次stat_updated界面刷新频率比推理还高纯属自己给自己找事。统计数据 1 秒更新一次足够了。5. 实测中遇到的问题与排查链路理论讲完讲几个真实折腾过的。这一节我尽量把“怎么发现的”写清楚因为排查思路比结论更有用。5.1 延迟越跑越大从队列长度和丢帧计数入手现象程序刚启动时画面跟手跑二十分钟以后画面明显滞后重启立刻恢复。排查顺序是这样的先在界面上把“队列长度”和“累计丢帧”显示出来。如果队列长度持续上涨说明消费慢于生产。接着打印推理耗时的滑动平均如果耗时稳定在 80ms 而采集是 30fps33ms 一帧那答案就很清楚了——每秒产生 30 帧只能消费 12 帧剩下的全在排队。我的处理分三层短期把消费策略从“追帧”改成“追新”延迟立刻恒定中期给模型降输入尺寸或者换量化版本把耗时压到 33ms 以内长期看是否需要加一张加速卡。三层同时上问题才算真正解决。顺带说一个隐蔽的变种延迟不是线性增长的而是每隔几分钟突然跳一下。这种通常是内存回收或者日志刷盘引起的抖动去查日志写入是不是同步的。把日志改成异步写入后台线程 队列 定期 flush跳变基本消失。5.2 误报漏报调参顺序千万别搞反误报和漏报同时存在的时候很多人会陷入“调阈值”的死循环。我的调参顺序是固定的先看图像质量。把误报的那几帧原图调出来如果目标本身模糊、过曝、欠曝那后面所有调参都是白费。先解决光源和曝光。再看 ROI 和类别过滤。很多误报根本不是模型错是背景里本来就有类似的东西只是你没过滤掉。然后看确认逻辑。单帧误报很常见连续 3 帧都误报的概率低得多。确认帧数加到 3能消掉一大半偶发误报。最后才动置信度阈值。而且只动enter_confexit_conf跟着一起按比例调保持滞回区间。这个顺序的道理是前两步是消除“假目标”第三步是消除“偶发”第四步才是调整“判定标准”。倒过来做你会发现阈值已经调到 0.9 了还在误报然后就卡死了。漏报的处理思路不同。漏报往往是目标太小、太暗、或者被遮挡。先做数据增强补充训练样本再考虑提高输入分辨率。单纯降enter_conf会换来一堆误报得不偿失。5.3 长时间运行的稳定性内存、句柄、磁盘三件套跑一晚上出问题是这类项目的经典形态。我固定检查三项内存用tracemalloc或者任务管理器看趋势。图像帧是最容易泄漏的尤其是 numpy 数组被闭包持有、或者信号参数没释放。我的习惯是信号传的对象都是新建的、不引用大数组以外的任何东西。句柄相机句柄、文件句柄、网络连接。程序如果做了“断线重连”重连时老的句柄有没有真的关掉我见过一个重连逻辑每次重连都 new 一个相机对象但不 close 旧的跑一晚上句柄数上千。磁盘报警截图和日志是最容易把盘写满的。截图必须做上限控制比如按天分目录、保留最近 7 天、超过就删。日志用滚动文件单文件 50MB 上限。这条如果不做现场跑到第三周一定会因为磁盘满而崩而且崩得莫名其妙。6. 配置化与现场落地把可调的东西都挪出去项目做到最后代码改动的频率会越来越低参数调整的频率会越来越高。所以从第一天起凡是“现场可能需要调”的东西都别硬编码。6.1 一个配置文件该装下什么我的config.yaml通常长这样camera: nic_ip: 192.168.1.10 serial: SN-XXXXXX exposure_us: 800 gain_db: 0 pixel_format: BGR8 trigger_mode: hardware jumbo_frame: 9000 model: path: models/det.onnx input_size: [640, 640] conf_thres: 0.45 iou_thres: 0.50 device: cuda:0 runtime: consume_policy: latest # latest | fifo queue_size: 4 draw_fps_limit: 25 alarm: confirm_frames: 3 window_size: 10 window_hits: 5 enter_conf: 0.60 exit_conf: 0.35 cooldown_sec: 5.0 recover_frames: 10 io: channel: 0 pulse_ms: 150 active_level: high auto_reset_ms: 500 logging: dir: logs max_file_mb: 50 keep_days: 7 save_snapshot: true这张表看起来长但每一项都是被现场问过“这个能不能改”之后加进来的。特别是io.pulse_ms和io.active_level不同产线的继电器接法不一样不开放出来现场就得改代码重新打包。配置加载我建议用pydantic或者手写一层校验把类型和范围卡死。配置文件写错一个字段名程序安安静静地用默认值跑这种问题最难查。6.2 现场调试的清单式流程我给自己定了一个固定流程按顺序走能省掉大量来回网卡巨帧、IP 段、防火墙三项确认相机客户端里先出图确认曝光和光源合适我们的程序里只开取流不开推理看帧率和丢帧率开推理关报警看框准不准、耗时多少开报警用调试模式观察状态机跳转记录一组标准参数截图存档让产线跑 30 分钟看误报漏报和稳定性。第 3 步和第 4 步分开做是我吃过亏之后改的。一开始我把所有功能一起开出问题以后根本分不清是取流的问题还是推理的问题还是报警逻辑的问题白白多花了半天。6.3 界面上的“自证清白”区域最后分享一个我觉得特别有用的设计在界面角落放一块状态区实时显示这些指标——指标说明正常范围采集帧率相机每秒出图数接近设定值推理帧率实际完成推理数≥ 产线节拍队列长度待处理帧数0-2丢帧计数累计丢弃帧数缓慢增长推理耗时滑动平均稳定状态机当前状态在四态间合理跳转报警总数本次运行累计与产出匹配有了这块区域现场再反映“它好像不太对”我第一句话就是“你看右下角那几个数”十个问题里有七个当场就能定性。这比让客户描述“有时候会卡一下”有效太多了。个人体会是这套东西的技术难点其实都不在“难”而在“散”。相机、推理、界面、报警、IO、配置每一块单独看都是常规操作但要把它们捏成一个能在产线上连续跑三十天的程序需要的是把边界划清楚、把状态说明白、把参数挪出去。我做完第一个版本的时候觉得功能都齐了真正上线才发现功能齐和能交付之间隔着的就是这些看起来不起眼的细节。