1. 双路视觉方案的核心设计思路
1.1 为什么要在RK3588上做双路视觉
香橙派5搭载的RK3588芯片,CPU部分为4核Cortex-A76加4核Cortex-A55的big.LITTLE架构,GPU为Mali-G610 MP4,NPU算力达到6 TOPS。这个配置放在边缘计算场景里,单路yolov5s推理跑满30帧基本没什么压力,但一旦上双路,问题就来了——两路摄像头同时采集、两路推理任务同时调度、两路结果同时输出,资源争抢会变得非常明显。
我最初做双路方案的时候,想当然地觉得“6 TOPS算力,两路yolov5s各占一半不就完了”。实测下来完全不是这么回事。NPU的调度机制、CPU的线程分配、内存带宽的占用,任何一个环节出现瓶颈,都会导致其中一路出现明显的帧率抖动甚至丢帧。更麻烦的是,这种抖动不是均匀分布的,而是随机出现——可能前10秒两路都很稳,第11秒突然有一路卡了200毫秒,然后恢复。这种问题在调试阶段极其折磨人。
所以双路视觉方案的核心矛盾不是“能不能跑”,而是“怎么让两路都稳定地跑”。这就引出了QoS严格优先的概念。
1.2 QoS严格优先到底在解决什么问题
QoS(Quality of Service)在嵌入式视觉场景里,说白了就是“当资源不够分的时候,谁先拿、谁后拿、谁拿多少”。严格优先(Strict Priority)是最简单粗暴的一种策略:高优先级的任务永远先拿到资源,低优先级的任务必须等高优先级任务满足之后才能拿剩下的。
这个策略听起来很霸道,但在双路视觉场景里恰恰是最合理的选择。原因在于,视觉任务对延迟的敏感度是不一样的。假设你一路做的是目标检测(比如检测画面里有没有人),另一路做的是目标跟踪(比如跟踪某个特定目标)。检测任务偶尔丢一帧问题不大,下一帧补上就行;但跟踪任务如果丢帧,目标ID可能就断了,重新关联的代价很高。这时候你就需要让跟踪任务享有严格优先权。
再比如,一路是主摄像头,一路是辅助摄像头。主摄像头的画面要实时显示给用户看,辅助摄像头的画面只用来做后台分析。那主摄像头的采集和推理就必须严格优先,辅助摄像头可以在主摄像头空闲的间隙里跑。
注意:严格优先不是万能的。如果高优先级任务一直占着资源不放,低优先级任务可能会饿死。所以在实际部署中,通常需要配合超时机制或者资源配额来使用。
1.3 线程池在其中的角色
线程池是QoS策略落地的载体。你不能每次来任务就创建一个线程,那样开销太大,而且线程数量不可控。线程池的核心思想是预先创建一批线程,任务来了就丢进队列,线程从队列里取任务执行。
但线程池的默认行为是FIFO(先进先出),这跟QoS严格优先是冲突的。所以你需要定制线程池的队列行为——让高优先级的任务能够插队,或者至少让高优先级任务有自己的专属队列和专属线程。
我在RK3588上试过三种方案:
| 方案 | 实现方式 | 优点 | 缺点 |
|---|---|---|---|
| 单池单队列 | 所有任务进一个队列,FIFO | 实现简单 | 无法区分优先级 |
| 单池双队列 | 两个队列,高优先级队列先消费 | 实现较简单,优先级生效 | 低优先级可能饿死 |
| 双池双队列 | 两个独立线程池,各自独立队列 | 隔离性好,互不影响 | 资源利用率可能偏低 |
最终我选的是单池双队列方案,配合超时降级机制。具体实现后面会详细讲。
1.4 整体架构拆解
整个双路视觉方案的架构可以分成四层:
- 采集层:两路MIPI摄像头或者USB摄像头,通过V4L2接口采集原始帧数据
- 预处理层:对原始帧做缩放、格式转换(比如从YUYV转RGB)、归一化
- 推理层:调用RKNN API在NPU上跑yolov5s模型
- 后处理与输出层:解析推理结果,做NMS,然后输出到屏幕或者网络
QoS严格优先主要作用在预处理层和推理层的任务调度上。采集层是硬件驱动的,你很难干预;后处理层通常很快,不太会成为瓶颈。真正需要精细调度的是预处理和推理这两个环节。
2. 核心细节解析与实操要点
2.1 RK3588的NPU调度机制
RK3588的NPU通过RKNN Runtime来调用。RKNN Runtime内部有自己的任务队列和调度器,但它对上层应用暴露的接口是相对简单的——你调用rknn_run,它就执行一次推理。多个线程同时调用rknn_run时,Runtime内部会做串行化处理,也就是说同一时间只有一个推理任务在NPU上执行。
这意味着什么呢?意味着如果你开两个线程同时跑推理,它们实际上是在抢NPU的使用权。谁先抢到,谁就先执行。这个抢占顺序是不确定的,取决于操作系统的线程调度。
所以,如果你想要QoS严格优先,就不能依赖RKNN Runtime的内部调度,而要在应用层就把优先级排好。具体做法是:只用一个线程去调用rknn_run,这个线程从一个优先级队列里取任务。高优先级的任务先取,低优先级的后取。这样就实现了应用层的严格优先。
2.2 线程池的阻塞队列选择
线程池的阻塞队列选择直接决定了QoS策略能不能落地。Java的ThreadPoolExecutor提供了几种队列:ArrayBlockingQueue、LinkedBlockingQueue、PriorityBlockingQueue、SynchronousQueue。C++的线程池库通常也提供类似的选项。
在RK3588的C++环境下,我推荐用std::priority_queue配合自定义比较器来实现优先级队列。但std::priority_queue不是线程安全的,所以你需要自己加锁。具体做法是:
#include <queue> #include <mutex> #include <condition_variable> #include <vector> #include <functional> struct Task { int priority; // 数字越小优先级越高 std::function<void()> func; bool operator<(const Task& other) const { return priority > other.priority; // 注意:priority_queue默认是大顶堆 } }; class PriorityTaskQueue { public: void push(Task task) { std::unique_lock<std::mutex> lock(mutex_); queue_.push(std::move(task)); cond_.notify_one(); } Task pop() { std::unique_lock<std::mutex> lock(mutex_); cond_.wait(lock, [this] { return !queue_.empty(); }); Task task = queue_.top(); queue_.pop(); return task; } bool try_pop(Task& task, int timeout_ms) { std::unique_lock<std::mutex> lock(mutex_); if (!cond_.wait_for(lock, std::chrono::milliseconds(timeout_ms), [this] { return !queue_.empty(); })) { return false; } task = queue_.top(); queue_.pop(); return true; } private: std::priority_queue<Task> queue_; std::mutex mutex_; std::condition_variable cond_; };这个队列的关键点在于operator<的定义。std::priority_queue默认是大顶堆,也就是说operator<返回true的元素会被排在后面。所以如果你想让优先级数字小的排在前面,就要在operator<里返回priority > other.priority。这个逻辑我第一次写的时候搞反了,结果高优先级的任务反而排在后面,调试了半天才发现。
提示:如果你用的是
LinkedBlockingQueue这类无界队列,要注意内存增长问题。双路视觉场景下,如果推理速度跟不上采集速度,队列会越积越长,最终OOM。所以建议用有界队列,配合拒绝策略(比如丢弃最旧的任务)。
2.3 双路任务的优先级划分
在实际的双路视觉方案里,我通常会把任务分成三个优先级:
- P0(最高优先级):主摄像头的采集和推理任务。这是必须保证的,不能丢帧。
- P1(中等优先级):辅助摄像头的推理任务。可以偶尔丢帧,但不能完全饿死。
- P2(最低优先级):日志记录、状态上报、界面刷新等辅助任务。这些任务晚一点执行没关系。
线程池的配置是这样的:总共开4个线程。其中2个线程专门处理P0任务,1个线程处理P1任务,1个线程处理P2任务。P0线程从高优先级队列取任务,P1线程从中优先级队列取任务,P2线程从低优先级队列取任务。
但这里有个问题:如果P0任务很少,P0线程就会空闲,而P1和P2任务却堆积着。这时候就需要动态调整——让P0线程在空闲时去帮忙处理P1任务。这就是所谓的“工作窃取”(work stealing)机制。
我实现了一个简化版的工作窃取:P0线程在尝试从高优先级队列取任务时,如果超时(比如10毫秒),就去尝试从P1队列取任务。这样既保证了P0任务的严格优先,又避免了资源浪费。
2.4 内存带宽的争抢问题
RK3588的内存带宽是有限的。双路1080p的RGB帧,每帧大约6MB,30帧就是180MB/s。两路加起来就是360MB/s。再加上NPU推理时的权重读取、中间结果写入,内存带宽很容易成为瓶颈。
我实测过,在双路1080p场景下,如果不对内存访问做优化,NPU的利用率只能到60%左右,剩下的时间都在等内存。优化方法有几个:
- 使用DMA缓冲区:V4L2采集出来的帧直接放到DMA缓冲区里,避免CPU拷贝。
- 零拷贝推理:RKNN支持从DMA缓冲区直接读取数据,省去一次内存拷贝。
- 降低分辨率:如果业务允许,把辅助摄像头降到720p,能省不少带宽。
这些优化手段跟QoS严格优先是互补的。QoS解决的是“谁先拿”的问题,内存优化解决的是“总共能拿多少”的问题。两者结合,才能让双路方案真正稳定。
3. 实操过程与核心环节实现
3.1 环境准备与依赖安装
香橙派5的官方系统是Ubuntu 20.04或者22.04。我建议用22.04,因为内核版本更新,对RK3588的支持更好。系统烧录好之后,第一件事是更新源和安装基础依赖:
sudo apt update sudo apt upgrade -y sudo apt install -y cmake g++ git libopencv-dev python3-opencv然后安装RKNN Toolkit和RKNN Runtime。这两个东西的版本必须匹配,否则会出各种奇怪的错误。我用的版本是RKNN Toolkit 1.5.2和RKNN Runtime 1.5.2。安装步骤参考官方文档就行,这里不赘述。
注意:RKNN Runtime的库文件要放到
/usr/lib下面,并且要执行sudo ldconfig刷新动态链接库缓存。否则程序运行时会报“找不到librknnrt.so”。
3.2 摄像头采集与预处理
双路摄像头采集我用的是V4L2接口。香橙派5有两个MIPI CSI接口,可以接两个MIPI摄像头。如果MIPI摄像头不够,也可以用USB摄像头,但USB摄像头的延迟通常比MIPI大,而且带宽共享,双路同时跑容易出问题。
采集线程的核心逻辑是这样的:
void capture_thread(int camera_id, PriorityTaskQueue& queue) { int fd = open_camera(camera_id); struct v4l2_buffer buf; while (running) { // 从V4L2取一帧 if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) { continue; } // 构造预处理任务 Task task; task.priority = (camera_id == 0) ? 0 : 1; // 主摄像头P0,辅助P1 task.func = [&buf, camera_id]() { preprocess_and_infer(buf, camera_id); }; queue.push(std::move(task)); // 把缓冲区还给V4L2 ioctl(fd, VIDIOC_QBUF, &buf); } }预处理包括缩放和格式转换。yolov5s的输入是640x640,所以要把1080p的帧缩放到640x640。缩放用OpenCV的cv::resize就行,但要注意OpenCV的resize默认是双线性插值,速度还可以。如果追求极致速度,可以用RGA(Rockchip Graphics Accelerator)硬件加速,但RGA的API比较复杂,我一开始没敢用,后来发现OpenCV的resize在RK3588上跑640x640大概需要3-5毫秒,可以接受。
3.3 推理任务的优先级调度
推理任务的核心是调用RKNN的rknn_run。但前面说了,多个线程同时调用rknn_run会互相抢,所以我的做法是只用一个推理线程,这个线程从优先级队列里取任务。
void inference_thread(PriorityTaskQueue& queue) { while (running) { Task task; if (queue.try_pop(task, 10)) { task.func(); // 执行推理 } } }但这样有个问题:如果P0任务和P1任务都在队列里,P0任务会先被取出来执行。这符合严格优先的要求。但如果P0任务一直来,P1任务就会一直排在后面,可能饿死。
为了解决这个问题,我加了一个“老化”机制:每个任务在队列里等待超过一定时间(比如500毫秒),优先级就提升一级。这样即使P1任务一开始优先级低,等久了也会变成P0,从而有机会被执行。
struct Task { int priority; std::chrono::steady_clock::time_point enqueue_time; std::function<void()> func; int effective_priority() const { auto wait_ms = std::chrono::duration_cast<std::chrono::milliseconds>( std::chrono::steady_clock::now() - enqueue_time).count(); // 每等待200毫秒,优先级提升1 return priority - static_cast<int>(wait_ms / 200); } bool operator<(const Task& other) const { return effective_priority() > other.effective_priority(); } };这个老化机制在实际运行中效果很好。P1任务最多等500毫秒就能被执行,不会饿死。P0任务因为优先级本来就高,基本不需要老化。
3.4 后处理与结果输出
推理完成后,输出的是yolov5s的原始输出(三个尺度的特征图)。需要做解码和NMS才能得到最终的检测框。这部分计算量不大,但也不能忽视。我实测过,在RK3588上做一次完整的后处理(包括解码和NMS)大概需要2-3毫秒。
后处理我放在推理线程里直接做了,没有再开单独的线程。因为后处理的结果需要跟原始帧对应,如果放到另一个线程里,还要做帧的同步,反而更复杂。
结果输出有两个方向:一个是本地显示,一个是网络传输。本地显示用OpenCV的imshow就行,但要注意imshow必须在主线程里调用,否则会出问题。网络传输我用的是RTSP推流,用FFmpeg的libavformat库实现。
3.5 完整代码框架
把上面的东西串起来,完整的代码框架大概是这样的:
#include <opencv2/opencv.hpp> #include <rknn_api.h> #include <thread> #include <vector> PriorityTaskQueue task_queue; std::atomic<bool> running{true}; int main() { // 初始化RKNN rknn_context ctx; init_rknn(&ctx, "yolov5s.rknn"); // 启动采集线程 std::thread cap0(capture_thread, 0, std::ref(task_queue)); std::thread cap1(capture_thread, 1, std::ref(task_queue)); // 启动推理线程 std::vector<std::thread> infer_threads; for (int i = 0; i < 2; i++) { infer_threads.emplace_back(inference_thread, std::ref(task_queue)); } // 主线程做显示 while (running) { // 从结果队列取结果并显示 display_results(); std::this_thread::sleep_for(std::chrono::milliseconds(30)); } // 清理 cap0.join(); cap1.join(); for (auto& t : infer_threads) t.join(); rknn_destroy(ctx); return 0; }这个框架跑起来之后,双路1080p的yolov5s推理,主路能稳定在25-30帧,辅路在15-20帧。如果辅路降到720p,可以稳定在25帧左右。
4. 常见问题与排查技巧实录
4.1 推理结果错乱或者花屏
这个问题我遇到过好几次,表现是检测框的位置完全不对,或者画面出现花屏。排查下来通常是两个原因:
一是输入数据的格式不对。yolov5s的RKNN模型要求输入是NHWC格式的RGB数据,而且要做归一化(除以255)。如果你传进去的是BGR或者没有归一化,结果就会错乱。
二是DMA缓冲区的同步问题。如果你用了零拷贝,V4L2的缓冲区在推理完成之前就被重新入队了,NPU读到的可能是已经被覆盖的数据。解决办法是等推理完成后再把缓冲区还给V4L2。
提示:RKNN Toolkit提供了一个
rknn_inputs_set接口,可以指定输入数据的格式和归一化参数。建议在模型转换的时候就设置好,这样运行时就不用再处理了。
4.2 帧率不稳定,忽高忽低
帧率不稳定的原因很多,我按排查顺序列一下:
| 排查项 | 检查方法 | 解决方法 |
|---|---|---|
| CPU频率 | cat /sys/devices/system/cpu/cpu*/cpufreq/scaling_cur_freq | 设置performance模式 |
| NPU频率 | cat /sys/class/devfreq/fdab0000.npu/cur_freq | 设置performance模式 |
| 内存带宽 | cat /proc/meminfo看可用内存 | 降低分辨率或优化内存访问 |
| 线程争抢 | top -H看各线程CPU占用 | 调整线程池配置 |
| 温度降频 | cat /sys/class/thermal/thermal_zone*/temp | 加散热片或风扇 |
我遇到最多的是温度降频。RK3588跑双路推理的时候发热量不小,如果散热不好,跑几分钟之后CPU和NPU都会降频,帧率就掉下来了。加一个散热风扇之后问题就解决了。
4.3 低优先级任务被饿死
前面提到的老化机制就是解决这个问题的。但老化机制也有副作用:如果P0任务特别多,P1任务老化之后变成P0,就会跟真正的P0任务抢资源,导致P0任务的延迟增加。
我的经验是,老化的速度要控制好。每等待200毫秒提升一级,最多提升2级。这样P1任务最多变成P0-2,不会跟真正的P0任务平起平坐。实测下来,这个策略能在保证P0任务延迟的同时,让P1任务也有机会执行。
4.4 RKNN初始化失败
RKNN初始化失败通常是因为库版本不匹配。检查方法:
# 查看RKNN Runtime版本 strings /usr/lib/librknnrt.so | grep -i version # 查看模型文件的版本 python3 -c "from rknn.api import RKNN; r = RKNN(); print(r.version)"如果两个版本不一致,就要重新转换模型或者升级Runtime。我建议在模型转换的时候就记录下RKNN Toolkit的版本,部署的时候用相同版本的Runtime。
4.5 双路摄像头同时打开失败
香橙派5的MIPI CSI接口有两个,但并不是随便接两个摄像头就能同时工作。有些摄像头模组的I2C地址是固定的,两个模组接上去会冲突。解决办法是买支持地址配置的模组,或者一个用MIPI一个用USB。
USB摄像头的话,要注意带宽。两个USB摄像头同时跑1080p,如果都接在同一个USB控制器上,带宽可能不够。香橙派5有多个USB接口,建议把两个摄像头接在不同的控制器上。
4.6 常见问题速查表
| 现象 | 可能原因 | 快速验证 | 解决 |
|---|---|---|---|
| 推理结果错乱 | 输入格式不对 | 检查输入是否RGB、是否归一化 | 调整预处理 |
| 帧率抖动 | 温度降频 | 查看温度传感器 | 加散热 |
| 低优先级饿死 | 无老化机制 | 观察P1任务执行频率 | 加老化 |
| RKNN初始化失败 | 版本不匹配 | 对比Runtime和Toolkit版本 | 统一版本 |
| 双路打开失败 | I2C地址冲突 | 单独测试每个摄像头 | 换模组或换接口 |
| 内存增长 | 队列无界 | 监控内存使用 | 改用有界队列 |
| 画面花屏 | DMA同步问题 | 检查缓冲区入队时机 | 推理完成后再入队 |
5. 性能优化与扩展思路
5.1 模型轻量化对QoS的影响
yolov5s本身已经是一个比较轻量的模型了,但在RK3588上跑双路,还是有点吃力。如果业务允许,可以考虑进一步轻量化:
- 剪枝:去掉一些不重要的通道,模型大小能减少30%左右,推理速度提升20%。
- 量化:RKNN支持INT8量化,量化之后推理速度能提升一倍,但精度会掉1-2个百分点。
- 换更小的模型:比如yolov5n,参数量只有yolov5s的三分之一,速度更快,但精度也低一些。
模型轻量化之后,QoS的压力会小很多。因为推理速度快了,任务在队列里等待的时间就短了,优先级调度的重要性相对降低。但QoS严格优先仍然是有意义的,因为它能保证在突发流量下,高优先级任务不会被低优先级任务拖累。
5.2 多核CPU的任务分配
RK3588有8个核心,4个A76大核和4个A55小核。默认情况下,Linux的调度器会把任务随机分配到任意核心上。但你可以通过taskset或者sched_setaffinity来绑定核心。
我的做法是把推理线程绑定到A76大核上,把采集和预处理线程绑定到A55小核上。这样推理任务能享受到大核的高性能,采集任务在小核上跑也不会太慢。
# 把推理线程绑定到CPU 4-7(A76大核) taskset -cp 4-7 <inference_thread_pid> # 把采集线程绑定到CPU 0-3(A55小核) taskset -cp 0-3 <capture_thread_pid>这个优化能带来大概10-15%的性能提升,而且帧率稳定性更好。
5.3 后续可以扩展的方向
双路视觉方案跑通之后,可以往几个方向扩展:
一是增加路数。RK3588的NPU算力有限,三路1080p基本就到顶了。但如果降到720p,四路也能跑。这时候QoS策略就更重要了,因为资源更紧张。
二是接入更复杂的模型。比如yolov8或者yolo26n,这些模型精度更高,但计算量也更大。这时候就需要更精细的QoS策略,比如按帧的重要性来分配优先级,而不是简单地按摄像头分配。
三是跟其他传感器融合。比如接入激光雷达或者毫米波雷达,做多传感器融合。这时候QoS就不只是视觉任务之间的调度了,还要考虑不同传感器数据的时间同步问题。
我在实际项目里遇到过一个需求:主摄像头做目标检测,辅助摄像头做车牌识别。车牌识别的优先级比目标检测高,因为车牌识别需要更高的分辨率,而且对帧的完整性要求更高。这时候QoS策略就要调整成:车牌识别任务P0,目标检测任务P1。实测下来,这种配置能在保证车牌识别率的同时,让目标检测也不至于太差。
最后分享一个小技巧:如果你不确定优先级怎么划分,可以先跑一遍压力测试,记录每个任务的平均执行时间和最大延迟。然后根据业务对延迟的容忍度来划分优先级。容忍度低的划P0,容忍度高的划P1。这样划分出来的优先级策略,通常比拍脑袋想出来的更合理。