首页 / 资讯中心 / 文章详情

香橙派RK3588双路视觉实战:线程池方案与YOLOv5s部署优化

香橙派RK3588双路视觉实战:线程池方案与YOLOv5s部署优化 ★ FEATURED ARTICLE
1. 双路视觉方案的整体设计思路1.1 为什么要在香橙派RK3588上做双路视觉单路摄像头跑yolov5s在RK3588上其实已经能跑得比较舒服了。RK3588内置的NPU算力有6TOPSyolov5s量化成RKNN之后单帧推理时间大概在20到40毫秒之间具体取决于输入分辨率和量化精度。但实际项目里单路往往不够用——比如做立体视觉、双目测距、多角度监控、或者前后双摄的避障方案都需要同时处理两路视频流。问题就出在这里两路摄像头同时采集如果串行处理帧率直接砍半如果简单开两个线程各跑各的又会遇到资源争抢、NPU排队、内存带宽打满的问题。我一开始也是直接开两个std::thread结果跑起来帧率忽高忽低有时候一路卡死另一路也跟着遭殃。后来才改成线程池的方案把采集、推理、后处理拆成独立任务用线程池统一调度稳定性才上来。这个方案适合谁如果你已经能在香橙派RK3588上跑通单路yolov5s现在想扩展到双路或者你正在做多摄像头的小型边缘计算设备那这篇内容就是给你准备的。不需要你精通C多线程但至少要能看懂基本的线程、锁、条件变量这些概念。1.2 双路方案的核心架构拆解整个双路视觉方案我把它拆成三层采集层、推理层、后处理层。每一层都是独立的任务单元通过线程池来调度执行。采集层负责从两个MIPI摄像头或者USB摄像头拿数据。RK3588的MIPI接口支持多路输入但实际用的时候要注意带宽分配。我用的方案是每路一个采集线程采集到的帧直接丢进各自的任务队列。推理层是核心两路图像都要过yolov5s模型。这里有个关键点RK3588的NPU是共享资源两路同时推理会排队。我的做法是给每一路分配一个独立的线程池每个线程池内部再控制并发数。这样两路之间互不阻塞但每路内部可以并行处理多帧。后处理层包括NMS、画框、结果输出。这部分计算量不大但也不能忽略。我把它和推理放在同一个线程池里推理完直接做后处理减少数据搬运。注意不要试图用一个全局线程池处理所有任务那样锁竞争会非常严重。两路各一个线程池是经过实测比较平衡的方案。1.3 线程池方案相比裸线程的优势裸线程的问题在于创建销毁开销大、数量不可控、异常处理麻烦。你开两个线程跑两路看起来简单但一旦某一路的摄像头掉线或者推理超时那个线程就卡住了另一路虽然还在跑但整个系统的状态已经不一致了。线程池的好处是任务和线程解耦。采集任务、推理任务、后处理任务都是独立的任务单元线程池负责调度。某个任务失败了不影响其他任务继续执行。而且线程数量可控不会因为任务堆积导致系统资源耗尽。我实测下来双路1080p输入、yolov5s推理每路一个线程池、每个线程池4个线程整体CPU占用在60%左右NPU占用大概70%帧率能稳定在每路15到20帧。这个数据后面还会详细说怎么调出来的。2. 线程池的核心细节与实操要点2.1 线程池的基本结构设计线程池说白了就是“一堆线程一个任务队列”。线程池启动的时候创建固定数量的线程这些线程不断从队列里取任务执行。任务队列空了线程就阻塞等待有新任务进来唤醒一个线程去处理。在C里实现线程池核心组件包括任务队列用std::queuestd::functionvoid()存任务配合std::mutex和std::condition_variable做同步。工作线程每个线程跑一个循环不断尝试从队列取任务。停止标志用来通知所有线程退出。提交接口外部通过这个接口把任务丢进队列。我用的线程池大概长这样class ThreadPool { public: ThreadPool(size_t threads) : stop(false) { for (size_t i 0; i threads; i) { workers.emplace_back([this] { while (true) { std::functionvoid() task; { std::unique_lockstd::mutex lock(this-queue_mutex); this-condition.wait(lock, [this] { return this-stop || !this-tasks.empty(); }); if (this-stop this-tasks.empty()) return; task std::move(this-tasks.front()); this-tasks.pop(); } task(); } }); } } templateclass F void enqueue(F f) { { std::unique_lockstd::mutex lock(queue_mutex); tasks.emplace(std::forwardF(f)); } condition.notify_one(); } ~ThreadPool() { { std::unique_lockstd::mutex lock(queue_mutex); stop true; } condition.notify_all(); for (std::thread worker : workers) worker.join(); } private: std::vectorstd::thread workers; std::queuestd::functionvoid() tasks; std::mutex queue_mutex; std::condition_variable condition; bool stop; };这个实现是最基础的版本够用但不够精细。后面我会讲怎么根据双路视觉的场景做优化。2.2 任务队列的阻塞策略选择任务队列满了怎么办这是线程池设计里必须回答的问题。常见策略有三种策略行为适用场景阻塞等待队列满时提交方阻塞直到有空位采集频率稳定不能丢帧丢弃最新队列满时直接丢掉新任务实时性要求高旧帧比新帧重要丢弃最旧队列满时丢掉队首任务放入新任务实时性要求高新帧比旧帧重要双路视觉场景下我选的是丢弃最旧。原因很简单摄像头采集是连续的如果推理跟不上队列里堆积的都是旧帧处理旧帧没有意义不如直接丢掉保证处理的是最新画面。这个策略在监控和避障场景里特别重要你总不想让机器人根据一秒前的画面做决策吧。实现上就是在enqueue的时候判断队列大小超过阈值就pop掉队首void enqueue(F f) { { std::unique_lockstd::mutex lock(queue_mutex); if (tasks.size() max_queue_size) { tasks.pop(); // 丢弃最旧的任务 } tasks.emplace(std::forwardF(f)); } condition.notify_one(); }max_queue_size设多少我试过5、10、20最后定在8。太小了容易丢帧太大了延迟高。8这个数字大概对应400毫秒的缓冲超过这个延迟画面就已经明显滞后了。2.3 双路线程池的隔离与通信两路各一个线程池意味着有两个独立的ThreadPool实例。每路有自己的任务队列、自己的工作线程。这样做的好处是隔离性好一路出问题不会直接影响另一路。但隔离也带来一个问题两路的结果怎么汇总比如做双目测距需要两路同一时刻的帧做匹配。我的做法是加一个同步队列两路推理完的结果都往这个队列里放后处理线程从队列里取配对的结果。同步队列的实现要点是每路结果带一个时间戳后处理线程根据时间戳做配对。时间戳差距超过阈值的直接丢弃避免用不同步的帧做计算。struct FrameResult { int camera_id; uint64_t timestamp; std::vectorDetection detections; cv::Mat frame; }; // 同步队列 std::queueFrameResult sync_queue; std::mutex sync_mutex; std::condition_variable sync_cv;后处理线程的逻辑是从同步队列取结果按时间戳排序找到两路时间戳最接近的一对做后续处理。这个逻辑不复杂但要注意队列不能无限增长同样需要设置最大长度。实操心得同步队列的长度建议设小一点比如4。因为配对需要等待队列太长会导致延迟累积。我一开始设了20结果后处理延迟越来越高最后画面比实际慢了快两秒。3. 双路视觉方案的完整实操流程3.1 环境准备与依赖安装香橙派RK3588上跑这套方案系统我用的Ubuntu 20.04官方镜像烧写完之后需要装一些基础依赖。sudo apt update sudo apt install -y build-essential cmake git libopencv-dev sudo apt install -y librga-dev librockchip-mpp-devOpenCV用系统自带的就行版本是4.5.4够用。RKNN的库需要从官方仓库拿编译安装之后yolov5s的RKNN模型也要提前转换好。转换的步骤这里不展开假设你已经有一个能跑的yolov5s.rknn文件。摄像头方面我用的是两路MIPI摄像头通过/dev/video0和/dev/video1访问。如果你用USB摄像头设备节点可能不一样用v4l2-ctl --list-devices查一下。检查NPU驱动是否正常cat /sys/kernel/debug/rknpu/version正常的话会输出NPU的版本号。如果这个文件不存在说明NPU驱动没加载需要检查内核配置。3.2 采集线程的实现与参数配置采集线程的任务很简单从摄像头读帧丢进线程池的任务队列。但有几个参数需要仔细调。分辨率双路1080p对RK3588来说压力不大但如果你用4K带宽和NPU都会吃紧。我建议双路都用1080p实测下来最平衡。帧率摄像头输出帧率设30fps但实际处理帧率可能只有15到20fps。采集线程不要等处理完再采下一帧那样会丢帧。正确做法是采集线程独立运行采到的帧直接入队队列满了就丢最旧的。像素格式MIPI摄像头一般输出NV12格式这种格式在RK3588上可以直接给NPU用不需要额外转换。如果你用USB摄像头可能是YUYV或MJPEG需要转成NV12或者RGB这会增加CPU开销。采集线程的代码框架void capture_thread(int camera_id, ThreadPool pool, cv::VideoCapture cap) { while (running) { cv::Mat frame; cap frame; if (frame.empty()) continue; uint64_t ts get_timestamp_ms(); pool.enqueue([frame, ts, camera_id]() { // 推理任务 infer_and_postprocess(frame, ts, camera_id); }); } }这里有个细节frame是浅拷贝多个任务共享同一块内存。如果采集线程继续读下一帧可能会覆盖正在处理的数据。所以要么深拷贝要么用cv::Mat的引用计数机制。我用的cv::Mat本身有引用计数cap frame每次会重新分配内存所以直接传值没问题。但如果你用cap.retrieve(frame)复用内存就必须深拷贝。3.3 推理任务的拆分与NPU调度推理任务是整个方案里最重的部分。yolov5s在RK3588 NPU上跑一次大概20到30毫秒两路同时跑就是40到60毫秒。如果串行帧率直接掉到15fps以下。我的做法是每路的线程池里推理任务可以并行提交但NPU内部会排队。RKNN的推理接口是线程安全的多个线程同时调用rknn_run驱动会自动串行化。所以实际上NPU还是串行执行但CPU端的预处理和后处理可以并行。为了减少NPU排队时间我做了两件事第一预处理提前做。图像的resize、归一化、格式转换这些在CPU上完成不要占用NPU时间。RKNN的输入要求是NHWC格式的uint8或者int8我直接在采集线程里把图像resize到640x640然后传给推理任务。第二批量推理。如果两路帧率要求不高可以把两路的帧拼成一个batch一次推理出两个结果。RKNN支持batch推理但需要模型转换时指定batch size。我试过batch2推理时间从两次20毫秒变成一次35毫秒省了5毫秒但灵活性差了最后没采用。推理任务的核心代码void infer_and_postprocess(cv::Mat frame, uint64_t ts, int camera_id) { // 预处理 cv::Mat resized; cv::resize(frame, resized, cv::Size(640, 640)); // RKNN推理 rknn_input inputs[1]; inputs[0].index 0; inputs[0].buf resized.data; inputs[0].size resized.total() * resized.elemSize(); inputs[0].type RKNN_TENSOR_UINT8; inputs[0].fmt RKNN_TENSOR_NHWC; rknn_inputs_set(ctx, 1, inputs); rknn_run(ctx, nullptr); // 获取输出 rknn_output outputs[3]; // ... 后处理 }注意每个线程池里的RKNN上下文要独立。RKNN的rknn_context不是线程安全的多个线程共用一个context会出问题。我的做法是每个工作线程创建一个独立的context用线程局部存储thread_local管理。3.4 后处理与结果输出的实现后处理包括NMS、画框、输出结果。这部分计算量不大但要注意不要阻塞推理线程。NMS用OpenCV自带的cv::dnn::NMSBoxes就行速度够快。画框用cv::rectangle和cv::putText如果不需要显示可以跳过画框直接输出检测结果。结果输出我用了两种方式一种是本地显示用cv::imshow另一种是网络推流用FFmpeg推RTMP。本地显示会占用主线程建议单独开一个显示线程。网络推流更复杂后面单独讲。后处理的代码框架void postprocess(rknn_output* outputs, cv::Mat frame, int camera_id) { std::vectorcv::Rect boxes; std::vectorfloat scores; std::vectorint class_ids; // 解析输出填充boxes/scores/class_ids // ... // NMS std::vectorint indices; cv::dnn::NMSBoxes(boxes, scores, 0.5, 0.45, indices); // 画框 for (int idx : indices) { cv::rectangle(frame, boxes[idx], cv::Scalar(0, 255, 0), 2); } // 输出到同步队列 FrameResult result; result.camera_id camera_id; result.timestamp ts; result.frame frame.clone(); // ... 入队 }3.5 双路同步与结果汇总双路同步是双目方案的核心。两路帧的时间戳要对齐否则测距结果会漂。我的同步策略是后处理线程从同步队列里取结果维护两个缓冲区分别存两路的最新结果。每次取到新结果就和另一路缓冲区里时间戳最接近的结果配对。时间戳差距超过30毫秒的直接丢弃不参与计算。void sync_thread() { FrameResult buffer[2]; bool has_buffer[2] {false, false}; while (running) { FrameResult result; { std::unique_lockstd::mutex lock(sync_mutex); sync_cv.wait(lock, [] { return !sync_queue.empty() || !running; }); if (!running sync_queue.empty()) break; result sync_queue.front(); sync_queue.pop(); } buffer[result.camera_id] result; has_buffer[result.camera_id] true; if (has_buffer[0] has_buffer[1]) { uint64_t diff std::abs((int64_t)buffer[0].timestamp - (int64_t)buffer[1].timestamp); if (diff 30) { // 配对成功做双目处理 process_stereo(buffer[0], buffer[1]); } // 清空缓冲区等待下一对 has_buffer[0] has_buffer[1] false; } } }这个逻辑简单但有效。实测下来两路时间戳差距基本在10毫秒以内30毫秒的阈值足够宽松。4. 常见问题与排查技巧实录4.1 帧率不稳定、忽高忽低这是最常见的问题。原因通常有三个NPU排队、内存带宽不足、线程池任务堆积。排查步骤先看NPU占用。cat /sys/kernel/debug/rknpu/load如果一直接近100%说明NPU是瓶颈。解决办法是降低输入分辨率或者换更轻量的模型。再看内存带宽。cat /proc/meminfo看MemAvailable如果很低说明内存不够。双路1080p的帧缓冲大概需要几十兆加上模型和中间结果2GB内存是底线。最后看线程池队列长度。如果队列一直满说明处理速度跟不上采集速度。要么提高处理速度要么降低采集帧率。我遇到过一次帧率不稳最后发现是USB摄像头和MIPI摄像头共用USB总线带宽不够。换成两路MIPI就好了。4.2 某一路摄像头掉线导致整个系统卡死这个问题很典型。如果采集线程里cap frame阻塞了整个线程就卡住了线程池里的任务也得不到执行。解决办法是给采集加超时。OpenCV的VideoCapture没有直接的超时接口但可以用cap.set(cv::CAP_PROP_BUFFERSIZE, 1)减少缓冲或者用V4L2的原生接口自己控制超时。更稳妥的做法是加一个看门狗线程定期检查采集线程的心跳。如果超过一定时间没有新帧就重启采集线程。void watchdog_thread() { while (running) { std::this_thread::sleep_for(std::chrono::seconds(2)); for (int i 0; i 2; i) { if (get_timestamp_ms() - last_frame_time[i] 2000) { // 重启采集线程 restart_capture(i); } } } }4.3 内存泄漏与资源耗尽长时间运行后内存越来越少最后OOM。这个问题多半是cv::Mat没有正确释放或者RKNN的输出没有释放。检查点rknn_outputs_release有没有调用。cv::Mat有没有循环引用。线程池的任务队列有没有无限增长。我踩过一次坑rknn_output里的buf是malloc出来的每次推理完必须rknn_outputs_release否则每帧泄漏几KB跑几个小时就爆了。4.4 常见问题速查表现象可能原因排查方法解决办法帧率低NPU瓶颈查看NPU负载降低分辨率或换轻量模型帧率波动内存带宽不足查看MemAvailable减少缓冲帧数一路卡死采集阻塞查看线程状态加超时和看门狗内存泄漏输出未释放监控内存变化确保release调用同步失败时间戳偏差大打印时间戳调整同步阈值画面延迟高队列太长查看队列长度减小队列上限实操心得调试多线程问题最好的工具是gdb加thread apply all bt。卡死的时候attach上去看看每个线程的调用栈一目了然。另外日志要打时间戳和线程ID不然根本分不清哪条日志是哪个线程打的。4.5 性能调优的几个关键参数最后分享几个我调优时反复试出来的参数线程池大小每路4个线程。太少了任务排队太多了上下文切换开销大。4个是实测比较平衡的值。队列长度8。对应大概400毫秒的缓冲再大延迟就明显了。同步阈值30毫秒。RK3588上两路时间戳差距一般在10毫秒以内30毫秒足够宽松。NPU频率默认是1GHz可以调到1.2GHz性能提升大概10%但发热增加。如果散热做好可以调。CPU调度策略把采集线程绑到大核上推理线程绑到小核上减少争抢。用taskset或者pthread_setaffinity_np都行。这套方案我跑了大概三个月每天连续运行8小时以上没出过大问题。中间遇到过几次摄像头掉线看门狗都自动恢复了。如果你也在做类似的双路视觉项目希望这些经验能帮你少踩几个坑。
阅读完成 · 觉得有帮助?
咨询建站