香橙派 RK3588 上跑多路视觉,最让人头疼的往往不是把 yolov5s 模型跑起来,而是跑起来之后你根本不知道它在干什么。两个画面同时出框,CPU 占用忽高忽低,推理时延到底是哪一段拖了后腿,检测目标是增多还是减少,全靠感觉和猜。我这次做的"阶段三:可观测 stats",就是把这种"靠感觉"变成"有数据",把双路视觉方案的每一帧耗时、每一路的检测统计、系统资源消耗全部量化出来,画面里直接叠加 OSD 信息,终端里持续输出结构化日志。整篇文章基于我实际调试香橙派 RK3588 双路视觉方案的完整过程,适合已经能把 yolov5s 在板端跑通、准备往"工程化"方向推进的朋友参考。
先说清楚这一阶段在整个方案里的位置。我手里的双路视觉方案二,前两个阶段分别完成了 yolov5s 模型在 RK3588 上的 RKNN 转换与单路视频流推理,以及双路相机画面的同时采集与检测。阶段三"可观测 stats"做的不是新功能,而是给这套系统装上仪表盘——采集端、推理端、输出端三个环节各自产生了哪些数据,在单位时间内发生了多少次检测、每次检测分属什么类别、单帧全链路耗时是多少,全部变成可读、可统计、可回溯的指标。
1. 阶段定位:从"能跑"到"看得见地跑"
1.1 双路视觉方案二此前完成了什么
方案二是一个面向边缘场景的双目视觉检测系统,硬件采用香橙派 RK3588 系列开发板(我调试用的是 RK3588 平台,系统为 Ubuntu 22.04,Python 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/video0,USB 摄像头出现在/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_size=3): self.cap = cv2.VideoCapture(device_path) self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 2) self.queue = queue.Queue(maxsize=queue_size) self.running = False def start(self): self.running = True self.thread = threading.Thread(target=self._loop, daemon=True) 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, alpha=0.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.valuealpha=0.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_minutes=60): 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, frames=30): 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_thres=0.25, iou_thres=0.45): self.rknn = RKNNLite() assert self.rknn.load_rknn(rknn_path) == 0 assert self.rknn.init_runtime(core_mask=RKNNLite.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 降到 480x480,NPU 推理耗时大约下降 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-20ms | 22-25 | 4-6ms | 默认配置 |
| 480x480 + 置信度 0.25 + 三核全开 | 12-14ms | 28-32 | 3-4ms | 小目标损失明显 |
| 640x640 + 置信度 0.5 + 三核全开 | 18-20ms | 24-27 | 2-3ms | 候选框更少,NMS 更快 |
| 640x640 + 单核 NPU | 35-40ms | 12-14 | 4-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 做在前面——它让后续所有阶段都有了量化依据。