ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

香橙派5双路USB摄像头跑yolov5s:共享线程池架构与RK3588并发调度实践

香橙派5双路USB摄像头跑yolov5s:共享线程池架构与RK3588并发调度实践 1. 写在前面这套双路视觉方案到底在解决什么问题香橙派5这块板子我前后折腾了快两个月才把双路USB摄像头同时跑yolov5s这套方案稳定下来。先说结论在RK3588上用yolov5s做一路目标检测基本没什么难度真正恶心的永远是第二路。这里的“第二路”不是再复制一遍代码而是当两路视频流同时进来时CPU核、NPU、内存带宽、USB带宽全都在打架任何一处调度不好画面就开始卡、掉帧甚至僵死。所以“双路视觉方案二”选择了共享线程池。很多人一听线程池就以为只是优化代码实际上在这个场景里它解决的是“多路任务如何均匀吃满硬件资源”的调度问题。两路视频如果不做统一调度最常见的结果是一路好好的另一路掉到10帧以下或者两路都活着但CPU满载、风扇狂转、偶发丢帧。共享线程池能让两路任务进入同一条队列、由固定数量worker消费资源使用更平滑也方便后续扩展第三路、第四路。这篇教程是整个系列偏中期的内容假设你已经跑通过了单路yolov5s手上有一块香橙派5系统是arm64版本的Debian系镜像NPU驱动和rknn-toolkit2已经装好。如果你还没跑通单路建议先回去把单路流程走一遍再来搞双路否则后面代码排查的时候会分不清是模型问题还是并发问题。阶段一交付的东西很明确双路采集、共享线程池、单NPU执行线程、简单可视化和FPS统计。这个阶段只做“把双路任务用线程池调度起来”的核心骨架不做多模型切换、不做零拷贝优化、不写C版本那些放到阶段二和阶段三。之所以压着不做是因为把线程池跑通、搞清楚每一帧的走向比盲目优化更重要。1.1 香橙派5的硬件底子先说清楚香橙派5用的是RK35884颗Cortex-A76大核加4颗Cortex-A55小核配合6TOPS算力的NPU。这个组合做一路yolov5s其实很富余做双路就开始紧张。根源在于NPU不像CPU可以真正并行RK3588的NPU同一时刻只能串行执行一个模型实例两路同时提交推理请求总有一路在排队。共享线程池要解决的就是把那种“一路忙死、一路闲死”的状态改成两路任务均匀排队。内存方面我会按16GB版本写但8GB版本跑双路720p也够。关键在于队列积压控制只要每一帧数据在管道里堆积超过三帧内存就会明显往上走。1080p的BGR帧大约是1920×1080×3/1024/1024≈5.9MB如果积压十帧就是60MB再叠加Python对象引用和OpenCV缓冲内存翻倍是很容易的事。所以线程池的任务队列必须设置上限一帧没地方放就让采集线程丢了它保最新不保全量。1.2 阶段一具体要做到什么这一阶段完成后你应该能看到两路画面同时带着yolov5s框实时输出之前那种“一路正常一路僵死”的情况尽量少。我会给出线程池大小、任务队列深度、模型量化选择和采集缓冲设置的完整经验值并解释每个参数为什么这样定。如果你只想傻瓜式跑通照抄配置即可如果你想搞懂原理再把方案往后做我会把调度逻辑拆开讲明白。2. 为什么方案二要改成共享线程池2.1 双路独立线程的老方案究竟差在哪最朴素的做双路视觉是给每一路各开一个线程线程里循环执行“采集→预处理→推理→后处理”。从代码上看很简单但性能上问题很大。RK3588虽然有8个CPU核但Python线程在解释器层面有GILCPU密集型逻辑很难跨核并行就算你用C或者把CPU工作拆分到多个进程两路独立线程也会因为NPU串行而互相干扰。更有意思的是独立线程路数一多系统就花大量时间在线程切换和上下文切换上实际有效计算比例反而下降。另一个容易被忽略的问题是任务不对称。两个摄像头的场景不会完全一样画面亮度、运动幅度、目标数量都会影响每帧预处理和后处理耗时。独立线程方案里负责复杂场景的那一路会把线程占住另一路空闲也没法帮忙共享线程池则不一样pool里的worker谁空了谁就接下一个任务不存在“有人摸鱼”的问题。2.2 共享线程池如何让两路任务共用同一批worker共享线程池本质上就是一个固定数量的worker线程集合加一个任务队列。两路采集线程只负责拿帧拿到帧之后封装成任务丢进队里池里的worker依次消费。worker去执行统一的处理函数包括letterbox缩放、通道转换、归一化、提交推理请求、后处理等。好处主要有三点。第一线程数与任务数解耦两路视频不会变成2倍线程线程数始终等于池中worker数第二空闲worker能动态承接任意一路任务负载更均衡第三任务队列天然做了一个缓冲采集线程可以暂时比推理快但队列有界满了就丢旧帧保证系统不会因为一帧卡住而整体崩溃。我把这个设计类比成银行柜台。每路一个专属柜员两路就是两个柜台但某个柜台办业务很慢另一个柜台闲在那里也没办法帮忙共享线程池更像叫号机加三个柜员谁闲了谁接下一个号整体吞吐量自然更高。这一类比不是万能的但新手理解调度逻辑会非常直观。2.3 池子开多大、队列放多深参数怎么定线程池大小和队列深度是阶段一最关键的调参目标不能随便设。先算线程池RK3588有8个CPU核但要留出2个采集线程、1个NPU执行线程、1个主线程以及系统和日志的开销所以实际可用的worker数大概是3到4。我实测pool_workers3比较稳4在部分场景会提升预处理速度但GIL争用也更明显。公式可以写成worker max(2, 可用核数 - 采集线程数 - 推理线程数 - 主线程数 - 系统保留)8核减1主线程、2采集线程、1推理线程、1系统保留剩下正好3。如果你用C或者完全绕开GIL这个数字可以上调到4或5。队列深度不能太大。我建议推理队列infer_queue的maxsize取1到2整个系统的任务上限控制在worker数量的一到两倍。也就是说最多允许“一帧正在被worker预处理一帧正在NPU推理一两帧在排队”超过就丢帧。这个约束的核心原因是丢一帧对实时检测几乎无感知但堆积会抬高内存峰值并增大输出延迟。有了线程池和队列之后真正跑起来还需要把“预处理”和“NPU推理”这两件事拆开放在不同的执行角色上。这就进入阶段一的工程实现部分了。3. 先准备好硬件环境和可运行的yolov5s模型3.1 系统镜像、驱动与依赖建议我使用的镜像以官方Orangepi OS为基准arm64架构跑的是Linux 5.10或6.1内核。装好之后第一件事不是急着跑Python而是先确认三件事NPU节点存在也就是/dev/rknpu设备节点摄像头节点存在一般是/dev/video0和/dev/video1系统能同时识别两个摄像头可以用lsusb或者v4l2-ctl --list-devices确认。依赖方面建议直接用rknn-toolkit2提供的Python环境不要用全局python3避免版本污染。这里提醒一个坑rknn-toolkit2的Python侧和固件里的rknpu驱动版本必须匹配否则加载模型时会报版本错误。如果你是从旧板卡上拷过来的rknpu驱动建议先升级到当前镜像对应的版本。3.2 yolov5s从pt到rknn的完整转换这一步假设你已经有一条能出推理结果的单路链路。如果之前用的是rknn_model_zoo里的现成模型那你更关心的是当前rknn模型能否被多个线程同时调用。我的建议是不要让多个worker同时调同一个rknn实例。瑞芯微的NPU底层不像GPU那样支持并发上下文同一实例并发推理很容易出现段错误或结果错乱。阶段一采用“多个worker做CPU活一个独立推理线程专门调rknn”的设计正是为了规避这个坑。转换模型时有两个关键点固定输入尺寸和固定opset。yolov5s导出ONNX时用export.py指定--opset 12并把输入固定到640×640。不要在导出时保留动态shape转换工具对动态维度的支持比较折磨人。量化建议用几百张有代表性的真实图片不要用纯黑图或纯白图否则色块检测会明显变差。我常用int8量化推理更快双路时更游刃有余fp16精度高一些但单帧推理会慢5到8ms。你可以同时导出两个rknn文件运行时通过配置切换。3.3 双路摄像头的采集方式别直接用默认参数双路摄像头建议不要直接cv2.VideoCapture(0)加cv2.VideoCapture(2)就开跑默认参数下经常出现花屏、丢帧、画面偏色。主要原因不是OpenCV而是UVC摄像头默认缓冲被填满延迟和丢帧一起恶化。我建议显式设置属性CAP_PROP_FOURCC设成MJPGCAP_PROP_FRAME_WIDTH和HEIGHT设成1280×720CAP_PROP_BUFFERSIZE设成1。720p的MJPG对USB带宽比1080p友好不少双路同时30fps更容易保证。另一个很多人忽略的是USB控制器带宽。把两个摄像头插在同一个USB Hub或同一个Controller上即使它们都是720p带宽也可能互相抢占。你可以用lsusb -t命令看每个摄像头挂在哪个Bus下尽量把它们分到两个不同Bus。如果只有一个USB控制器阶段一就把分辨率降到720p实测比强上1080p稳定得多。4. 阶段一工程落地共享线程池跑通双路yolov5s4.1 整体架构双采集线程共享线程池单NPU执行线程用一张表展示阶段一的线程角色划分线程角色数量职责采集线程2cap.read()取帧提交到共享线程池共享线程池worker3预处理、提交推理请求、后处理NPU推理线程1从推理队列取blobrknn.inference主线程1初始化、启停控制、统计输出注意共享线程池的worker并不直接调rknn.inference而是把预处理好的blob塞进推理队列后等待结果。这个“等待”会占用worker。如果推理队列已满worker会阻塞进而通过信号量反压到采集线程让采集线程丢帧。整个系统就靠这层反压维持内存稳定。这样设计的直接收益是当NPU正在推理第N帧时另一个worker已经在预处理第N1帧或者在后处理第N-1帧的结果。CPU和NPU能同时工作而不是等一个完整链路走完才开始下一帧。4.2 核心代码逐段拆解下面给出一个可运行的Python骨架复制到香橙派5上改一下模型路径和设备号就能跑。因为篇幅原因后处理只保留最简单的输出打印完整的NMS和画框代码和你自己单路版本保持一致即可。import queue import threading import time import cv2 import numpy as np from concurrent.futures import ThreadPoolExecutor # 阶段一配置 CAMERAS [/dev/video0, /dev/video1] IMG_WIDTH, IMG_HEIGHT 1280, 720 MODEL_PATH /home/orangepi/models/yolov5s_int8.rknn POOL_WORKERS 3 INFER_QUEUE_MAX 2 STOP_EVENT threading.Event() # 带信号量限制提交量的ThreadPoolExecutor防止任务无界堆积 class BoundedThreadPoolExecutor: def __init__(self, max_workers, max_tasks): self.pool ThreadPoolExecutor(max_workersmax_workers) self.sem threading.Semaphore(max_tasks) self._futures set() def submit(self, fn, *args): self.sem.acquire() future self.pool.submit(fn, *args) self._futures.add(future) future.add_done_callback(self._done) return future def _done(self, future): try: future.result() except Exception as exc: print([executor] task error:, exc) finally: self.sem.release() self._futures.discard(future) def shutdown(self): self.pool.shutdown(waitTrue) # 推理队列worker往里放blobNPU线程往外取 infer_queue queue.Queue(maxsizeINFER_QUEUE_MAX) def preprocess(frame): # 最简单resize RGB CHW 归一化不做完整letterbox时精度会略差 img cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) img cv2.resize(img, (640, 640), interpolationcv2.INTER_LINEAR) img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1)) return np.expand_dims(img, axis0) def process_task(cam_id, frame, rknn): blob preprocess(frame) # 若推理队列满直接丢弃该帧保证内存可控 if infer_queue.full(): return infer_queue.put((cam_id, frame, blob)) def infer_loop(rknn): while not STOP_EVENT.is_set(): try: cam_id, frame, blob infer_queue.get(timeout1) except queue.Empty: continue t0 time.time() outputs rknn.inference(inputs[blob]) infer_ms (time.time() - t0) * 1000 # 在这里接你自己的后处理比如解析outputs并画框 # draw_boxes(frame, outputs) print(fcam{cam_id} infer {infer_ms:.1f}ms, fps{1000 / max(infer_ms, 1):.1f}) def capture_loop(cam_id, executor, rknn): cap cv2.VideoCapture(CAMERAS[cam_id], cv2.CAP_V4L2) cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*MJPG)) cap.set(cv2.CAP_PROP_FRAME_WIDTH, IMG_WIDTH) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, IMG_HEIGHT) cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) while not STOP_EVENT.is_set(): ok, frame cap.read() if not ok: continue executor.submit(process_task, cam_id, frame, rknn) def main(): from rknnlite.api import RKNNLite rknn RKNNLite() rknn.load_rknn(MODEL_PATH) rknn.init_runtime() executor BoundedThreadPoolExecutor( max_workersPOOL_WORKERS, max_tasksPOOL_WORKERS * 2 ) t_infer threading.Thread(targetinfer_loop, args(rknn,), daemonTrue) t_infer.start() threads [] for i in range(len(CAMERAS)): t threading.Thread( targetcapture_loop, args(i, executor, rknn), daemonTrue ) t.start() threads.append(t) try: while True: time.sleep(1) except KeyboardInterrupt: print(stopping...) STOP_EVENT.set() finally: executor.shutdown() if __name__ __main__: main()代码里我特意把rknn实例传给process_task但实际不会在worker里调inference。这样每个worker拿到的只是blob真正NPU调用只发生在infer_loop这一个线程里从根源上避免多线程同时操作rknn实例导致的崩溃。这个骨架可以直接扩展画框就放在infer_loop中outputs之后输出视频可以用OpenCV显示或保存成mp4两路并用时显示两个窗口即可。如果要用两个窗口记得所有显示逻辑统一放到infer_loop里不要在采集线程里imshow。OpenCV的GUI在多线程下很容易出问题。4.3 两个关键的流水线细节反压与丢帧策略有人会问既然是实时检测为什么队列满了要丢帧而不是等一等核心原因是“实时视觉”追求的是最新一帧不是处理完所有帧。如果某段路上耗时抖动比如一下子来了两个很复杂的画面队列堆积越多显示延迟越大最终看起来就是画面越来越卡。丢旧帧之后worker很快处理到最新帧画面反而跟手。另一个细节是infer_queue.get(timeout1)在推理线程空转时会每秒醒来一次这不会造成太大开销。但如果你看到日志里经常出现empty说明采集或预处理明显跟不上应该检查摄像头是不是没保持30fps推流而不是去加大线程池。4.4 实测数据这样跑起来大概什么水平我在香橙派5上测试模型是yolov5s int8量化两路摄像头都是720p MJPGpool_workers3推理队列maxsize2。单帧NPU推理大约12到16ms两路总处理能够稳定在50到60fps单路分别保持在25到30fps左右。CPU占用大概在60%到80%之间浮动内存峰值控制在2GB以内。如果同一份模型改成fp16单帧推理会到20ms上下此时两路同时30fps就很勉强会掉到24fps左右。所以阶段一我更推荐int8。你会发现模型轻量化不是锦上添花而是双路方案的刚需。5. 排错实录从花屏到内存暴涨再到GIL5.1 摄像头打不开、花屏和偶发丢帧排查顺序我建议固定为物理连接→USB带宽→OpenCV参数→线程调度。先lsusb -t看摄像头挂哪个Bus两个摄像头别挤同一个Hub再换一根好的USB线劣质线是花屏和掉帧的隐形元凶最后在cap.set里把BUFFERSIZE设为1编码格式优先MJPG。如果还是一路画面正常一路花屏大胆怀疑是两个摄像头抢带宽降720p或把帧率降一半再试。还有一个容易被忽略的问题部分USB摄像头在上电后需要1到2秒才能稳定输出采集线程如果过早开始read可能连续读到坏帧。可以在cap.open成功后sleep两秒再进主循环或者在循环里连续丢弃前10帧。5.2 RKNN推理报错、结果偏移和量化掉精度最常见报错是模型输入shape不匹配原因多半是导出ONNX时保留了动态维度。解决方法是重新导出固定640×640的模型并确认rknn.load_rknn时用的模型文件和init_runtime的target_platform一致。结果偏移也就是框位置不准通常出现在后处理解析三输出的时候。RKNN转换工具可能会把三个输出合并成一个也可能保持三个你需要打印outputs长度和形状判断。一般来讲yolov5s经过rknn转换后如果outputs长度是1说明已经做了anchor合并后处理逻辑要按单输出写如果长度是3就按原始stride解析三个feature map。量化掉精度的问题我的经验是换更多与真实场景接近的量化图片而不是盲目加大量化图片数量。如果你的摄像头主要拍仓库货架就让量化图集中包含货架、光线阴影、远处小目标否则色块召回率明显下降。5.3 内存暴涨和CPU占用异常的排查很多人看到“跑着跑着内存涨到5GB以上”就开始怀疑模型其实问题基本出在任务堆积。ThreadPoolExecutor如果不加信号量限制采集线程每读到一帧就提交一个任务推理速度一旦跟不上worker和队列积压的帧就会指数级堆积。我给BoundedThreadPoolExecutor加的max_tasks就是干这个的实测把它设为POOL_WORKERS*2内存曲线非常平稳。CPU占用异常偏高除了GIL问题外还可能是后处理里用了大量Python循环解析框。把NMS和坐标转换尽量向量化不要对每一类别做for循环。另一个常见问题是OpenCV窗口显示放在采集线程里imshow本身占CPU还容易导致线程崩溃统一到infer_loop里处理会好很多。关于GIL这里必须给新手一个准确预期Python的ThreadPoolExecutor并不能让三四个worker同时在多核上跑Python字节码。但好消息是预处理里的cv2.resize、cvtColor等C库调用会释放GIL所以实际还是能并行一部分。真正要把RK3588的8个核都利用起来后续阶段三我会用C重写调度层。Python阶段一用来验证架构完全没问题但别指望CPU占用降到很低。6. 阶段一收尾验收清单与下一步思路6.1 阶段一验收清单拿这份清单逐项打勾两路摄像头都能正常显示画面没有持续花屏推理线程日志里单帧推理时间稳定在预期范围不出现频繁的几十毫秒尖峰两路FPS都大于等于25分辨率720p下内存峰值不超过3GB连续运行30分钟没有崩溃或僵死拔掉一路摄像头再插回去系统能自动恢复运行。如果满足这六条阶段一就算真正过了。如果某条不满足回到对应章节排查不要急着往后做。6.2 接下来可以怎么扩展这个线程池骨架这个骨架的价值在于你后面无论加什么功能都不需要推翻重写。比如要跑两路不同的模型就把infer_queue里的任务带上model_id推理线程根据model_id加载对应rknn实例要做动态分辨率就把preprocess里的尺寸参数变成任务字段。更远一步把RKNN的输入输出改成零拷贝API去掉Python侧的numpy转换性能还能再上一个台阶那就是阶段二甚至阶段三的内容了。说回双路视觉本身我个人在实际操作中最深的体会是真正决定方案上限的不是NPU跑多快而是你有没有一套能控制积压、分配空闲算力的调度骨架。代码能跑通只是第一步能在一路卡死时另一路还保持输出才是这套共享线程池设计真正值钱的地方。你拿这个骨架去接四路工业相机、接云台追踪、接自动巡检核心调度逻辑都不用变变的只有任务处理函数而已。最后再分享一个小技巧每次调试完线程池参数把当时的pool_workers、infer_queue大小、FPS和CPU占用记在一张表格里。这类并发系统的性能表现非常依赖当时的摄像头型号、分辨率和系统负载没有唯一最优参数只有适合你当前场景的组合。记下数据下次换摄像头或者加一路时你就能快速回到一个靠谱的起点而不是从头猜。
返回列表