做双路视觉这件事最折磨人的往往不是模型精度也不是NPU跑不动而是你辛辛苦苦把两路yolov5s推理都调通了画面却在肉眼可见地变卡。香橙派5这类RK3588平台上我前面几篇已经把单路摄像头取流、yolov5s转rknn、NPU推理、画框这些环节都跑通了但两路同时一开延迟就像滚雪球一样越滚越大。这篇记录的是我换到“丢旧帧背压”方案之后的完整做法包括队列为什么一定会膨胀、单槽位邮箱怎么设计、采集线程和推理线程怎么拆分、实测延迟能压到多少以及几个我在香橙派5上踩到并且觉得你一定也会踩的坑。适合已经把单路跑通、正卡在双路实时性上的朋友。1. 双路实时瓶颈先想明白延迟是从哪来的1.1 算术账30帧进、15帧出队列一定失控假设每路摄像头是1080p30fps帧间隔大约是33ms。yolov5s的INT8模型在RK3588的NPU单核上推理大概要2025ms加上画框、缩放、颜色转换这些前后处理一轮下来约30ms。单路情况下这个数字还能看30帧的输入能勉强跟得上。但双路同时开工两个推理任务如果排在同一个NPU核上轮流执行每路有效吞吐会掉到15fps左右。这时候如果你还用无限制长度的FIFO队列去接采集帧算一下就知道了每秒进来30帧每秒只取走15帧队列每秒会多积压14帧左右。跑60秒之后每路队列里摞了接近900帧。新到的帧要排在900个“前辈”后面等待处理按15fps的消费速度计算它得等差不多60秒才能被看到。你屏幕上显示出来的其实是半分钟以前的画面。更麻烦的是内存。1080p的BGR图像一帧就要6MB左右900帧就是5.4GB香橙派5标配8GB内存也扛不住长时间跑轻则卡死重则进程被系统杀掉。这里真正的问题不是推理慢而是“所有帧都想要”这个目标本身。实时检测系统里把算力花在旧帧上等于白花钱。1.2 背压的两种形态阻塞式和丢弃式说到背压做流处理和消息队列的人应该不陌生。经典做法是队列满了就让生产者阻塞下游处理完一个上游才被允许继续产出一个这就是阻塞式背压。像Linux管道、GStreamer的dataflow走的都是这套。但嵌入式视觉里摄像头是个没法真正“暂停”的硬件。传感器一直在采集曝光驱动层的buffer池也不会因为你处理慢就停止填数据。你不可能让镜头“等一等再曝光”。所以这类场景实用的背压形态变成了丢弃式让队列长度封顶新帧来了就把旧帧覆盖掉消费端永远只拿最新的一帧。丢帧动作本身就是背压的体现——下游消费能力决定了有效的处理速度队列长度有上限端到端延迟也就有了上界。用生活化的话说这就像驿站货架只有一个格子。快递员采集线程每送来一个新件就把架子上那个旧件扔垃圾桶。取件员推理线程每次来货架上永远只有最新的一件。取件员慢一点不要紧因为货架永远只有一件旧件早就被新件盖掉了你永远不会拿到半年前的快递。2. 丢旧帧方案的核心设计单槽位邮箱与最新帧优先2.1 为什么“丢旧保新”是对的而不是“丢新保旧”既然弹性缓冲从“无数个格子”缩成一个格子总得有帧被丢。丢哪头如果目的是录像取证那确实不能丢甚至应该“丢新保旧”尽量保留历史数据。但实时检测恰恰相反。目标在移动。0.5秒之前的画面里人和车的位置已经变了。模型花几十毫秒去算一个过期的框结果对控制回路、自动跟随、安全告警都没有意义。实时系统追求的是响应新鲜度而不是帧的完整覆盖率。香橙派这类边缘设备尤其如此NPU算力就那么多硬件资源有限只能把每一帧宝贵算力花在离当前时刻最近的画面上。所以这个方案里有一条铁律新帧到达无条件覆盖旧帧。旧帧还没被消费抱歉直接扔。推理线程永远拿最新永远不追着队列跑。2.2 单槽位LatestSlot的数据结构与操作语义我用Python实现了一个单槽位邮箱代码放在下面。结构不复杂核心就是一个条件变量保护的一格缓冲import threading import time class LatestSlot: 单槽位邮箱新帧直接覆盖旧帧消费端拿到的永远是最近一帧。 def __init__(self): self._cv threading.Condition() self._frame None self._seq 0 self._ts 0.0 self._ready False def push(self, frame, seq): with self._cv: self._frame frame self._seq seq self._ts time.perf_counter() self._ready True self._cv.notify_all() # 唤醒正在等待的消费者 def pop(self, timeout_ms500): 拿最新帧并标记为已消费超时没新帧返回(None, -1)。 deadline time.monotonic() timeout_ms / 1000.0 with self._cv: while not self._ready: remain deadline - time.monotonic() if remain 0: return None, -1 self._cv.wait(remain) frame, seq self._frame, self._seq self._ready False return frame, seq def peek(self, timeout_ms500): 只读最近一帧不标记消费适合显示/推流端持续出画面。 deadline time.monotonic() timeout_ms / 1000.0 with self._cv: while not self._ready: remain deadline - time.monotonic() if remain 0: return None, -1 self._cv.wait(remain) return self._frame, self._seqpush是非阻塞的采集线程把新帧引用交出去就继续去抓下一帧pop是消费端的拿了就跑并且在当前这一帧被消费完之前不会重复处理同一帧。你可能会问推理线程要是处理得比采集快怎么办那pop会在没数据时挂起等待只有push进来才被唤醒不会空转消耗CPU。C那边的写法本质上一样无非就是mutex加条件变量再加一个shared_ptr交换语义完全相同这里就不重复贴了。2.3 消费端等待策略什么时候用pop什么时候用peek有个细节我一开始没处理好推理线程调用pop超时返回None之后要不要拿旧帧将就着再推一遍我的答案是不要。重复推理同一帧纯粹是浪费NPU画面没有任何更新白耗算力。但显示端和推流端恰恰相反。播放器需要连续的画面没有新结果时会希望重复显示上一帧而不是直接黑屏。所以我在输出侧单独挂一个out_slot用peek去取最近的处理结果这样画面能一直保持“有东西”而推理线程那边始终只认新鲜帧。两套语义分开逻辑就清晰了。还有一点要注意一个槽位最多挂一个消费者。如果两个线程都去pop同一个槽位会出现一个读到帧、另一个苦苦等的情况。实际工程里我把每个LatestSlot的访问控制在单生产者单消费者输出端另外建独立的槽位各管各的。3. 香橙派5上的双路实现采集线程与推理线程的四种搭法3.1 整体线程模型双路丢旧帧方案在香橙派5上的落地线程分配可以画成四条线两路摄像头各一个采集线程两个推理线程外加一个可选的显示/推流线程。每路的链路都是“采集→push到slot→推理线程pop→处理后push到out_slot→显示/推流端peek”。采集线程的职责很单纯从摄像头拿到原始帧立刻push进槽位不管槽位里是不是还有上一帧正在被推理。推理线程的职责更单纯从槽位拿最新帧跑yolov5s出框把结果给输出槽位。两路之间除了共享NPU之外完全没有耦合哪路出问题都不会拖垮另一路。3.2 采集线程拿到帧立刻压槽位摄像头打开方式取决于你用的是USB还是MIPI CSI。USB摄像头最简单直接给设备号import cv2 import itertools cap0 cv2.VideoCapture(0) cap1 cv2.VideoCapture(1) # 如果你的香橙派系统里CSI摄像头已经注册成了video节点也可以走GStreamer管线 # 比直接v4l2好用的地方在于驱动侧做了缓冲管理 # cap cv2.VideoCapture( # v4l2src device/dev/video0 ! video/x-raw,formatNV12,width1920,height1080,framerate30/1 ! # videoconvert ! video/x-raw,formatBGR ! appsink drop1, # cv2.CAP_GSTREAMER)采集线程的主体就是while循环里面read加push读不到帧时稍微睡一下防止空转stop_event threading.Event() def capture_worker(name, cap, slot): seq itertools.count() while not stop_event.is_set(): ok, frame cap.read() if not ok: time.sleep(0.01) continue slot.push(frame, next(seq))这里有个容易忽略的好习惯把seq序号带上。后边统计丢帧率、排查卡顿全靠它。3.3 推理线程RKNNLite拉取最新帧并出框推理线程代码里pop超时我习惯设100ms。没有新帧就continue有帧就做前处理、推理、后处理。前处理的letterbox函数我沿用之前几篇里那个顺手贴一下方便你对照代码完整性import numpy as np def letterbox(img, size(640, 640)): src_h, src_w img.shape[:2] scale min(size[0] / src_h, size[1] / src_w) new_w int(src_w * scale 0.5) new_h int(src_h * scale 0.5) resized cv2.resize(img, (new_w, new_h)) canvas np.full((size[0], size[1], 3), 114, dtypenp.uint8) ox (size[1] - new_w) // 2 oy (size[0] - new_h) // 2 canvas[oy:oy new_h, ox:ox new_w] resized return canvas, scale, (ox, oy)推理线程主体def infer_worker(name, slot, out_slot, rknn, cfg): last_seq 0 dropped 0 while not stop_event.is_set(): frame, seq slot.pop(timeout_ms100) if frame is None: continue # 统计被丢弃的帧数seq跳了多少说明中间有多少旧帧被覆盖了 if last_seq and seq ! last_seq 1: dropped seq - last_seq - 1 print(f[{name}] skip {seq - last_seq - 1}, total dropped {dropped}) last_seq seq img, ratio, (ox, oy) letterbox(frame, (cfg.in_h, cfg.in_w)) rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) outputs rknn.inference(inputs[rgb[None, :, :, :]], data_formatnhwc) boxes postprocess_yolov5s(outputs, frame.shape[1::-1], ratio, ox, oy, cfg) drawn draw_detections(frame, boxes) out_slot.push(drawn, seq)postprocess和draw_detections是系列前面已经写好的逻辑就是从RKNN输出里解析出坐标、置信度再画上去这里不重复占用篇幅。3.4 双RKNN实例与NPU核分配RKNNLite实例必须一路一个。两个推理线程绝对不是共享同一个实例那会直接互相踩内部状态轻则推理结果错乱重则段错误。每路各加载一遍模型内存开销也就二三十兆对双路来说完全可以接受。NPU核分配方面RK3588有3个NPU核。跑双路时我建议显式绑定核0和核1避免rknn驱动调度器把两个任务都塞到同一个核上排队。绑定方式取决于你装的rknn-toolkit2版本from rknnlite.api import RKNNLite def load_rknn(path, core_maskNone): rknn RKNNLite() if rknn.load_rknn(path) ! 0: raise RuntimeError(load rknn model failed) if core_mask is not None: rknn.init_runtime(core_maskcore_mask) else: rknn.init_runtime() return rknn # 新版本支持core_mask旧版本没有这个参数就都不传让驱动自调度 rknn0 load_rknn(yolov5s.rknn, core_mask1) # NPU核0 rknn1 load_rknn(yolov5s.rknn, core_mask2) # NPU核1实测下来不绑核时双路吞吐经常只有绑核的六七成。绑核之后两路可以稳定跑到20fps上下画面延迟基本恒定。如果你两路模型不一样比如一路yolov5s另一路yolov5n绑定更是建议做的否则调度器大概率会按自己的脾气来你控制不了算力倾斜。4. 实测数据延迟、丢帧率和资源占用4.1 测试条件先交代测试环境方便你对照自己的板子项目配置主板香橙派5RK3588S即RK3588平台被动散热片系统Ubuntu 22.04/20.04官方镜像均可摄像头两路USB UVC 1080p30fps模型yolov5s INT8640×640输入RKNN格式运行方式双RKNNLite实例绑定NPU核0/核1单帧耗时NPU推理2025ms前后处理58ms如果你用的是MIPI CSI摄像头驱动侧细节会略有不同但上层这套LatestSlot逻辑是通用的测试数据也能作为参考。4.2 FIFO队列与丢旧帧的延迟对比同样跑60秒阶段一那种无上限FIFO队列和阶段二的单槽位丢旧帧方案表现差异非常大指标运行60秒后FIFO队列阶段一丢旧帧背压阶段二输入帧率30fps/每路30fps/每路有效输出帧率约15fps/每路约15fps/每路队列长度持续增长约900帧/路恒定为1新帧端到端延迟约60秒且继续增长6080ms稳定丢帧率0全攒着不丢约50%丢旧帧方案的端到端延迟构成非常清晰采集等待一帧的时间约33ms加NPU推理时间约25ms加前后处理约8ms总在6080ms这个区间来回晃。如果摄像头改成720p推理时间还能再降延迟能压到50ms出头。4.3 用帧序号缺口统计真实丢帧率代码里那个seq序号就是用来验证背压到底生效没有的。你可以这样想采集线程每秒push 30帧seq从0一路涨到30推理线程每秒只pop走15帧pop出来的seq一定不是连续的。中间跳过多少就说明有多少旧帧被覆盖丢弃了。跑起来之后我的日志里每路大概每2秒输出一行skip 2、skip 1、skip 3……一小时累计丢帧率稳定在50%上下。这50%不是系统丢的是设计上主动丢的丢的都是“即便算了也已经过时”的帧这就是背压在工作。资源占用方面两路1080p采集加推理python进程总CPU占用大概50%到70%主要花在视频解码、BGR转换和画框上内存稳定在1.5GB以内。相比阶段一那种无限队列内存占用低了一个数量级。5. 翻车清单丢旧帧方案里几个容易忽略的坑5.1 底层V4L2队列和上层邮箱是两码事丢旧帧只管应用层管不到驱动层。用MIPI CSI摄像头时我踩过一个典型的坑应用层邮箱只有一个格子看着一切正常但推理线程一慢驱动侧的V4L2 buffer池先爆了。因为ISP驱动就那么几个buffer采集线程从DQBUF拿到NV12原始帧后如果一直攥着不还驱动没有空buffer可用ISP就开始报错丢输出。解决方法是把“取帧”和“处理帧”分离采集线程拿到原始帧后立刻拷贝一份或者转成BGR马上把V4L2 buffer还给驱动慢速的推理全在上层邮箱做。GStreamer的appsink之所以顺手就是因为它帮你做了buffer回收这件事。USB摄像头也有类似问题UVC驱动的环形buffer深度是固定的长时间不读就会出现画面变成慢动作甚至卡死。5.2 不要在push里做深拷贝cv2.read()每次返回的都是新分配的numpy数组采集线程把数组引用直接交给邮箱就行根本不需要copy()。如果你强行在push里copy1080p BGR一帧6MB两路30fps就是每秒360MB的额外内存写入RK3588带宽虽然扛得住但CPU占用会明显上升推理线程抢不到CPU延迟反而恶化。唯一例外是高级的零拷贝/DMA buffer复用场景。如果你让摄像头直接写入一块预分配内存那块内存下一帧会被覆盖那push前就必须复制否则推理线程可能一边读一边被采集线程改写画面出现撕帧。用OpenCV默认read()的话这个坑碰不到不折腾直接传引用就对了。5.3 NPU核绑定、模型轻量化和散热双路持续满负荷跑yolov5sRK3588的发热是实打实的。被动散热片撑不到半小时NPU温度到80度左右就会降频有效fps会从20掉到1215。我处理过热问题的顺序是先加一个USB小风扇再把不担纲主检测的某一路换成yolov5n模型最后才考虑降低输入分辨率。注意换分辨率不是简单改letterbox参数转模型时要用对应尺寸的数据集重新校准量化不然mAP会掉得很难看。另外我试过把其中一路模型换成yolov8的rknn导出坑位其实和yolov5s一模一样丢旧帧的逻辑完全不用改。选型判断标准始终是实时场景一律最新优先。5.4 输出端也要丢旧帧推理线程处理得快只解决了一半问题。显示端、推流端、编码器如果还在用FIFO旧画面照样会从另一端堆回来。我给输出端挂了同样的单槽位邮箱推流线程用peek拿最近一帧处理结果去编码。ffmpeg推流时加-fflags nobuffer能降低一部分缓冲延迟但本质问题还得靠上层控制发送速率不能指望编码器帮你自动丢帧。你甚至可以做一个调试开关正常运行丢旧帧一旦需要保存事件证据比如检测到特定目标时就把最近N帧原始图落盘这样既保住实时性又不会漏掉关键事件。最后说点做这一路下来的体会。实时双路视觉真正难的地方不是把yolov5s塞进NPU而是你能不能接受“每一帧都要处理”这个想法本身是错的。丢旧帧背压说白了就是承认算力不够然后把有限的算力全部花在最近的画面上。这个LatestSlot小结构我后来从摄像头取流一路用到了RTSP拉流、夜间低照度检测、甚至和mpp编码器对接的中间缓冲上原理全都一样。后边我打算把阶段一和阶段二的方案合并成可配置的通用双路框架再补上关键帧保序的增强版。如果你也正在香橙派或者RK3588上折腾双路视觉希望这篇能帮你少走我走过的弯路。