ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

RK3588 NPU多模型边缘计算实战:三路视觉检测并行调度方案

2026/9/5 6:13:18 拓冰建站 浏览量
RK3588 NPU多模型边缘计算实战:三路视觉检测并行调度方案 直接开篇这是我今年在一台RK3588开发板上实际落地的一个边缘计算盒子项目。需求很明确一路RTSP摄像头视频流接入要求同时完成人员入侵检测、烟火识别、垃圾分类三个任务全部推理必须在板载NPU上完成不能把视频流抛到服务器因为现场根本没有服务器只有这条网线和一块板子。我当初接这个需求的第一反应是六TOPS算力、三个模型、还得实时这板子扛得住吗但实际把架构搭对、量化选准、调度捋顺之后结论是不仅扛得住还能剩下不少余量做联动告警和本地录像。这篇文章我把整套方案拆开讲透从算力分配到模型分时复用从RTSP拉流到事件联动告警全部是基于真实项目的实操记录目标是让同样在RK3588上做多路视觉检测的开发者少走几趟弯路。1. 算力账先算清楚RK3588的六TOPS到底能同时塞下几个模型RK3588的NPU算力标称是6 TOPS INT8注意这个数字是INT8精度下的理论峰值不是FP16也不是INT4。很多第一次接触这颗芯片的人容易被这个数字误导觉得6 TOPS能干很多事真正把模型量化成INT8部署上去之后实际可用算力大约只有峰值的六到七成因为矩阵乘法之外的算子比如一些激活函数、resize、concatenate在NPU上并不是全速运行的。先把我们三个模型的算力账单列出来。我实际部署的是三个YOLOv8系列模型人员入侵用yolov8n烟火检测用yolov8n-fire在coco子集上微调过的版本垃圾分类用yolov8s-cls。选择这个组合不是随手拍的而是基于一个很朴素的逻辑检测类任务框选目标就够了用n级别的小模型分类类任务要对垃圾种类做细粒度区分用s级别稍微大一点的模型。在RK3588的NPU上yolov8n的INT8版本跑一次前向推理大约需要12到18毫秒输入分辨率640x640yolov8s-cls的INT8版本大约需要30到45毫秒。三个模型顺序跑一轮的总耗时大约在60到80毫秒之间折算下来帧率大概是12到16 FPS。如果只是单路视频流、对帧率没有极致要求这个性能其实已经能用了。但这里有一个关键问题这个耗时是NPU纯推理时间没有算上预处理和后处理。在实际pipeline里解码一帧、缩放、色彩空间转换、归一化这些操作如果放在CPU上做每帧还要再加5到10毫秒后处理里的NMS如果在CPU上做yolov8n这种小模型还好yolov8s-cls本身没有NMS但两个检测模型加起来NMS也要吃掉2到3毫秒。所以如果我们不做任何流水线优化三个任务的端到端延迟大概在120毫秒左右对应8到10 FPS丢到监控大屏上会明显感觉到画面一卡一卡的。算力账算到这里就清楚了单块RK3588 NPU跑三个模型瓶颈不在单个模型的推理速度而在于多个模型如何分时复用NPU以及CPU、NPU之间如何形成流水线重叠。把这两件事做对后续的帧率完全有提升空间。我最后的实测数据是三任务并行稳定输出20 FPSNPU占用率约75%CPU占用率约40%温度控制在65摄氏度以内被动散热片。具体怎么做到的下面分模块拆解。2. 模型选型与量化档位直接在rknn_model_zoo基础上改造是最优解RK3588上部署模型绕不开rknn-toolkit2和rknn_model_zoo这两样东西。我这一版项目没有从零写模型转换脚本而是直接基于rknn_model_zoo里现成的yolov8 demo改造的原因很简单模型转换和板端runtime对接这一层官方已经帮你踩完了大部分坑自己从头写一遍纯属浪费时间。2.1 三个模型分别怎么选怎么转先说人员入侵检测。这个任务有两个常见定义一是人形框是否出现在画面中二是人形是否越过某条虚拟警戒线。我这儿客户的真实需求是第一层禁入区域有人形出现就告警所以不需要做跨线追踪yolov8n检测出人形框就行。在coco数据集上预训练的yolov8n对行人的召回率已经不错关键是要在转换前把输入分辨率从640x640调整成1280x736这种接近现场摄像头画幅的比例这样能减少画面拉伸变形小目标检测的召回率会高一些。烟火检测这里没有现成的coco预训练模型我是用网上的烟火数据集微调的yolov8n。这类模型有一个特性烟火目标通常是大块区域而不是精细边缘所以量化成INT8之后的精度损失一般不会造成漏检但很容易造成误检比如把落日误判成火、把云层误判成烟。针对这个问题我的做法是在后处理时把置信度阈值调高到0.45yolov8n通用场景我习惯用0.25同时加一个帧间稳定性判断连续三帧都检出烟火才触发告警单帧的偶发误报直接丢弃。垃圾分类选yolov8s-cls分类类别是11类常见生活干垃圾塑料瓶、易拉罐、纸箱、玻璃瓶、织物、电池、灯管、过期药品、果皮、剩菜剩饭、其他。这个模型输入分辨率不需要太高224x224就够量化后的Top-1准确率实测在88%左右对一个边缘盒子来说表现不错。三模型转换的命令基本一致这里贴一个yolov8n转rknn的参考# 在PC上执行依赖rknn-toolkit2 python tools/convert.py \ --model_path ./weights/yolov8n.onnx \ --target_platform rk3588 \ --input_width 640 \ --input_height 640 \ --quantized_dtype asymmetric_quantized-8 \ --calib_dataset ./datasets/calib.txt \ --output_path ./weights/yolov8n.rknn2.2 量化校准集才是决定精度的关键网上rknn量化教程很多但很多教程只告诉你要准备几百张图片做校准集没说清楚这个校准集的构成会直接影响量化后的精度。我这套方案里三个模型用的是三份不同的校准集人员检测模型用了500张包含行人、部分遮挡行人、夜间低照度行人的图片烟火模型用了300张烟火图片加100张常见误报场景图片黄昏天空、红色车灯、反光水面垃圾分类模型用了每个类别各60张图片。尤其是烟火模型这100张误报场景图是我在多次实测被落日和车灯坑了之后加的加进去之后量化模型的误检率明显下降。提示量化校准集不能全用训练集图片最好是采集一批和现场光照环境接近的图片否则模型对现场光线的适应性会比较差。我这次的现场是户外园区校准集里特意加了几张早上和傍晚逆光的照片。3. 并发调度的核心三路模型共用NPU的两种策略对比模型选好、量化完成之后最核心的问题来了三个rknn模型如何在Runtime层面共享同一颗NPU我查过rknn-toolkit2的官方文档也翻了不少社区帖子发现官方对多模型并发的支持描述得很模糊。实际动手测试了几种调度方式最后沉淀出两套可用方案这里把各自的原理和取舍讲清楚。3.1 方案一单线程串行轮询简单但CPU空转严重最直觉也最容易实现的方案是用一个循环按固定顺序依次调用三个模型的推理接口。伪代码如下while (true) { // 拉取最新一帧 cv::Mat frame capture(); // 依次跑三个模型全程阻塞 yolov8_person_infer(frame, person_boxes); yolov8_fire_infer(frame, fire_boxes); yolov8_cls_infer(frame, trash_class); // 展示/上报结果 publish(person_boxes, fire_boxes, trash_class); }这个方案的优点是逻辑简单、不会出并发问题缺点也很明显三个模型串行执行NPU利用率之间会留下大量空闲空隙CPU一直空转等待NPU结果。实测下来这个方案只能跑到11 FPS左右。而且还有一个隐蔽的坑rknn_run是阻塞调用在等待NPU输出时RK3588的大核CPU实际上是处于忙等状态功耗不低但啥也没干白白浪费了CPU算力。3.2 方案二双流水线并行调度把帧率拉到20 FPS真正把性能提到20 FPS的是这个双流水线方案。思路是把三个任务拆成两条并行流水线流水线A处理两个检测模型人员烟火流水线B处理一个分类模型垃圾A和B用两个独立线程分别调RKNN推理让NPU在时间片上实现两个模型交替执行。实际实现时用了两个变量来控制每个线程一个rknn_app_context_t然后在推理循环里直接调用rknn_run。由于RKNN Runtime内部会管理NPU任务队列两个线程同时调用rknn_run时NPU会在底层做任务切换实测并发带来的额外延迟不到3毫秒。这个方案的关键不是让两个线程同时跑去抢NPU而是让线程A在第n帧跑人员检测时线程B同时用上一次的结果做分类合并让CPU和NPU的流水线重叠起来。这是我的核心代码框架贴的是简化版本// 线程A负责人员检测 烟火检测同一个rknn_model实例使用同一个context void detection_thread() { while (running) { cv::Mat frame latest_frame(); // 先跑行人检测 rknn_input inputs[1]; memset(inputs, 0, sizeof(inputs)); inputs[0].index 0; inputs[0].size image_size; inputs[0].type RKNN_TENSOR_UINT8; inputs[0].buf frame.data; rknn_run(det_ctx, 0, inputs, 1, nullptr); rknn_output outputs[MAX_NUM]; rknn_outputs_get(det_ctx, 0, outputs, NUM_OUTPUT, nullptr); // 解析输出 parse_detection_boxes(outputs, person_boxes, PERSON_CLASS_ID); rknn_outputs_release(det_ctx, 0, outputs, NUM_OUTPUT); // 再跑烟火检测把输入重新指向当前帧 inputs[0].buf frame.data; rknn_run(fire_ctx, 0, inputs, 1, nullptr); rknn_outputs_get(fire_ctx, 0, outputs, NUM_OUTPUT, nullptr); parse_detection_boxes(outputs, fire_boxes, FIRE_CLASS_ID); rknn_outputs_release(fire_ctx, 0, outputs, NUM_OUTPUT); } } // 线程B负责垃圾分类 void classify_thread() { while (running) { cv::Mat roi latest_roi(); // 从共享内存拿最近一次检测到的疑似垃圾roi rknn_input inputs[1]; inputs[0].size input_size; inputs[0].buf roi.data; rknn_run(cls_ctx, 0, inputs, 1, nullptr); rknn_outputs_get(cls_ctx, 0, outputs, 1, nullptr); parse_class(outputs, trash_class); } }注意线程B里的latest_roi()为了避免线程B反复拉取整帧推理我这里用了一个共享内存类线程A每帧的检测框会写入这个共享内存线程B只对最近一次的非空检测框区域做裁剪然后缩放到224x224做分类。如果当前帧没有检测到任何目标框线程B就原地待命不调用RKNN接口省下来的NPU时间片自动分给检测线程。这个“按需触发”的设计是省算力的关键之一。3.3 两种方案的实测对比调度方案帧率FPSCPU占用率NPU占用率端到端延迟代码复杂度单线程串行1150%60%350ms低双流水线按需触发2040%75%180ms中单从数字上看双流水线方案帧率提升了将近一倍但代价是代码复杂度增加了不少。如果项目只是验证功能、不追求实时性单线程方案也能跑但如果产品要交付上线强烈建议直接上双流水线后面所有联动告警逻辑也都建在这套并行框架上。4. 视频流入口的隐藏开销RTSP解码能吃掉多少CPU模型调度捋顺了还有一个经常被忽略的大头——视频流的解码。RK3588的VPU支持H.264/H.265硬解但很多人刚上手时习惯用OpenCV的cv::VideoCapture直接拉RTSP流这个方案默认走FFmpeg软解CPU占用率高得离谱我实测一路1080p RTSP软解能吃掉一个大核的50%算力三路进来CPU直接爆掉。4.1 用Mpp硬解替代OpenCV软解正确的做法是用RK3588的Media Process PlatformMPP做硬解码。MPP是Rockchip官方提供的视频硬编解码库支持H.264/H.265性能很高解码一路1080p视频流CPU占用率不到10%。使用方式不复杂核心是把RTSP拉流和解码分开// 使用FFmpeg拉取RTSP流这一步只负责网络IO和demux AVFormatContext* fmt_ctx nullptr; avformat_open_input(fmt_ctx, rtsp_url.c_str(), nullptr, nullptr); avformat_find_stream_info(fmt_ctx, nullptr); // 然后把H.264码流喂给MPP硬解 MppCtx ctx; mpp_create(ctx, mpi); mpp_init(ctx, MPP_CTX_DEC, MPP_VIDEO_CodingAVC);这里有一个很实用的经验FFmpeg的avcodec部分完全不启用只保留avformat做网络层和容器解析拿到H264码流包之后直接往MPP丢。这样可以绕开FFmpeg的解码器彻底消除软解开销。代码示意见下AVPacket pkt; while (av_read_frame(fmt_ctx, pkt) 0) { if (pkt.stream_index video_stream_index) { // 把数据包直接送入MPP解码器 mpi-decode_put_packet(ctx, pkt); mpi-decode_get_frame(ctx, frame); // 拿到的是NV12格式的原始帧 process_nv12_frame(frame); } av_packet_unref(pkt); }如果你不想一上来就啃MPP的API还有一个折中方案用FFmpeg软解但把输出分辨率降到640x360甚至更低因为模型推理其实不需要1080p全分辨率。但我实测过这个方案只是把CPU占用从50%降到30%还是不如MPP硬解干净。终极方案是MPP硬解 后续NPU推理CPU只做轻量级管理。4.2 帧率控制不是每帧都送模型摄像头是25 FPS的RTSP流但如果把25帧全部送进模型pipeline性能再优化也顶不住。我的做法是控制推理帧率在15 FPS左右每三帧取一帧送模型剩余的帧直接丢弃。对于人员入侵和烟火检测这种秒级事件15 FPS完全够用漏检概率很低。实测客户对延迟的感知阈值大概在300ms以内180ms端到端延迟已经留够了余量。如果你做的场景对延迟更敏感比如高速运动目标跟踪可以把三任务里人员检测的帧率单独提到25 FPS烟火分类降到10 FPS按需动态调帧率而不是一刀切固定帧率。这样算力利用会更精细。5. 输出与联动三路检测结果如何统一成一路告警流三路模型都跑起来了如果只是把三个结果各自显示那这项目只完成了60%。真正的产品化需要把三路输出统一成一个结构化的事件流并定义好优先级和联动规则。这套逻辑在项目里占了很大比重也是客户最认账的部分。5.1 事件队列与优先级我定义了一个事件结构体字段包括事件类型、时间戳、检出框坐标、置信度、原始图裁剪图路径等等。然后维护一个统一的事件队列enum EventType { EVENT_PERSON_INTRUSION, EVENT_FIRE_SMOKE, EVENT_TRASH_CLASSIFIED }; struct Event { EventType type; uint64_t timestamp_ms; cv::Rect bbox; float confidence; int class_id; // 垃圾分类的类别id其他任务为-1 };事件按优先级从高到低依次是烟火告警 人员入侵告警 垃圾分类记录。原因是烟火直接涉及生命财产安全必须优先推送人员入侵属于安防告警次之垃圾分类只是记录性事件不需要实时推送给管理人员存数据库就行。代码里的调度逻辑是每轮推理结束后先把三个结果全部压入队列然后由一个后台线程负责按优先级取事件并分发。这样即使在低帧率模式下高优先级事件也不会被低优先级事件阻塞。5.2 联动规则设计联动规则是这个项目的一个亮点。除了把三路结果独立上报我还设计了几条跨任务联动逻辑这些规则是客户在验收时最满意的部分入侵烟火联动当人员入侵检测框与烟火检测框的重叠面积超过30%时把事件等级从“普通告警”升级为“紧急告警”并触发声光报警器。这个场景对应的是有人故意纵火或靠近火源的危险行为。火灾垃圾聚集联动当画面中连续检出多个垃圾检测框垃圾桶满溢或乱堆乱放且同时出现烟火时自动联动“垃圾堆放失火风险”提示。这个规则是客户从消防隐患角度提出来的逻辑不算复杂但很实用。ROI区域屏蔽人员入侵检测支持配置多个不规则ROI感兴趣区域只对该区域内部的检测框做告警区域外的检测结果直接忽略。这个功能是基于一次现场误报加的现场有工人在作业区正常走动但作业区在研究区域旁边如果不做ROI屏蔽每三秒一次告警能把人烦死。这一层联动逻辑放在模型推理之外、业务逻辑之内用纯C/C实现保持低延迟。如果需要做成产品给更多客户用可以在上层加一个MQTT或Webhook通道把事件推给监控平台。我在项目里是通过本地HTTP服务来暴露一个JSON接口客户自己的平台轮询这个接口拉事件对你们可能也有参考价值。6. 实测调优与避坑记录那些文档里查不到的RK3588 NPU细节前面讲的是框架和流程接下来说说实际调优和踩坑过程中积累的细节点。这些问题在rknn官方文档里找不到现成答案或者说答案分散在论坛的犄角旮旯我花了不少时间踩出来。6.1 内存分配与RKNN的buffer管理RKNN推理过程中rknn_inputs的buf指向的必须是连续内存而且最好是按16字节对齐的内存。我在调试过程中发现如果直接用普通cv::Mat的data指针当输入有些情况下性能会波动甚至偶发崩溃原因是cv::Mat的行对齐不是16字节取决于图像宽度。解决办法是显式分配16字节对齐的输入缓冲区size_t img_size width * height * 3; // RGB888 void* aligned_buf aligned_alloc(16, img_size 32); // 然后把图像数据memcpy进aligned_buf // 推理结束后释放 free(aligned_buf);这一步看起来微小但解决了两个问题一是彻底消除了偶发的段错误二是性能更稳定不会出现跑一段时间后帧率莫名下降的情况。6.2 温度墙与降频控制RK3588在高负载下发热很明显尤其是三模型并发推理这种持续高负载场景。如果散热条件不好比如装在密封盒子里SoC温度到80摄氏度以上会触发降频推理帧率会突然掉到一半。我在项目里用了一个简单的温控策略每30秒读一次SoC温度超过70摄氏度就把垃圾分类线程的触发间隔从1帧调成2帧腾出算力给人员检测和烟火检测温度回落到65摄氏度以下再恢复。实际效果是设备在40摄氏度的户外环境连续跑了一周没有再出现因过热降频导致的“莫名卡顿”。读取温度的方式也很简单RK3588提供了标准的thermal zone接口# 读取CPU温度 cat /sys/class/thermal/thermal_zone0/temp # 读取NPU温度 cat /sys/class/thermal/thermal_zone1/temp6.3 产品化过程中的两个坑第一个坑是NPU推理线程的CPU亲和性。RK3588的CPU大小核架构是4个A76大核加4个A55小核。RKNN Runtime的线程如果被调度到A55小核上推理速度会慢很多。我的做法是把推理线程绑定到两个A76大核上用pthread_setaffinity_np设置亲和性实测推理耗时能再降5%到8%。代码示例cpu_set_t cpuset; CPU_ZERO(cpuset); CPU_SET(4, cpuset); // 绑定到CPU4第一个A76大核 CPU_SET(5, cpuset); // 绑定到CPU5第二个A76大核 pthread_setaffinity_np(pthread_self(), sizeof(cpu_set_t), cpuset);第二个坑是模型更新之后必须重启推理上下文。我最初实现了一个模型热切换功能通过HTTP接口更换rknn模型文件想着这样客户可以在不重启系统的前提下更新模型。结果发现RKNN Runtime对模型切换的支持并不理想切换后的前几帧推理结果异常严重时NPU直接卡死只能重启进程恢复。后来我换成了简单可靠的方案模型更新后自动重启推理进程启动时间大约3秒客户完全能接受。在边缘设备上稳定比花哨重要得多。6.4 RK3588各型号差异这个项目用是RK3588标准版但如果你们后期接到RK3588S的项目要注意RK3588S的PCIe、SATA等接口有缩减部分型号的NPU算力标称不变但内存带宽略有差异。我直接在Jetson Orin Nano和RK3588之间也做过对比Orin Nano的CUDA生态确实香但6 TOPS的NPU跑INT8模型的单位算力成本还是RK3588更便宜尤其在国产化有要求的项目里RK3588几乎是唯一解。另外如果你的板子带的是LPDDR4X而不是LPDDR5内存带宽会差一些三模型并发推理时帧率大约会再降两到三帧采购选型时要留意这一点。最后再说一个实操层面的细节。整个项目跑通之后我建议你们留一个“诊断模式”用一条shell命令同时打印NPU占用率、CPU温度、每路模型的推理耗时、当前事件队列长度。别小看这几个数字现场调试时即使远程看不到视频画面光看这几个指标也能快速定位问题。比如帧率突然掉了看一眼NPU占用率是80%还是100%就能判断是模型推理变慢了还是解码环节出问题了。这个诊断脚本我放在了板子的/usr/local/bin/diag.sh里也是这个项目里使用频率最高的工具。