做过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用的RK3588S,CPU配置是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, name="cam"): 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(target=self._read_loop, daemon=True) 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(timeout=1) 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_workers=2, thread_name_prefix="left") right_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="right") 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_workers=2是一个合理值——两路加起来最多占用4个线程,刚好吃满A76。
如果max_workers=1,预处理、推理、后处理串行执行,帧率会明显下降;如果max_workers=3,两路加起来6个线程抢4个核,CPU调度抖动加剧、温度快速上升,RK3588一旦到了80多度就会降频,实际性能反而更差。
4.3 信号量限流:把无界队列改成"有界"
我给线程池加了一个信号量限流,让每个线程池最多只能积压一个任务,超出的提交直接丢弃(因为反正有最新帧会在下一轮提交):
import threading from concurrent.futures import ThreadPoolExecutor class BoundedExecutor: def __init__(self, max_workers, max_pending=1): self.executor = ThreadPoolExecutor(max_workers=max_workers) self.semaphore = threading.BoundedSemaphore(max_workers + max_pending) def submit(self, fn, *args, **kwargs): # 信号量满了直接说明系统处理不过来,这一帧放弃 if not self.semaphore.acquire(blocking=False): 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.04,Python 3.10,两个720p免驱USB摄像头,模型是yolov5s转INT8 rknn,输入640×640:
| 场景 | 左路fps | 右路fps | CPU总占用 | 内存占用 | 温度 |
|---|---|---|---|---|---|
| 单路基准 | 35~40 | - | 约50% | 约300MB | 60°C |
| 双路串行 | 15~18 | 15~18 | 约70% | 约350MB | 68°C |
| 双路共享一个线程池 | 20~22 | 10~12 | 约85% | 约500MB并持续上涨 | 75°C |
| 双路各一个线程池(本方案) | 18~22 | 18~22 | 约90% | 约380MB,稳定 | 79°C |
数据会因摄像头型号、OpenCV版本、散热条件有浮动,但趋势很明显:本方案让两路的帧率基本一致,不会出现"一路好一路坏"的偏科情况。内存稳定说明节流策略起到了作用。温度79°C确实偏高,建议加主动散热或者把输入分辨率降到VGA再交给模型缩放,能有效降温。
5.2 坑一:USB控制器的带宽,而不是线程池
排查过程中遇到一个非常容易误导人的现象:右路比左路明显卡顿,掉帧严重,而且重插摄像头后"病情"还会左右互换——有时是左路卡,有时是右路卡。一开始我怀疑是线程池参数问题,调了半天没有任何改善。
用dmesg查看USB报错才发现问题本质:两个USB摄像头插在了同一个USB控制器下,USB 2.0理论上限480Mbps,720p的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连续性和任务堆积量"的监控,打印到日志。双路视觉这种系统,谁先出现堆积趋势,谁就是下一个要出问题的点,早发现早处理,比事后崩了再排查省太多时间。