
做过RK3588嵌入式视觉的朋友应该都有体会单路yolov5s在香橙派5上跑通只是热身赛一旦场景变成同时看两个方向很多在单路时根本不会暴露的问题全跑出来了。我前段时间给设备加双路视觉一开始想得很简单——把单路逻辑复制两份各开一个线程跑结果一开机就发现两路帧率互相拖累内存还在悄悄上涨开了半小时直接卡死。排查到最后才发现问题根本不在模型推理而在任务编排。这篇是香橙派RK3588yolov5s教程系列的第14篇前面基础部分已经把开发环境、模型转换、单路NPU推理都过了一遍这次专门聊双路视觉方案一两路视频流各配一个线程池。这个方案适合双目测距、库区双向巡逻、双工位视觉检测这类场景也适合刚把单路跑通、想上双路但不知道从哪下手的朋友。读完你会清楚为什么不用一个线程池管两路线程池参数怎么定ThreadPoolExecutor的隐藏坑在哪以及实测跑出来的真实数据。1. 为什么双路视觉要单独设计线程模型1.1 从单路复制到双路我踩过的第一个坑单路yolov5s的典型写法很直接一个while循环里读帧、预处理、NPU推理、后处理循环往复。单路这么写完全没问题因为整个流程串行逻辑清晰延迟也可控。但直接把这份逻辑复制成两路、各开一个Python线程去跑立刻就出问题。第一个问题是cv2.VideoCapture.read()是阻塞的摄像头帧率、USB控制器调度稍微一波动两个读帧线程就会互相挤压表现就是一路卡顿时另一路也跟着掉帧。第二个问题更隐蔽两路推理其实都在抢同一块CPU和同一个NPU但完全没有排队机制结果就是两边都跑不顺CPU占用率却已经逼近100%。后来我看了设备上的CPU占用曲线才想明白视觉处理这个活儿真正的CPU消耗大头其实不在NPU推理而在前处理的图像缩放、通道转换以及后处理的NMS。两路视频意味着这些操作全部翻倍。如果线程模型设计得差光调度开销就能吃掉不少算力。1.2 三种组织方式对比各自线程池不是最聪明的但是最稳的做双路方案时我整理了三种常见做法这里直接列个表对比方案实现方式优点缺点A. 单线程串行一个循环里先处理左路再处理右路代码最简单不存在线程安全问题单帧处理时间翻倍右路延迟被左路阻塞B. 每路一个线程两个线程各自跑读帧推理死循环逻辑直观互不等待读帧阻塞会拉着整个推理链路抖动共享RKNN实例有线程安全风险C. 每路采集线程独立线程池采集线程只负责取最新帧推理任务丢给各自线程池采集与推理解耦两路队列天然隔离一路故障不影响另一路代码稍复杂需要管理任务提交节奏方案A我就不多说了适合玩票不适合任何需要稳定帧率的场景。方案B看起来是正常的多线程实际跑起来你会发现两个线程的调度完全是乱的——读帧阻塞、CPU竞争、NPU排队搅在一起很难定位问题。最终我选了方案C。它的核心价值有三个第一把采集周期和处理周期解耦摄像头慢一点不会拖垮推理第二左路和右路各自有独立的线程池和任务队列两者互不排队从机制上消除了一路吃掉另一路的可能第三故障隔离做得很好右边摄像头线松了或者画面花屏左边依然能正常跑。这也是标题里两路各一个线程池的由来。2. 动手之前先算资源账RK3588的核实力和瓶颈2.1 真正吃CPU的是预处理与后处理不是NPU香橙派5用的RK3588SCPU配置是4个A76大核加4个A55小核NPU标称6 TOPS实际是3个NPU核心组成的。很多人一听6 TOPS觉得跑yolov5s绰绰有余单路测下来也确实是这么回事。但你只要用top看一眼进程CPU占用就会发现你以为的NPU推理很占资源其实是错觉。一个完整的yolov5s推理链路可以拆成三段预处理图像缩放640×640 letterbox、BGR到RGB通道转换、归一化。OpenCV的cv2.resize和cv2.cvtColor在ARM上看着不起眼但双路跑起来光这两步就能消耗掉一个A76核心的相当一部分算力。NPU推理调用rknn模型跑卷积这段时间Python线程其实是挂起等待NPU返回的CPU占用反而不高。后处理解析推理输出、做阈值过滤、NMS、坐标映射全是CPU密集操作。所以双路视觉真正要精打细算的是CPU资源不是NPU算力。这也是为什么后面线程池的工作线程数需要按CPU核心数倒推而不是随便设个3、4个。2.2 双路并行时的资源占用估算我按自己的实测数据粗算一下方便你提前做判断。假设yolov5s用的是INT8量化模型输入640×640单路在NPU上推理大约25ms左右预处理加后处理合计大约需要10~20ms的CPU时间取决于OpenCV编译优化和是否用RGA硬件加速。也就是说单路单帧全流程大约40~60ms投影到帧率是20~30fps实际上单路能跑到35fps以上是因为NPU推理和CPU处理有重叠空间。双路的时候情况变成两路都要CPU做预处理、后处理两路的NPU任务还要排队。如果采用每路一个线程池并行四个A76大核基本被吃满总输出帧率大约能维持在两路合计35~45fps的水平。如果采用串行方案一路一帧处理完再处理另一路总帧率会直接跌到20fps以下。这里有个非常重要的结论双路视觉的瓶颈在CPU预处理/后处理以及NPU推理的排队策略。线程池参数、任务提交节奏都必须围绕这个瓶颈来设计。3. 核心代码采集线程 每路独立线程池3.1 采集层设计永远只保留最新帧先看采集层。我在方案C里没有让主线程直接调用cap.read()而是为每路视频单独开一个采集线程只做一件事不停地把最新帧读出来放到一个受保护的变量里。import cv2 import threading import time class VideoStream: def __init__(self, source, namecam): self.name name self.cap cv2.VideoCapture(source) # 关键把内部缓冲压到最小避免读到越来越旧的帧 self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) self.lock threading.Lock() self.latest_frame None self.frame_id 0 self._stop False self._thread threading.Thread(targetself._read_loop, daemonTrue) self._thread.start() def _read_loop(self): while not self._stop: ok, frame self.cap.read() if not ok: time.sleep(0.005) continue with self.lock: self.latest_frame frame self.frame_id 1 def get_latest(self): with self.lock: return self.frame_id, self.latest_frame def stop(self): self._stop True self._thread.join(timeout1) self.cap.release()这里的关键是CAP_PROP_BUFFERSIZE, 1。VideoCapture默认会在内部缓存多帧如果处理速度跟不上你读到的其实是几秒前的旧帧这在视觉任务里是致命的。把缓冲压到最小配合每次覆盖上一帧的逻辑就能保证推理用的永远是尽可能新的画面。另一个细节是采集线程里不写任何耗时逻辑。有些人喜欢在采集线程里顺手做缩放这会拖慢采集周期得不偿失。3.2 推理任务封装与RKNN模型实例隔离接下来是推理任务。我把它封装成一个可调用的类输入是帧和帧ID输出是检测结果import numpy as np import cv2 class InferTask: def __init__(self, rknn_model, frame, frame_id): self.rknn rknn_model self.frame frame self.frame_id frame_id def run(self): # 1. 预处理letterbox 缩放到 640x640 img letterbox(self.frame, 640) # 2. NPU 推理 outputs self.rknn.inference(inputs[img]) # 3. 后处理解析、NMS、坐标映射 boxes postprocess(outputs) return self.frame_id, boxes有个特别重要的细节两路的RKNN模型实例必须分开加载。很多人的第一反应是两个线程池共用同一个rknn对象多省内存但RKNN的Python接口对单context的并发调用并不是完全线程安全的轻则报错重则直接段错误。正确做法是把同一个.rknn模型加载两次左右路各持有一个独立实例from rknnlite.api import RKNNLite def load_rknn(rknn_path): rknn RKNNLite() rknn.load_rknn(rknn_path) rknn.init_runtime() return rknn left_rknn load_rknn(yolov5s.rknn) right_rknn load_rknn(yolov5s.rknn)实测下来RK3588的NPU驱动会把两个context的任务分配到不同的NPU核心上并行处理这也是双路方案在NPU层面能成立的前提。3.3 主循环节流提交与结果挂载线程池、采集线程、推理任务都准备好了最后是主循环。我在主循环里做的事情足够简单from concurrent.futures import ThreadPoolExecutor # 两路各自独立的线程池 left_pool ThreadPoolExecutor(max_workers2, thread_name_prefixleft) right_pool ThreadPoolExecutor(max_workers2, thread_name_prefixright) left_cam VideoStream(0, left) right_cam VideoStream(2, right) left_result None right_result None last_left_id 0 last_right_id 0 while True: # 只取最新帧读到的帧ID大于上次才提交 left_id, left_frame left_cam.get_latest() right_id, right_frame right_cam.get_latest() if left_id last_left_id and left_frame is not None: last_left_id left_id future left_pool.submit(InferTask(left_rknn, left_frame, left_id).run) future.add_done_callback(lambda f: set_result(left, f)) if right_id last_right_id and right_frame is not None: last_right_id right_id future right_pool.submit(InferTask(right_rknn, right_frame, right_id).run) future.add_done_callback(lambda f: set_result(right, f)) # 这里拿 left_result / right_result 去做显示、上报、存储 ...注意我用帧ID大于上次才提交这个条件做了节流——如果推理速度跟不上采集速度采集线程的最新帧ID会一直变大但主循环只会在ID更新时提交一次不会因为没有处理完而疯狂往线程池里塞任务。这就从根源上解决了任务堆积问题。4. 线程池参数背后的坑队列、max_workers与失败隔离4.1 ThreadPoolExecutor的无界队列问题Python标准库的concurrent.futures.ThreadPoolExecutor很好用但它有一个隐藏的坑内部任务队列是无界队列用的是queue.SimpleQueue。这意味着如果你提交任务的速度持续大于处理速度任务就会无限堆积在内存里。视觉场景是典型的高速生产、慢速消费。摄像头每秒产30帧如果你的推理实际只能处理20帧每秒钟就有10个任务堆积。表面上程序还在跑实际内存持续上涨延迟越来越大最后变成看起来活着、实际已经废了。我第一次用单线程池共享双路任务时就踩了这个坑内存涨到1.2GB才反应过来。所以记住一句话在实时视觉任务里任何时候都不能把任务无限丢给线程池。要么在提交端节流要么给线程池限流。4.2 max_workers该设多少按核数倒推这是我最常被问到的问题。很多人觉得线程池工作线程越多越好在RK3588上这就是典型的认知误区。我们重新算账RK3588S有4个A76大核和4个A55小核。A55核心跑后台服务和系统调度还行跑图像预处理和NMS这种重活会非常吃力所以主要算力来源就是4个A76。一台正常运行的香橙派5上系统服务、SSH、采集线程、显示/上传线程大约会占掉0.5到1个A76核心的等效算力。剩余的3到3.5个核心要分给两路推理链路。每路在同时做预处理和NPU等待时大约需要1.5到2个核心。所以每路线程池max_workers2是一个合理值——两路加起来最多占用4个线程刚好吃满A76。如果max_workers1预处理、推理、后处理串行执行帧率会明显下降如果max_workers3两路加起来6个线程抢4个核CPU调度抖动加剧、温度快速上升RK3588一旦到了80多度就会降频实际性能反而更差。4.3 信号量限流把无界队列改成有界我给线程池加了一个信号量限流让每个线程池最多只能积压一个任务超出的提交直接丢弃因为反正有最新帧会在下一轮提交import threading from concurrent.futures import ThreadPoolExecutor class BoundedExecutor: def __init__(self, max_workers, max_pending1): self.executor ThreadPoolExecutor(max_workersmax_workers) self.semaphore threading.BoundedSemaphore(max_workers max_pending) def submit(self, fn, *args, **kwargs): # 信号量满了直接说明系统处理不过来这一帧放弃 if not self.semaphore.acquire(blockingFalse): return None future self.executor.submit(fn, *args, **kwargs) future.add_done_callback(lambda f: self.semaphore.release()) return future这套信号量限流丢弃策略非常适合视觉场景摄像头帧源是无限的丢掉来不及处理的旧帧完全不影响最终体验反而保证了内存不涨、延迟可控。简单说线程池里面干活的线程是厨师信号量就是门口的服务生桌子坐满就不再接客而不是让客人在走廊无限排队。5. 实测数据与两个真实坑的排查5.1 双路实测帧率、CPU与内存表现放一组我自己的实测数据硬件是香橙派5 16GB版RK3588S带主动散热片系统Ubuntu 22.04Python 3.10两个720p免驱USB摄像头模型是yolov5s转INT8 rknn输入640×640场景左路fps右路fpsCPU总占用内存占用温度单路基准35~40-约50%约300MB60°C双路串行15~1815~18约70%约350MB68°C双路共享一个线程池20~2210~12约85%约500MB并持续上涨75°C双路各一个线程池本方案18~2218~22约90%约380MB稳定79°C数据会因摄像头型号、OpenCV版本、散热条件有浮动但趋势很明显本方案让两路的帧率基本一致不会出现一路好一路坏的偏科情况。内存稳定说明节流策略起到了作用。温度79°C确实偏高建议加主动散热或者把输入分辨率降到VGA再交给模型缩放能有效降温。5.2 坑一USB控制器的带宽而不是线程池排查过程中遇到一个非常容易误导人的现象右路比左路明显卡顿掉帧严重而且重插摄像头后病情还会左右互换——有时是左路卡有时是右路卡。一开始我怀疑是线程池参数问题调了半天没有任何改善。用dmesg查看USB报错才发现问题本质两个USB摄像头插在了同一个USB控制器下USB 2.0理论上限480Mbps720p的YUYV原始流每路约需要200Mbps左右两路加起来基本逼近带宽上限偶尔就丢包。解决方式有几个按优先级排序把摄像头视频格式改成MJPEG。MJPEG在摄像头内部压缩带宽占用直接砍一半以上打开方式是在VideoCapture上设置cv2.CAP_PROP_FOURCC为MJPG。两个摄像头分别插在不同USB控制器对应的接口上。香橙派5的多个USB口并不一定共用同一个控制器拿lsusb -t看一眼拓扑再分配。如果条件允许一路USB、一路MIPI或者两路都用MIPI接口彻底绕开USB带宽问题。这个坑提醒我们当双路方案出现一路明显偏科的异常时先查外设带宽再怀疑软件排队。5.3 坑二同一个RKNN实例被双线程并发调用有朋友看我代码后想省内存自己改成只加载一个RKNN模型两个线程池共用结果程序跑几秒就崩溃。这里再说清楚一点RKNN的Python接口对单个context对象的并发调用没有做线程安全保证底层的NPU驱动虽然有调度但上层python wrapper很可能在并发访问时出现问题。正确做法始终是一个context对应一路也就是启动时加载两次模型。内存占用增加约80~100MB换来的是稳定。如果你测试后确认自己的RKNN版本支持单context多线程并发那可以试但生产项目务必回到一路一实例的保守做法。另外一个相关经验如果你后续要上四路模型实例就是四个内存占用会到600MB以上还在可接受范围。但如果每路用的模型结构很重比如yolov5m或yolov8s建议优先考虑轻量化模型或者把其中一路的输入分辨率降到480否则A76核心会被预处理压垮。6. 关于每路一个线程池的最终体会这套方案跑了一段时间后我的体会是双路视觉的难点从来不在能不能跑而在能不能稳定地跑。每路独立线程池听起来是个很朴素的方案但它确确实实解决了一路的阻塞会传导给另一路的问题也避免了任务队列无限堆积导致的内存失控。线程池在这个场景里不是用来提升算力的而是用来隔离故障、平滑抖动、划定资源边界。如果你后续手头有现成的RK3588设备想继续深挖我记得RKNN还支持同一个context绑核到不同NPU核心的配置可以进一步压低两路互相干扰。我自己还在测试三路方案届时会继续更新这个系列把线程池换成进程级隔离再对比一轮。最后给一个落地建议也是我踩过几次坑之后的固定动作每路各自线程池的代码务必在工程里加上帧ID连续性和任务堆积量的监控打印到日志。双路视觉这种系统谁先出现堆积趋势谁就是下一个要出问题的点早发现早处理比事后崩了再排查省太多时间。