拿到一块 RK3588,很多人第一件事就是跑通一个 YOLO,然后发个朋友圈庆祝。但我这个项目没这么轻松:一块 RK3588 的 NPU 上,要同时支撑人员入侵检测、烟火检测、垃圾分类三个 AI 任务。这三个任务听起来是三个模块,实际上就是三套模型、三条推理管线、三份后处理逻辑,而且全挤在同一个 6 TOPS 的 NPU 上。这篇文章把我从选模型、转 rknn、写多线程推理、调帧率到踩坑排错的全过程都写出来,准备做边缘智能盒、工地园区监控一体机,或者想把 RK3588 NPU 性能榨干的朋友,可以直接拿这份方案去改。
先说结论:单块 RK3588 完全能同时跑这三个任务,但绝对不是“三个模型分别推理”这么简单。真正的难点在 NPU 核心怎么分配、多线程推理怎么调度、视频帧怎么同步,以及后处理怎么做到既快又稳。下面我会把整个项目的设计思路、模型转换、并发实现、帧调度、性能实测和常见问题全部拆开讲,里面有不少是我实测踩坑换来的经验,常规文档里不会写这么细。
1. 项目概述与整体设计思路
1.1 这个需求到底是谁提出来的
这类需求在智能安防、智慧园区里非常常见。一个典型场景是这样的:一台 RK3588 边缘计算盒子上,接到一路或者几路网络摄像头。摄像机 A 对着围栏区域,要检测有没有人翻越闯入,这叫人员入侵;摄像机 B 对着仓库角落或者杂物堆,要实时识别有没有火光和烟雾,这叫烟火检测;还有一个摄像头或者一个触发信号,对着垃圾桶投放口,人要扔垃圾的时候拍一张照片,识别这袋垃圾属于可回收、厨余、有害还是其他,这叫垃圾分类。
三个任务如果分开,每个都单独部署一台设备,成本翻三倍,客户肯定不接受。于是全部压到一块 RK3588 上。RK3588 这块芯片本身就是为了边缘 AI 设计的,内置的 NPU 理论算力 6 TOPS(INT8),三核设计,比很多同价位的开发板强不少,这也是它能扛住三个模型并发的重要原因。
1.2 硬件平台选型与系统环境
我用的是一块常见的 RK3588 评估板,8GB 内存版本,带散热风扇和 12V/3A 电源。系统是官方的 Debian 11,内核 5.10,NPU 侧用的是 Rockchip 的 rknpu2 驱动和配套的 librknnrt.so 运行时。这里有个比较关键的点:RKNN-Toolkit2 的 PC 端版本和板端运行时版本要匹配,否则模型在板子上加载会直接失败。我自己踩过多版本混用的坑,建议直接用当时官网配套发布的最新版本,并且在板子上用 rknn-toolkit2-lite 自带的 demo 先跑通一遍,确认环境没问题再开始做项目。
摄像头接入我用的是支持硬件解码的 GStreamer 管线,RK3588 自带的 MPP 硬解能非常有效地把 H.264/H.265 视频流的解码压力从 CPU 上卸下来。软件框架是 C++ 写的,推理部分用 Rockchip 的 C API,没有用板端 Python,因为 Python 在频繁调用 NPU 时解释器开销和内存抖动比较明显,生产环境还是 C++ 更可控。
1.3 一切困难都出在“同时”两个字上
如果只是顺序跑三个模型,事情非常简单:加载三个 rknn 文件,一帧一帧依次跑一遍就行。但实际项目里,视频源是 25 帧每秒,如果三个模型串行处理每一帧,一帧的总延迟可能超过 100 毫秒,帧率直接掉到不到 10 FPS,而且后面模型处理的是前面模型延迟之后的旧画面,报警联动会慢半拍。
“同时”两个字背后包含三个维度的挑战:
- 算力分配:NPU 只有 3 个核心,3 个模型同时跑,是轮流占用全核,还是各自绑定一个核,直接决定吞吐量和单模型延迟。
- 内存管理:每个模型要独立加载权重、输入输出缓冲区,三个模型同时常驻,要避免内存碎片和反复申请释放。
- 帧同步:一路视频流要喂给多个推理线程,如果处理不过来是排队还是丢帧,逻辑上必须想清楚。
这三个问题就是我们后面所有设计和调优的主线,下面逐个展开。
2. 模型选择、训练与 RKNN 转换
2.1 人员入侵检测:选 YOLOv5s 还是 YOLOv8s
人员检测是目标检测里最成熟的方向之一,所以我不推荐自己从头训练一个检测网络,直接用开源 YOLO 系列预训练模型,然后用目标场景的数据做微调。我这里对比过 YOLOv5s、YOLOv6s、YOLOv8s 三个常见系列的 RKNN 部署效果。
实际部署下来,YOLOv5s 在 RK3588 上对算子兼容性最好,导出 ONNX 后基本不用改图就能转 rknn;YOLOv8s 检测精度更好,尤其是对小目标的召回率,但模型结构里 DFL 模块在导出时要注意,某些 RKNN 版本会报不支持。如果你对精度要求更高,可以用 YOLOv8s,但要先在 PC 端把完整导出流程跑通,再上板验证。
人员入侵检测的输入分辨率我建议固定 640×640。有人为了提速降到 416×416,但入侵场景通常要求尽可能早地发现人,小分辨率会让较远处的人检测不稳定,反而误报漏报变多。用 YOLOv5s 640×640 INT8 量化后的 rknn 模型,在 RK3588 上跑一次推理大概 20 到 30 毫秒(三核全用),这个速度完全够 25 FPS 实时处理。
2.2 烟火检测:小目标和误报是最大的坑
烟火检测和人员入侵不一样。人员入侵检测只要框出“人”就行,但烟火检测要同时区分“火”“烟”和大量看起来很像的干扰物:红色车灯、晚霞、白色水汽、工地扬尘、喷雾,都可能被模型误判成火或者烟。
我的做法是:用 YOLOv5s 作为基础结构,但训练数据里除了公开的火灾烟雾数据集外,额外加入了大量负样本和难样本——比如工地常见的水泥扬尘、雾天的背景、红色的机械设备。类别就两类:fire 和 smoke。训练时用 mosaic 和 mixup 增强,把火焰的形态、烟雾的半透明特性尽量学进去。
部署时的输入分辨率仍然保持 640×640,这比把图缩小更重要,因为烟火目标往往不大。后处理阶段我会做“时序确认”:一个报警必须连续 3 帧以上都能检测到才触发,单帧偶发的高置信度直接忽略。这个策略非常有效,实测能把误报率降低一半以上,而且对真实的火灾烟火影响不大,因为真火和真烟在视频里一般会持续多帧出现。
2.3 垃圾分类:不要用目标检测网络,用轻量分类器
垃圾分类这个任务和前面两个不同,它本质上是一个图像分类问题,不是在画面里找物体位置。一开始有人想用 YOLO 直接检测垃圾,然后按类别框出来,但这个做法性价比太低了。我们场景是垃圾桶投放口,人在投放时画面里主体就是那袋垃圾,直接裁出来丢给分类网络就行。
我选的是 MobileNetV3-Small,输入分辨率 224×224,权重只有几 MB,在 RK3588 NPU 上推理单核只要几毫秒。分类类别按实际项目需求定为四类:可回收垃圾、厨余垃圾、有害垃圾、其他垃圾。如果你项目的类别更细,比如要区分塑料瓶、纸箱、易拉罐、电池,那就收集对应类别的数据,网络结构不用变,只改最后一层分类头。
垃圾分类模型的训练数据并不难弄,公开的垃圾分类数据集有不少,自己针对投放口场景补拍 200 到 500 张每类图片就足够做微调。这个任务里最容易被忽略的是光线问题:投放口环境光照不稳定,白天晚上差别很大,训练时一定要做亮度扰动和模糊扰动,否则在晚上识别率会掉得很明显。
2.4 ONNX 转 RKNN 完整流程与量化经验
RK3588 的 NPU 不能直接跑 PyTorch 或 TensorFlow 模型,需要把模型转成 rknn 格式,转换工具是 PC 端的 rknn-toolkit2。整个流程我在 PC 上完成,转换脚本核心代码如下:
from rknn.api import RKNN rknn = RKNN(verbose=True) rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform="rk3588", quantized_dtype="w8a8", optimize_level=3 ) ret = rknn.load_onnx(model="yolov5s.onnx") assert ret == 0, "load onnx failed" ret = rknn.build(do_quantization=True, dataset="calib.txt") assert ret == 0, "build failed" ret = rknn.export_rknn("yolov5s.rknn") assert ret == 0, "export failed"这里最需要注意的就是 mean_values 和 std_values。YOLOv5/YOLOv8 训练时把输入图像归一化到 0 到 1,所以这里 mean 用 0,std 用 255,让 NPU 端直接对 0 到 255 的原始像素做归一化,跟模型训练时保持一致。垃圾分类如果用 ImageNet 预训练的分类网络,mean 和 std 要用 ImageNet 那组常见统计值,比如 mean=[123.675, 116.28, 103.53],std=[58.395, 57.12, 57.375]。这个值如果不对,分类结果会很差,而且是那种“看不清但想不通为什么”的坑。
量化这一步,需要准备一个 calib.txt 文件,里面列出几百张有代表性的图片路径,用来做 INT8 量化校准。图片要尽量覆盖真实场景的光线、角度、目标大小。烟火检测如果对 INT8 精度不满意,可以试一下混合量化,把某些对精度影响大的层保留 FP16,这个路径在 rknn-toolkit2 里叫 hybrid_quantization,能明显改善小目标的检测精度,代价是模型变大、推理变慢,建议作为精度兜底方案而不是默认方案。
提示:导出 ONNX 时一定要去掉模型里的 NMS 层,rknn 是不带 NMS 推理的,NMS 要在板端自己用 CPU 写。YOLO 官方仓库的 export.py 一般有 no-nms 参数,网上各种坑基本都是这一步没做到位。
3. NPU 并发调度:单卡跑三个模型的实现方案
3.1 先搞懂 RK3588 NPU 的并发能力
要谈并发,得先知道 NPU 内部是怎么工作的。RK3588 的 NPU 有三个核心,每个核心内部都是一大堆 MAC 阵列,也就是乘累加单元组成的计算网格。卷积运算在这里被拆成大量并行的乘加操作,MAC 阵列的数量决定了理论算力。三个核心合起来是 6 TOPS INT8 算力,平均每个核心约 2 TOPS。
在软件层面,Rockchip 的 rknpu2 运行时支持同时存在多个 rknn_context,每个 context 对应一个模型。多个 context 可以分别设置核心绑定关系,这就是我们做并发的底层基础。另一个重要接口是 rknn_set_core_mask,它允许你在推理时动态指定当前模型用哪个核或者哪几个核。核绑定不是一次性设置,而是每次推理前都可以改,这就给了我们非常灵活的调度空间。
3.2 三种并发策略对比
我在项目里实际对比了三种方案:
第一种是全局串行,也就是三个模型每次都分别占用全部三个核心依次推理。这样每个模型单次推理最快,但处理完一帧三个任务的总耗时是三项相加,帧率上不去,适合对单帧延迟要求低、对吞吐量要求不高的场景。
第二种是核心绑定并行,每个模型固定绑一个核,三个模型真正同时跑。这个方案下每个模型的单次推理变慢,因为算力只有原来的三分之一,但三个任务能同时进行,整体吞吐是三个模型并行叠加。我们实测 YOLOv5s 640 输入单核推理大约 55 到 60 毫秒,也就是说每个模型大约能跑到 16 到 18 FPS,对安防场景完全够用。
第三种是动态组合,把重模型绑两个核、轻模型绑一个核,两个核的模型和单核的模型并行。这个方案适合模型权重不平衡的情况,比如人员检测和烟火检测都很重,但垃圾分类很轻,可以让垃圾分类和另外两个模型共用时间片,或者单独占用一个核。
三种方案的对比我整理成了一张表:
| 并发策略 | 单模型延迟 | 整体吞吐 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 全核串行 | 最低 | 最低 | 最简单 | 三个任务不同步触发 |
| 核绑核并行 | 变高 | 最高 | 中等 | 三个任务都要持续实时 |
| 动态组合 | 可调 | 中等偏高 | 较复杂 | 模型权重差异大 |
我最终选的是第二种:人员入侵绑 core 0,烟火检测绑 core 1,垃圾分类绑 core 2。这样每个模型都有独立算力,不会互相干扰,逻辑也最清晰。实际部署时我会在每个推理线程启动后加一个 0 到 3 毫秒的随机初始延迟,避免三个线程同时调用 rknn_run 撞车,虽然新版驱动已经优化了不少,但这个习惯能减少启动瞬间的 CPU 争抢。
3.3 关键代码:多线程推理框架
整个推理框架可以抽象成一个独立的模型类,每个模型一个线程。核心结构如下:
#include "rknn_api.h" #include <thread> #include <mutex> #include <atomic> #include <opencv2/opencv.hpp> struct ModelCtx { rknn_context ctx; rknn_input_output_num io_num; vector<rknn_tensor_attr> in_attrs; vector<rknn_tensor_attr> out_attrs; int core_id; // 0/1/2 };加载模型时,每个模型单独初始化:
int load_rknn(ModelCtx &mc, const char *path) { FILE *fp = fopen(path, "rb"); fseek(fp, 0, SEEK_END); size_t size = ftell(fp); fseek(fp, 0, SEEK_SET); void *model_data = malloc(size); fread(model_data, 1, size, fp); fclose(fp); int ret = rknn_init(&mc.ctx, model_data, size, 0, NULL); free(model_data); if (ret < 0) return ret; rknn_query(mc.ctx, RKNN_QUERY_IN_OUT_NUM, &mc.io_num, sizeof(mc.io_num)); mc.in_attrs.resize(mc.io_num.n_input); mc.out_attrs.resize(mc.io_num.n_output); for (uint32_t i = 0; i < mc.io_num.n_input; i++) { mc.in_attrs[i].index = i; rknn_query(mc.ctx, RKNN_QUERY_INPUT_ATTR, &mc.in_attrs[i], sizeof(rknn_tensor_attr)); } for (uint32_t i = 0; i < mc.io_num.n_output; i++) { mc.out_attrs[i].index = i; rknn_query(mc.ctx, RKNN_QUERY_OUTPUT_ATTR, &mc.out_attrs[i], sizeof(rknn_tensor_attr)); } return 0; }推理线程的主循环长这样,每次从帧池里拿最新帧,做完预处理后设置核心绑定,再执行推理:
void infer_loop(ModelCtx &mc, std::mutex &frame_lock, cv::Mat &latest, std::atomic<bool> &running) { while (running) { cv::Mat frame; { std::lock_guard<std::mutex> lk(frame_lock); if (latest.empty()) { std::this_thread::sleep_for(std::chrono::milliseconds(5)); continue; } frame = latest.clone(); } cv::Mat resized, blob; letterbox(frame, resized, 640, 640); cv::cvtColor(resized, blob, cv::COLOR_BGR2RGB); rknn_set_core_mask(mc.ctx, core_mask_from_id(mc.core_id)); rknn_input inputs[1] = {0}; inputs[0].index = 0; inputs[0].type = RKNN_TENSOR_U8; inputs[0].size = blob.total() * blob.elemSize(); inputs[0].fmt = RKNN_TENSOR_NHWC; inputs[0].buf = blob.data; rknn_inputs_set(mc.ctx, 1, inputs); rknn_run(mc.ctx, nullptr); vector<rknn_output> outputs(mc.io_num.n_output); for (uint32_t i = 0; i < mc.io_num.n_output; i++) { outputs[i].want_float = 1; outputs[i].index = i; } rknn_outputs_get(mc.ctx, mc.io_num.n_output, outputs.data(), nullptr); // 在这里解析输出,做对应任务的后处理 run_post_process(mc, frame, outputs.data()); rknn_outputs_release(mc.ctx, mc.io_num.n_output, outputs.data()); } }这个框架非常简单,但已经很接近生产级。要注意的是 rknn_outputs_get 拿到的输出如果不及时解析并调用 rknn_outputs_release 释放,跑一段时间会内存泄漏,这个问题在性能监控里非常容易被当成“板子内存不够”来排查,实际罪魁祸首就是漏释放。
3.4 内存与零拷贝优化
三个模型同时常驻,内存占用大概是:两个 YOLOv5s INT8 模型每个权重加中间计算缓冲约 100MB 左右,MobileNetV3-Small 更小,几十 MB,加上系统、OpenCV、视频解码缓冲,8GB 内存版本完全没压力。如果你用的是 4GB 版本也能跑,但要小心别让 OpenCV 的调试窗口和日志把内存吃满。
真正的优化重点在数据拷贝。默认输入路径会把 cv::Mat 的数据拷贝给 NPU,对 640×640 的图来说一次拷几千字节不是问题,但每秒几十次调用,累积起来 CPU 占用并不小。追求极致性能的话,可以用 rknn_create_mem 分配 NPU 侧输入输出内存,配合 rknn_set_io_mem 做零拷贝推理。这个方案代码复杂度高一些,而且多个模型同时用零拷贝,内存对齐和生命周期管理要格外小心。我建议前期先用普通输入路径把流程跑通,性能不够再用零拷贝优化,不要一上来就追求最复杂方案。
预处理上还有一个省 CPU 的技巧:三个模型输入分辨率不一样,人员检测和烟火检测都是 640×640,垃圾分类是 224×224。可以让视频帧先统一缩小到 960×540 左右,然后每个模型各自再做 letterbox 缩放。虽然看上去多做了一次 resize,但避免了三个模型各自从 1920×1080 原始分辨率做缩放,实际总 CPU 开销更小。
4. 视频流接入与帧调度实战
4.1 摄像头接入:RTSP 和本地摄像头怎么选
安防场景里最常用的是 RTSP 网络流。RK3588 接入 RTSP 一定不要用 CPU 软解,要用 GStreamer 加 mpph264dec 硬解管线。我在代码里用 OpenCV 的 CAP_GSTREAMER 后端,管线这样写:
rtspsrc location=rtsp://192.168.1.64:554/stream1 latency=0 ! rtph264depay ! h264parse ! mpph264dec ! videoconvert ! video/x-raw,format=BGR ! appsinklatency 设为 0 非常关键。默认的 RTSP 缓冲会给视频流增加几百毫秒延迟,对于人员入侵这种需要快速报警的场景,延迟越少越好。实际使用中如果网络环境差,可以把协议改成 TCP,也就是在 rtspsrc 里加 protocols=tcp,画面会更稳定,但代价是有线网络延迟会稍高一点。
如果是 USB 摄像头或 MIPI 摄像头,走 V4L2 接入就行,注意把摄像头分辨率设成 1920×1080 或 1280×720 这类标准分辨率,不要用不常见的分辨率,否则 mpp 硬解和后续 letterbox 的兼容性都可能出问题。
4.2 帧同步与跳帧策略
多个推理线程共享一路视频流时,最忌讳的是用队列把所有帧都保存下来。假设摄像头是 25 FPS,但你的推理速度只有 17 FPS,队列会越积越长,报警延迟越来越大。我的做法是共享“最新帧”缓冲区:每个推理线程每次直接从共享变量里拿当前最新帧的拷贝,处理完再拿下一帧。如果推理期间来了好几帧新画面,直接跳过,永远处理最新帧。
这种策略的本质是“宁可跳帧,不可积压”。对实时视觉任务来说,处理 0.5 秒前的画面比处理 0.1 秒前但卡住很久的画面更有价值。丢帧其实没所谓,因为视频本来就是连续的,你的算法每秒钟能处理十几帧,已经能完整覆盖实际场景里的运动变化。
垃圾分类这个任务比较特殊,它的触发不是每帧都需要,而是投放动作发生时拍一张照。实际操作时我用了一个触发信号,比如 GPIO 检测到投放口门打开,或者简单一点用人员检测的结果:当检测到人员框出现在垃圾桶投放口区域时,抓当前帧做裁剪和分类。这样垃圾分类线程大部分时间闲置,几乎不占 NPU 资源,把核心 2 留给其他突发任务或者做多路视频轮询都可以。
4.3 后处理:入侵判定、烟火判定、分类结果联动
后处理是整个系统里最体现工程经验的部分。YOLO 模型输出的只是原始的预测框数据,要变成业务报警,还有很长一段路。
人员入侵的判定我是这样做的:先用 YOLO 检测出人的框,取框底部中心点作为人的落脚点,然后判断这个点是否进入了预先设定的虚拟围栏区域。虚拟围栏用多边形表示,可以在配置界面里人工画。判断点是否在多边形内部,用经典的射线法,代码很短:
bool point_in_polygon(float x, float y, const vector<cv::Point2f> &poly) { bool inside = false; int n = poly.size(); for (int i = 0, j = n - 1; i < n; j = i++) { float xi = poly[i].x, yi = poly[i].y; float xj = poly[j].x, yj = poly[j].y; if (((yi > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) inside = !inside; } return inside; }选定落脚点而不是人的中心点,是为了让人跨过围栏线时能精确判断“进来了”还是“还没进来”。如果用中心点,人一半身子探入围栏区域时中心点还在外面,报警会慢半拍。
烟火检测的后处理核心是抑制误报。单帧检测到火或者烟,还不能立刻触发报警,我用了一个五帧滑窗:五帧里有三帧以上检测到,才真正报警。这个策略能过滤掉大量单帧噪声。另外,烟和火的置信度阈值我分开设,火一般设 0.4,烟设 0.35,因为烟的视觉特征更模糊,阈值太高容易漏报小烟头。
垃圾分类的结果直接和业务联动。分类置信度大于 0.7 时直接输出类别;置信度低于 0.5 时输出“未识别”,提示用户重新投放;中间段可以配一个“不确定”状态,让用户在屏幕上确认。报警和识别结果通过 MQTT 上报上级平台,同时联动 RK3588 的 MPP 硬编码模块对报警前后各 10 秒的视频进行本地录像。这里我特意用硬件编码而不是软件编码,就是为了在推理高负载时不让 CPU 编码抢走太多资源。
5. 性能实测与优化方向
5.1 实测数据与资源占用
整个系统调通后,我在 1080p 的 RTSP 视频源上做了连续 12 小时的稳定性测试,用 top 和 /sys/kernel/debug/rknpu 下面的节点观察资源占用。核心数据如下:
| 任务 | 模型 | 输入尺寸 | 绑定核心 | 单次推理延迟 | 实际帧率 |
|---|---|---|---|---|---|
| 人员入侵 | YOLOv5s INT8 | 640×640 | core 0 | 约 58ms | 约 16 FPS |
| 烟火检测 | YOLOv5s INT8 | 640×640 | core 1 | 约 57ms | 约 16 FPS |
| 垃圾分类 | MobileNetV3-Small INT8 | 224×224 | core 2 | 约 8ms | 触发式 |
系统整体 CPU 占用大约 60% 到 80%,主要消耗在 OpenCV 的 resize、letterbox 以及 YOLO 后处理的 NMS 上,NPU 占用接近满载。内存稳定在 1.5GB 上下,没有持续增长。这个数据说明三个任务在单块 RK3588 上完全跑得动,而且还有一定余量。
如果你的业务要求更高帧率,比如人员入侵要跑到 25 FPS,可以考虑把人员检测模型换成 YOLOv5n,单核延迟能压到 35 毫秒左右,精度下降有限,但对小目标确实不如 v5s。这是一个经典的精度和速度取舍,得看具体场景。我自己在正式项目里宁可保持 16 FPS 也要用 v5s,因为人员漏报比帧率低更致命。
5.2 瓶颈分析与优化手段
从实测数据看,NPU 推理本身不是唯一瓶颈,CPU 端的预处理和后处理经常被忽略。我测过,一次完整的 YOLO 推理里,NPU 部分大约 58 毫秒,但预处理加后处理也要接近 15 到 20 毫秒。如果 CPU 被系统其他任务抢占,这个数字会更难看。
优化手段集中在三条线。第一是让 OpenCV 用上 NEON 优化,RK3588 是 ARM 平台,编译 OpenCV 时开启 NEON 和 VFPV3,resize 和 cvtColor 能快 20% 到 30%。第二是把 NMS 做大核上的多线程版本,或者用更高效的非极大值抑制实现,YOLOv5 自带的 NMS 在单线程上处理 640×640 的输出有一定开销,改成多线程后整体延迟能少 3 到 5 毫秒。第三是用输入输出的双缓冲,在当前帧做 NPU 推理的同时,用 CPU 并行准备下一帧的输入数据和解析上一帧的输出,这个重叠能显著提高流水线吞吐。
这里还有一个很多人不注意的点:电源和散热。RK3588 满载跑三个模型时 NPU 功耗不低,如果用劣质电源或者散热片没贴好,芯片温度超过 80 度后会自动降频,帧率会突然从 16 掉到 10 甚至更低,而且这个现象非常难排查,因为它不是固定的性能瓶颈,而是间歇性的。所以一定要用正规电源,最好带锁固的插座,别用那种几块钱的劣质充电头。
5.3 散热与 PWM 风扇控制
既然说到散热,就顺便把 RK3588 的风扇控制一起讲了。RK3588 的开发板大部分带一个 PWM 风扇,默认由内核 thermal 管理,温度高了自动加速。你可以通过 /sys/class/thermal/cooling_device*/cur_state 查看当前风扇档位。如果你想让风扇在特定温度就全速跑,可以修改设备树里的 fan 节点,或者直接用用户态程序写 /sys/class/hwmon/hwmon0/pwm1。
读取风扇转速也很简单,如果板子把测速线接出来了,通常在 /sys/class/hwmon/hwmon0/fan1_input 里直接读到 RPM 值。有些板子没暴露这个节点,可以用 pwm 占空比反推大概转速。这里提醒一点:如果你改了 dts 里的 fan 参数导致开机异常,别慌,RK3588 有 Maskrom 模式,按住 recovery/maskrom 键,用 USB Type-C 数据线连电脑,重新烧录镜像就能救回来,不用换板子。
6. 常见问题与排查技巧
6.1 模型转换与加载报错
我最常被问到的是“模型转 rknn 失败”和“rknn_init 返回负数”。转换失败十有八九是模型里有 rknn 不支持的算子。解决办法不是硬怼,而是先看 rknn-toolkit2 的日志,它会明确指出第一个不支持的算子名称。NMS 是最常见的,第二种是某些激活函数或上采样方式,比如双线性插值在某些版本上支持不完整。YOLO 系列模型基本都能顺利转换,小众的 Transformer 模型则要谨慎评估。
rknn_init 返回负数,先检查三件事:rknn 文件是否在 PC 端和目标平台上用同一个版本的运行时导出;板端 librknnrt.so 是否被替换成了不兼容的版本;模型文件是否损坏。还有一个很隐蔽的问题:如果板端内存不足,rknn_init 也可能返回错误,可以用 free -m 看看剩余内存。
6.2 推理结果异常
结果不对,首先要区分是“模型部署问题”还是“业务逻辑问题”。如果检测框位置偏得出奇,那大概率是 letterbox 的坐标映射没处理好。YOLO 推理是在 640×640 的 letterbox 图上做的,但业务显示和报警判断在原图上,需要记录缩放比例和 pad 偏移,把框坐标换算回原图。我一开始漏掉 pad 偏移,导致围栏判定错位,排查了半天才发现是坐标映射问题。
如果输出全是零或者一个目标都检测不到,先检查输入数据的格式。rknn 的输入通道顺序默认是 NHWC,也就是 RGBRGB 这样排布,如果你的图像数据是 BGR,要先 cvtColor 转成 RGB。另外,输入预处理必须和训练时保持一致,YOLOv5 用的归一化是 0 到 1,mean 和 std 一定要配对正确,否则模型识别率会降到没法用。
垃圾分类如果识别结果总是同一类或者置信度很低,十有八九是分类网络的 mean/std 和实际预处理不一致。ImageNet 预训练模型对归一化非常敏感,差一点整个特征分布就偏了。建议在 PC 端先用一张固定图片测试原始 ONNX 和转换后 rknn 的输出差异,确保一致,再拿到业务里联调。
6.3 系统级与多路流的坑
系统层面还有一个我踩过比较久的坑:RK3588 有时候在开机时打印 “can't find suitable delayline” 的报错。这个报错跟 AI 推理本身没关系,是 DRM 显示子系统在初始化 HDMI 或 MIPI 屏时找不到合适的时序参数。如果你的盒子是无头模式,也就是不接显示器,这个报错可以忽略,不影响 NPU 和视频处理。但如果你确实需要接屏,就得检查内核设备树里的 display timing 配置,或者换一个更标准的屏幕分辨率。
多路视频流接入时,千万不要每个摄像头都单独开一个解码管线,RK3588 的 MPP 硬解能力再强也架不住滥用。多个摄像头共享一路 RTSP 流的话,可以在应用层做一次解码、多次分发,让三个推理线程从同一个最新帧缓冲区取数据。这样不仅省了解码资源,还保证了三个模型看到的是同一时刻的画面,对人员入侵和烟火检测联动非常有意义。
另外,我要特别提醒一个看起来很低级但非常常见的问题:反复调用 rknn_outputs_get 却忘记 rknn_outputs_release。这种内存泄漏的成长速度很慢,可能要连续跑几个小时之后内存才逐渐上涨,但它会最终导致整个进程 OOM 崩溃。我在做稳定性测试时抓到过一次,进程重启之前内存已经涨到 6GB。所以每次推理循环里,拿到输出、解析完、一定要立即 release。
我做完整套系统之后最大的感受是:RK3588 这块 NPU 的性能比很多人想象中强,但真正拉开项目差距的不是芯片,而是工程细节。核绑定怎么配、帧缓冲怎么设计、后处理怎么抑制误报、输出内存怎么释放,这些细节决定了一台设备到底是“能跑 demo”还是“能上线”。如果你正在调类似的项目,可以先从最简单的串行版本跑通三个模型,再逐步加多线程和核心绑定,千万不要一上来就追求最复杂的方案。等整个链路稳定了,你会发现单块 RK3588 同时处理这三个任务,其实还留了不少余量给接下来的扩展。