2026/10/31 1:13:36

香橙派 RK3588 双路 yolov5s 可观测 stats:量化每一帧性能

香橙派 RK3588 双路 yolov5s 可观测 stats:量化每一帧性能 香橙派 RK3588 上跑多路视觉最让人头疼的往往不是把 yolov5s 模型跑起来而是跑起来之后你根本不知道它在干什么。两个画面同时出框CPU 占用忽高忽低推理时延到底是哪一段拖了后腿检测目标是增多还是减少全靠感觉和猜。我这次做的阶段三可观测 stats就是把这种靠感觉变成有数据把双路视觉方案的每一帧耗时、每一路的检测统计、系统资源消耗全部量化出来画面里直接叠加 OSD 信息终端里持续输出结构化日志。整篇文章基于我实际调试香橙派 RK3588 双路视觉方案的完整过程适合已经能把 yolov5s 在板端跑通、准备往工程化方向推进的朋友参考。先说清楚这一阶段在整个方案里的位置。我手里的双路视觉方案二前两个阶段分别完成了 yolov5s 模型在 RK3588 上的 RKNN 转换与单路视频流推理以及双路相机画面的同时采集与检测。阶段三可观测 stats做的不是新功能而是给这套系统装上仪表盘——采集端、推理端、输出端三个环节各自产生了哪些数据在单位时间内发生了多少次检测、每次检测分属什么类别、单帧全链路耗时是多少全部变成可读、可统计、可回溯的指标。1. 阶段定位从能跑到看得见地跑1.1 双路视觉方案二此前完成了什么方案二是一个面向边缘场景的双目视觉检测系统硬件采用香橙派 RK3588 系列开发板我调试用的是 RK3588 平台系统为 Ubuntu 22.04Python 3.10配置了两路摄像头输入。第一阶段的工作主要是模型侧把 yolov5s 的 PyTorch 权重通过 rknn-toolkit2 转换成 RKNN 格式经历了 ONNX 导出、精度检查、int8 量化、rk3588 目标平台适配最终在板端用 RKNN Python API 完成单路视频流的实时推理。第二阶段解决的是双路问题两路摄像头各自独立的采集通道、两路画面如何送入同一个推理管线、检测结果怎么分别归属到对应的相机画面上。这个阶段跑通之后系统的逻辑形态是清晰的但运行状态始终是个黑盒——板端 CPU 到底在多少负载下工作NPU 推理单帧需要多少毫秒两路画面加起来实际出帧率是多少这些我完全说不出来。遇到效果不理想的时候只能凭感觉猜是模型问题还是采集问题。1.2 可观测 stats 要解决的三个痛点第一个痛点是时延不可见。推理耗时不是一个固定值它随 NPU 负载、画面内容、系统调度波动。没有精确到毫秒的计量你没法判断 yolov5s 在 640x640 输入下到底占了多少推理时间也没法知道 OpenCV 读帧和图像预处理又花了多少时间。第二个痛点是检测统计不可见。双路视觉的价值是同一时刻对比两路画面的差异但差异不能只靠人眼盯屏幕。我需要知道每路视频流连续 N 帧内的目标总数、按类别分布、目标数量变化趋势。这些数据不落地后面做联动逻辑——比如某路连续 5 帧检测到目标但另一路没有——就无从下手。第三个痛点是资源状态不可见。RK3588 的 NPU 算力是共享资源双路推理叠加后NPU 占用率、CPU 占用率、内存占用率决定了系统还能不能承受第三路或额外的业务逻辑。把资源数据纳入 stats才是真正面向工程部署的状态管理。2. 双路相机的接入与通道分配2.1 摄像头选型与接口确认我在方案二里用了两路摄像头一路走 MIPI CSI 接口一路走 USB 接口。之所以不都走 MIPI是因为香橙派 RK3588 平台的 MIPI CSI 接口对摄像头模组的兼容性要求比较高两路不同型号的 MIPI 模组同时接入时DTS 设备树和驱动的调校成本会明显上升。混搭 USB 之后只要系统能识别 UVC 设备通道稳定性和调试难度都会好很多。使用v4l2-ctl --list-devices可以快速确认两路摄像头分别挂载在哪个节点。我的板子上 MIPI 摄像头出现在/dev/video0USB 摄像头出现在/dev/video1。这个顺序不一定固定代码里应该通过设备名匹配而不是硬编码节点编号来绑定摄像头否则重启后很容易出现通道互换导致画面内容错位的问题。2.2 双路采集的线程模型选择双路视频流同时进行核心选择是多线程 队列缓冲而不是单线程循环切换。原因很直接cv2.VideoCapture.read()在 RK3588 上是一个会阻塞的操作如果单线程串行读两路摄像头一路画面卡顿会直接拖慢另一路导致两路帧率互相牵制。我的做法是为每路摄像头各起一个采集线程采集线程只做一件事——把读到的帧放入一个带容量上限的队列。队列上限设成 2 到 3 帧超过就丢弃最旧帧。这样做的目的是保证推理线程拿到的一定是最新画面而不是积压的旧画面。实时视觉系统里旧帧堆积带来的滞后感远比丢帧更影响体验。import threading import queue import cv2 import time class VideoStream: def __init__(self, device_path, queue_size3): self.cap cv2.VideoCapture(device_path) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 2) self.queue queue.Queue(maxsizequeue_size) self.running False def start(self): self.running True self.thread threading.Thread(targetself._loop, daemonTrue) self.thread.start() return self def _loop(self): while self.running: ret, frame self.cap.read() if not ret: time.sleep(0.01) continue if self.queue.full(): try: self.queue.get_nowait() except queue.Empty: pass self.queue.put(frame) def read_latest(self): try: return self.queue.get_nowait() except queue.Empty: return None采集线程和推理线程之间通过队列解耦采集跟不上就丢旧帧推理跟不上就让队列短暂为空互不阻塞。实际跑下来两路 1080p 输入、推理端降到 640x640 再进模型这种模型的压力下系统整体节奏非常稳定。2.3 为什么不用多进程有人可能会问RK3588 是八核 CPU双路视觉是不是该用多进程来隔离资源我试过 multiprocessing 方案但最终回到多线程原因有两个。第一RKNN 推理库在进程模式下每个子进程都要独立加载模型、独立初始化 NPU 上下文内存占用直接翻倍。香橙派这类开发板的内存通常有限双路推理共用一个模型实例在内存上要划算得多。第二进程间传输视频帧需要序列化和跨进程共享内存OpenCV 的 Mat 直接跨进程传是走 pickle 还是走共享内存都需要额外处理工程复杂度远高于线程间传引用。线程模式唯一的代价是 Python GIL但这个代价集中在预处理和后处理阶段。真正的推理是在 RKNN 的 C 层运行时里完成的GIL 在 NPU 推理期间不构成瓶颈。实测双路推理的吞吐量完全够用。3. stats 指标设计与统计逻辑3.1 观测指标分三层可观测 stats 不能只放一个 FPS 数字。我把指标拆成三层分别对应系统的不同状态视图。系统层记录的是整个板子当前的资源状态包括 CPU 占用率、内存占用率、NPU 占用率。NPU 占用率在 RK3588 上可以通过读取调试节点拿到需要 root 权限。CPU 占用率我直接读/proc/stat内存占用率读/proc/meminfo。这些数据每 2 秒采样一次用于判断双路推理是不是把板子资源吃满了。推理层记录的是单次推理全链路的耗时拆解。一次检测并不是只有 NPU 推理这一步它包含从原始帧到模型输入的预处理、NPU 前向推理、后处理 NMS 三个环节。我把每一段的耗时分别计时这样优化时能精确知道钱花在了哪里。结果层记录的是两路画面各自检测出来的目标数量、类别分布、单位时间内目标总数变化。这一层的统计直接服务于上层业务逻辑比如根据某一路画面连续 N 帧没有检测到目标来触发提醒。3.2 滑动窗口与 EMA 平滑统计里最容易犯的错是直接拿当前这一帧的耗时当指标。单帧耗时波动极大系统调度抖动就能让数值在 15ms 和 40ms 之间反复横跳直接看只会觉得系统很不稳定。我用的是指数移动平均EMA给最近的采样更高的权重让曲线既能跟上真实变化又不会被瞬时抖动带偏。class SmoothMetric: def __init__(self, alpha0.1): self.alpha alpha self.value 0.0 self.count 0 def update(self, sample): if self.count 0: self.value sample else: self.value self.alpha * sample (1 - self.alpha) * self.value self.count 1 return self.valuealpha0.1 表示约 10 帧内把新样本的影响逐步体现出来。如果想要更平滑可以把 alpha 降到 0.05如果想更快反映性能变化就调到 0.2。开发调试期用 0.1 是比较折中的选择。FPS 的计算不能靠1 除以单帧耗时直接得到正确做法是记录相邻两帧完成时刻的时间差再对瞬时 FPS 做 EMA 平滑。我的 FpsCounter 里还做了一步处理当画面长时间没有新帧时FPS 会自然归零而不是显示一个保留着历史均值的虚假数字。3.3 检测类别计数的统计口径双路视觉里一个典型场景是统计每一路都看到了什么、看到了多少个。yolov5s 的 COCO 80 类里我只关心少数几个类别但统计模块我在设计上保留了全类别的计数能力。统计口径需要注意连续帧里同一个物体会被重复检出如果直接累计每一帧的检测数量短时间内这个数字会无限膨胀。所以类别计数分成两种口径。一种是帧内计数即当前这帧画面里检测到的目标数用于判断画面当前的目标密集程度另一种是累计计数用于阶段性的流统计每次进行周期性清空或按时间窗口聚合。我在方案里用了一个小时级的环形数组记录最近 60 分钟每分钟的各类别检出次数这样既能看瞬时状态也能回溯趋势。class DetectionStats: def __init__(self, window_minutes60): self.window [{} for _ in range(window_minutes)] self.current_idx 0 self.frame_count 0 self.frame_objects [] def record_frame(self, class_ids): self.frame_count 1 minute_idx int(time.time() // 60) % len(self.window) if minute_idx ! self.current_idx: self.window[minute_idx] {} self.current_idx minute_idx for c in class_ids: self.window[minute_idx][c] self.window[minute_idx].get(c, 0) 1 self.frame_objects.append(len(class_ids)) if len(self.frame_objects) 300: self.frame_objects.pop(0) def recent_avg_objects(self, frames30): recent self.frame_objects[-frames:] return sum(recent) / len(recent) if recent else 0.0这个模块把可观测变成真正能服务业务的统计能力。阶段三做得越扎实后面做双路联动、目标轨迹跟踪时就越省事。4. 代码实现yolov5s 推理封装与双路 stats 融合4.1 RKNN 推理类的封装要点板端推理我用的是 rknn-toolkit2-lite 的 RKNN 运行时接口加载经过 int8 量化的 yolov5s RKNN 模型。封装推理类有三个关键点第一模型初始化只做一次。双路视觉共用同一个 RKNN 对象但在推理逻辑里要避免两路同时调用rknn.inference()。RKNN 的 Python API 不是线程安全的两个线程同时调用同一个实例的 inference 方法轻则阻塞重则 NPU 上下文损坏。我的做法是在推理对象里加一把锁双路推理对锁进行串行竞争同一时刻只有一路在调用 NPU。import threading from rknnlite.api import RKNNLite class YoloRKNN: def __init__(self, rknn_path, conf_thres0.25, iou_thres0.45): self.rknn RKNNLite() assert self.rknn.load_rknn(rknn_path) 0 assert self.rknn.init_runtime(core_maskRKNNLite.NPU_CORE_0_1_2) 0 self.conf_thres conf_thres self.iou_thres iou_thres self._lock threading.Lock() def detect(self, bgr_frame): with self._lock: img, scale, pad self._preprocess(bgr_frame) t0 time.perf_counter() outputs self.rknn.inference(inputs[img]) infer_ms (time.perf_counter() - t0) * 1000 boxes, ids, scores self._postprocess(outputs, scale, pad) return boxes, ids, scores, infer_ms第二预处理要把 OpenCV 的 BGR 转 RGB、letterbox 缩放、维度转换全部封装好。yolov5s 输入是 640x640源画面比例不匹配时需要做等比例缩放并填充灰边检测输出的坐标才能正确映射回原图。第三后处理要把模型的原始输出1x25200x85解码成边界框列表包含坐标缩放、置信度过滤和 NMS。这一段我用纯 Python 实现在 640x640 输入下每帧约耗时 8 到 12ms是后处理的正常水平。4.2 双路异步推理与丢帧策略双路视觉的推理循环和单路有本质区别。单路可以简单读一帧、跑一帧、画一帧双路如果也这么同步做路与路之间会互相等待整体节奏完全绑死。我把双路设计成采集异步 推理串行 显示异步的流水线。采集线程持续填帧推理主循环从两路队列各取最新帧按顺序对两帧分别调用detect()得到两路各自的检测结果再交给绘制线程去叠加 OSD。绘制线程独立跑不在推理循环里做imshow因为显示环节的窗口刷新和视频编码都会阻塞主循环。推理循环里最重要的是丢帧策略。如果某一时刻队列里没有新帧我直接跳过这一次推理而不是阻塞等待。等待会导致另一路也跟着停下来时间一长两路的时序就会错位。跳帧带来的代价是可以接受的检测间隔抖动换来的是两路各自的实时性都不被对方拖累。def run_dual_inference(streams, model, stats_list): while True: for i, stream in enumerate(streams): frame stream.read_latest() if frame is None: continue boxes, ids, scores, infer_ms model.detect(frame) stats_list[i].record_frame(ids) stats_list[i].update_latency(infer_ms) draw_osd(frame, boxes, ids, scores, stats_list[i]) stream.show(frame)这里的draw_osd放在推理线程里完成绘制不经过独立线程因为 OSD 叠加本身是纯 CPU 操作耗时在 1ms 以内不值得再引入一次线程切换。4.3 OSD 叠加与终端结构化日志可观测不能只留在代码内部用户必须能看到。我在两路画面的左上角叠加了 FPS、本帧目标数、单帧推理耗时这三项核心指标右上角叠加了当前画面的类别统计。OSD 文本用 OpenCV 的cv2.putText字体用cv2.FONT_HERSHEY_SIMPLEX字号根据画面分辨率动态调整。叠加文字时注意一点画布底色是黑色半透明矩形否则文字叠在白色物体上会看不清。终端日志我用 JSON 格式输出因为 JSON 可以直接被后续的数据采集系统消费。每秒打印一行包含两路的完整统计快照。{ts: 1710000000.0, cam0: {fps: 26.3, infer_ms: 18.2, objects: 3, classes: {person: 2, car: 1}}, cam1: {fps: 24.1, infer_ms: 19.7, objects: 5, classes: {person: 4, bicycle: 1}}}如果不想处理 JSON也可以直接打印人类可读的对齐文本。但我在实际工程里都推荐 JSON原因很简单阶段三把数据以结构化形式输出后后续做远程监控、历史回放、性能对比都可以直接复用不需要再改推理代码。5. 实测数据分析与性能调优5.1 双路并发跑出的 stats 长什么样双路视觉方案二完整跑起来后我用 stats 模块采集了一组真实运行数据。yolov5s 模型使用 int8 量化输入 640x640推理核心 NPU_CORE_0_1_2三核全开。单路画面独立运行时单帧全链路耗时约 22ms 到 25ms双路串行推理时两路加起来单帧全链路约 40ms 到 45ms折算成每路有效 FPS 在 22 到 25 之间浮动。资源侧的数据也很有意思。双路跑满时 CPU 占用大约在 55% 到 70%这是包含了图像采集、letterbox 预处理、后处理 NMS 和 OSD 叠加的全部负载内存占用约 1.2GB主要被两个视频帧缓冲、模型运行时和系统缓存消耗。NPU 占用率在推理高峰能达到 85% 到 95%说明 NPU 确实是整个系统的核心瓶颈。5.2 从 stats 反推瓶颈有了 stats 之后优化不再是拍脑袋。我看到后处理耗时在总耗时中占比很高就开始量化它。单独测过之后发现我的纯 Python NMS 实现每帧耗掉 8 至 12ms相比 NPU 推理的 18 至 20ms后处理几乎占了一半。这就是明显的瓶颈。针对这个瓶颈第一步优化是降低 NMS 的输入规模。yolov5s 输出 25200 个候选框但绝大多数候选框置信度极低。我在后处理解码时把置信度低于 0.25 的候选框直接过滤掉只保留高置信度候选进入 NMS。这个改动把候选框数量从 25200 降到通常的几百个后处理耗时立刻从 10ms 左右降到 4 到 6ms。这就是 stats 的价值——它明确地告诉你钱该花在哪里而不是让你盲目换模型或者换硬件。第二步是分辨率对齐。双路视觉里两路摄像头分辨率不一致时比如一路 1280x720一路 1920x1080模型输入都是同样缩放到 640x640但缩放后的有效信息密度完全不同。1080p 的画面缩放后细节保留程度明显优于 720p这从检测结果的类别置信度和目标数量上都能体现出来。如果追求两路一致性优先建议把两路采集分辨率统一到同一档位。5.3 轻量化与参数调优建议可观测 stats 还帮我验证了一组调参结论。第一模型输入尺寸从 640x640 降到 480x480NPU 推理耗时大约下降 35%但小目标检测能力下降明显如果你的项目检测目标偏小不建议轻易降。第二int8 量化对 yolov5s 精度的影响在双路场景中尚可接受个别类别的置信度会有小幅下降但换来的速度提升值得。第三NPU core_mask 的选择。RK3588 有 3 个 NPU 核心在init_runtime里可以用NPU_CORE_0_1_2三核全开也可以只用单核NPU_CORE_0。双路视觉场景下三核全开确实能获得最低的单帧推理延迟但如果你同时还要跑一些轻量级的 NPU 任务比如图像预处理加速就需要考虑给其他任务留出计算资源。我的经验是纯双路 yolov5s 推理用三核全开没毛病但系统里如果还有别的 AI 任务就要通过 stats 观察 NPU 占用率再调整。以下是我实测的一组对比数据供参考参数组合单帧 NPU 推理耗时双路合计 FPS后处理耗时备注640x640 置信度 0.25 三核全开18-20ms22-254-6ms默认配置480x480 置信度 0.25 三核全开12-14ms28-323-4ms小目标损失明显640x640 置信度 0.5 三核全开18-20ms24-272-3ms候选框更少NMS 更快640x640 单核 NPU35-40ms12-144-6ms双路同时跑比较吃力yolov5s 本身就是轻量化模型在 RK3588 上三核全开时单路推理其实不到 20ms整套方案的主要矛盾从来不是跑不动而是跑起来之后说不清状态。阶段三把状态彻底说清楚了后面再优化模型结构或者调整摄像头参数每一步都有数据支撑。6. 踩坑记录双路 stats 背后的坑6.1 两路摄像头的 buffer 冲突双路视觉最常见的坑出现在采集端。两个摄像头同时调用 OpenCV 时其中一个cap.read()有时会返回空帧或花屏而且不是每次都出现是偶发性的。最初我以为是摄像头硬件问题后来发现是 V4L2 缓冲机制冲突。解决办法是在VideoCapture打开后立即设置CAP_PROP_BUFFERSIZE为一个较小的值同时给两个摄像头分别设置不同的CAP_PROP_BUFFER_SIZE避免共享缓冲区域的竞争。如果你的采集线程里用了cap.grab()和cap.retrieve()分离式读取尤其要注意 grab 之后必须在较短时间内 retrieve否则缓冲会被下一帧覆盖。6.2 RKNN 实例的并发调用问题RKNN 推理对象不是线程安全的。最初我图省事让双路各自持有一个 RKNN 实例以为这样就不用加锁。结果模型加载就占了双倍内存而且在两个线程同时推理时NPU 驱动层面偶发返回错误码表现为推理结果全为空的异常帧。后来改成单实例 互斥锁内存占用降下来了两个线程串行调用同一个推理对象稳定跑了一整天没有再复现。需要说明的是如果你确实需要双路并行推理而不只是串行调用更稳妥的方案是用 C 层 API 或 RKNN 的多实例上下文但 Python 层面对工程复杂度的提升非常大普通场景单实例串行完全够。另一个坑是 NPU 核心分配。如果两个 RKNN 实例分别绑定了相同 core_mask在资源竞争时也会出现异常。用单实例方案后这个坑直接不存在了。6.3 统计代码本身影响性能的细节可观测 stats 是把双刃剑——统计模块如果写得粗糙反而会成为新的性能瓶颈。我踩过的一个具体问题是在统计里大量使用字典计数每次record_frame都对字典做逐项累加一个类别几十项每帧累加几十次看起来似乎不算什么但在 25 FPS 的双路场景下Python 的字典操作开销被放大了。实际测下来这个操作让每帧额外多花了 2ms占比不小。解决方法是减少统计粒度帧内统计只保留 class_id 的 list先不在这里维护计数等日志输出前再一次性聚合。另一个细节是 FPS 统计和单帧耗时统计不要每次都调用time.time()一个循环里调用三次以上时间函数会引入显著的固定开销。正确做法是每次循环只取一次时间戳单帧耗时用前后两次时间戳的差值计算。这些细节看起来像是在扣微优化但可观测这一层本来就是精细活儿统计数据的准确性首先取决于统计本身不给系统拖后腿。日志输出也需要注意频率。JSON 日志每秒打印一次即可不要在每一帧都打印完整统计否则日志系统本身会成为最耗时的模块。我在代码里用一个记录上次打印时间的变量差值超过 1 秒才输出一次。终端刷屏太快也会影响问题排查人眼根本来不及读。6.4 双路 OSD 绘制时的显示线程问题最后提醒一下显示部分的坑。普通桌面环境里cv2.imshow需要在主线程调用但头一次实验我把它放到推理线程里导致窗口响应卡顿画面切换有明显延迟。后来我在 RK3588 的无头部署场景里干脆不用窗口直接通过 RTSP 或 HTTP 把叠加 OSD 后的帧流推送出去。若用官方桌面输出推荐把两路画面各自独立显示不要拼成一个画面再显示因为拼接会强制两路帧率对齐一路慢会拖累另一路。可观测过程中你还会发现很多新问题但只要能实时看到 stats定位问题的速度就能比对着黑盒瞎猜快一个量级。这就是我为什么坚持把阶段三的可观测 stats 做在前面——它让后续所有阶段都有了量化依据。