上一篇我们终于把单路yolov5s在香橙派RK3588上跑稳了,后台收到最多的私信就是问双路视觉怎么做。单路能跑不算完,很多实际项目要同时看两个方向,比如一台AGV机器人前避障后跟随,或者一个入口相机一个通道相机。直接复制两份单路代码怼上去,帧率会掉得让你怀疑人生。这篇讲双路视觉方案一的核心思路:两路摄像头各自独立,采集线程负责抓帧,推理线程池负责处理,一路一个池,互不干扰。适合已经把单路yolov5s跑通的读者,想升级到双路并发场景直接看这篇。
1. 双路视觉方案的整体思路与设计取舍
1.1 双路视觉到底在解决什么问题
先说个我实际踩过的场景。实验室里有个小项目,一台移动底盘要同时看前后两个方向,前面检测障碍物,后面跟踪目标。我当时图省事,直接起了两个Python进程,每个进程跑一套独立完整的单路检测逻辑。跑起来发现两个问题非常明显:
第一个问题是进程间抢资源。两个进程都在不断地打开摄像头、读帧、推理、画框,操作系统的调度器一会儿把CPU时间片切给A一会儿切给B,结果就是两边帧率都不稳,A画面跳帧,B也跟着抖,完全没法用。
第二个问题更难察觉,就是日志和异常互相干扰。某一路摄像头偶发掉帧,那一堆报错刷屏,另一路的实时性也被拖累。我当时最烦的就是A进程崩了,B进程还在继续跑,但没人知道A已经死了,整个系统的状态变成"半残废"。
所以双路视觉要解决的核心问题不是简单的"两路都走一遍单路逻辑",而是并发隔离。每一路要有自己独立的数据通路、独立的计算资源池、独立的出错边界。这就是"一路一个线程池"方案诞生的背景。
1.2 为什么选择"一路一池"而不是共用一个池子
设计双路方案时,我认真对比过三种组织方式。
第一种,两路共用一个ThreadPoolExecutor,总线程数固定为4。这个方案代码写起来最省,只创建一个池子,所有摄像头把任务丢进去。但用起来有个非常现实的毛病:任务竞争不均衡。ThreadPoolExecutor内部就是一个公共任务队列,哪个worker空闲就取哪个任务执行,根本不关心任务是从哪路来的。假如第一路因为画面里目标突然变多,预处理和后处理耗时暴增,它就会往公共队列里塞大量任务,把四五个worker全啃住,第二路的任务就排队排到天荒地老。结果就是,一路异常,全系统陪葬。
第二种,每路独立线程池,主线程只负责启动和收结果。路与路之间的任务队列、worker线程、锁全部物理隔离。第一路就算疯狂塞任务,最多影响它自己池子里的两个worker,第二路该多少帧还多少帧。这就是性能隔离的价值。代价是线程数翻倍,内存和CPU占用略高,但香橙派5是8核CPU,两个池子各2个worker加起来4个推理线程,加上2个采集线程,总共6个,完全扛得住。
第三种,直接上多进程。隔离最彻底,但进程间通信要处理帧数据的传递,共享内存和IPC都非常麻烦,不适合一个入门教程就铺开讲。我会在后面单独的篇章里写多进程方案,这篇先把线程池方案吃透。
所以"一路一池"本质上是"性能隔离优先"的设计选择。它在工期和技术难度上的性价比是最高的。
1.3 线程池内部的流水线配合
确定一路一池之后,还要想清楚池子里的worker到底干什么活。我一开始想得很简单,worker就是负责推理,结果写完发现预处理太慢,worker被绑死在GPU前处理上,推理反而排队。
最后我把每个worker设计成跑一条完整的小流水线:取帧 -> 预处理 -> 推理 -> 后处理 -> 更新结果。这里面有个容易被新手忽略的关键问题:RKNN的Python接口并不是完全线程安全的。同一个RKNN runtime对象,如果多个线程同时对它做inference,短时间看不出毛病,长时间高并发跑会出现偶发报错,或者推理结果突然变得很奇怪。
我的解决办法是给每一路单独加一把threading.Lock,把推理那一步锁起来。注意是每一路一把锁,绝对不能全局一把锁,否则两路推理又被串行化了,方案一的核心优势就没了。
有人会问:既然推理被锁住了,那一个worker和两个worker有什么区别?区别在预处理和后处理。yolov5s的letterbox、缩放、归一化这些步骤在CPU上跑,NMS和坐标换算也在CPU上跑,这些是可以并行计算的。两个worker配合起来,一个在等锁推理的时候,另一个已经把下一帧的预处理做完了。实测比单worker的吞吐量能高10%-15%。
2. 部署前的基础准备:接线、系统、模型转换
2.1 硬件接线:强烈建议先上双USB摄像头
双路视觉的第一步是搞定两路图像输入。RK3588芯片本身是支持多个MIPI CSI接口的,香橙派5板子上也引出了摄像头排线座,但我必须说实话:MIPI摄像头在RK3588上的适配是出了名的折腾。不同厂商的模组有不同的设备树配置、I2C地址、上电时序,稍微对不上就是黑屏或者花屏。香橙派的Linux系统适配MIPI屏幕和摄像头,本身就是一篇长文章的体量,不适合在双路线程池方案里混着讲。
所以这一步我的建议非常明确:想要快速把双路方案跑起来,用两个USB摄像头。USB摄像头走的是UVC标准协议,插上去系统自动识别为/dev/video0、/dev/video1,内核自带驱动,不需要改设备树,不需要动板级配置。先别管MIPI,等双路逻辑彻底跑顺了,再回头单独研究MIPI摄像头适配,你会发现反而更有思路。
接线时有个细节容易被忽略:两个USB摄像头最好分别插在板子不同的USB控制器上,而不是都插在同一个扩展hub。USB摄像头的数据带宽并不小,如果两个都挤在一个hub上共享同一个USB通道,高帧率下很容易丢包,表现为画面卡顿或者花帧。我实测把两个摄像头顶在香橙派5的前后两个USB口,比插同一个hub稳定很多。
2.2 系统与NPU运行环境确认
这篇默认你已经把单路教程跑通,但环境这块我还是要花两分钟确认几个点,因为这些是双路方案的基座:
- 系统是烧写的Ubuntu 20.04,这个镜像是用烧录工具直接写入SD卡或者eMMC的,没问题
- RKNN runtime(librknnrt.so)和rknn-toolkit2的版本必须匹配,我用的1.6.0版本,之前遇到过runtime版本不一致导致NPU推理报错的坑
- 确认NPU设备节点正常。在终端执行
ls /dev/dri/,如果能看到renderD128,说明NPU设备已被系统正常识别 - 确认NPU频率是正常档位,用
cat /sys/kernel/debug/clk/clk_npu/clk_rate查看当前频率,如果明显偏低,大概率是散热问题或者电源问题
这些点如果没确认,双路跑起来出问题了,排查范围会大一倍。单路能稳定跑,是双路上手的前提条件。
2.3 yolov5s模型转RKNN的三个关键点
模型转换的完整流程前几篇写过,这里只把双路场景最容易出问题的三个点拎出来说。
第一,yolov5s.pt先转ONNX时,输出节点要对齐。锚点设置、640x640输入尺寸这些保持默认就行。很多人习惯在命令行直接导出,我建议脚本里显式指定 opset=12 和 simplify,避免后面转RKNN时出现不支持的算子。
第二,量化数据集要贴近真实场景。我用的量化数据是从实际摄像头抓帧存下来的,每个目标类别各准备了几十张图。有人直接拿COCO的原图做量化,跑出来的模型在自己的应用场景里掉点严重。原因很简单,量化校准集需要和部署场景的图像分布尽量一致。
第三,转出来的.rknn文件先单独写个小脚本加载测一下,确认板子上能正常加载和推理,再开始写双路代码。这一步能省掉后面一半的排查时间。不要在双路代码里才第一次加载模型,一旦报错,你根本分不清是模型的问题还是并发的问题。
3. 双线程池方案的核心实现
3.1 代码结构总览
双路方案的代码封装,我设计成CameraPipeline类,每一路摄像头对应一个实例。这样做的好处是方案二、方案三切换时,只需要换类的内部实现,上层主程序基本不用动。
整体结构是这样的:
dual_vision.py ├── CameraPipeline 类 │ ├── __init__ 摄像头初始化、线程池创建、模型加载 │ ├── _capture_loop 采集线程的while循环 │ ├── _work_loop 线程池worker的while循环 │ ├── start() 启动采集线程+提交worker任务 │ ├── stop() 优雅退出 │ └── get_result() 获取当前路最新检测结果 └── 主程序 ├── 创建两个CameraPipeline ├── 启动 └── 主线程循环读取结果+显示+统计FPS类内部主要维护四个成员:frame_queue帧队列、executor线程池、capture_thread采集线程、last_result最新结果。这个设计思路是"生产者-消费者"模型的经典变体,采集线程是生产者,池子里的worker是消费者。
3.2 采集线程:保证不漏帧但也不堵死
摄像头采集我坚持用独立线程,而不是在主循环里直接cap.read()。原因是cv2.VideoCapture.read()是阻塞式调用,当摄像头底层没有新帧时,read会一直等着。如果主线程被read卡住,推理和后处理全部停摆。
采集线程的代码看起来很短,但每个细节都有讲究:
import cv2 import queue import threading import time class CameraPipeline: def __init__(self, cam_id, model_path, workers=2, queue_size=4): self.cam_id = cam_id self.cam = cv2.VideoCapture(cam_id) self.cam.set(cv2.CAP_PROP_FRAME_WIDTH, 640) self.cam.set(cv2.CAP_PROP_FRAME_HEIGHT, 640) self.cam.set(cv2.CAP_PROP_FPS, 30) self.frame_queue = queue.Queue(maxsize=queue_size) self.stop_event = threading.Event() self.capture_thread = threading.Thread( target=self._capture_loop, name=f"capture-{cam_id}", daemon=True ) def _capture_loop(self): while not self.stop_event.is_set(): ok, frame = self.cam.read() if not ok: time.sleep(0.01) continue if self.frame_queue.full(): try: self.frame_queue.get_nowait() except queue.Empty: pass self.frame_queue.put(frame)重点在于queue.Queue(maxsize=queue_size)加"满时丢旧帧"。为什么要给队列设上限?如果不设上限,推理一旦慢下来,队列会无限堆积,内存吃到爆不说,更严重的是延迟会越来越大,画面显示的永远是几十帧之前的内容,这在实时视觉里是致命的。
设置了上限以后,满队列时的策略是弹出最旧的帧再放入新帧,保证队列里始终是最新的几帧。代价是有可能跳过中间一两帧,但在动态目标检测场景里,偶尔丢一帧的影响微乎其微。这个策略我用英文术语概括就是"latest-frame-wins",很多实时视频管线都这么干。
3.3 双线程池的创建与任务提交
线程池部分用Python内置的concurrent.futures.ThreadPoolExecutor,不需要额外安装第三方库。这个类在标准的线程池方案里非常合适,因为它自带线程管理、任务队列和优雅关闭,省去自己造轮子。
from concurrent.futures import ThreadPoolExecutor class CameraPipeline: # ... 接上面 __init__ def _setup_pool(self): self.executor = ThreadPoolExecutor( max_workers=self.workers, thread_name_prefix=f"infer-{self.cam_id}" ) for i in range(self.workers): self.executor.submit(self._work_loop, i) def start(self): self.capture_thread.start() self._setup_pool()注意到一个问题:我提交给线程池的不是"一个任务处理一帧",而是"一个长期循环任务,任务内部不停取帧处理"。为什么要用长期循环而不是每帧提交一次?两方面考虑。
第一是调度开销。每帧都submit会让线程池频繁进行任务入队、出队、分配,这些操作在嵌入式设备上都是有成本的。长期循环任务提交一次就一直在跑,池子里的worker空转时会从内部队列等帧,开销小很多。
第二是天然限流。不管帧队列里积压了多少帧,同一时刻只有max_workers个worker在处理,多出来的帧老实排队。这等于自动实现了流量控制,避免瞬时帧率暴增把系统打崩。
thread_name_prefix这个参数很多人忽略,但它极其有用。当多线程程序出现问题,只能用threading.enumerate()或者抓线程栈来排查时,如果线程名是"infer-0"和"infer-1",你能一眼看出是哪一路卡住了。不加这个前缀,所有线程都叫ThreadPoolExecutor-0,排查看起来想砸键盘。
3.4 worker流水线:取帧、预处理、推理、后处理
_work_loop是整个双路方案的心跳,每个worker都在跑这个循环。我把它拆开逐段讲:
import numpy as np import threading import time class CameraPipeline: def __init__(self, cam_id, model_path, workers=2, queue_size=4): # ... 前面代码省略 self.infer_lock = threading.Lock() self.rknn = self._load_rknn_model(model_path) self.last_result = None self.last_boxed = None def _work_loop(self, worker_id): while not self.stop_event.is_set(): try: frame = self.frame_queue.get(timeout=0.5) except queue.Empty: continue t0 = time.time() # 预处理:CPU上执行,可以和另一worker的推理并行 rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) resized = cv2.resize(rgb, (640, 640)) resized = resized.astype(np.float32) / 255.0 input_tensor = np.expand_dims(resized, 0).transpose(0, 3, 1, 2) # 推理:需要持锁,防止同一runtime并发 with self.infer_lock: outputs = self.rknn.inference(inputs=[input_tensor]) # 后处理:CPU上执行 boxes, scores, class_ids = self._post_process(outputs) # 更新结果,主线程从这里读 self.last_result = (boxes, scores, class_ids, time.time()) # 顺带把画好框的图也存下来,方便显示 for box, score, cls in zip(boxes, scores, class_ids): x1, y1, x2, y2 = [int(v) for v in box] cv2.rectangle(resized, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.putText( resized, f"{cls}:{score:.2f}", (x1, y1 - 6), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 0, 255), 2 ) self.last_boxed = resized我特别想展开讲讲为什么用self.infer_lock而不是让每个worker都new一个独立的RKNN runtime。
RK3588的NPU算力就那么多,6 TOPS的INT8性能。如果每路池子里每个worker都加载一份独立的runtime,等于同时创建多个NPU上下文抢占NPU。yolov5s这种轻量模型,单个推理本身只要十几到几十毫秒,多个runtime分片反而会因为NPU调度的上下文切换增加额外开销。我实测的结果是:一份runtime加锁串行推理,比两份runtime并行推理,总吞吐量和稳定性都要好。
这个方案还有一个额外好处,就是内存占用低。每个RKNN runtime都要占用一部分模型常驻内存,3份runtime意味着模型常驻内存×3,在8GB内存的板子上虽说不至于崩,但完全没必要。
3.5 主程序整合:双池启动、双路显示与帧率统计
主程序写起来反而简单,因为它只做三件事:创建两路、启动两路、循环展示结果。
def main(): pipeline0 = CameraPipeline(0, "yolov5s.rknn", workers=2, queue_size=4) pipeline1 = CameraPipeline(1, "yolov5s.rknn", workers=2, queue_size=4) pipeline0.start() pipeline1.start() fps_list = [0.0, 0.0] while True: for idx, pipe in enumerate([pipeline0, pipeline1]): if pipe.last_boxed is not None: # 左右拼接显示,注意先统一高度 pass # 实际拼接 frame0 = pipeline0.last_boxed frame1 = pipeline1.last_boxed if frame0 is not None and frame1 is not None: if frame0.shape[0] != frame1.shape[0]: frame1 = cv2.resize(frame1, (640, frame0.shape[0])) combined = np.hstack([frame0, frame1]) cv2.imshow("dual_vision", combined) key = cv2.waitKey(1) & 0xFF if key == ord('q'): break pipeline0.stop() pipeline1.stop() cv2.destroyAllWindows()这里有一个小细节要注意:左右拼接要保证两路画面高度一致,否则np.hstack会在维度检查时报错。我习惯统一resize到640宽,高度按实际保持一致。如果两路摄像机安装位置或分辨率不同,画出来之后一高一矮会很别扭,最好在显示前强制对齐高度。
另外stop()方法里要做的事情也不复杂:设置stop_event为True,然后等待采集线程结束,再executor.shutdown(wait=True)。这样退出时不会因为线程还活着而报错。
4. 参数选择与性能实测:线程池大小、队列长度怎么定
4.1 线程池大小:为什么每路选2个worker
先说结论:我每一路用2个worker,两路共4个推理线程,加上2个采集线程,总共6个活跃线程。
为什么不是1个?因为worker不仅仅是推理,还要做预处理和后处理。这两部分在CPU上跑,是可以并行的。单worker意味着worker在推理的时候CPU是闲着的大部分状态,等于浪费了一半以上的CPU算力。2个worker可以让一个worker在排队等锁推理的同时,另一个worker完成下一帧的预处理,把CPU的间隙时间填上。
为什么不是3个或更多?我在香橙派5上实测过。当worker数量超过2个时,线程间的锁竞争和CPU调度开销急剧上升,多个线程同时抢同一个RKNN推理锁,排队时间变长,很多CPU时间花在线程切换而不是实际计算上。帧率不升反降,CPU温度倒是升得欢快。我试过3个worker一路,结果是总帧率反而比2个worker时略低。
香橙派5用的是RK3588,8核CPU,我所用的Ubuntu 20.04操作系统本身也要占一些核。留几个核给系统、SSH、显示和其他后台服务,是保证长期运行稳定性的必要代价。
4.2 阻塞队列长度:那个隐患最大的参数
线程池的阻塞队列选择是个关键参数,但很多人随手就填一个数字,不在乎。我在双路方案里用的队列长度是4。
队列长度本质上决定了两件事:缓冲能力和延迟。队列越长,缓冲越多,短时的推理波动越不容易丢帧;但代价是延迟变大,处理的是更早的帧。队列越短,延迟越可控,但突发帧到来时丢帧率提高。
怎么算出合理的队列长度?我用的是最粗糙也最实用的估算方法。假设摄像头是30FPS,帧间隔约33ms。推理峰值耗时会波动,最短十几毫秒,最长可能到60毫秒。当推理峰值超过帧间隔时,队列里每帧到来会积压约0到1帧。取个余量,队列长度4足够平滑掉绝大多数波动。
如果你用的是60FPS的摄像头,帧间隔只有16ms左右,那么同样大小的缓冲区能缓冲的"时间长度"就减半了,建议把队列长度调整到6-8。总原则永远是那句:"宁可丢帧,不可延长延迟"。实时视觉系统里,延迟比丢帧更可怕。
4.3 实测性能数据:双路能跑多少帧
放一组我在香橙派5(RK3588, 8GB内存, Ubuntu 20.04, RKNN runtime 1.6.0)上实测的数据。摄像头是两颗普通1080p USB摄像头,模型处理分辨率640x640,INT8量化。
| 配置 | 单路FPS | 双路合计FPS | 双路单路FPS | CPU占用 |
|---|---|---|---|---|
| 每路1个worker | 32 | 54 | 27 | 约45% |
| 每路2个worker | 35 | 60 | 30 | 约65% |
| 每路3个worker | 34 | 58 | 29 | 约75% |
这张表有三个信息值得品。第一,2个worker确实比1个worker有提升,但提升幅度不是翻倍,只有10%-15%,符合理论预期。第二,3个worker开始倒退了,因为锁竞争和调度开销超过了并行收益。第三,两路同时跑时单路FPS掉到30左右,几乎是单路独立运行帧率的一半还不到,这个现象的原因是NPU本身只有一个,两路共享算力,加上CPU带宽、内存带宽也都被两路分掉了。
如果你的实际环境跑出的帧率和这个差距比较大,先检查两个东西:一是摄像头是否真的输出640x640分辨率,很多摄像头并不是标准的640x640,比如初始输出是1280x720,你代码里set了640x640,摄像头可能根本不支持这个分辨率,吞掉了设置,内部还是在做缩放,白白增加CPU负担;二是看画面里目标数量,目标多的时候NMS计算开销上涨明显。
4.4 性能优化的优先顺序
如果对帧率不满意,我建议按下面的优先顺序排查优化。
第一步是看预处理。yolov5s的letterbox和归一化如果用纯Python写,会在每个像素循环上浪费大量时间。尽量全部用cv2和numpy向量化操作,不要出现for循环遍历像素。我见过有人预处理要花40ms,优化后只要5ms。
第二步是看后处理。画面里目标很多时,候选框数量暴增,NMS成为瓶颈。可以适当降低conf阈值,让进入NMS的候选框数量少一些;或者改用更简洁的NMS实现,比如直接用numpy写循环控制iou,比cv2.dnn.NMSBoxes在某些场景下更快。
第三步是看采集。检查摄像头的实际输出能力,有的USB摄像头号称1080p 60FPS,实际输出可能只有720p 15FPS。用v4l2-ctl --list-formats-ext看看摄像头真实支持的格式和帧率。
最后再动线程池参数。不要一开始就调大worker数,99%的情况瓶颈不在线程数。
5. 常见问题与排查技巧实录
5.1 问题一:两路摄像头设备节点错乱
症状很典型:两个USB摄像头插上去,代码里写死cv2.VideoCapture(0)和cv2.VideoCapture(1),但经常出现打不开、画面黑屏、或者两个都打开成了同一个摄像头的情况。
原因也很明确:Linux下USB设备的枚举顺序不固定,每次插拔和重启,/dev/video0和/dev/video1可能会对调。你代码里写死的索引,有时候碰巧是对的,有时候就是错的。
排查方法,先用ls /dev/video*看有哪些节点,再用v4l2-ctl --list-devices查看每个物理设备对应哪几个video节点。v4l2-ctl是v4l-utils包里的工具,Ubuntu下用apt install v4l-utils安装。
最严谨的解决方案是不要硬编码设备索引,而是通过摄像头的序列号或USB路径来识别设备。我写了一个简易的查找函数,通过解析v4l2-ctl --list-devices的输出来匹配指定的摄像头品牌型号或者USB端口位置。这样无论设备节点怎么变,代码都能找到正确的设备。
如果时间紧不想写这个,也有个笨办法:每次开机后先看一下设备枚举顺序,然后固定插拔顺序。但这是治标不治本,一重启可能又乱了。
5.2 问题二:队列堆积导致延迟越来越大
这个坑我自己踩得很惨。第一次写完双路代码,跑起来发现画面显示的检测框比真实场景慢了大概一秒多,我一开始还以为是摄像头本身的延迟,折腾半天才发现是队列的问题。
原因是我第一版写的队列满了之后不是丢旧帧,而是直接put_nowait抛异常,异常被捕获后不知道怎么处理,结果是采集线程被卡住反复重试,整条流水线被拖慢。后来改成"队满丢旧帧"的策略,延迟立刻恢复正常。记住这个代码片段:
if self.frame_queue.full(): try: self.frame_queue.get_nowait() except queue.Empty: pass self.frame_queue.put(frame)这个"丢旧帧"逻辑一定要写在put之前。如果你写的顺序反了,队列满了以后新帧就无法进入,流水线等于断流。
5.3 问题三:RKNN推理偶尔报错或结果错乱
症状是双路跑起来几分钟到几小时不等,某一路的推理突然报错,比如RuntimeError: Input timestamp invalid,或者检测框的位置和置信度突然乱跳。
第一排查方向是并发问题。确认是不是多线程同时调用同一个RKNN runtime导致的,把self.infer_lock临时改成全局串行锁,如果问题消失,那就确定是runtime线程安全问题。然后在正式代码里保留每个实例独立的推理锁即可。
第二个排查方向是温度。香橙派5的NPU满载时发热非常厉害,不加散热片和风扇,温度轻松飙到80度以上。温度过高时NPU会自动降频,推理耗时拉长,偶尔还会出现异常。用cat /sys/class/thermal/thermal_zone0/temp看温度,单位是毫摄氏度。如果长时间超过75度,建议装散热风扇,或者至少加一块导热硅脂和散热铝片。
第三个排查方向比较隐蔽,是模型文件被换出到swap导致推理延迟爆表。如果你的系统内存紧张,RKNN模型常驻内存可能被操作系统换到swap分区,推理时重新从swap加载,延迟会从十几毫秒飙升到几百毫秒。解决办法是限制双路系统的其他内存占用,必要时关闭swap或者调高swapiness。
5.4 问题四:摄像头FPS参数设置无效
cv2.VideoCapture.set(cv2.CAP_PROP_FPS, 30)这行代码在便宜的USB摄像头上经常是无效的。摄像头固件根本不理你这个设置,依然按照自己默认的帧率输出。如果发现帧率和预期不符,用v4l2-ctl看看摄像头实际支持的像素格式和帧率组合。
有些摄像头在1080p下只能跑15FPS,但切到720p就能稳定30FPS。这属于硬件能力限制,代码绕不过去,只能调整输入分辨率。在双路方案里,如果发现某路摄像头FPS上不去,我建议把该路的采集分辨率设为720p,模型处理依然是640x640,这样摄像头到模型之间的缩放开销也更小。
5.5 双路方案避坑经验速查表
把这次跑双路踩过的坑整理成一张表格,方便以后快速对照:
| 坑 | 现象 | 解决方案 |
|---|---|---|
| 线程名没加标识 | 排查问题时分不清哪路卡住 | 设置thread_name_prefix为infer-0/infer-1 |
| 线程数开太多 | 帧率不升反降,CPU温度飙升 | 每路保持2个worker |
| 全局推理锁 | 两路全被串行化,双路变两倍延迟 | 每路独立infer_lock |
| 队列无上限 | 延迟越来越大,内存缓慢上涨 | 使用maxsize=4-8,满时丢旧帧 |
| 双路高度不一致 | np.hstack维度报错 | 显示前先resize对齐高度 |
| MIPI摄像头强行上车 | 设备树、上电时序一堆问题 | 先用USB摄像头跑通逻辑 |
最后再分享一个小经验。双路视觉方案一跑通之后,你可以顺手做一个"斜率检测":每等一段时间统计两路的FPS和队列长度,如果发现某一路队列长期接近满、FPS持续低于预期,就打印一条告警。这个机制能帮你提前发现摄像头老化、场景复杂度突变、NPU降频等问题,比等画面卡死再去排查要省时间得多。我自己后来把这段逻辑封装成了一个简单的HealthMonitor类,稳定运行了两周都没出过岔子。这个方向后续还可以扩展:有空我再写RK3588上双路视频流的推流方案,配合ffmpeg把两路画面合成一路推出去,很多远程监控项目的需求就是这个。