1. 双路视觉方案的整体设计思路拆解1.1 为什么要在RK3588上做双路视觉香橙派5搭载的RK3588芯片CPU是4核A76加4核A55的big.LITTLE架构NPU算力标称6TOPS支持INT8量化推理。这个配置跑单路yolov5s在640x640输入下NPU推理大概能到30-40ms一帧也就是25-30FPS左右。但一旦上双路摄像头问题就来了——两路MIPI或者USB摄像头同时出流如果每路都按30FPS采集再各自跑一遍yolov5sNPU根本吃不下CPU也会被前处理和后处理拖垮。我当初做这个方案的时候第一版就是老老实实两路都排队推理结果帧率直接掉到个位数延迟累积到两三秒画面完全没法看。后来才意识到双路视觉的核心矛盾不是“怎么把两路都跑起来”而是“在算力有限的前提下怎么让两路都保持可用”。这就引出了整个方案的设计主线丢旧帧背压。所谓丢旧帧背压说白了就是当推理速度跟不上采集速度时不要傻傻地把所有帧都塞进队列等着处理而是主动丢弃那些已经过时的旧帧只保留最新的一帧。背压这个词来自流控领域意思是下游处理不过来时要向上游反馈压力让上游降速或者丢数据。在双路视觉场景里背压的对象就是采集线程和推理线程之间的帧队列。1.2 阶段一和阶段二的分工这个教程是分阶段的。阶段一我理解主要解决的是单路跑通的问题——摄像头采集、yolov5s模型转换、RKNN推理、结果绘制这一整条链路先跑起来。阶段二才进入双路和性能优化的深水区。丢旧帧背压方案是阶段二的核心它要解决的是双路并发时的实时性问题。为什么把丢旧帧放在阶段二而不是一开始就做因为如果你连单路都没跑稳根本不知道瓶颈在哪。我见过不少人一上来就搞多线程、搞队列、搞丢帧策略结果单路推理本身就有问题比如模型转换时量化参数不对、输入尺寸和模型不匹配、后处理解码写错了这些基础问题没解决上层再怎么优化都是白搭。所以阶段二的定位很明确在单路链路已经验证正确的前提下引入双路并发和实时性优化。1.3 丢旧帧背压的核心思想用一个生活化的类比你去奶茶店排队店里只有一个店员在做奶茶。如果队伍排了20个人每个人点的都是现做的那第20个人拿到奶茶的时候前面19杯可能已经凉了。丢旧帧背压的做法相当于店员只做最新那个顾客点的单前面排队的如果等太久就直接告诉他们“你这杯不做了重新点”。听起来有点粗暴但在视觉推理场景里旧帧的检测结果本来就没有意义——你关心的是当前画面里有什么而不是两秒前画面里有什么。具体到实现上每一路摄像头对应一个采集线程和一个推理线程中间用一个容量为1的帧缓冲队列连接。采集线程拿到新帧后不是往队列里追加而是直接覆盖掉队列里已有的旧帧。推理线程每次从队列取帧时拿到的永远是最新的一帧。如果推理线程正在处理上一帧采集线程又来了新帧那就把队列里等待的那帧替换掉。这样队列永远不会堆积延迟始终控制在一帧推理时间以内。这个方案的关键参数是队列容量。容量设为1是最激进的丢帧策略延迟最低但丢帧率最高。容量设为2或3可以在延迟和丢帧率之间做权衡。我实测下来双路yolov5s在RK3588上队列容量设为1时每路有效推理帧率大概8-12FPS延迟稳定在100ms以内对于大多数监控和交互场景已经够用了。1.4 方案选型的对比与取舍在确定丢旧帧背压之前我试过几种其他方案这里做个对比方便你根据自己场景选择。方案延迟帧率实现复杂度适用场景全帧排队高累积增长低低离线处理不要求实时固定间隔丢帧中中低帧率稳定的场景丢旧帧背压低稳定中中双路实时视觉动态降分辨率低高高对精度要求不高的场景模型轻量化背压低高高高帧率实时场景全帧排队就是最朴素的做法采集到的每一帧都进队列推理线程按顺序处理。这个方案在单路低帧率下没问题但双路高帧率下队列会无限增长内存暴涨延迟越来越大。固定间隔丢帧是每隔N帧丢一帧实现简单但如果推理时间波动大还是会出现延迟抖动。动态降分辨率是根据队列长度动态调整输入尺寸队列长了就降到320x320短了再升回640x640这个方案效果好但实现复杂而且分辨率切换本身有开销。丢旧帧背压的优势在于实现相对简单延迟上界明确不会出现延迟累积。缺点是丢帧率不可控如果推理特别慢可能大部分帧都被丢了。但对于实时视觉来说“看到最新的”比“看到所有的”重要得多。2. 核心细节解析与实操要点2.1 线程模型与队列设计整个双路视觉的线程模型是这样的主线程负责初始化和监控每路摄像头有一个采集线程每路有一个推理线程另外还有一个显示线程。采集线程和推理线程之间用帧队列连接推理线程和显示线程之间用结果队列连接。帧队列的实现是整个方案的核心。在Python里我推荐用queue.Queue(maxsize1)配合非阻塞的put_nowait和get_nowait。采集线程拿到新帧后先尝试get_nowait把旧帧取出来丢掉再put_nowait放入新帧。如果队列为空get_nowait会抛Empty异常直接忽略即可。推理线程用get阻塞等待新帧拿到后处理。这里有个细节要注意帧的拷贝问题。如果你直接把摄像头的buffer放进队列采集线程下一帧采集时可能会覆盖这个buffer导致推理线程拿到的是被修改过的数据。所以入队前必须做一次拷贝或者用camera.capture_buffer这类返回独立buffer的接口。拷贝本身有开销640x640x3的uint8数组大概1.2MBmemcpy一次大概0.5ms可以接受。import queue import threading import numpy as np class FrameBuffer: def __init__(self): self.queue queue.Queue(maxsize1) self.lock threading.Lock() self.dropped 0 self.total 0 def put(self, frame): with self.lock: self.total 1 try: self.queue.get_nowait() self.dropped 1 except queue.Empty: pass self.queue.put_nowait(frame) def get(self, timeout1.0): return self.queue.get(timeouttimeout)这段代码里put方法先尝试取出旧帧如果取到了说明发生了丢帧计数器加一。然后放入新帧。get方法阻塞等待超时1秒。丢帧计数器可以用来监控系统状态如果丢帧率超过80%说明推理太慢需要考虑降分辨率或者换更轻的模型。2.2 采集线程的实现要点采集线程用OpenCV的VideoCapture或者香橙派自带的v4l2接口。我实测下来USB摄像头用OpenCV的CAP_V4L2后端比较稳MIPI摄像头可能需要用mediactl配置好管道后用v4l2直接读。采集线程的主循环很简单读一帧做必要的格式转换比如YUYV转BGR然后调用frame_buffer.put。但这里有几个坑。第一cap.read()本身是阻塞的如果摄像头出流不稳定可能会卡住。解决办法是设置cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)把驱动层的缓冲也设为1避免驱动层堆积旧帧。第二格式转换很耗CPUYUYV转BGR在1080p下可能要10ms以上。如果摄像头支持MJPG输出优先用MJPG解码由硬件做CPU占用低很多。第三采集线程要设置成daemon线程主线程退出时能自动结束。def capture_thread(cam_id, frame_buffer, stop_event): cap cv2.VideoCapture(cam_id, cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*MJPG)) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) while not stop_event.is_set(): ret, frame cap.read() if not ret: continue frame_buffer.put(frame) cap.release()注意CAP_PROP_BUFFERSIZE这个参数不是所有摄像头都支持设置后可以用cap.get读回来确认。如果不支持驱动层还是会缓冲几帧这时候丢旧帧策略只能在应用层生效驱动层的延迟没法消除。2.3 推理线程与RKNN绑定推理线程是CPU和NPU交互的地方。RK3588的NPU通过RKNN Runtime调用每个推理线程最好绑定一个独立的RKNN context避免多线程竞争同一个context导致锁冲突。香橙派的RKNN Toolkit2支持多context但每个context会占用一定的NPU内存双路的话内存要留够。推理流程是从帧队列取帧resize到模型输入尺寸yolov5s通常是640x640归一化转成RKNN需要的NHWC或者NCHW格式调用rknn.inference拿到输出后做后处理解码bbox、NMS最后把结果和原始帧一起放进结果队列。这里的关键是前处理和后处理的耗时。前处理包括resize和归一化在CPU上做640x640大概3-5ms。后处理包括解码和NMSyolov5s有3个输出层解码大概2-3msNMS大概1-2ms。加起来每帧CPU侧开销大概8-10msNPU推理大概30-40ms总共40-50ms一帧。双路交替的话NPU利用率大概80-90%基本跑满。def inference_thread(rknn_ctx, frame_buffer, result_queue, stop_event): while not stop_event.is_set(): try: frame frame_buffer.get(timeout0.5) except queue.Empty: continue input_data preprocess(frame) # resize normalize outputs rknn_ctx.inference([input_data]) boxes postprocess(outputs) # decode NMS result_queue.put((frame, boxes))preprocess里用cv2.resize的时候插值方式选INTER_LINEAR就够了INTER_CUBIC更慢但精度提升有限。归一化直接除以255用numpy的广播操作比循环快得多。如果模型是INT8量化的输入要转成uint8归一化在模型内部做这样前处理更快。2.4 显示线程与结果同步显示线程从结果队列取(frame, boxes)把bbox画到frame上然后显示或者推流。结果队列的容量可以设大一点比如5因为显示本身很快不会成为瓶颈。但如果显示也慢比如要推RTSP流那结果队列也要用丢旧帧策略。双路显示的时候可以用cv2.hconcat把两路画面拼成一张宽图显示或者用两个窗口分别显示。拼图的话要注意两路分辨率一致不一致要先resize。我一般用拼图方便对比两路的效果。显示线程里画框用cv2.rectangle和cv2.putText这两个操作在640x480下大概1-2ms可以接受。如果要显示FPS和丢帧率可以在画面上叠加文字用cv2.putText。FPS计算用滑动窗口比如最近30帧的时间差比瞬时FPS稳定。3. 实操过程与核心环节实现3.1 环境准备与依赖安装香橙派5出厂系统一般是Ubuntu 20.04或者22.04内核版本5.10。首先确认NPU驱动已经加载ls /dev/rknpu应该能看到设备节点。如果没有需要更新内核或者手动加载rknpu模块。RKNN Toolkit2的安装按官方文档来Python版本建议3.8或3.9太高了有些依赖包不兼容。# 确认NPU设备 ls -l /dev/rknpu* # 安装RKNN Toolkit2 pip install rknn_toolkit2-1.5.0-cp38-cp38-linux_aarch64.whl # 安装OpenCV sudo apt install python3-opencv # 确认摄像头设备 v4l2-ctl --list-devices摄像头这块USB摄像头一般是/dev/video0和/dev/video1MIPI摄像头可能是/dev/video11之类的。用v4l2-ctl --list-formats-ext看支持的分辨率和格式。如果要用双路MIPI香橙派5有两个MIPI CSI接口但需要配置设备树这个比较麻烦我建议先用USB摄像头验证方案再换MIPI。3.2 模型转换与量化参数yolov5s的RKNN转换流程是PyTorch的.pt转ONNXONNX转RKNN。转ONNX的时候注意opset版本RKNN Toolkit2对opset 12支持比较好。转RKNN的时候要做量化量化数据集用COCO的几百张图就够了但最好包含你实际场景的图这样量化精度更高。from rknn.api import RKNN rknn RKNN() rknn.config( mean_values[[0, 0, 0]], std_values[[255, 255, 255]], target_platformrk3588, quantized_dtypeasymmetric_quantized-8, optimization_level3 ) rknn.load_onnx(modelyolov5s.onnx) rknn.build(do_quantizationTrue, datasetquant_dataset.txt) rknn.export_rknn(yolov5s.rknn)mean_values和std_values设成0和255意味着归一化在模型内部做输入直接是0-255的uint8。optimization_level3是最高优化级别会做一些算子融合。量化类型用asymmetric_quantized-8比对称量化精度好一点。量化数据集每行是一个图片路径准备200-500张就行。转换完之后用rknn.eval_perf测一下推理耗时确认在30-40ms左右。如果超过50ms可能是量化没做好或者模型结构有问题需要检查。3.3 双路并发的完整代码框架把前面的线程模型串起来主程序大概长这样import threading import queue import cv2 import numpy as np from rknnlite.api import RKNNLite def main(): stop_event threading.Event() # 初始化两个RKNN context rknn1 RKNNLite() rknn1.load_rknn(yolov5s.rknn) rknn1.init_runtime(core_maskRKNNLite.NPU_CORE_0) rknn2 RKNNLite() rknn2.load_rknn(yolov5s.rknn) rknn2.init_runtime(core_maskRKNNLite.NPU_CORE_1) # 创建帧队列和结果队列 fb1 FrameBuffer() fb2 FrameBuffer() rq1 queue.Queue(maxsize5) rq2 queue.Queue(maxsize5) # 启动线程 threads [ threading.Thread(targetcapture_thread, args(0, fb1, stop_event)), threading.Thread(targetcapture_thread, args(2, fb2, stop_event)), threading.Thread(targetinference_thread, args(rknn1, fb1, rq1, stop_event)), threading.Thread(targetinference_thread, args(rknn2, fb2, rq2, stop_event)), threading.Thread(targetdisplay_thread, args(rq1, rq2, stop_event)), ] for t in threads: t.daemon True t.start() # 主线程等待 try: while True: time.sleep(1) except KeyboardInterrupt: stop_event.set()这里用了RKNNLite而不是RKNN因为Lite版本在板端推理更轻量。core_mask参数指定NPU核心RK3588有3个NPU核心可以给两路各分一个第三核留给系统或者其他任务。如果两路都用同一个核心会有竞争推理时间会变长。3.4 丢帧策略的参数调优丢帧策略的核心参数是队列容量和推理超时。队列容量前面说了设1最激进设2或3可以降低丢帧率但增加延迟。我实测下来双路yolov5s在640x640下队列容量1时丢帧率大概60-70%容量2时丢帧率40-50%但延迟增加一帧。如果你的场景对延迟不敏感但对连续性要求高可以设2。推理超时是frame_buffer.get(timeout0.5)里的0.5秒。这个超时是防止推理线程在队列空的时候无限阻塞导致无法响应stop_event。设0.5秒比较合适太短了会频繁空转浪费CPU太长了退出时响应慢。还有一个隐藏参数是摄像头的曝光时间。如果环境光暗摄像头会自动增加曝光时间导致帧率下降。这时候采集线程的出帧速度变慢丢帧率反而降低但画面会模糊。如果要做运动检测最好手动锁定曝光牺牲亮度换帧率。4. 常见问题与排查技巧实录4.1 推理结果错位或闪烁这个问题我遇到过好几次表现是bbox画在画面上但位置和实际物体对不上或者两路画面的bbox串了。原因通常是帧和结果的对应关系乱了。在丢旧帧策略下帧队列里的帧可能被替换但推理线程拿到的帧和它对应的结果必须是一致的。如果你在推理线程里先取帧处理完再把帧和结果一起放结果队列那没问题。但如果你把帧和结果分开传递就可能出现帧A的结果画到了帧B上。解决办法是确保帧和结果绑定传递。result_queue.put((frame, boxes))这种元组传递是最安全的。另外如果显示线程做了resize或者裁剪bbox坐标也要相应变换这个容易漏。4.2 NPU推理时间波动大RK3588的NPU推理时间不是恒定的受温度、频率、内存带宽影响。我实测过刚启动时推理35ms跑十分钟后温度上来可能变成45ms。如果散热不好甚至会降频到50ms以上。香橙派5的散热片如果只是被动散热双路满载下温度能到70度以上。解决办法加个风扇或者用金属外壳辅助散热。软件上可以用cpufreq把CPU governor设成performance避免降频。NPU频率也可以用rknn.set_npu_freq调整但一般默认就行。如果推理时间波动超过20%丢帧率会不稳定体验会变差。4.3 摄像头出流不稳定USB摄像头在双路同时工作时可能会因为USB带宽不足导致出流卡顿。USB 2.0的带宽是480Mbps两路1080p MJPG大概各占100Mbps加起来200Mbps理论上够。但如果摄像头质量差或者USB Hub共享带宽就会出问题。解决办法是两路摄像头插在不同的USB控制器上香橙派5有多个USB口查一下lsusb -t看拓扑结构尽量让两路走不同的根Hub。MIPI摄像头的话出流稳定性好很多但配置复杂。如果要用MIPI建议先用media-ctl配好管道用v4l2-ctl测试单路出流确认稳定后再上双路。4.4 常见问题速查表现象可能原因排查方法解决办法帧率极低全帧排队未丢帧看队列长度启用丢旧帧策略延迟累积队列无限增长打印队列size设maxsize1bbox错位帧结果未绑定检查传递方式元组绑定传递推理变慢NPU降频查温度频率加散热锁频摄像头卡顿USB带宽不足lsusb -t分不同控制器画面撕裂显示未同步检查显示线程加锁或双缓冲内存泄漏帧未释放监控内存及时del旧帧丢帧率过高推理太慢测单帧耗时降分辨率或换模型4.5 独家避坑经验第一个坑cv2.VideoCapture的read()方法返回的frame是共享buffer如果你不拷贝直接放进队列下一帧read会覆盖它。这个坑我踩过表现是推理结果时好时坏后来发现是buffer被覆盖了。解决办法是frame frame.copy()虽然多一次拷贝但安全。第二个坑RKNN的inference方法不是线程安全的。如果你两个推理线程共用一个RKNN context会出各种奇怪的问题比如输出乱码、程序崩溃。必须每个线程一个context或者加锁串行化。加锁的话就失去了双路并行的意义所以还是多context好。第三个坑Python的GIL。虽然RKNN推理在NPU上做释放了GIL但前处理和后处理是Python代码受GIL限制。双路推理线程的前后处理会互相抢GIL导致实际并行度下降。如果前后处理耗时占比高可以考虑用C重写或者用多进程代替多线程。我实测下来Python下双路的前后处理开销比单路只多了30%左右还能接受。第四个坑显示线程的cv2.imshow必须在主线程调用否则在某些平台上会崩溃。如果主线程在time.sleep可以把显示逻辑放主线程推理和采集放子线程。或者用cv2.imshow的替代方案比如推流到RTSP这样显示就不依赖主线程了。5. 性能实测与优化方向5.1 实测数据记录我在香橙派58GB版本上跑这个方案环境温度25度加了小风扇。两路USB摄像头MJPG格式640x48030FPS采集。yolov5s模型640x640输入INT8量化。实测数据如下指标单路双路丢旧帧双路全排队推理帧率22FPS10FPS每路6FPS每路端到端延迟45ms95ms500ms丢帧率0%65%0%CPU占用45%75%95%NPU占用50%90%90%内存占用1.2GB1.8GB2.5GB双路丢旧帧下每路有效帧率10FPS延迟95ms对于实时监控够用了。全排队方案延迟500ms以上而且随着时间推移还会增长完全不可用。5.2 进一步优化的方向如果10FPS还不够有几个优化方向。第一降输入分辨率到416x416推理时间能降到20ms左右帧率能到15FPS每路但小目标检测精度会下降。第二换更轻的模型比如yolov5n参数量只有yolov5s的四分之一推理时间能降到15ms。第三用RK3588的NPU多核并行把一路推理拆到多个核心上但这个需要RKNN Toolkit支持模型并行目前支持有限。还有一个方向是异步后处理。把NMS放到另一个线程做推理线程只负责NPU推理这样推理线程的循环更快能更快地取下一帧。但后处理线程也要用丢旧帧策略否则后处理会成为新瓶颈。5.3 方案的可扩展性这个丢旧帧背压框架不限于双路三路四路也可以只要NPU和CPU扛得住。每增加一路就多一个采集线程、一个推理线程、一个帧队列。NPU核心分配上RK3588有3个核心可以两路共享一个核心或者三路各一个核心。如果路数更多就得考虑分时复用或者降分辨率。这个框架也可以扩展到其他模型不只是yolov5s。只要模型能转成RKNN推理接口一致就可以套用。比如yolov8、yolox、甚至分类模型和分割模型都可以用同样的丢旧帧策略。区别只在于前处理和后处理的实现。我在实际项目里还把这个方案用到了工业质检场景两路摄像头分别看产品的正反面丢旧帧策略保证了检测的实时性虽然丢帧率有60%但产线速度不快10FPS足够捕捉到每个产品。如果产线速度再快就得考虑触发式采集而不是连续采集了。最后分享一个小技巧丢帧计数器不要只用来监控还可以用来动态调整策略。比如丢帧率超过80%时自动把输入分辨率从640降到416丢帧率降下来后再升回去。这个自适应逻辑我试过效果不错但实现起来要注意分辨率切换时模型要重新初始化有几百毫秒的空窗期。如果对连续性要求极高还是固定分辨率更稳。