1. 双路视觉方案的整体设计思路
1.1 为什么要在RK3588上做双路视觉
香橙派5搭载的RK3588芯片,最被低估的能力之一就是它的多路视频输入与NPU并行推理能力。很多人拿到板子之后,跑通一路YOLOv5s就收工了,觉得6TOPS算力不过如此。但实际做项目的时候你会发现,单路视觉能做的事情非常有限——一个摄像头只能看一个方向,一个工位,一条产线。真正有工程价值的场景,比如双目测距、多工位质检、全景监控、无人机前下视避障,都需要两路甚至更多路摄像头同时工作。
我一开始也是单路跑通就满足了,后来接了一个小项目,需要在同一块板子上同时处理两路MIPI摄像头的画面,每路都要做YOLOv5s目标检测。第一版代码写出来,两路各开各的线程,各加载各的模型,结果跑起来NPU利用率忽高忽低,帧率从单路的25fps直接掉到两路各8fps,CPU占用率飙到300%以上。问题出在哪里?就是资源没有共享,两路推理各自为政,NPU上下文频繁切换,线程调度开销巨大。
这个阶段一的核心目标,就是把两路视觉的推理任务统一到一个共享线程池里,让NPU的推理请求排队执行,而不是互相抢占。听起来简单,但里面涉及到线程池的设计、任务队列的选择、RKNN上下文的复用、以及两路摄像头数据流的同步问题。我踩过的坑包括:线程池任务队列用无界队列导致内存暴涨、RKNN context多线程访问崩溃、两路摄像头时间戳不同步导致结果错位。这些在后面都会详细展开。
1.2 共享线程池方案的核心优势
先说说为什么选线程池,而不是简单的“一路一个线程”。假设你有两路摄像头,每路30fps,每帧都要做一次YOLOv5s推理。如果每路各自创建一个推理线程,那么同一时刻可能有两个推理请求同时到达NPU。RK3588的NPU虽然有三个核心,但RKNN的运行时在默认配置下,一个context同时只能处理一个推理任务。两个线程同时调用rknn_run,要么排队等待,要么直接报错。
线程池的好处在于:它把推理任务抽象成一个个独立的“任务单元”,由固定数量的工作线程从队列中取出执行。你可以控制工作线程的数量,比如设置为2,那么最多同时有两个推理在跑。但更重要的是,线程池让你可以统一管理任务的生命周期、统一处理异常、统一做性能统计。而且当摄像头路数增加到4路、8路的时候,线程池的扩展成本几乎为零,只需要往队列里多扔任务就行了。
另一个关键优势是资源复用。RKNN的模型加载和context初始化是非常耗时的操作,单次加载YOLOv5s的RKNN模型大概需要1.5到2秒。如果每路摄像头都独立加载一次模型,两路就是3到4秒的启动时间,而且内存里会有两份完全相同的模型权重。共享线程池的方案下,模型只加载一次,context只初始化一次,所有推理任务共用这个context。内存节省了,启动时间也缩短了。
1.3 阶段一的目标与边界
这个阶段一,我们只做最核心的事情:把双路MIPI摄像头的采集、推理、结果输出跑通,并且用共享线程池来管理推理任务。暂时不做复杂的后处理优化,不做多模型切换,不做动态分辨率调整。先把骨架搭起来,确保两路视频流能稳定地进入线程池,推理结果能正确地对应到各自的摄像头。
具体来说,阶段一的目标包括:两路MIPI摄像头同时采集,分辨率设置为1920x1080,帧率30fps;YOLOv5s模型通过RKNN加载一次,支持多线程并发调用;线程池的工作线程数量可配置,任务队列使用有界阻塞队列;每路摄像头的推理结果打上时间戳和摄像头ID,输出到各自的缓冲区。性能目标是在两路同时推理的情况下,每路至少达到15fps的有效检测帧率。
边界方面,阶段一不涉及模型量化调优、不涉及多线程下的NPU核心绑定、不涉及零拷贝的内存优化。这些留到阶段二和阶段三。先把功能跑通,再谈性能压榨。
2. RK3588 NPU与RKNN多线程推理的核心细节
2.1 RKNN context的多线程访问规则
这是整个方案里最容易翻车的地方。RKNN的API文档里有一句话写得很含蓄:“同一个context不建议在多线程中同时调用推理接口。”什么叫“不建议”?我实测下来的结论是:如果你真的在两个线程里同时调用同一个context的rknn_run,有概率直接段错误,有概率返回错误码,也有概率看起来正常但结果错乱。RK3588的NPU驱动在底层对context的访问是有状态机的,并发调用会破坏状态机的状态。
那怎么办?两个方案。方案一:每个线程创建独立的RKNN context,加载同一份模型文件。这样每个context独立管理自己的NPU内存和状态,互不干扰。缺点是内存占用翻倍,模型加载时间翻倍。方案二:共享一个context,但在调用rknn_run的时候加锁,保证同一时刻只有一个线程在跑推理。这样内存只有一份,但推理变成了串行。
我最终选的是方案二,原因很简单:YOLOv5s在RK3588上的单次推理时间大约是30到40毫秒,两路摄像头每路30fps,意味着每33毫秒就有一帧需要推理。如果串行执行,两路加起来每66毫秒才能完成两帧,平均每路只有15fps。这刚好卡在性能目标的边缘。但如果用方案一,两个context并行推理,理论上可以做到每路25fps以上,但内存占用会增加大约200MB,而且两个context同时访问NPU时,驱动层面的调度开销也不小。
实际测试下来,方案二在加了锁之后,两路各15fps是稳定的,CPU占用率在180%左右,NPU利用率大约60%。方案一虽然帧率更高,但偶尔会出现某个context初始化失败的情况,稳定性不如方案二。所以阶段一先用方案二把功能跑通,阶段二再考虑用RKNN的rknn_set_core_mask把不同context绑定到不同的NPU核心上,实现真正的并行。
注意:如果你用的是RKNN Toolkit2的1.5.0以上版本,
rknn_run的线程安全性有所改善,但官方仍然建议加锁。不要心存侥幸,我因为没加锁调试了整整一个下午,最后发现是并发调用导致的随机崩溃。
2.2 线程池的任务队列选择:有界 vs 无界
线程池的核心组件之一就是任务队列。Java的ThreadPoolExecutor里,队列的选择直接决定了线程池的行为。在C++里我们自己实现线程池,同样面临这个选择。无界队列(比如std::queue加锁)的好处是永远不会丢任务,生产者往里面扔就行了。坏处是如果生产速度大于消费速度,队列会无限增长,最后内存耗尽。
双路摄像头每路30fps,每帧产生一个推理任务,那么每秒产生60个任务。如果线程池只有2个工作线程,每个任务耗时35毫秒,那么每秒最多消费约57个任务。生产60,消费57,每秒积压3个任务。跑10分钟就是1800个任务积压,每个任务持有一帧1920x1080的RGB数据,大约6MB,1800个任务就是10.8GB内存。板子直接OOM。
所以必须用有界队列。队列容量设置为多少?我的经验值是工作线程数量的4到8倍。2个工作线程,队列容量设为8到16比较合适。当队列满的时候,生产者有两种策略:阻塞等待或者丢弃最旧的任务。对于视觉推理场景,我推荐丢弃最旧的任务,因为摄像头的实时性要求高,积压的旧帧没有处理价值。具体实现上,可以用一个环形缓冲区作为任务队列,当队列满时,覆盖最旧的任务。
// 简化版的有界阻塞队列 template<typename T> class BoundedBlockingQueue { public: explicit BoundedBlockingQueue(size_t capacity) : cap_(capacity) {} bool push(T item) { std::unique_lock<std::mutex> lock(mtx_); not_full_.wait(lock, [this] { return queue_.size() < cap_ || stopped_; }); if (stopped_) return false; if (queue_.size() >= cap_) { queue_.pop(); // 丢弃最旧的任务 } queue_.push(std::move(item)); not_empty_.notify_one(); return true; } bool pop(T& item) { std::unique_lock<std::mutex> lock(mtx_); not_empty_.wait(lock, [this] { return !queue_.empty() || stopped_; }); if (stopped_ && queue_.empty()) return false; item = std::move(queue_.front()); queue_.pop(); not_full_.notify_one(); return true; } private: std::queue<T> queue_; size_t cap_; std::mutex mtx_; std::condition_variable not_full_, not_empty_; bool stopped_ = false; };这段代码里有个细节:push的时候如果队列满了,先pop掉最旧的,再push新的。这样保证队列里永远是最新的任务。但要注意,被丢弃的任务如果持有动态分配的内存,需要正确释放,否则会内存泄漏。我建议任务对象用std::shared_ptr管理,这样丢弃时自动释放。
2.3 两路MIPI摄像头的采集与同步
香橙派5的MIPI接口有两个,可以同时接两个摄像头。我用的是两个OV5647,通过v4l2接口采集。这里有个坑:两个摄像头的/dev/video节点编号不是固定的,每次启动可能变。所以不能硬编码/dev/video0和/dev/video1,要通过v4l2-ctl --list-devices查询设备名称来动态绑定。
采集线程的设计上,每路摄像头一个独立的采集线程,负责从V4L2缓冲区取数据,转换成RGB格式,然后封装成推理任务扔进线程池。采集线程的优先级要设高一点,避免因为线程池繁忙导致采集丢帧。Linux下可以用pthread_setschedparam设置SCHED_FIFO优先级,但需要root权限。
两路摄像头的时间戳同步是个麻烦事。V4L2的buffer里带的时间戳是单调时钟,但两个摄像头的时钟源可能有微小偏差。阶段一我们不做硬同步,只在任务里记录采集时刻的std::chrono::steady_clock::now(),推理结果输出时带上这个时间戳。如果后续要做双目测距,再考虑用硬件触发信号做同步。
3. 共享线程池的完整实现与实操步骤
3.1 环境准备与依赖安装
先确认你的香橙派5系统版本。我用的官方Ubuntu 22.04镜像,内核版本5.10。RKNN的驱动和运行时需要单独安装。去官方渠道下载rknn_toolkit2的runtime库,版本选1.5.2或以上。安装步骤如下:
# 安装RKNN运行时库 sudo cp librknnrt.so /usr/lib/ sudo ldconfig # 安装V4L2工具 sudo apt install v4l-utils # 确认NPU设备节点 ls /dev/rknpu* # 应该看到 /dev/rknpu0 或 /dev/rknpu然后确认MIPI摄像头被正确识别:
v4l2-ctl --list-devices # 输出示例: # rkisp_mainpath (platform:rkisp0-vir0): # /dev/video0 # /dev/video1 # ...如果摄像头没有出现,检查排线是否插紧,MIPI接口的方向是否正确。香橙派5的MIPI接口比较脆弱,插拔的时候要小心。
3.2 YOLOv5s RKNN模型的加载与初始化
模型加载只做一次,放在主线程里。加载完成后,把rknn_context指针传给线程池的每个工作线程。注意,虽然context是共享的,但每个线程需要自己的输入输出内存缓冲区吗?不需要。因为我们在rknn_run外面加了锁,同一时刻只有一个线程在跑推理,所以输入输出缓冲区也可以共享。但为了减少锁的粒度,我建议每个工作线程有自己的输入缓冲区,输出缓冲区共享。这样数据拷贝可以在锁外面做。
// 模型加载 rknn_context ctx; int ret = rknn_init(&ctx, model_path, 0, 0, nullptr); if (ret < 0) { printf("rknn_init failed: %d\n", ret); return -1; } // 查询输入输出属性 rknn_input_output_num io_num; rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num)); printf("input num: %d, output num: %d\n", io_num.n_input, io_num.n_output); // 设置输入属性 rknn_tensor_attr input_attr; input_attr.index = 0; rknn_query(ctx, RKNN_QUERY_INPUT_ATTR, &input_attr, sizeof(input_attr)); printf("input dims: %d %d %d %d\n", input_attr.dims[0], input_attr.dims[1], input_attr.dims[2], input_attr.dims[3]);YOLOv5s的输入通常是1x3x640x640,数据类型是INT8或FP16。如果你用的是INT8量化模型,输入数据需要做归一化和量化。量化参数从input_attr里读scale和zp。
3.3 线程池的创建与任务提交
线程池的类设计如下:
class RknnPool { public: RknnPool(rknn_context ctx, int num_threads, size_t queue_cap) : ctx_(ctx), queue_(queue_cap), stop_(false) { for (int i = 0; i < num_threads; ++i) { workers_.emplace_back([this] { workerLoop(); }); } } ~RknnPool() { { std::unique_lock<std::mutex> lock(queue_.mtx_); stop_ = true; } queue_.not_empty_.notify_all(); for (auto& t : workers_) { if (t.joinable()) t.join(); } } bool submit(InferTask task) { return queue_.push(std::move(task)); } private: void workerLoop() { InferTask task; while (queue_.pop(task)) { // 执行推理 std::lock_guard<std::mutex> lock(infer_mtx_); rknn_inputs_set(ctx_, 1, &task.input); rknn_run(ctx_, nullptr); rknn_outputs_get(ctx_, task.out_num, task.outputs, nullptr); // 后处理 task.callback(task.outputs); rknn_outputs_release(ctx_, task.out_num, task.outputs); } } rknn_context ctx_; BoundedBlockingQueue<InferTask> queue_; std::vector<std::thread> workers_; std::mutex infer_mtx_; bool stop_; };关键点:infer_mtx_这个锁保证了同一时刻只有一个线程在调用RKNN的推理接口。queue_.pop是阻塞的,当队列为空时工作线程会休眠,不消耗CPU。
任务提交的代码在采集线程里:
void captureLoop(int cam_id, RknnPool& pool) { cv::VideoCapture cap(cam_id); cap.set(cv::CAP_PROP_FRAME_WIDTH, 1920); cap.set(cv::CAP_PROP_FRAME_HEIGHT, 1080); cap.set(cv::CAP_PROP_FPS, 30); while (running) { cv::Mat frame; cap >> frame; if (frame.empty()) continue; // 预处理:resize到640x640,归一化 cv::Mat blob; cv::dnn::blobFromImage(frame, blob, 1/255.0, cv::Size(640, 640), cv::Scalar(), true, false); InferTask task; task.cam_id = cam_id; task.timestamp = std::chrono::steady_clock::now(); task.input = blob; task.callback = [cam_id](rknn_output* outputs) { // 后处理并输出结果 }; pool.submit(std::move(task)); } }3.4 推理结果的后处理与输出
YOLOv5s的RKNN输出通常是三个张量,对应三个不同尺度的特征图。后处理包括:解码边界框、置信度过滤、NMS。这部分代码比较长,网上有很多参考实现。我重点说几个容易出错的地方。
第一,量化模型的输出是INT8,需要先反量化成浮点数。反量化公式是float_val = (int8_val - zp) * scale。scale和zp从输出张量的属性里读。
第二,NMS的阈值设置。YOLOv5s默认的置信度阈值是0.25,NMS的IoU阈值是0.45。但在RK3588上,由于量化误差,建议把置信度阈值稍微调低到0.2,避免漏检。
第三,两路结果要分开存储。每个任务里带了cam_id,后处理完成后把结果放到对应的缓冲区。缓冲区用std::vector加锁保护,或者用无锁队列。阶段一先用简单的互斥锁。
4. 常见问题与排查技巧实录
4.1 摄像头采集丢帧或花屏
这个问题我遇到的最多。现象是画面偶尔出现绿色条纹或者直接卡住。原因通常是MIPI带宽不足或者V4L2缓冲区数量不够。解决方法:把V4L2的缓冲区数量从默认的4增加到8,通过VIDIOC_REQBUFS设置。另外,两路摄像头同时采集时,MIPI的时钟频率要调高,在设备树里改clock-frequency参数。
还有一个坑是USB摄像头的带宽共享问题。如果你用的是USB摄像头而不是MIPI,两个USB摄像头同时工作会争抢USB总线的带宽。香橙派5的USB3.0和USB2.0是分开的,尽量把两个摄像头插在不同的控制器上。
4.2 RKNN推理结果全零或乱码
这个问题的排查思路:先确认输入数据是否正确。把输入到RKNN的blob数据保存成图片,看看是不是正常的画面。如果输入正常,再检查量化参数。INT8模型的输入需要量化,如果你直接传浮点数进去,结果肯定不对。量化公式是int8_val = round(float_val / scale) + zp。
另一个常见原因是模型的输入尺寸和实际传入的尺寸不一致。YOLOv5s的RKNN模型输入是640x640,如果你传了416x416的数据,推理结果会完全错乱。用rknn_query查一下input_attr.dims确认。
4.3 线程池任务积压导致延迟增大
现象是画面延迟越来越高,从最初的几十毫秒增加到几秒。原因就是生产速度大于消费速度。排查方法:在任务提交和完成时打时间戳,统计队列长度。如果队列长度持续等于容量上限,说明消费不过来。
解决方案有三个:一是减少工作线程的推理耗时,比如用更小的模型或者降低输入分辨率;二是增加工作线程数量,但受限于NPU的并行能力,增加到4个以上收益递减;三是主动丢帧,在采集线程里判断队列长度,如果超过阈值就跳过当前帧,不提交任务。
我实测下来,两路1080p、YOLOv5s、2个工作线程的情况下,队列容量设为8比较合适。超过8就丢帧,保证延迟在200毫秒以内。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 摄像头无画面 | MIPI排线松动 | v4l2-ctl --list-devices | 重新插拔排线 |
| 推理结果全零 | 输入未量化 | 保存输入blob检查 | 按scale和zp量化 |
| 程序随机崩溃 | RKNN并发调用 | 加日志确认调用栈 | 加锁保护rknn_run |
| 帧率逐渐下降 | 任务队列积压 | 打印队列长度 | 丢帧或增加线程 |
| 内存持续增长 | 任务对象未释放 | valgrind检查 | 用shared_ptr管理 |
| NPU利用率低 | 锁竞争严重 | perf分析 | 减小锁粒度 |
4.5 实操心得与避坑建议
第一个心得:不要一上来就追求高帧率。先把两路跑通,哪怕每路只有5fps,确认结果正确、内存稳定、连续跑1小时不崩溃,再逐步优化。我见过太多人一开始就调各种参数,最后连基本功能都没跑通。
第二个心得:日志要打够。每个任务的提交时间、开始推理时间、完成时间、队列长度,这些都要打出来。出问题的时候,日志是唯一的线索。但注意日志本身也有开销,用printf会拖慢速度,建议用内存缓冲区记录,定期刷盘。
第三个心得:RKNN的context在程序退出时要正确释放。rknn_destroy必须调用,否则下次启动可能初始化失败。我遇到过因为没调rknn_destroy导致NPU驱动状态异常,重启才能恢复的情况。
第四个心得:两路摄像头的曝光参数要分别设置。如果两个摄像头对着不同亮度的场景,自动曝光会导致画面亮度不一致,影响检测效果。用v4l2-ctl手动设置曝光值和增益。
5. 性能实测与阶段一的收尾
5.1 实测数据记录
我在香橙派5(8GB内存版本)上跑了完整的测试。两路OV5647,分辨率1920x1080,帧率30fps。YOLOv5s的RKNN模型,INT8量化,输入640x640。线程池2个工作线程,队列容量8。
测试结果:单路推理耗时平均35毫秒,两路同时推理时,每路的有效检测帧率稳定在14到16fps之间。CPU总占用率约180%到220%,NPU利用率约55%到65%,内存占用约1.2GB。连续运行2小时,没有崩溃,没有内存泄漏,帧率波动在正负2fps以内。
对比单路独立线程的方案,共享线程池的方案在帧率上略低(单路独立可以到18fps),但稳定性好很多,而且内存占用少了约200MB。对于阶段一的目标来说,这个结果是可以接受的。
5.2 阶段一到阶段二的过渡思路
阶段一我们把骨架搭好了,但还有明显的优化空间。阶段二我计划做三件事:第一,用rknn_set_core_mask把两个工作线程绑定到不同的NPU核心上,实现真正的并行推理,目标是把每路帧率提到20fps以上。第二,引入零拷贝机制,让摄像头采集的DMA缓冲区直接作为RKNN的输入,省掉一次内存拷贝。第三,把后处理也放到线程池里,用单独的后处理线程池,避免后处理阻塞推理线程。
这些优化都需要对RK3588的NPU驱动和V4L2的DMA机制有更深入的理解。阶段一先把功能跑稳,阶段二再压榨性能。如果你也在做类似的项目,建议先把阶段一的代码跑通,确认两路视频流能稳定工作,再往下走。跳过阶段一直接做优化,大概率会陷入“调不完的bug、测不准的性能”的泥潭。
我个人在实际操作中的体会是,嵌入式视觉项目最耗时的不是写代码,而是排查环境问题和理解硬件限制。RK3588的NPU很强,但它的强是建立在正确使用的前提下的。共享线程池这个方案,本质上是用软件架构来弥补硬件资源的有限性。把任务排队、把资源复用、把异常隔离,这三件事做好了,双路视觉的稳定性就有了基础。