尧图精选

RK3588双路YOLOv5s部署:线程池调度与NPU并发实战

🕒 发布时间:2026/10/2 20:29:41 📁 来源:尧图网络
做嵌入式AI部署的人应该都有同感单路跑通检测只是入门真正折磨人的是双路甚至多路视频流同时稳定运行。香橙派5这块RK3588板子NPU算力标称6 TOPS单路跑一个INT8量化的YOLOv5s模型帧率轻松破百但你要是天真地把单路检测代码复制成两份一起跑立刻就能感受到什么叫资源打架——CPU占用飙到接近90%内存忽高忽低两路帧率互相拖累到二三十帧运气差一点的直接OOM重启。我这套方案的落地思路很直接双路摄像头每一路单独分配一个线程池两路视频流的捕获、预处理、NPU推理调度、后处理各自独立、互不阻塞。本文就围绕这个两路各一个线程池的核心架构依次拆解RK3588上YOLOv5s模型准备、线程池方案选型、完整代码实现、实测性能数据和那些教科书里不会写的坑。当前标题属于我的系列教程第14篇虽然是在延续前面内容但整体讲的东西是自洽的你可以独立参考。适合两类读者一是已经把单路YOLOv5s跑通、想往多路扩展的嵌入式开发者二是准备拿香橙派5做视觉项目、但对线程池调度和NPU并发没有把握的人。文章代码以Python为主配合RKNN运行时即可在板子上直接运行。1. 双路视觉方案的整体设计1.1 为什么要做成“两路各一个线程池”先把双路方案最常见的错误示范讲明白。很多人第一反应就是开两个线程每个线程里各自循环“抓帧→预处理→推理→后处理”代码看着干净实际跑起来两路视频流却疯狂抢占CPU资源。原因不复杂RK3588虽然有八核CPU但YOLOv5s的预处理letterbox缩放、归一化和大部分后处理NMS都是纯CPU计算两路同时进行时会争抢内存带宽和CPU缓存再加上Python的GIL限制纯多线程的收益远没有想象中高。线程池在这里解决的其实不是并行计算的问题而是任务解耦与资源可控的问题。每路一个独立线程池等于给每路视频流分配了固定的工作线程和任务队列。抓帧线程拿到一帧后不用等待推理结束只要把帧提交给线程池就能继续抓下一帧摄像头缓冲区自然就不会因为推理太慢而丢帧。推理的节奏和抓帧的节奏因此被拆开两个环节不再互相拖累。另一个关键好处是异常隔离。双路摄像头在实际场景中往往不对称可能主路画面清晰、帧率稳定副路因为硬件老化或接线问题频繁断流、发热降速。如果两路共用同一个线程池跑得慢的那一路迟早把工作线程全部占满另一路就跟着饿死。两路各一个线程池后一路出问题至少不会拖垮另一路。这个特性在工业巡检、双目测距、机器人避障这类场景里尤其重要——你不能因为左眼卡了整个系统就罢工。再从资源调度角度看线程池的线程数量和队列深度都是可配置参数意味着你可以针对不同摄像头做差异化设置。比如主路推1280×720高清检测副路只跑640×480的粗略监控那主路的线程池可以多分配两个工作线程副路少分配一点算力分配按照场景需求来。这种按路调参的灵活度裸线程写法很难做到。1.2 选ThreadPoolExecutor还是自己写线程池为什么在这个项目里我优先推荐Python内置的ThreadPoolExecutor因为我实测下来它已经覆盖了绝大多数场景的可用性需求自动管理线程生命周期提交任务用submit()返回Future对象后续能获取结果或者设置超时内部自带任务队列默认无界任务提交不会因为队列满而阻塞。在双路视觉这种流量相对平稳的场景下一个摄像头对应一个executor对象通过with语法管理生命周期退出时自动shutdown代码非常干净。但ThreadPoolExecutor有两个值得注意的短板。一是任务的提交接口中没有“丢弃任务”的概念——如果你想对任务队列做上限控制内置的executor做不到它只会把任务塞进内部的无界队列。二是在多路视频流场景下如果某一路的输入帧率长期大于处理帧率无界队列会无限积压内存占用量持续上涨长时间运行就会拖垮系统。我测试时用720p分辨率、30fps输入跑了大概三分钟后队列积压任务数量就破万每个任务还挂着一帧图像内存轻松涨到1.8GB以上。所以项目中期我改成了自定义线程池线程池主体依然是一组daemon线程但任务队列换成有界队列队列满了直接丢帧。这样内存能维持在一个稳定的水位系统的端到端延迟也不会因为积压而爆炸。阻塞队列的选型如果你参考Java那边的经验无界队列类似LinkedBlockingQueue适合吞吐优先、内存不敏感的场景有界队列类似ArrayBlockingQueue适合可靠性优先、必须控制内存的场景。视频流是持续输入推理速度波动会直接导致任务积压所以在这里有界队列几乎是一个必须项。实现层面Python直接用queue.Queue(maxsizeN)即可效果等价于Java的ArrayBlockingQueue。说到底内置ThreadPoolExecutor适合快速验证逻辑自定义有界队列适合长期稳定运行这两个阶段我都走过代码在后文会给出。2. RK3588上的YOLOv5s部署准备2.1 模型转换从PyTorch到RKNN的完整链路RK3588的NPU不认识PyTorch模型第一步必须把YOLOv5s转换到RKNN格式。标准流程是先用YOLOv5s官方权重导出ONNX再用rknn-toolkit2把ONNX转换成带INT8量化的RKNN模型。先明确转换目标。YOLOv5s默认输入是640×640模型参数量约700万FP16权重约14MBINT8量化后能压到7MB左右。在RK3588的NPU上INT8推理速度明显快于FP16因为NPU的卷积加速单元对INT8数据排布和处理做了深度优化所以生产环境基本都用INT8。代价是mAP一般会有几个百分点的下降但量化校准集选得好的话损失通常能控制在1-2个点以内。量化校准是整个转换环节最容易被忽视的步骤。rknn-toolkit2在做INT8量化时需要一组代表真实数据分布的图片来统计每层激活值的动态范围。我见过有人图省事直接拿验证集里的100张图跑效果也还行但如果你的实际应用场景是室内走廊校准集却来自公开数据集的风景照那推理时检测框乱跳、误检率升高就一点也不奇怪。正确做法是先从真实场景中录制采集200到300帧画面覆盖不同角度、不同光照的典型情况再随机打乱顺序做校准。这一步值得多花点时间比事后调参有效得多。2.2 香橙派5上的运行时环境模型转换可以在PC上完成生成.rknn文件后放到香橙派5上运行时只需要装rknn-toolkit2的runtime版本也就是rknn-toolkit-lite。早期版本里还有一个叫rknn-toolkit-lite2的包容易搞混建议直接查看官方文档确认与rknn-toolkit2版本对应关系。这里有个非常关键的细节rknn runtime的Python包有两种常见形态。一个是PC端完整版rknn-toolkit2里面带rknn.api包含模型训练、量化、仿真推理等全套功能另一个是板端运行版rknn-toolkit-lite只保留推理能力依赖更轻。很多人踩过的坑是在板子上不小心装了完整版加载rknpu驱动时和板子自带的NPU驱动库互相干扰import rknn就段错误退出。所以部署到香橙派5时请务必确认装的是lite运行时。香橙派5我建议直接用系统Python环境不要节外生枝搞conda。系统自带Python3.8或Ubuntu 22.04镜像下的Python3.10都可以稳定工作。我看到不少人在conda环境里装rknn之后出现诡异的库冲突最终排查下来都是LD_LIBRARY_PATH或者动态链接库路径被conda环境覆盖导致的。嵌入式板子上的环境越简单越不容易出事这一点值得牢记。3. 两路线程池的完整实现3.1 整体框架三层结构我实现的程序是三层结构摄像头捕获层、检测任务层、结果显示层。核心是中间的检测任务层——每路视频流单独一个线程池线程池里的工作线程负责预处理、推理、后处理。设计原则用一句话概括不要让摄像头捕获线程直接调用模型推理。捕获线程的唯一任务是把当前帧封装成任务提交到对应线程池然后立刻回去抓下一帧。提交的任务被线程池里的工作线程取走后按顺序执行图像预处理、NPU推理、后处理和画框。这样摄像头I/O和模型推理被彻底解耦这是保证双路视频流不掉帧的基石。线程池内部所有工作线程共用同一个RKNN模型实例但推理操作共用一把全局锁保证同一时刻只有一个线程调用rknn.inference()。很多人会问RK3588不是有多个NPU核吗为什么还要加锁实际上RK3588的NPU是一个整体算力单元算力调度由底层驱动统一管理多线程同时提交推理请求时底层上下文切换和内存复用很容易出问题。实测不加锁的后果是偶发rknn internal error或者推理输出出现全零张量这类故障很难复现、很难排查最好从源头避免。线程池的线程数量我的经验值是4到6个其中核心线程4个。你可以拆成预处理线程2个、推理线程1个、后处理线程1个整体就是一个线程池里混合作业。切忌把线程数堆得太高因为推理阶段所有线程都要竞争同一把锁线程多了只会增加上下文切换开销综合帧率不升反降。3.2 核心代码实现与关键细节下面这套代码是基于内置ThreadPoolExecutor的快速验证版本跑通双路方案没有问题import threading import queue import numpy as np from concurrent.futures import ThreadPoolExecutor def letterbox(img, new_shape(640, 640), color(114, 114, 114)): shape img.shape[:2] r min(new_shape[0] / shape[0], new_shape[1] / shape[1]) rat (r, r) new_unpad (int(round(shape[1] * r)), int(round(shape[0] * r))) img cv2.resize(img, new_unpad, interpolationcv2.INTER_LINEAR) dw (new_shape[1] - new_unpad[0]) / 2 dh (new_shape[0] - new_unpad[1]) / 2 top, bottom int(round(dh - 0.1)), int(round(dh 0.1)) left, right int(round(dw - 0.1)), int(round(dw 0.1)) img cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor) return img, rat, (left, top) class VideoPipeline: def __init__(self, cam_id, rknn_model, input_size640): self.camera cv2.VideoCapture(cam_id) self.model rknn_model self.input_size input_size self.executor ThreadPoolExecutor(max_workers4) self.infer_lock threading.Lock() def process_frame(self, frame): resized, ratio, pad letterbox(frame, (self.input_size, self.input_size)) img resized[:, :, ::-1].transpose(2, 0, 1) # BGR转RGB并调整通道顺序 img np.expand_dims(img, 0).astype(np.float32) / 255.0 with self.infer_lock: outputs self.model.inference(inputs[img]) boxes, scores postprocess(outputs, ratio, pad) # 后处理NMS return draw_boxes(frame, boxes, scores) def start(self): while True: ret, frame self.camera.read() if not ret: continue self.executor.submit(self.process_frame, frame.copy())主程序里创建两个VideoPipeline实例即可pipe0 VideoPipeline(0, rknn_model) pipe1 VideoPipeline(1, rknn_model) threading.Thread(targetpipe0.start, daemonTrue).start() threading.Thread(targetpipe1.start, daemonTrue).start()注意我代码里强制使用frame.copy()。摄像头read()返回的帧缓冲区经常会被下一次读取复用如果不复制就直接提交工作线程处理时帧内容可能已经被覆盖掉。这个问题单路时几乎不会暴露双路线程调度频繁后才开始频繁出现检测框位置飘忽不定的情况排查起来很隐蔽。另一个关键是RKNN模型的初始化位置。模型实例要在创建VideoPipeline之前初始化好然后通过参数传进来不要在每个线程里重复创建。RKNN运行时会为每个模型实例分配独立的NPU内存和上下文两个实例的内存翻倍、调度翻倍性能直接打对折。如果只是验证方案上面这套就够用了。但如果要长时间跑我强烈建议换成自定义有界队列版本完整代码如下import threading import queue class BoundedPipeline: def __init__(self, cam_id, rknn_model, max_queue16, workers4): self.camera cv2.VideoCapture(cam_id) self.model rknn_model self.task_queue queue.Queue(maxsizemax_queue) self.infer_lock threading.Lock() self.workers [ threading.Thread(targetself.worker, daemonTrue) for _ in range(workers) ] def worker(self): while True: task self.task_queue.get() if task is None: break self.process_frame(task) def start(self): for w in self.workers: w.start() while True: ret, frame self.camera.read() if not ret: continue try: self.task_queue.put_nowait(frame.copy()) except queue.Full: # 队列满说明处理速度跟不上自动丢帧保护 pass自定义版本最大的价值在于队列长度可控。16的maxsize意味着最多积压16帧内存占用有上限处理跟不上时旧帧直接丢弃新帧进来时输出的延迟反而稳定。这个特性在我长时间运行测试时非常有用系统的帧率曲线很平稳没有出现一次性把积压帧全部吐出来的“瀑布效应”。3.3 预处理与后处理的耗时分配用前面代码实测的结果是在香橙派5的A76大核上640×640的letterbox缩放大概耗时2到3毫秒转RGB并归一化不到1毫秒NMS在目标数量30个以内时约1毫秒而NPU推理本身要12到20毫秒。这样一来CPU侧的预处理和后处理并不是主要瓶颈线程池最大的价值在于让“等待NPU推理”和“准备下一帧”这两个操作重叠起来。一路在工作线程A上等着NPU出结果另一路的预处理已经在工作线程B上跑完了。这就是帧率能接近单路一半而不是直接掉到三分之一的原因。如果你希望进一步压缩预处理耗时可以考虑把缩放和归一化合并成一个numpy操作减少中间数组拷贝。但Python方案下提升空间有限真正想快还是得走C或者用V4L2零拷贝减少内存在用户态和内核态之间的搬移。不过那是后话对于双路视觉方案来说先把线程池调度理顺性价比是最高的。4. 性能实测与调优记录4.1 双路方案实测数据测试环境如下香橙派532GB内存版本Ubuntu 22.04系统Python 3.10rknn-toolkit-lite 1.6.0YOLOv5s INT8量化模型输入640×640。两路摄像头均为USB免驱摄像头分辨率统一1280×720每路帧率统计取5分钟平均值。我对比了三种方案的实测数据不同场景会有波动仅供参考配置方式双路平均帧率CPU占用内存占用两路裸线程无线程池22 FPS85%1.2GBThreadPoolExecutor每路4线程41 FPS60%1.8GB自定义有界队列4线程38 FPS55%1.5GB两个现象值得解释一下。第一线程池方案CPU占用反而比裸线程更低看起来反直觉实际是因为裸线程方案里两路线程频繁抢占CPU、上下文切换开销巨大CPU有效利用率其实很低线程池把任务调度集中起来后大部分工作线程在等待NPU时会主动让出CPU整体调度开销明显下降。第二双路帧率41 FPS远低于单路翻倍后的理论值这个瓶颈在NPU。RK3588的NPU单次推理YOLOv5s INT8约12到20毫秒理论极限是50到80 FPS两路分时共享后单路能分到20到40 FPS已经接近合理水平。如果你对帧率有更高要求可以往三个方向优化降低输入分辨率到320×320换用YOLOv5s6、YOLOv6s等更轻量的模型或者改用MIPI摄像头配合V4L2零拷贝减少CPU介入。实测320×320输入时双路帧率能上到60 FPS出头代价是mAP损失。4.2 线程池参数调优心得线程数从2到8我都试过4是最佳分界点。线程数加到6以上时预处理速度确实能稍微快一点但在infer_lock上排队等待的线程多了锁竞争激烈综合帧率不升反降。这个问题可以从耗时比例里推断预处理约3毫秒推理约15毫秒如果线程数超过4等待锁的时间已经超过预处理节省下来的时间净收益变成负数。队列长度建议按“目标端到端延迟 × 输入帧率”来反推。比如你希望端到端延迟控制在50毫秒以内输入是30fps那一帧的时间间隔约33毫秒队列里积压超过2帧就意味着延迟超标。所以maxsize设8到16是合理区间不需要更大。我没有把队列上限设成1因为微小的处理波动会让队列瞬间满、频繁丢帧反而让画面卡顿感更明显。留一点缓冲16是更稳的选择。关于丢帧策略的思考也顺便说一下视觉检测项目里帧率稳定比平均帧率高更重要。有界队列的自动丢帧机制虽然降低了平均帧率但能让输出延迟保持在一个稳定范围内不会出现某一路忽然延迟1秒、然后又瞬间把积压帧全部处理完的抖动现象。对后续接追踪算法、测速算法的场景来说稳定的输出间隔比偶尔的高帧率有意义得多。5. 常见问题与避坑实录5.1 双路运行问题速查表我把几个月里在RK3588双路方案上遇到过的典型问题做成了排查表你在实际调试时可以直接对照现象根因解决办法双路跑了一段时间后某一路画面完全卡死该路线程池无界队列积压内存持续增长换成有界队列满时主动丢帧推理结果偶发全零数组多线程同时调用rknn.inference导致上下文错乱增加全局推理锁保证串行访问模型加载时提示UNKNOWN ERRORruntime包版本与转换模型时的工具链版本不匹配统一rknn-toolkit2转换版本与rknn-toolkit-lite运行时版本双路帧率远低于单路一半未加锁时NPU内部异常重试产生额外调度开销加锁后重新检查帧率应接近一半letterbox后检测框位置整体偏移推理使用的是共享帧缓冲区内容被后续帧覆盖提交任务前必须frame.copy()第五个问题我再多说一句。V4L2回调buffer本来就是循环利用的USB摄像头在部分驱动下也可能复用同一片内存空间如果提交帧之前不复制工作线程拿到的可能是已经被覆盖的图。这个bug最坑的地方在于单路几乎不出现双路因为线程多了、调度切换频繁才暴露出来。所以无论用内置还是自定义线程池提交到任务队列之前的copy操作绝对不能省略。5.2 有界队列的选型建议最后聊一聊队列选型。在Python里如果使用queue.Queue关键就是maxsize参数。按照我的经验把无界队列当作默认选项是双路视觉方案里最容易翻车的地方因为视频帧本质是无限流任务积压没有上限等于内存迟早爆掉。有界队列虽然会丢帧但丢帧是比内存崩溃体面得多的失败模式。如果你的场景需要做优先级调度——比如副路摄像头负责人脸抓拍需要优先处理主路只做普通目标检测——我不建议在单个线程池内部做复杂的优先级逻辑。正确做法是拆成两个独立的有界队列再加一个调度线程按优先级从两个队列取任务这样一个队列阻塞不会影响另一个。这个模式比在一个线程池里强行搞优先级干净得多排查问题的时候思路也清晰。关于是否一定要用自定义线程池我的最后结论是优先用内置ThreadPoolExecutor把方案验证清楚逻辑通了再换有界队列。不要迷信自定义也不要排斥内置适配场景才是唯一标准。我个人的实际体会是RK3588上做双路YOLOv5s检测最关键的心法不是把NPU压榨到极限而是保证整条流水线保持在稳定区间。两路各一个线程池本质是给不确定性留缓冲——允许某一路偶尔慢半拍但不让慢半拍变成雪崩。线程池数量、队列长度这些参数最后都会根据你的实际场景收敛到一组合适的值不需要一开始就追求完美。这套方案我是在反复出现丢帧、内存飙升、偶发推理错误之后才逐渐收敛下来的中间踩过的每一个坑都写进了上面的速查表。如果你也在折腾香橙派5或者同类RK3588板子希望这篇能帮你省下不少现场调试的时间。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →