☰
RK3588双路视觉部署:独立线程池实现yolov5s稳定推理
2026/10/31 1:03:13 网站建设 项目流程

做RK3588双路视觉,最先劝退人的往往不是模型精度,而是两路视频流同时进来之后,系统表现出的那种"手忙脚乱":一会儿卡顿,一会儿掉帧,一会儿NPU报错,甚至整机假死。这篇文章是香橙派RK3588部署yolov5s系列教程的第14篇,也是双路视觉方案的第一篇,核心就一句话:两路摄像头,每路独立吃一个线程池,谁也不占谁的道。

我先把这条路的实底交给你:这里用的"方案一"不是把两个摄像头丢进同一个线程池里抢任务,而是为每一路单独维护一个线程池,两个池子互不干扰。听起来简单,但里面涉及线程数怎么定、阻塞队列选哪种、NPU上下文怎么共享、CPU怎么绑核,一步没想清楚,后面跑起来就是灾难。这套内容适合已经在RK3588上跑通单路yolov5s、正准备往双路四路扩展的开发者,也适合刚接触嵌入式AI、想搞明白"多路视觉到底应该怎么设计线程模型"的入门选手。我尽量把每个"为什么"都讲透,你照着做就能复现。

1. 双路视觉方案:为什么每路必须配一个独立线程池

1.1 方案一的核心思路:资源隔离而不是单纯多线程

先想一个问题:两路摄像头同时工作,最朴素的做法是不是"全局一个线程池,来两路帧就往里丢"?我一开始也是这么干的,结果一路偶尔卡顿,另一路跟着一起遭殃,严重时两路都卡。原因很简单:全局线程池里所有任务共用一个队列,当第一路预处理耗时变长、或者后处理堆积时,它会把第二路的任务一起堵在后面,造成"一荣俱荣,一损俱损"。

方案一改成"两路各一个线程池",本质上是做资源隔离。每一路拥有自己的任务队列、自己的工作线程,队列满了只丢自己这一路的帧,不会把另一路拖下水。这在高并发视觉系统里跟"微服务拆库"是一个道理:两个业务共享一个数据库连接池,一旦慢查询出现,所有业务一起变慢;拆成独立资源池之后,故障范围被限制在单一路内。

这个思路在RK3588这种嵌入式平台上尤其重要。因为嵌入式设备的CPU、内存带宽、NPU算力都是硬上限,不可能像服务器那样靠堆资源解决问题。你要做的是通过线程模型把有限的资源切分成互不干扰的"通道"。

1.2 RK3588的并行能力盘点:CPU、NPU、摄像头接口

在动手写代码之前,先把RK3588的家底摸一遍,否则后面设计线程池就是拍脑袋。

RK3588采用8核CPU:4个Cortex-A76大核(最高2.4GHz)+ 4个Cortex-A55小核(1.8GHz)。跑yolov5s这类任务,大核负责预处理、后处理、逻辑控制,小核可以分担采集、IO、显示。NPU是三核设计,总算力6TOPS,支持INT4/INT8/FP16混合量化,yolov5s做INT8量化后,单帧NPU推理大概能跑在30ms以内。内存接口支持LPDDR4/4X/5,带宽足够,但多路同时拷贝图像时依然会抢带宽,这一点后面会专门说。

摄像头接口方面,香橙派5/5Plus/5Pro多数型号带双MIPI CSI,或者一路MIPI加多路USB,硬件上支持双路同时输入没有问题。但注意RK3588和RK3588S(香橙派5标准版用的是RK3588S)在接口数量上有差异,MIPI通道略有阉割,先确认你的板卡具体型号再选摄像头方案。

我实测下来,双路yolov5s同时推理,NPU资源是可以并发利用的,但瓶颈往往不在NPU,而在CPU端的预处理拷贝和图像缩放。如果两路都走"全局线程池+无界队列",图像数据越积越多,内存带宽先被打爆,NPU反而闲着等数据。所以两路各一个线程池不只是"线程隔离",更是在给内存带宽做交通管制。

2. 模型准备:yolov5s导出ONNX与RKNN转换实测

2.1 导出ONNX时最容易犯的错

RK3588上跑yolov5s,模型必须先过瑞芯微的RKNN-Toolkit2转成rknn格式。第一步是拿标准yolov5权重导出ONNX。这里第一个坑就是导出时的opset版本和动态维度设置。

我建议导出时固定输入尺寸,比如640x640,而不要开动态shape。虽然RKNN工具链现在支持动态shape,但在RK3588上动态shape会显著增加推理耗时,因为NPU内部要做内存重规划。导出命令大致是:

python export.py --weights yolov5s.pt --include onnx --opset 12 --batch-size 1

注意看导出的ONNX里是否包含NMS层。如果你用的是官方yolov5 6.0以上版本,默认export.py导出的是不带NMS的原始输出,很方便,因为NMS我们会在板端用CPU做。如果你下载的别人给的模型里带了NMS层,RKNN转换时大概率报不支持,或者转出来了但后处理非常慢,尽量避开。

另一个常见错误是输入节点名称和顺序。yolov5官方模型输入名是images,输出是三个尺度的检测头,分别对应(1, 255, 20, 20)、(1, 255, 40, 40)、(1, 255, 80, 80),这里的255是(5 + num_classes) * 3,因为每个anchor位置预测3个候选框。如果用的是COCO 80类,255正好对得上。后面写后处理时这三个尺度要分别解码。

2.2 量化校准,这一步直接影响精度

RK3588的NPU跑INT8是最快的,所以转换时几乎都会做量化。不要天真地以为把onnx丢进工具链选个int8就完事了,校准数据集的选择直接决定量化后模型的精度表现。

我踩过一次很狠的坑:偷懒用20张纯风景图做校准,结果模型对夜间车辆检测效果一塌糊涂,直接在部署时翻车。正确做法是从你的真实使用场景里抽200到500张代表性图片,覆盖不同光照、不同角度、不同目标大小,喂进校准器。RKNN-Toolkit2里通过dataset.txt指定校准图片列表,转换脚本核心部分长这样:

from rknn.api import RKNN rknn = RKNN() rknn.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588') ret = rknn.load_onnx(model='yolov5s.onnx') ret = rknn.build(do_quantization=True, dataset='dataset.txt') ret = rknn.export_rknn('yolov5s.rknn')

注意mean_values和std_values,这里如果你用yolov5官方预处理是RGB归一化除以255,那对应的就是mean 0、std 255。如果你的训练代码里用了其他归一化参数,必须保持一致,否则转换出来的模型在板端跑出来的框全偏。

转换完成后,我用瑞芯微的rknn_benchmark工具在板端验证一次单帧耗时和精度对比,这一步别省。我实测yolov5s int8 640输入,NPU推理单帧大约24到30ms,但加上预处理、后处理、画框,单路整链路大约40到50ms,这就是后面设计线程数的依据。

2.3 板端推理接口:rknn_context与内存模型

在板端调用rknn模型,用的是librknnmrt.so里的C接口,核心对象是rknn_context。每次rknn_init加载一个模型会得到一个独立的上下文,里面包含模型权重、输入输出缓冲区信息。这里有一个关键认知:同一个rknn_context的rknn_run不是线程安全的,多线程同时调用同一个context的推理接口,轻则报错,重则直接段错误。

所以双路方案里,要么每路单独rknn_init一个context(各自加载一份模型权重,内存占用翻倍),要么共用一个context但加锁串行化推理。我的建议是:两路共用同一个context,推理加锁串行化。原因很简单,RK3588的NPU虽然有三个核,但它的任务调度器会把多个推理请求排队执行,并不会因为你开两个context就真正并行两路,反而会多占一份权重内存。真正并发的是CPU端的预处理和后处理,这才是线程池要解决的。

如果你对时延特别敏感,想真正做到两路推理并行,可以考虑每路一个context并配合rknn_dup_context这类接口,但工程复杂度会上升,帧率提升有限。对大部分双路视觉安防、智慧交通场景,共用上下文加锁的方案已经够用。

3. 线程池参数与阻塞队列选型,这步直接决定延迟

3.1 线程数到底设成几

线程池线程数不是越多越好,尤其在这个场景里。RK3588有8个CPU核,但你的软件线程数如果超过核心数,操作系统频繁上下文切换,性能反而恶化。我实测两路各一个线程池,每路池子里蹲3个线程已经偏多,2个线程刚刚好。

为什么?因为每一路的处理链路是"抓帧 → 预处理 → NPU推理 → 后处理 → 显示"。NPU推理期间CPU线程大部分时间在阻塞等待,理论上可以让另一个线程趁机做下一帧的预处理或者上一帧的后处理。一个线程池里2个worker刚好形成交错流水线:线程A在等NPU结果时,线程B可以做下一帧的letterbox缩放。如果只放1个线程,那么NPU等待期CPU完全闲置,帧率上不去;放3个以上,锁竞争和缓存争用带来的损耗超过收益。

我给的参数建议是:每路线程池核心线程数2,最大线程数2,不要用无界队列。两路一共4个worker线程,加上主线程的采集循环,整个系统活跃线程稳定在6到7个,任务清晰,问题好定位。

3.2 阻塞队列三个选项的实测对比

线程池的排队策略,在Java里叫阻塞队列,在C++里就是std::deque加std::condition_variable,思路一样。这里的选择会直接影响实时性,我说三个常见的坑。

第一个:无界队列,比如C++里直接用std::deque不限制长度,Python的ThreadPoolExecutor默认就是这种。图像帧来得快、后处理跟不上时,队列里堆积的全是待处理的帧,内存越吃越多,实时性变成"延时播录像",最终系统内存耗尽。我第一版犯过这个错,跑双路十分钟后队列里堆了上百帧,画面延迟肉眼可见。有界队列是底线。

第二个:丢弃新任务的策略。队列满了直接拒绝新帧,这会导致刚采集到的最新一帧被丢掉,视觉上表现为画面突跳,但至少系统不会崩。适合人流统计这种对单帧丢失不敏感的场景。

第三个:丢弃最旧任务,队列满时把队头最老的那一帧丢掉,把新帧放进来。这个策略对视觉追踪最友好,因为最坏情况只是短暂跳变,但看到的永远是最新画面。我强烈推荐这个方案。具体实现就是有界队列+入队前检查容量,满了先pop掉一个旧任务再push新的。

我实测对比过"无界队列"、"满则拒"、"丢旧入新"三种策略,单单线程数2、双路同时跑yolov5s 640输入,丢旧入新的方案能稳定维持每路17到19fps,而满则拒会降低到12fps左右,无界队列则根本稳不住。理由很直觉:视觉任务中旧帧的生命周期很短,等它处理完时目标早就不在那个位置了,不如直接丢掉。

3.3 双线程池代码骨架

我用C++写了个最简骨架,思路通用于任何语言。核心是每路一个CameraWorker,包含一个有界队列、两个线程,一个是推理线程,一个是后处理线程,加上采集主循环只管往里塞帧。

// 每路视觉链路的有界任务队列,容量固定2 class BoundedTaskQueue { public: explicit BoundedTaskQueue(size_t cap) : cap_(cap) {} bool push(std::shared_ptr<FrameData> data) { std::unique_lock<std::mutex> lock(mtx_); if (queue_.size() >= cap_) { queue_.pop_front(); // 丢最旧,入最新 } queue_.push_back(std::move(data)); cv_.notify_one(); return true; } bool pop(std::shared_ptr<FrameData>& out) { std::unique_lock<std::mutex> lock(mtx_); cv_.wait(lock, [this] { return !queue_.empty() || stop_; }); if (stop_) return false; out = queue_.front(); queue_.pop_front(); return true; } private: std::deque<std::shared_ptr<FrameData>> queue_; size_t cap_; std::mutex mtx_; std::condition_variable cv_; bool stop_ = false; }; // 推理锁:同一个rknn_context只允许一个线程同时rknn_run std::mutex g_infer_mutex; void infer_thread(std::shared_ptr<BoundedTaskQueue> q, rknn_context ctx) { while (true) { std::shared_ptr<FrameData> frame; if (!q->pop(frame)) break; // CPU预处理:letterbox、BGR转RGB,放到rknn_tensor_input std::lock_guard<std::mutex> lock(g_infer_mutex); rknn_inputs_set(ctx, 1, &frame->input); rknn_run(ctx, nullptr); rknn_outputs_get(ctx, 1, &frame->output, nullptr); // 解锁后把原始输出交给后处理线程 frame->raw_output_ready = true; frame->out_cv.notify_one(); } } void postprocess_thread(std::shared_ptr<BoundedTaskQueue> q) { while (true) { // 等output完成,解码三个尺度、NMS、画框、送显示 } }

这个骨架里有两个关键点。第一,线程池的工作不是"每个线程独立处理一帧",而是通过内部队列把推理和后处理流水化,这才是线程池在这个场景的正确用法。第二,g_infer_mutex是全局限流点,它保证了同一个rknn_context不会出现并发rknn_run,虽然两路都有各自线程池,但NPU推理实际是串行的,这一点在设计预期时要心里有数。

4. 双路采集到画面输出的完整链路实现

4.1 摄像头采集:V4L2与缓冲队列

双路视觉的采集环节,最常见的做法是OpenCV的VideoCapture读摄像头,简单但坑也多。OpenCV在RK3588上默认走V4L2,但抓帧阻塞和缓冲管理比较粗糙,双路同时跑时容易出现"一路read阻塞,另一路跟着卡"的现象。

我推荐直接用V4L2原生接口做采集,关键是用VIDIOC_REQBUFS申请多块缓冲区,内核和用户空间通过mmap零拷贝共享。采集线程只负责DQBUF取帧、QBUF回放,把帧数据封装成FrameData塞进线程池队列。每个摄像头独立申请4个缓冲区,来回循环,这样即使线程池暂时消费不过来,摄像头硬件还能继续写缓存,不会像OpenCV那样在去空等待时丢掉整个中断。

摄像头输出格式这块要注意,RK3588的MIPI摄像头很多默认输出NV12或NV21,而yolov5s需要RGB输入。直接用OpenCV的cvtColor转RGB会额外消耗CPU。更好的方案是先用瑞芯微的RGA硬件做格式转换和缩放,一条龙把NV12转成RGB并缩放到640x640。RGA是RK3588内置的2D硬件加速单元,拿来转格式几乎不耗CPU,这是双路能跑起来的隐藏功臣。如果你不想引RGA,至少也要记得把格式转换放线程池里做,别放在采集线程里。

4.2 帧提交、推理、后处理的协作流程

双路启动后,主循环里有两个采集线程分别读两路摄像头,各自往自己的线程池队列提交FrameData。每个队列容量固定为2,满了丢旧入新,保证延迟上限。

推理线程从队列取出帧后做预处理,然后持锁调rknn_run。这里我建议在数据里加一个std::atomic<bool>标记推理是否完成,后处理线程轮询或者用条件变量等这个标记,避免用两个队列来回传递指针时引入额外的锁开销。后处理拿到原始输出后做yolov5解码:三个尺度分别算box坐标、置信度、类别概率,过滤低置信度框,再做NMS。解码时记得坐标要还原到原图尺寸,因为预处理做了letterbox,左边和上边有灰边padding,算坐标时要把padding扣除,不然框会整体偏移。

后处理做完后,把画好框的帧推到一个显示队列。显示线程从显示队列取帧,在屏幕上刷新。显示这一环别看简单,最容易形成新的瓶颈,下一小节细说。

4.3 显示、叠加与VPU编码扩展

很多人在RK3588上用OpenCV的imshow直接弹窗显示,跑单路还凑合,双路就露馅了。imshow在高分辨率下CPU开销很大,而且依赖X11/Wayland,在无桌面的嵌入式环境里很难用。两种替代方案你按场景选。

如果你有HDMI或者MIPI屏幕,直接用DRM/KMS接口写一个简单的显示程序,申请一个显示帧缓冲,把后处理结果通过RGA缩放后再drmModePageFlip翻转上去,实测双路同时显示1080p50毫无压力。如果你不需要本地显示,只把双路结果用于保存或推流,那就走VPU编码。RK3588内置VPU支持H.264/H.265硬编码,多路1080p编码是基本功。我做过一个双路画面加框后VPU编码存本地录像的方案,编码几乎不占CPU,CPU占用率可以控制在30%以下,数据来自VPU模块而不是软编码器,这是双路方案里为数不多能"白捡"性能的地方。

显示和编码的共性是:它们的输入恰好是线程池后处理完的画框帧。我建议把画好框的帧单独存一份RGB或者NV12格式,再交给显示/编码模块,不要让显示线程再去读推理线程正在写的缓冲区,否则违背了双路各一个线程池的隔离初衷,又会出现资源竞争。

5. 实测数据与性能调优

5.1 双路yolov5s在RK3588上的帧率表现

直接说数据。香橙派5Pro(RK3588S),8GB内存,双路IMX219 MIPI摄像头,yolov5s模型INT8量化,输入640x640,每路线程池2个worker,两路各一个池子。实测结果如下:

场景单路帧率双路每路帧率系统CPU占用
640x640 + 全软预处理约22fps约15fps85%
640x640 + RGA预处理约26fps约19fps60%
416x416 + RGA预处理约33fps约24fps50%

可以看到,双路相比单路并不是简单地每路掉一半帧率,而是会再损失一点,原因主要是内存带宽争抢和锁等待。如果你对帧率要求不高,640x640双路每路15到19fps足够覆盖大多数安防巡检场景。如果你需要更高帧率,降到416x416输入,每路能到24fps左右,但小目标检测效果会下降,要自己权衡。

线程池线程数我对比过2和3的差距:2线程时每路19fps,3线程时反而降到17fps左右,多出来的那个线程在抢锁和CPU时间片,纯负数。所以参数上别贪多。

5.2 调优三板斧:CPU绑核、零拷贝、防抖输出

第一板斧是绑核。RK3588四个A76大核和四个A55小核性能差异明显。我建议把两路的推理线程、后处理线程绑到A76大核上,每路各占两个大核;两个采集线程绑到A55小核上,负责搬运数据。Linux下用pthread_setaffinity_np设置CPU亲和性,把这套绑核逻辑写进代码。实测绑核后双路帧率提升大约3到4fps,效果很直接。

第二板斧是零拷贝。采集的帧从内核mmap映射到用户空间是零拷贝的,但如果每个处理环节都做一次memcpy,双路高分辨率下带宽一下就吃满了。我的习惯是FrameData里只存指针和尺寸标记,预处理时让RGA直接读取采集缓冲区的内存地址,输出写回到另一个预先分配好的缓冲区,全程不显式拷贝。唯一需要拷贝的地方是NPU推理输入,因为rknn_inputs_set要求数据连续,这一步无法避免。

第三板斧是显示防抖。显示队列满的时候不要阻塞等待,直接丢最旧帧,保证显示永远是最新画面。输出端一卡就丢,绝不倒灌,这是我调试时总结的最有效原则。因为视觉系统里任何一环的"背压"都会传导到采集端,最终丢掉的是新帧而不是旧帧,那体验就毁掉了。

6. 高频问题排查与避坑实录

6.1 一踏入双路就遇到的典型问题速查

我把做这个项目过程中遇到的、以及身边人问得最多的几个问题整理成表,都是可以直接对照排查的:

现象可能原因解决方案
双路跑几分钟后越来越卡任务队列无界积压或显示队列阻塞改成有界队列+丢旧入新策略,检查显示消费速度
两路画面偶尔串在一起缓冲队列拷贝冲突或指针重复释放每路FrameData独立分配,禁止跨路共享缓冲区
NPU调用报ERR_MMAP或地址错误输入数据内存连续性和对齐不符合rknn要求用RGA或DMA分配内存,保证64字节对齐
模型转换后检测全乱校准集和实际场景偏差太大换真实场景图重新量化,必要时混合精度
一路卡顿另一路也跟着卡用了全局线程池或全局锁范围过大改为每路独立线程池,锁只锁rknn_run调用段
帧率低但CPU占用不高线程在锁等待上睡着检查rknn_run持有锁时长,把预处理挪到锁外
画面延迟越来越大输出端消费跟不上显示/编码线程独立,队列满丢旧帧

这张表里的问题,每一个我都实际碰到过,最典型的还是第一行:队列堆积。一旦双路跑起来后内存占用曲线不断上升,不用查别的,先看线程池队列里积了多少没处理的帧,十有八九是无界队列惹的祸。

6.2 我踩了两次才绕开的坑

说一个印象最深的教训。一开始我用Python的ThreadPoolExecutor搭双路线程池,代码写得很快,但跑起来后怎么调帧率都不行。后来才发现ThreadPoolExecutor默认的无界任务队列根本不给你控制容量和丢弃策略的机会,帧率波动时任务在队列里越积越多,延迟越来越高。最后我用C++重新实现有界队列并加入"丢旧入新"逻辑,同样输入条件下帧率直接翻了快一倍。由此我得到一个结论:在实时视觉这种对延迟敏感的领域,线程池的排队策略比线程数量重要得多,而标准库里的线程池实现往往不满足视觉场景的丢弃需求,宁可自己写三五十行代码,也别图省事。

另一个坑是RKNN版本的匹配问题。我一开始在开发板上下载的librknnmrt和PC端rknn-toolkit2版本不一致,模型转出来后板端加载一直报版本不兼容。这个问题在瑞芯微各版本迭代中特别频繁,一定要让PC端的RKNN-Toolkit2版本和板端运行时库版本严格对应。下载固件时最好直接选同一发布包里的配套版本,不要自己混搭。我后来习惯把板端/usr/lib/librknnmrt.so的版本信息打印出来,和转换脚本的版本做比对,几秒钟就能确认。

最后说一个我自己的调试习惯:双路联调时,永远先固定一路跑10分钟稳定了,再开第二路。不然CPU、内存带宽、NPU三个变量一起动,出了问题根本说不清是谁引起的。先单路压测找出上界,再双路对比观察损耗,你心里就有了一杆秤:什么性能是合理的,什么是异常的。这样排查问题的效率会高很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询