☰
RK3588双路yolov5s检测:可观测stats设计与落地
2026/10/31 1:13:43 网站建设 项目流程

香橙派5上做双路yolov5s检测,走到阶段三,最明显的感受是:模型能跑起来只是及格,能把系统状态一眼看穿才算真的能用。本篇文章继续“手把手从头到脚香橙派RK3588 yolov5s教程”系列的双路视觉方案二,目标是在前两个阶段的基础上,把可观测stats完整落地——在双路画面上实时叠加FPS、推理耗时、目标计数等关键指标,同时往外部输出结构化日志。这套统计体系适合所有在RK3588、RK3568这类带NPU的板子上做部署验证的开发者,尤其适合刚从PC端转到边缘设备上、被“程序不报错但就是卡”折磨过的人。

1. 先对齐:双路视觉方案二已经走到阶段三

1.1 这个系列目前解决了什么,还剩什么

这个系列一路写过来,环境、交叉编译、模型转换、单路检测、双路视觉方案一,每一篇解决一个具体问题。方案二跟前一套路线的核心区别,在于我最后选择了“两路视频流共享一个NPU推理核心”的结构,而不是给每路摄像头各配一套模型实例。RK3588的NPU虽然标称算力到6 TOPS,但它不是无限资源,真去跑两个独立模型实例并行推理,资源反而浪费得厉害。

阶段一解决的是双摄读取,具体来说是MIPI CSI和USB摄像头混接的时序问题;阶段二解决的是两路画面的目标检测与结果叠加,这时候已经有检测框和类别标签打到画面上了。阶段三做的不是新功能,而是把这些运行状态量化出来:单路推理耗时多少、两路综合FPS稳定在多少、当前画面识别到几个目标、各类别占比什么样。只有把这些数字实时暴露出来,后面做性能调优、模型裁剪、帧率限制才有依据。

其实很多做嵌入式AI的人都有一个幻觉:程序不崩溃、画面有输出,就以为系统没问题。实际上一路推流从30帧掉到8帧你未必能立刻察觉,NPU长时间满负荷运载带来的发热降频、内存缓慢增长,都藏在“看起来正常”的画面背后。stats不是锦上添花,是定位这一类问题的唯一抓手。

1.2 为什么阶段三要专门做stats可观测性

我先说两个真实翻车场景。第一个是画面结像延迟问题:双路视觉阶段二写完,我把设备放在窗边连续跑了一个下午,肉眼看画面一切正常,直到拿秒表去掐才发现第二路摄像头从真实场景变化到画面响应已经慢了将近1.5秒,用键盘敲回车做时间标记,再看画面里的延迟,整个链路已经卡到没法做实时交互。第二个更隐蔽,设备跑了一个半小时后FPS骤降,查了一圈发现是OpenCV显示线程的imshow调用把缓存队列堵住了,内存持续上涨,NPU倒是没满,CPU反而成了瓶颈。

这两次都是靠临时加打印才定位到的,过程极其痛苦。所以阶段三我直接在代码里内置一个可观测层,把关键指标全量暴露出来。可观测stats这一层做扎实,后续不管接显示屏、接上位机、接数据库,还是做多设备监控,都不用再对着黑盒猜问题。

2. 设计可观测stats前,先把统计口径定清楚

2.1 双路场景下我们真正关心的指标

做stats最容易犯的错是贪多,一堆指标往画面上怼,数字密密麻麻,最后啥也看不清。我最后只留了五类指标:单路FPS、推理耗时、预处理耗时、当前检测目标数、各类别计数分布。为什么是这几个,得掰开说。

单路FPS直接反映这条链路能不能支撑实时交互,低于15帧基本可以认为视觉反馈是“半残废”的;推理耗时反映NPU的负载状态,yolov5s在RK3588上一般是十几到几十毫秒的量级,如果持续走高说明资源竞争激烈或者散热降频;预处理耗时容易被忽略,但它恰恰是双路场景下CPU瓶颈的高发区,因为letterbox、归一化这些操作都是跑在CPU侧,两路图像同时做,CPU占用率直接翻倍。

目标检测数和类别分布则是功能正确性的在线验证。我在部署时会故意放一个固定数量的物体摆在镜头前,看stats里的计数是否匹配,快速判断模型到底是在正常工作还是已经退化成了“框框乱飞”。很多开发者测试模型只看单张图片的推理结果,到了连续帧场景才发现漏检、误检交替出现,有了连续计数曲线这个问题就无所遁形。

2.2 统计口径的几个坑:瞬时值、时间基准、计数规则

统计口径比大多数人预想的更容易踩坑。先说FPS:我一开始直接取“最近一秒内的处理帧数”,做出来效果极其糟糕,数字在20到60之间疯狂跳动,看上去像系统随时要崩溃。原因是线程调度、NPU负载波动让每帧间隔天然存在几十毫秒的抖动,瞬时值根本不具备参考性。最后改成EMA滑动平均,公式很简单:smoothed_fps = smoothed_fps * 0.9 + instant_fps * 0.1,实测曲线平滑得多,既保留趋势变化,又不会因为单帧卡顿就猛烈跳动。

时间基准是第二个坑。双路视觉有两个视频源、两个读取线程,如果各统计各的循环次数再去换算,时间轴完全对不上。我从阶段一就统一用time.perf_counter(),在帧进入队列时打一个单调时钟戳,所有FPS和耗时计算都基于这个戳,而不是线程内的循环计数。单调时钟保证不受系统时间跳变影响,这是长期运行监控的基础。

计数规则也要说清楚。yolov5s检测出来的目标是按帧计算的,画面里当前有几个人、几辆车,这种事单帧统计就够了;但“一分钟内累计经过多少人”属于跨帧跟踪问题,需要加跟踪器做ID分配,否则同一目标会被重复计数。阶段三的stats做的是单帧类别分布与框数量统计,跨帧累计逻辑我留到了方案二的后置阶段,如果没区分这两类口径,后面做统计报表很容易得到离谱的数值。

3. 阶段三落地:从双路推理链路到stats可视化

3.1 双路帧读取与串行推理的主干结构

先展示一下阶段三主干的代码骨架。双路摄像头读取用两个线程分别跑,各用一个带锁的队列做缓冲,避免主线程被摄像头IO阻塞。这里的核心思想是“读取并行、推理串行”。

import threading import queue import time import cv2 from collections import deque frame_buf = queue.Queue(maxsize=2) stats_lock = threading.Lock() cam_stats = {} def read_loop(index, tag): cap = cv2.VideoCapture(index) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) if not cap.isOpened(): print(f"[{tag}] open camera failed") return while True: ret, frame = cap.read() if not ret: continue ts = time.perf_counter() frame_buf.put((tag, frame, ts)) cap.release() threading.Thread(target=read_loop, args=(0, "cam0"), daemon=True).start() threading.Thread(target=read_loop, args=(2, "cam1"), daemon=True).start()

推理线程从队列里拿帧,先记录预处理计时,送到RKNN推理,再做后处理,最后更新stats。推理串行的原因很简单:RK3588的NPU核心虽然是多核,但一个进程内频繁切换不同请求的上下文是有开销的。实测两个线程同时调用推理接口,总吞吐反而不如单线程顺序调用稳定。串行推理能让NPU的核心利用率更集中,双路综合FPS损失控制在合理范围内。

这里有个容易被忽略的把控点:frame_buf的maxsize不能太大。我一开始设成10,结果摄像头线程拼命往队列塞帧,推理线程吃不过来,内存里堆积的全是待处理帧,延迟越来越大,而FPS却没有实质提升。双路场景下缓冲队列深度2到3就够了,宁可丢帧也要保证画面延迟可控。

3.2 yolov5s在RK3588上的一次完整推理流程

推理这一块,先把模型加载和初始化写清楚。RK3588的NPU推理统一走rknnlite.api接口,初始化时这几个参数是阶段三调试时反复验证过的:

from rknnlite.api import RKNNLite rknn = RKNNLite() ret = rknn.load_rknn("yolov5s.rknn") assert ret == 0, "load rknn failed" ret = rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1) assert ret == 0, "init runtime failed"

core_mask我用的NPU_CORE_0_1,表示让模型在两个NPU核心上协同推理。跑yolov5s这种轻量模型时,单核和双核的耗时差异不算巨大,但双核对稳定性有正向作用,高负载下不容易出现某个核心过热导致的调度异常。如果同时跑多个模型实例,再考虑NPU_CORE_0加NPU_CORE_1分开绑核。

推理链路的完整流程分四步:读帧、预处理、推理、后处理。预处理不是简单cv2.resize,yolov5s训练时用的是letterbox方式,需要保持宽高比,把图像缩放到640x640并在两侧填灰边。这步直接决定检测精度,很多人模型在PC上跑得准、在板子上跑得飘,一大半原因是预处理没对齐训练时的参数。

def letterbox(img, new_shape=(640, 640), color=(114, 114, 114)): shape = img.shape[:2] ratio = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad = (int(round(shape[1] * ratio)), int(round(shape[0] * ratio))) img_padded = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) dw = new_shape[1] - new_unpad[0] dh = new_shape[0] - new_unpad[1] dw_half = dw // 2 dh_half = dh // 2 canvas = cv2.copyMakeBorder( img_padded, dh_half, dh - dh_half, dw_half, dw - dw_half, cv2.BORDER_CONSTANT, value=color ) return canvas, ratio, (dw_half, dh_half)

推理后拿到的原始输出是一组张量,yolov5s在导出成RKNN后输出通常是(1, 25200, 85)这种结构,60个候选框按类别展开。后处理主要做置信度过滤和NMS,筛选出最终检测框。NMS不建议自己在板子上写纯Python版本,性能太差,我直接调用了cv2.dnn.NMSBoxes,实测在双路场景下这部分耗时能压到个位数毫秒。

3.3 stats画到画面上的具体实现

可观测stats最终要能肉眼看,所以把数字画到画面本身是必须的。我在每路画面的左上角叠加一行状态栏,格式固定为“摄像头ID、FPS、推理耗时、当前检测目标数”,右下角再叠类别分布。

def draw_stats(frame, tag, stats): info = f"{tag} FPS:{stats['fps']:.1f} infer:{stats['infer_ms']:.1f}ms det:{stats['det_num']}" cv2.putText( frame, info, (10, 36), cv2.FONT_HERSHEY_SIMPLEX, 0.75, (0, 255, 0), 2, cv2.LINE_AA ) cat_text = " ".join(f"{k}:{v}" for k, v in stats["cat_count"].items()) cv2.putText( frame, cat_text, (10, 72), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 1, cv2.LINE_AA )

画字看起来简单,但这里有个实际心得:中文没法直接通过putText渲染,OpenCV默认字体不含中文字形,写中文会变成乱码。我的做法是全部用英文标签或拼音缩写,类别名映射成短码,比如“person”显示成“PSN”,“car”显示成“CAR”,不追求好看,只追求可读和零依赖。

还有一个细节是stats的动态颜色提示:FPS低于25帧时字体颜色切到黄色,低于15帧切到红色。不需要工程师盯着数字去判断异常,颜色本身就是告警。类似报警逻辑用三次EMA采样做判断,避免瞬时波动导致颜色乱闪。

3.4 结构化日志输出,给后续接入留口子

画面可视化只是stats的一面,另一面是持久化。如果设备要长期放在现场,你不可能一直盯着屏幕,结构化日志才是事后分析的数据来源。我在阶段三的代码里增加了一个独立的日志线程,按固定间隔把stats快照以JSON格式写入文件系统。

{"ts": 1733900000.123, "cam": "cam0", "fps": 28.3, "infer_ms": 22.6, "det_num": 4, "cat_count": {"PSN": 3, "CAR": 1}} {"ts": 1733900000.223, "cam": "cam1", "fps": 27.1, "infer_ms": 23.1, "det_num": 2, "cat_count": {"PSN": 2}}

一行一条JSON,不需要数据库,后续用任何脚本工具都能直接解析。日志线程和推理线程解耦,定期flush缓冲区,防止设备异常断电丢太多数据。刚开始我是每帧都写日志,结果IO把系统拖慢了,后来改成每秒写一条,信息粒度对性能分析已经完全够用。接上位机时只需要把这个文件尾部用socket转发出去,不需要改推理侧任何代码。

4. 实测数据与调优记录

4.1 双路启用stats后的性能基线

阶段三跑稳定后,我在香橙派5上记录了完整的性能基线。环境条件是:双路USB摄像头均为640x480输入,模型是yolov5s转RKNN后的INT8量化版本,NPU_CORE_0_1,系统负载基本为空闲状态。

指标单路闭stats单路开stats双路闭stats双路开stats
综合FPS约48约46约25约24
平均推理耗时约16ms约17ms约22ms约23ms
CPU占用率约35%约42%约70%约85%
内存占用约380MB约390MB约520MB约540MB

最直观的结论是:stats本身的逻辑开销很小,FPS的损失主要在CPU侧,因为OpenCV画字、画框、维持日志IO都在吃CPU资源。双路场景下CPU占用接近85%是个危险信号,一旦系统还有其他后台任务,很容易撞到调度瓶颈,直接表现为推理线程不及时、FPS大跳水。

所以阶段三的实际代码里我做了一个关键调整:显示频率和推理频率解耦。模型检测每帧都跑,但stats绘制改为每3帧画一次,日志每1秒写一次。这样CPU占用率降了大约10个百分点,而肉眼看画面没有任何区别。检测结果仍然全量更新,只有文字叠加速度降低,视觉上完全无感。

4.2 三个见效明显的调优点

第一个调优点是绑核。RK3588有4个A76大核和4个A55小核,线程调度器默认行为不一定合理。我用os.sched_setaffinity把摄像头读取线程固定到A55小核,把推理线程固定到A76大核,显示线程跟推理线程共享一个大核。这样避免摄像头IO线程的频繁中断去抢占推理线程的CPU时间片,实测FPS稳定性好很多,最大帧间隔从120ms压到70ms以内。

第二个调优点是推理线程的帧输入策略。双路串行推理的天然问题是先处理的摄像头会比后处理的每晚一帧,画面感观不同步。我给先处理的那一路做了补偿:当它处理完开始等待下一帧时,如果发现自己被后一路追平,就把等待时间让渡出去,让两路的画面时刻差控制在一帧以内。实现上并不复杂,推理线程每轮处理前检查两路最新帧的时间戳差,差值过大时丢弃旧帧取最新。

第三个调优点是NPU负载监控。RK3588没有太直观的NPU占用率命令,我通过改推理耗时来间接判断:正常时yolov5s的推理耗时应该稳定在一个波动很小的区间,如果某个时刻耗时突然翻倍,基本可以断定NPU被其他任务抢占或者触发降频。我把这个检测逻辑做成了软告警,连续5帧推理耗时超过阈值的1.5倍就往日志里打一条WARN,不需要肉眼盯画面都能发现运行异常。

5. 双路stats常见问题与排查实录

5.1 FPS数字跳来跳去或全部为零

FPS跳变得先分清是计算问题还是系统问题。如果FPS在数值上疯狂跳动,多半是统计口径没做好,瞬时值没有平滑,或者统计周期太短。解决方法是验证EMA系数:系数太大反应慢,异常帧率变化要很久才能反映出来;系数太小又回到毛刺状态。我建议先从0.9开始调,根据实际曲线的平滑度和响应速度微调。

FPS全部为零也比较常见,原因是推理线程抛异常后整个循环退出,但主线程还在画画面,stats就停在初始值。这个坑让我意识到在生产代码里不能裸奔,推理循环最外层要加异常捕获,捕获后把错误信息写入日志,同时保留上一个有效stats并标记为“stale”,而不是让数字归零误导排障。如果持续0帧,就看三件事:摄像头是否被占用、模型路径是否正确、队列是否被写死。

5.2 两路画面里的检测框“打架”

双路视觉共用同一个模型,如果两路画面的字幕和检测框串了,问题通常出在帧标记逻辑上。我在推理线程里按(tag, frame, ts)三元组从队列取数据,tag跟随整条链路走,推理和后处理时都不丢。排查办法是在画面上把tag也打出来,确认显示的是对应摄像头的检测结果。曾经遇到过一种诡异情况:两个摄像头分辨率相同、画面内容相似,因为tag丢失,两路实际都在显示第一路的推理结果,肉眼根本发现不了,因为有盒子有标注,看起来“正常得很”。这种情况必须靠stats里的“摄像头ID”字段自查。

5.3 加完stats反而拖慢推理的解法

我在阶段三刚做完时,stats显示推理耗时并没有变化,但FPS硬生生掉了七八帧。定位过程很值得分享:用perf_counter把推理线程里每个子步骤都计时,结果发现耗时瓶颈不在模型推理,而在NMS和画框时的cv2.putText——OpenCV的字形渲染在ARM架构上性能并不好,每次画两行文字都要几毫秒,叠加两路画面就是十几毫秒。

解法有两个方向,我最后都用了:一是降低显示频率,检测每帧跑、画字每3帧一次;二是把putText的字体厚度从2降到1,同时把整型、浮点转字符串的操作提前用位运算和拼接优化一遍。这样处理后,显示开销从每帧十几毫秒降到3毫秒左右,对FPS的影响基本可以忽略。

5.4 其他容易踩的坑:内存增长、时间戳错位、输出延迟

长期跑下来还要防内存增长。RKNN推理如果不显式释放旧buffer,某些rknn版本会缓慢积累内存。我在双路场景下每处理完1000帧就主动调用一次轻量级的缓存整理,实测连续运行8小时,内存占用稳定在540到560MB之间,没有出现持续上涨。

时间戳错位是双路独有的坑:两个摄像头本身硬件时钟不同,不能用time.time()去比较两路帧的新旧,必须用采集线程入队时的perf_counter单调时钟。跨摄像头的时间基准统一,这在做两路画面同步输出时是硬要求,否则后路永远比前路看到的东西更“古老”。

输出延迟这个事单独提醒一句:如果你用了类似cv2.imshow的窗口模式做长期运行,它会主动阻塞等待用户界面事件,非常坑。阶段三我建议直接把显示线程做成“只把最新帧编码成JPEG写入共享内存或者推到HTTP预览前端”,不要在板子上直接弹窗口,这样既能远程查看画面,也不会被GUI事件拖住推理主链路。

跑完阶段三,我个人最深的体会是:可观测stats不是给后期维护者准备的保险,而是开发过程中的拐杖。没有这套数字反馈,我在前面整整熬了两天排查画面延迟问题,加上它之后,很多问题在发生的瞬间就能定位到具体环节。这个方案二接下来还有阶段四、阶段五要走,但stats这套层我会原样保留下去,它值得成为边缘视觉项目里的标准配置。

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

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

立即咨询