把 yolov5s 部署到香橙派5(RK3588)上跑单路目标检测,网上教程一抓一大把,但真正到了搞双路视觉的时候,画风就变了。你可能会发现:程序倒是能跑,画面和检测框却越来越不同步;NPU、CPU 的占用率看着都不高,延迟却像滚雪球一样越滚越大。我在这块板子上把双路 yolo 方案从初版的多线程直连,迭代到第二阶段的丢旧帧背压方案,才把两路画面的实时性重新抓回来。这篇教程继续走“手把手”路线,核心只讲一件事:为什么双路视觉要做背压、什么叫丢旧帧、以及 RK3588 上怎么用代码落地。适合已经能在板子上跑通单路 yolo,又想上双路的同学直接抄作业。
先给一个结论,免得你读到一半才反应过来:这套方案的核心不是“让推理变快”,而是“让队列不积压”。yolov5s 在 RK3588 的 NPU 上已经很能打了,但双路场景下采集速率和推理速率天然不对等,如果不在中间加一道“丢旧帧”的控制,帧就会在队列里排队,检测到的画面永远是几百毫秒前的旧世界。
1. 双路视觉为什么会卡在“帧积压”上
一个视频检测链路会经过很多“站”:sensor 采集 -> 驱动 buffer -> 应用队列 -> 前处理/resize -> NPU 推理 -> 后处理/NMS -> 结果队列 -> 显示/保存。每一站的处理能力不同。sensor 输出通常是 25/30fps,而 yolov5s 在 RK3588 上即使 int8 量化后,单帧 640x640 从 resize 到 NMS 走完全程,乐观也要 25~35ms,也就是每秒能处理 28~40 帧。单路勉强能“跟上”,但到了双路,一路只能分到大约一半的资源,单路推理周期就变到 50~70ms,也就是 14~20fps。采集速率远大于消费速率,积压不可避免。
我举个具体的例子。假设推理线程要花 60ms 处理一帧,期间摄像头已经生产了 2 帧(30fps 下每帧 33ms)。如果应用层队列是先进先出,推理线程处理完手中帧后,不是立刻拿到最新帧,而是先处理队列里积压的那 1、2 帧。每一帧的处理周期是 60ms,但实际画面已经往前走 120ms 了。你检测到的“此刻”,其实是 0.12 秒前的世界。这不只是数字不好看,在做无人机、小车避障的时候,0.12 秒的延迟就可能让检测结果彻底失效。
1.1 先把“延迟”和“帧率”这两件事拆开
很多人优化只看 FPS,其实 FPS 是吞吐量,端到端延迟是另一件事。FPS 衡量一秒钟能出多少帧结果,延迟衡量的则是“事件发生”到“算法看到这一事件”过去了多久。队列越长,FPS 可能不变(吞吐不变),但延迟线性增长。这就像排队点餐,吞吐量是每小时服务多少人,延迟是你从站到队尾到拿到饭的时间。如果队伍排了 50 个人,虽然每小时出餐数没变,但你可能已经饿晕了。双路视觉也是同样道理,队列里的帧就是排队等餐的客人,你真正关心的永远是“最新出锅的那盘菜”。
丢旧帧背压方案的核心,就是在不明显损失吞吐的前提下,把端到端延迟压到接近“单帧处理时间”。它不提高推理速度,而是保证每一帧推理都花在最新数据上。
1.2 单路没问题,双路为什么就崩了
单路 yolo 在香橙派上能跑,不代表双路也能把两套单路逻辑直接拼起来。第一个坑是 NPU 只有一个。RK3588 的 NPU 算力是 6 TOPS,跑 yolov5s 单路有余、双路不足,但你无法创建两个“并行”的 NPU 上下文来同时跑两路推理,底层硬件只有一个计算单元。第二个坑是 CPU。双路摄像头的前处理、后处理、解码全都要占 CPU 时间,8 核处理器听起来多,但 Python 多线程还有 GIL 的问题。第三个坑才是队列。采集线程不管推理端死活,只顾往队列里塞帧,队列就会变成延迟放大器。
所以阶段二的目标不是“把两路都跑到 30fps”,那不现实。合理目标是:把每路端到端延迟稳定控制在 100ms 以内,同时保证两路都持续输出有效检测结果。
2. 背压三选一,为什么必须选“丢旧帧”
当队列满了,你有三种处理方式:阻塞生产者、丢弃新帧、丢弃旧帧。很多人觉得“队列满就丢”就行,实际工程里差别非常大。
| 方案 | 行为 | 吞吐 | 延迟 | 适用场景 |
|---|---|---|---|---|
| 阻塞背压 | 采集线程等队列出现空位 | 降至消费者速度 | 低但抖动大 | 离线记录、保序分析 |
| 丢新帧 | 队列满时新来的帧被挤掉 | 稳定在消费者速度 | 中 | 实时预览,但容易漏掉突发关键帧 |
| 丢旧帧 | 队列满时清掉旧的,保留最新 | 稳定在消费者速度 | 最低可控 | 实时检测、避障、跟随 |
阻塞方案不好用在哪?采集线程一旦被卡住,摄像头驱动 buffer 也会跟着积压和溢出。在 V4L2 场景下,驱动内部遇到 buffer 满了会直接覆盖或丢帧,所以你“等”到的那一刻,拿到的帧反而不是最新的,还不如主动丢旧帧。阻塞还会让整个系统的节奏被最慢环节拖死,典型的生产线瓶颈效应。
丢新帧方案用deque(maxlen=N)就能实现,满的时候新帧把队头最老的挤掉。看起来也是丢旧帧,但它有一个隐蔽的窗口:消费者正在处理当帧时,队列里可能残留着旧帧;此时画面中出现了真正的关键事件,比如前方突然蹿出一个人,这一帧排队排到队尾,如果队列已经满,它会被后面来的帧挤掉,算法就永远看不到这个关键瞬间。安防、机器人场景里,这种漏检往往是致命的。
丢旧帧方案则不同:队列满的时候直接把里面的旧帧全部清空,只放最新一帧进去。消费者每次来取,永远拿到当前可用的最新帧。代价是帧的间隔变得不均匀,有时隔两帧,有时隔五帧,但对实时检测完全不是问题。损失的是采样密度,换来的是新鲜度。只有新鲜的数据,在实时任务里才有真正价值。
2.1 丢旧帧和deque(maxlen)不是一回事
我见过不少人以为deque(maxlen)就是丢旧帧,其实差别很关键。deque(maxlen=N)的语义是“队列始终保留最近 N 帧”,消费者取走最新帧后,队列里还可能残留一帧旧数据,下一次 get 如果生产者还没来得及放新帧,消费者就会拿到旧帧,这就是过期数据。丢旧帧的语义是“消费者每次取走的必须是自上次处理以后出现的最新一帧;如果没有新帧,就等着”。所以代码里要用 Condition 阻塞消费者,而不是直接在共享队列上做数组操作。
这个区别在双路场景会被放大。因为双路推理线程各自要配合 NPU 锁,谁能立刻拿到锁是不确定的。如果队列里残留旧帧,拿不到锁的线程把旧帧做完前处理、等锁、再推理,浪费一整轮。丢旧帧方案则保证每次拿到锁时,手里的数据还是新鲜的。
3. 香橙派 RK3588 双路 yolo 丢旧帧方案工程落地
下面直接进入可抄作业的部分。我默认你已经把 yolov5s 的 RKNN 模型跑通过,不再讲模型转换的每一个细节,只说环境上容易踩的坑和双路特有的代码逻辑。
3.1 环境准备说得再细一点
硬件上我是香橙派 5,两个 USB 摄像头做双路采集;如果你用双路 MIPI CSI,也能复用这套代码,只是设备名换成/dev/video0、/dev/video1。系统我用的 Ubuntu 20.04,RKNN 版本是 rknn-toolkit2 对应的板端 rknnlite。模型转换在 x86 PC 上完成:yolov5s.pt -> yolov5s.onnx -> yolov5s.rknn,量化用 int8,校准集准备几十张有代表性的图就行,别太少也别太多。转换完成后 .rknn 大概 5MB 左右,在 RK3588 上推理延迟大约 20~30ms。
一个非常关键的现场提醒:烧写完 Ubuntu 20.04 后,先做根分区扩容再做别的。香橙派官方镜像的 sdcard 镜像经常只给根分区划分 8GB,无论你的 SD 卡或 eMMC 有多大,剩余空间都是未分配的。双路视觉要缓存多帧图像和检测结果,磁盘满了程序会直接卡死,日志都来不及打印。扩容操作就是 fdisk 调整分区 + resize2fs,开机第一件事就做,别等出问题再想。
3.2 核心类:一个会丢旧帧的队列
自定义队列是整个阶段二最关键的部分,直接贴出完整代码。
import time import threading from collections import deque class DropOldFrameQueue: """丢旧帧背压队列:满了清旧帧,消费时永远取最新。""" def __init__(self, maxsize=2): self.maxsize = maxsize self._buf = deque(maxlen=maxsize) self._cond = threading.Condition() def put(self, frame, ts): with self._cond: # 队列达到上限说明消费者跟不上 # 此时不阻塞生产者,而是把旧帧全部清掉,只保留最新帧 if len(self._buf) >= self.maxsize: self._buf.clear() self._buf.append((frame, ts)) self._cond.notify_all() def get_latest(self, timeout=0.2): with self._cond: if not self._buf: self._cond.wait(timeout) if not self._buf: return None, None return self._buf.pop()解释一下为什么不用queue.Queue。queue.Queue是线程安全的生产者消费者队列,但get()默认弹出最老的帧,不符合丢旧帧思路;要实现丢旧帧还得自己加判断和清空逻辑,反而别扭。我这里用deque(maxlen=maxsize),但刻意不用它自带的自动淘汰:如果依赖 maxlen 自动挤掉老帧,消费者pop()取走最新帧后,队列里还会残留旧帧,下次get_latest()就可能拿到过期数据。所以代码里在put()阶段显式clear(),保证消费端永远只面对“当前最新帧”和“空队列”两种状态。
Condition.wait(timeout)的作用是让消费者在没有新帧时阻塞等待,避免空转吃满 CPU。超时设 0.2 秒,程序退出时最多等 0.2 秒,可以接受。如果你希望退出更快,把这个 timeout 调到 0.05。
对于双路,我建议为每路各建一个DropOldFrameQueue实例,推理线程和相机线程一对一。这样逻辑清晰,哪一路出了问题也方便单独排查。如果你想做一个共享队列统一调度,也可以,但队列元素里要加camera_id字段,代码复杂度会上一个台阶,没必要。
3.3 双路采集线程怎么写
采集线程只做一件事:从摄像头读帧,丢进队列。
def capture_worker(device, q, stop_event): cap = cv2.VideoCapture(device) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*'MJPG')) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 30) while not stop_event.is_set(): ret, frame = cap.read() if not ret: time.sleep(0.01) continue q.put(frame, time.monotonic()) cap.release()这里有个很容易被忽略的点:cap.read()这个简单调用背后,V4L2 驱动本身就在做丢旧帧。当应用来不及从驱动取帧时,驱动帧缓冲队列满了,新到的帧会覆盖旧的 buffer。所以哪怕你什么都不做,驱动层已经在帮你做第一次“丢旧帧”。我们应用层的丢旧帧,是把驱动层的背压策略延续到算法侧,保证 NPU 拿到的也是新鲜帧。
双路 USB 摄像头,在香橙派上通常对应设备 0 和 1;双路 MIPI CSI 也类似。但 MIPI 驱动对同时打开两路有带宽限制。如果发现只有一路能出图,先把采集分辨率从 1080p 降到 720p,或者把帧率从 30fps 降到 25fps。两路同时 1080p/30fps 也不是完全不行,但加上 yolo 推理,CPU 总会到瓶颈。
还有一点:不是摄像头帧率越高越好。双路时设 25 或 30fps 就够,因为推理大约只有每路 15~20fps,多出来的帧反正会被丢,还浪费 USB 带宽、增加 CPU 解码负担。丢旧帧是防御机制,不是让你故意高采低消的借口。
3.4 推理线程与 NPU 锁
RK3588 只有一个 NPU,不要天真地想“两个推理线程各初始化一个 RKNNLite 实例并行跑”。实测这么做轻则互相抢资源导致总体 FPS 不升反降,重则直接段错误退出。正确做法:全局一个 RKNNLite 实例,所有推理线程共用,加一把锁保证同一时刻只走一个 inference。
infer_lock = threading.Lock() def infer_worker(q, rknn, infer_lock, result_q, stop_event): while not stop_event.is_set(): frame, ts = q.get_latest(timeout=0.2) if frame is None: continue # yolov5s 前处理:letterbox + RGB + 归一化 + HWC -> CHW img = letterbox(frame, (640, 640)) rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) rgb = rgb.astype(np.float32) / 255.0 input_tensor = rgb.transpose(2, 0, 1)[None] with infer_lock: outputs = rknn.inference(inputs=[input_tensor]) boxes, scores, classes = post_process(outputs, frame.shape) result_q.put((frame, ts, boxes, scores, classes))前处理为什么放锁外面?因为 letterbox、cvtColor 都是 CPU 活,应该在等待锁之前就做完,等你抢到 NPU 时直接喂数据,节省锁占用时间,让两路整体吞吐更高。我之前看到有人把前处理也丢进锁里,结果两路并发效率一降再降。推理线程等待锁的这段时间,自己的输入队列已经在执行丢旧帧逻辑,所以它拿到的帧永远是最新一帧,锁等待和帧过期完全解耦。
关于推理的输入输出再补两句。如果你转换时 RKNN 输出是[1, 25200, 85],后处理要做一次大遍历;如果转换时指定了三个输出分支,就把后处理改成 rknn_model_zoo 里的 yolov5 版本。别手写 NMS,浪费时间,直接用现成的改改。另外每次inference()不要重新分配输入数组,把输入 tensor 的 shape 固定,尽量复用同一块内存。NPU 在嵌入式端最烦频繁分配大块连续内存,跑一段时间会明显变慢。
3.5 别忘了:结果队列同样要丢旧帧
很多人实现到这里就算完了,其实坑还在后面。显示线程如果跟不上推理速度,结果队列用普通queue.Queue一样会积压,推理结果越堆越多,画面照样延迟。所以结果队列也建议用DropOldFrameQueue(maxsize=1)。
显示线程把两路画面拼接时,我用np.hstack或cv2.hconcat,让左右画面尽量接近。双路信号源没有硬同步的话,不要去纠结毫秒级对齐,把每帧的时间戳打上,显示时标注每一路的先后即可。如果你的应用是“双路目标检测 + 测距”,需要双目同步曝光,那是另一个硬件课题——用 GPIO 把两个 sensor 的同步信号接在一起。这跟丢旧帧背压不是一回事:丢旧帧只保证每路内部“新鲜”,不保证两路之间“同时”。
4. 参数调节:队列长度与延迟实测
背压方案最关键的旋钮就是队列长度 maxsize。给你一个工程估算公式:端到端延迟约等于“采集延迟 + 队列等待 + 前处理 + NPU 推理 + 后处理 + 显示缓冲”,其中队列等待是可调项,近似等于队列里滞留的帧数除以平均帧率。队列越长,等待越久。
我在香橙派 5 上实测的数据大概是这样(双 720p 摄像头,yolov5s int8 640x640,每路独立队列):
| 队列长度 | 每路平均有效 FPS | 每路端到端延迟 | 丢帧率 | 主观感受 |
|---|---|---|---|---|
| 1 | 约 20 | 约 50~70ms | 高,约 1/3 | 画面跟手,但偶发 NPU 空转 |
| 2 | 约 22 | 约 70~90ms | 中,约 1/3 | 跟手且稳定,推荐 |
| 3 | 约 22 | 约 100~120ms | 低 | 开始有点钝 |
| 5 | 约 22 | 超过 150ms | 低 | 明显滞后,避障不可用 |
为什么我推荐 maxsize=2 而不是 1?队列长度 1 虽然延迟更低,但消费者get_latest()的瞬间,生产者可能刚好在两帧之间,队列是空的,消费者就得空等一个周期。30fps 下空等就是 33ms,这 33ms 里 NPU 闲着,所以 maxsize=1 的平均 FPS 反而不如 2。maxsize=2 用一帧的缓冲吸收了采样抖动,延迟只多 20ms 左右,平均吞吐更高,画面也稳。
4.1 队列长度不是唯一旋钮,采集帧率也要配对
很多人只调队列长度,忘了和采集帧率联动。如果推理只有每路 15fps,那把摄像头帧率直接设成 20fps 就够,甚至 25fps 都行。丢帧率会明显下降,USB/CPU 带宽也宽裕。有人喜欢把摄像头开到 30fps 然后疯狂丢旧帧,表面上看“我采集到了最多数据”,实际多出来的帧全都浪费,还白白吃掉带宽和功耗。
我的偏好是:双路推理目标 20fps,就把采集帧率设 25fps,队列长度 2。这个组合下延迟稳定在 80ms 左右,看起来跟手,CPU 占用也不夸张。
4.2 进阶调法:两路队列长度可以不对称
如果右路是主视角、更重要,右路队列长度可以设小一点,保证右路优先新鲜;左路可以稍微大一点,吸收抖动。但建议先把两路都设 2,跑几天看看数据再调。不要一开始就搞不对称,否则左右时间戳差得多,做融合的时候会很痛苦。
5. 常见问题与排查实录
最后把我踩过的坑整理成速查表,照着查能省很多时间。
| 现象 | 原因 | 处理 |
|---|---|---|
| 双路摄像头只能打开一路 | CSI 带宽或设备树冲突 | 降低分辨率/帧率,或换 USB 摄像头 |
| NPU 推理时 Python 进程崩溃 | 两个 RKNNLite 实例并发 | 全局单实例 + 推理锁 |
| 烧写后根分区没空间 | 官方镜像默认分区小 | fdisk 扩容 + resize2fs |
| 检测框和画面明显错位 | 丢旧帧导致时间戳不同步 | 显示时带上时间戳,或减小队列长度 |
cap.read()延迟比预期大 | MJPG 解码 + USB 缓冲 | 降低分辨率,或改用 MIPI 摄像头 |
| int8 量化后检测精度下降 | 校准集不合适 | 换校准集,对关键输出层保留浮点 |
| 长时间运行后延迟缓慢增长 | 结果队列没有丢旧帧 | 结果队列也用DropOldFrameQueue(maxsize=1) |
单独说两个最坑的。
第一个是“看似一切正常,但延迟慢慢增长”。我遇到过一次,排查了很久,最后发现是结果队列用了普通队列,显示线程把推理结果越积越多,画面从 50ms 延迟一路涨到 300ms。整个链路里,输入侧做了背压,输出侧没做,等于一半工作白干了。从那以后我给自己定了一条规则:在嵌入式实时视觉里,凡是队列,都先问一句“满了怎么办”,答不上来就默认用丢旧帧。这个习惯帮我少踩了无数坑。
第二个是双路 MIPI 同时打开的驱动问题。两路 CSI 在 RK3588 上不是简单插上就能同时用,不同摄像头模组的时序、分辨率、数据 lane 数都影响带宽。实测如果两路都开 1080p,很容易出现只有一路出图或者画面花屏;降到 720p/25fps 就稳定。如果你是 USB 摄像头,基本没有这个问题,但也别忽略供电。双路 USB 摄像头在香橙派上同时跑,最好用带独立供电的 USB HUB,否则可能随机掉帧甚至设备重枚举。
最后再分享一个让调试变轻松的小技巧:双路背压程序跑起来后,先别急着接真实场景,用两个播放固定视频的文件摄像头做模拟输入,把队列长度、丢帧率、端到端延迟打印到屏幕上。这样你能在房间里就调好参数,再拿到现场实拍。调试的时候把时间戳打在每个检测框上,一眼就能看出背压有没有生效——如果时间戳和当前时间差始终在 100ms 以内,说明帧流是“新鲜”的;如果差值越拉越大,说明链路里还有地方在积压。
阶段二到这里就完整了。双路视觉真正的难点不在模型,而在数据流控制上。能把“丢旧帧”这条链路吃透,双路 yolo 的实时性就已经超越大多数半路出家的实现。接下来你在这个框架上做双路结果异步融合、按 ROI 分区调度 NPU,都会顺畅很多。