
兄弟们双路视觉这坑我是一路踩过来的。上一篇文章咱们把单路yolov5s在香橙派RK3588上跑通了评论区马上就有人问那两路摄像头怎么办直接开两个进程各跑一路还是把两路帧塞进一个线程池这两个方案我都试过后面会讲为什么都不如“两路各一个线程池”来得干净。这篇咱们就聚焦双路视觉方案一把线程池的设计思路、参数怎么定、代码怎么写、上板后怎么调一次性说透。全程使用香橙派5搭配RK3588平台模型沿用yolov5s量化版适合已经跑通单路、正在往多路扩展的兄弟们参考。为什么要把双路做成“两路各一个线程池”因为视觉流水线的瓶颈根本不在于摄像头数量而在资源调度。两个摄像头同时采集、两路模型同时推理、两路结果同时回传如果共用一个线程池一个任务卡住整条线都被拖死如果拆成两个进程模型权重内存翻倍、进程间通信还要走序列化CPU开销白白浪费。双线程池正好卡在中间线程轻量、内存共享、逻辑隔离每条通道的调度策略还能单独调这才是嵌入式多路视觉的正确姿势。1. 整体方案选型为什么非要用两个线程池1.1 一个线程池塞两路的隐患先说很多人第一反应的做法开一个全局线程池两路摄像头的帧都往里面submit。代码看起来很简单但坑在后面。第一个问题是故障传染。摄像头一突然掉线或采集异常它的任务会在线程池队列里堆积worker被这一路的任务占满摄像头二明明健康也被拖累两路画面同时卡死。排查的时候日志输出混在一起分不清哪路是哪路。第二个问题是背压失效。单线程池的队列是无界的ThreadPoolExecutor默认就是无界队列一路摄像头帧率高于消费速度时任务无限堆积内存直线上升。今天跑两路还能忍以后加到四路、八路内存先崩。第三个问题是资源争抢。两个通道的预处理、推理、后处理任务混在同一个池里调度器无法区分哪个任务更重要。比如一路要做实时避障、另一路只是录像存档紧急程度完全不同但你没法在一个池子里给某一路更高的优先级只能干着急。说白了单线程池适合“任务类型相同、量不大”的场景。多路摄像头视觉这种天然带通道属性的负载必须按通道隔离。1.2 双进程方案的代价那直接开两个Python进程各跑一路总行了吧行是行但代价有点肉疼。首先是内存翻倍。RKNN模型加载后占用大约200MB左右两个进程就是两份加上Python解释器、OpenCV运行时、帧缓冲区双路跑下来轻松多吃400到600MB。香橙派5的内存是8GB或16GB版本看着够用但嵌入式设备的资源规划讲究的是给后续功能留余量能共享的绝不多占。其次是通信开销。两路处理完的结果如果都要汇总到主控比如同时触发报警、联动云台你就免不了multiprocessing.Queue或Pipe。每一帧结果都要pickle序列化再传回主进程CPU多花时间不说时序还容易乱。最关键的是GPU/NPU强、CPU弱的RK3588上进程切换成本比线程切换高不少。RK3588虽然有4个A76大核加4个A55小核但进程间上下文切换、地址空间切换都会吃掉宝贵的CPU周期而这些周期本来可以用在做图像预处理上。1.3 双线程池的取舍逻辑所以我的方案是主进程常驻两个通道各自维护一个ThreadPoolExecutor每个线程池只处理自己那一路的完整流水线——采集、预处理、推理、后处理、画框全部在这一池内完成。通道之间零共享除了两个线程池都归属同一个进程、共用同一份内存空间外互相完全独立。这个方案的好处非常直接故障隔离一路崩溃重建线程池另一路完全无感。独立调参摄像头一帧率低线程数可以调成1摄像头二要求高吞吐线程数调到3互不干扰。内存可控模型实例虽然每路一个但都在同一进程地址空间权重数据可复用整体内存开销远小于双进程。排查容易线程池命名带通道前缀日志一打就能分清哪路出问题。当然它的边界也很明确双线程池适合两路逻辑相对独立的场景。如果两路要做立体匹配、深度融合这种强耦合算法那就不该用独立的池得改成共享内存加同步机制那是另一个话题咱们后续再聊。2. 线程池核心参数设计每个参数背后都有实测依据2.1 max_workers 数量怎么定线程数别拍脑袋填得按单帧处理耗时和输入帧率来推算。我先给出在香橙派RK3588上跑yolov5s640x640输入INT8量化模型输出用官方yolov5后处理改造版的实测参考值处理环节耗时参考MIPI摄像头取帧1080p8~12msletterbox等比缩放3~5msNPU推理单batch指定单核18~28msNMS后处理框少时3~6ms画框与显示输出2~4ms一帧从进来到出结果大约35~55ms。也就是说单worker一轮处理能力大约在20~25fps。如果输入源是30fps单worker会持续积压任务这时候就该加worker。但worker不是越多越好。RK3588的NPU是三个物理核yolov5s单batch推理时同一个RKNNLite实例的inference会在内部排队。你开4个worker同时往同一个NPU实例提交4个推理请求实际是串行执行的CPU那边的上下文切换开销却翻倍了。实测下来每个通道2个worker是最佳平衡点一个worker在处理预处理和等待NPU返回时另一个worker能顶上处理下一帧的CPU部分流水线就转起来了。我的建议配置每通道max_workers2重要实时场景最多调到3。超过3以后吞吐提升不到5%CPU占用却能涨十几个百分点完全不划算。2.2 队列容量有界队列才是背压的关键ThreadPoolExecutor内部自带的任务队列是无界的这意味着线程池处理不过来时会无限堆积任务。所以真正要控制的是与线程池衔接的帧队列和结果队列。我在这套方案里使用的生产者-消费者结构是摄像头采集线程 → frame_queue(maxsize2) → 线程池(2 workers) 线程池处理结果 → result_queue(maxsize8) → 主显示线程这里有个容易忽视的点帧队列容量一定要小越小越好。为什么因为视觉系统要的是“新鲜帧”不是“完整帧序”。检测到障碍物的moment已经过去了你再把10帧前的结果画上去有什么意义帧队列容量设成2只要消费者处理不过来就直接丢帧始终保持处理的是最新帧。这就相当于给系统加了背压机制上天然防止内存增长。结果队列容量可以稍微大一点因为NMS之后的检测结果数据量很小几组坐标和置信度就算积压8帧也没多少内存。但也不能无界否则显示线程卡住时结果队列一样会爆。2.3 NPU核分配三个核怎么分给两路RK3588的NPU由3个核组成通过rknn-toolkit2的init_runtime接口可以指定NPU核心掩码npu_core_mask。双路方案里最常见的分配方式有两种。第一种两路各自指定单核通道一用npu_core_mask10通道二用npu_core_mask11。好处是两路推理物理隔离互不竞争单路推理延迟稳定坏处是第三个核完全闲置而且如果某一路要加载更大的模型比如yolov5m单核可能算力不够。第二种两路都指定npu_core_mask12也就是让NPU自动调度。实测下来自动调度在负载均衡上确实有优势但两个实例交叉提交推理时偶发出现等待抖动单帧延迟能从20ms跳到40ms。我的建议双路yolov5s按单核隔离来设。两个核各管一路留一个核空闲。空闲核不是浪费它会在NPU负载低时自动被调度捡漏实测对双路吞吐还有约5%的正向贡献。等将来第三路加入时再改掩码分配也不迟。3. 核心代码实现双路线程池视觉流水线实例3.1 顶层结构与VisionChannel封装先把整个类的骨架摆出来。核心思路是DualChannelVision负责创建和管理两个VisionChannel每个VisionChannel内部拥有自己的线程池、队列和RKNN模型实例。from concurrent.futures import ThreadPoolExecutor import threading import queue import cv2 import numpy as np from rknnlite.api import RKNNLite class VisionChannel: 单路摄像头完整视觉流水线采集 - 线程池(预处理推理后处理) - 结果队列 def __init__(self, channel_id, cfg): self.channel_id channel_id self.cfg cfg self.frame_queue queue.Queue(maxsizecfg.get(frame_queue_size, 2)) self.result_queue queue.Queue(maxsizecfg.get(result_queue_size, 8)) self.thread_pool ThreadPoolExecutor( max_workerscfg.get(max_workers, 2), thread_name_prefixfchan{channel_id}, ) self.frame_id 0 self.running False self.local threading.local() # 每个worker独立持有RKNN实例 def start(self): self.running True self.cap cv2.VideoCapture(self.cfg[camera]) if not self.cap.isOpened(): raise RuntimeError(fchannel {self.channel_id}: camera {self.cfg[camera]} open failed) self._producer threading.Thread(targetself._capture_loop, daemonTrue) self._producer.start() def stop(self): self.running False self.thread_pool.shutdown(waitTrue) if self.cap: self.cap.release()这个封装的要点在于thread_name_prefix一定要带上通道编号。后面排查日志时通过线程名马上能定位是哪路出了问题省去大量翻日志的时间。3.2 采集线程满则丢帧保持新鲜采集线程是整个流水线的源头。它只干两件事从摄像头读出原始帧把带帧号的帧放进有界队列。队列满就丢弃绝不让采集线程阻塞等待。def _capture_loop(self): while self.running: ret, frame self.cap.read() if not ret: continue # 队列满则直接丢帧GRAB中新帧优先 if self.frame_queue.full(): continue self.frame_queue.put((self.frame_id, frame)) self.frame_id 1 self.cap.release()这里为什么要给每帧带frame_id因为线程池有两个worker并行处理谁先处理完谁先入队输出顺序必然乱。frame_id就是后面重排序的凭据。丢帧策略这里多说一句。很多人习惯在frame_queue.put()里加timeout做阻塞等待但我实测下来在30fps的摄像头下一阻塞就丢采集节奏后续帧跟着抖动。满则丢帧看起来“浪费”实际上保证的是系统永远在处理最新画面这对检测类应用才是正确选择。3.3 线程池任务threading.local 懒加载RKNN模型每个人都知道RKNNLite的推理函数挺快的但很少有人注意RKNNLite实例并不建议在多个线程间共享调用inference。不同版本的rknn-toolkit2对并发inference的支持程度不一样最新版本虽然加了线程安全的处理但同实例并发时NPU内部排队和线程等待依然会放大延迟。稳妥做法让每个worker线程都持有自己的RKNNLite实例。怎么做到利用threading.local在worker首次执行任务时懒加载初始化后续任务直接复用。def _get_rknn(self): rknn getattr(self.local, rknn, None) if rknn is None: rknn RKNNLite() rknn.load_rknn(self.cfg[model_path]) rknn.init_runtime(npu_core_maskself.cfg.get(npu_core_mask, 1 2)) self.local.rknn rknn print(fchannel {self.channel_id} worker {threading.current_thread().name}: RKNN loaded) return rknn于是线程池的任务函数就变得非常干净def _process_frame(self, fid, frame): rknn self._get_rknn() img, ratio, (dw, dh) letterbox(frame, new_shape(640, 640)) img img[:, :, ::-1].transpose(2, 0, 1) # BGR-RGB, HWC-CHW img np.ascontiguousarray(img, dtypenp.uint8) outputs rknn.inference(inputs[img]) dets postprocess(outputs, conf_thres0.45, iou_thres0.45) draw draw_boxes(frame.copy(), dets, self.channel_id) return fid, draw, dets def _dispatch(self, fid, frame): future self.thread_pool.submit(self._process_frame, fid, frame) future.add_done_callback(self._on_task_done) def _on_task_done(self, future): try: fid, draw, dets future.result() if self.result_queue.full(): # 队列满时丢弃最旧的结果保持输出实时性 try: self.result_queue.get_nowait() except queue.Empty: pass self.result_queue.put((fid, draw, dets)) except Exception as exc: print(fchannel {self.channel_id} task error: {exc})注意这个细节结果队列满时我是先丢弃最旧的再放入最新的。这跟帧队列满则丢是同一个思路——实时检测宁可丢旧结果也不让新结果等。3.4 帧序号重排乱序画面的兜底方案两个worker并行处理帧时乱序是必然的。举个实际发生的情况worker A处理第10帧耗时50msworker B处理第11帧只用了35ms于是第11帧先入结果队列。如果直接按队列顺序显示画面就会出现第11帧先画、第10帧后画的“回退”效果人眼看着就是一顿一顿的。解决方案是在结果消费端维护一个重排序缓冲区class FrameReorderBuffer: 按frame_id顺序输出检测结果容忍少量乱序 def __init__(self, window_size4): self.buffer {} self.next_expected 0 self.window_size window_size def push(self, fid, draw, dets): if fid self.next_expected: # 过旧的帧直接丢弃 return [] self.buffer[fid] (draw, dets) # 裁剪缓冲区防止不连续导致内存膨胀 while len(self.buffer) self.window_size: old_fid min(self.buffer.keys()) del self.buffer[old_fid] return self._pop_ordered() def _pop_ordered(self): frames [] while self.next_expected in self.buffer: frames.append(self.buffer.pop(self.next_expected)) self.next_expected 1 return frames使用上消费端从result_queue取出结果后交给ReorderBuffer.push()返回的顺序帧列表再逐一显示或上传。实测这个窗口设到4帧以内内存占用和延迟都可控如果乱序太严重窗口内凑不齐连续帧说明worker数设多了调回1个保证串行处理更划算。4. RK3588上板实操环境、绑定大核、实测数据4.1 环境准备与摄像头权限香橙派5跑这套方案我用的系统是官方Ubuntu 20.04内核版本5.10。需要的组件体量如下rknn-toolkit2 及 RKNNLitePython端推理库OpenCV-python 4.6建议自己编译带GStreamer的版本否则MIPI摄像头走V4L2也能用numpy、Python 3.8/3.10均可两个摄像头设备要注意udev权限。默认情况下/dev/video0和/dev/video1在普通用户下可能无法访问临时最粗暴的办法是sudo chmod 777 /dev/video0 /dev/video1但如果要长期跑最好写udev规则sudo vim /etc/udev/rules.d/99-camera.rules内容指定用户名即可同时给摄像头固定别名防止插拔顺序变化导致video0/video1互换。4.2 CPU核心绑定这是实测提升最明显的一步RK3588的8个CPU核心分成两簇0-3是A55小核4-7是A76大核。双路视频处理这种负载小核基本帮不上忙把处理线程绑到大核上立竿见影。我在代码里加了核心绑定逻辑放在进程启动入口处import os def bind_current_thread_to_cpus(cpus): # cpus {4, 5, 6, 7} 指A76大核 os.sched_setaffinity(0, cpus) # 在 main 的入口执行一次 bind_current_thread_to_cpus({4, 5, 6, 7})另外把CPU调成performance模式echo performance | sudo tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor注意这里的通配符会覆盖全部8个核。做完这个操作CPU主频从低负载的1.2GHz直接拉满到2.4GHz左右实测双路yolov5s的处理帧率大约提升15%。代价是功耗上升、机身发热明显香橙派5的散热片必须加风扇否则长时间满载会触发降频。有一个常见误区要提醒别把8个核全绑给Python线程。Python的多线程受GIL限制CPU密集型计算无法真正并行。这里之所以还能用多线程提速核心原因是OpenCV的底层图像操作、RKNN的NPU推理都通过C/C接口实现会在执行期间释放GILPython层的调度压力并不大。但NMS这种纯Python后处理还是会被GIL卡住这也是为什么线程池每路2个worker就够了再多也是排队等GIL。4.3 实测数据双路同时推理的资源账在香橙派5上完整跑通这套双路方案后我记录了这样一组数据指标单路基线双路双线程池方案一输入分辨率1920x10801920x1080两路AI输入尺寸640x640640x640单路推理延迟约22ms约24ms单路整体帧率约28fps约26fpsCPU占用约22%约45%NPU占用约35%约70%内存增量约420MB约780MB整体帧率掉了2fps左右主要损耗不在NPU而在线程调度和GIL的竞争。CPU占用涨了一倍但还在可控范围NPU利用率接近70%说明双路推理确实把芯片的算力盘活了。有一点值得玩味双路整体吞吐并没有因为拆成两个线程池而翻倍大约是1.8倍。丢失的0.2倍印证了RK3588的内存带宽和GIL确实是整套系统的隐性瓶颈。想突破的话就得靠多进程加共享内存的进阶方案了这个架构可以留给后面“方案二”去讲。双线程池方案的好处是稳定、简单、易维护先把这个吃透比盲目上多进程更划算。5. 踩坑实录双路线程池常见问题与排查指南5.1 RKNN并发推理崩溃请务必自查模型加载位置上面我特意强调用threading.local给每个worker懒加载RKNNLite实例这不是炫技是因为我确实遇到过并发崩溃。有一次我图省事在VisionChannel.start()里加载一个RKNNLite实例然后让两个worker线程共享它做inference。跑单路10分钟没事双路一开跑到3分钟的时候进程直接segfault没有任何Python报错。查了半天锁定在RKNNLite实例对多线程并发inference的支持问题上。不同版本的rknnlite.a库实现差异非常大有些版本虽然能跑不崩但推理延迟会从20ms恶化到60ms表现就是画面卡顿。后来改成threading.local懒加载方案每个worker持有一个独立实例跑了24小时双路压测再没崩过。5.2 线程池shutdown时卡死Future回调里的坑停机的坑也很典型。做平滑退出时我一开始写的是def stop(self): self.running False self.thread_pool.shutdown(waitTrue)线程池的worker在_process_frame里调用rknn.inference()如果NPU正在处理一个超大batchinference阻塞时间可能较长。此时shutdown(waitTrue)会一直等到inference返回表面看起来就是“程序关不掉”。解决思路不是不等而是给停机流程设超时并确保采集线程先停掉让任务队列自然排空def stop(self, timeout3): self.running False if self._producer: self._producer.join(timeouttimeout) self.thread_pool.shutdown(waitFalse) # 后续再统一join等待线程池真正退出更稳妥一点的做法是给rknn.inference()传入一个timeout参数如果RKNNLite版本支持或者干脆把waitTrue换成waitFalse让线程池的任务在后台慢慢消化主线程不受卡顿。5.3 MIPI摄像头输出1080i信号导致画面撕裂有兄弟用MIPI接口接了一个老式高清摄像头采集出来的画面有横向条纹、运动物体撕裂明显这不是线程池的问题而是摄像头输出的是隔行扫描interlace信号OpenCV的cap.read()直接拿到的帧没有去隔行处理。解决办法一是在V4L2层面把ioctl设置为强制逐行扫描但这个不一定总能生效更通用的办法是在采集线程里加去隔行# 在 _capture_loop 中 ret, frame self.cap.read() if ret and frame.shape[0] 1080: # detect interlaced and deinterlace with OpenCV frame cv2.cvtColor(frame, cv2.COLOR_YUV2BGR_I420) # 实际按摄像头格式调整更推荐的是直接换支持逐行输出的MIPI模组或者问清楚摄像头的输出模式在驱动层就设置成1080p逐行。隔行信号在AI任务里会明显影响检测精度尤其是对运动目标这是底层源的问题线程池调得再好也救不回来。5.4 线程池线程数变成1之后莫名的低吞吐有段时间我把某一路的max_workers调成1测试发现这路帧率远低于预期跑去查发现采集线程和worker线程都跑在同一个进程里OpenCV的cap.read()在某些MIPI驱动版本下会持有GIL之外的全局锁导致采集和推理互相等待。这个问题最终的解法很朴素把采集线程的CPU亲和性单独设到大核上让worker线程的亲和性全部分布在大核和小核混合区实测从20fps提到了26fps。如果你也遇到“单worker但吞吐异常低”的情况先别怀疑代码逻辑去查采集路径上的锁和线程亲和性。5.5 双路线程池常见问题速查表现象直接原因处理建议一路崩溃后另一路正常线程池隔离生效符合预期给崩溃通道加自动重启逻辑重建摄像头和线程池画面回退抖动并行worker乱序输出接入帧序号重排缓冲区frame_queue不断满处理速度低于采集速度调大max_workers或降低输入分辨率内存缓慢增长结果队列无界固定用有界队列满则丢旧帧RKNN init失败两个实例抢NPU核检查npu_core_mask配置两路用不同mask双路整体帧率低于1.7倍GIL/内存带宽瓶颈接受现状后续上多进程共享内存方案最后的体会这套“两路各一个线程池”的方案我在香橙派RK3588上跑了小半年感受最深的一点是视觉系统设计的关键不在模型多花哨而在流水线调度多稳定。线程池方案看起来简单但它把所有不确定因素——摄像头抖动、推理延迟波动、结果乱序——都通过队列设计和帧号机制兜住了。你只需要把这套骨架打磨熟以后从双路扩到四路无非就是再加两个VisionChannel实例的事。我个人的建议是动手之前先把第2节的参数设计逻辑捋一遍尤其是max_workers和队列容量这两个数字一定要基于你自己的实测延迟数据来定别直接抄我给的参考值。硬件的个体差异、openCV版本差异、模型量化效果差异都会让结果差不少。你现在踩过的每一个坑都会变成后续多路方案里的排雷经验。