一块 RK3588 的板子,要同时处理三路视觉任务:人员入侵要抓,烟火要盯,垃圾分类也要出结果。刚接到这个需求的时候,我第一反应是“先拆开跑”,无非就是三个模型轮流上。真正落地之后才发现,单块 RK3588 的 NPU 虽然给了 6 TOPS 算力,但三个任务各自的数据流、模型大小、实时性要求完全不同,处理不好就会互相抢资源,最后谁都不快。
这篇文章就围绕着“单块 RK3588 NPU 如何同时跑人员入侵、烟火检测、垃圾分类”这个场景,把我在实际项目里的方案选型、RKNN 转换、多进程并发调度、性能调优和踩坑记录都整理出来。适合正在做 RK3588 边缘视觉盒子、或者准备在 rknn-toolkit2 上部署多模型的人参考。我默认你已经有了 RK3588 开发板,跑过基础 demo,知道 RKNN 和 rknn-toolkit2 大概是什么,下面直接进入正题。
1. 任务拆解与资源预算
1.1 三个视觉任务在边缘端算力上的真正差异
先说结论:这三个任务虽然都是计算机视觉,但“体感”完全不同。人员入侵是一个典型的实时告警任务,人一旦踏入危险区域,留给系统的反应时间通常只有几秒。烟火检测是“越早越好”型任务,火焰初期可能就几十个像素,模型对小目标的召回率比帧率更重要。垃圾分类则是一个低频识别任务,垃圾箱面前的人一般会停留几秒甚至十几秒,识别结果晚个一两秒完全没影响。
这意味着三者对帧率、输入分辨率、模型容量的需求是三个方向:人员入侵需要高帧率+中分辨率;烟火检测需要中低帧率+高分辨率(尽量保住小目标);垃圾分类只需要极低帧率+小分辨率。如果我把三个任务都按“实时检测”的标准去设计,那 NPU 资源肯定不够;但如果按各自真实需求分配,6 TOPS 其实是富余的。
1.2 RK3588 的 NPU 家底盘点
RK3588 的 NPU 标称算力是 6 TOPS(INT8),由 3 个 NPU core 组成。注意,FP16 下的实际算力会打折到 3 TOPS 左右,所以量产方案基本都要走 INT8 量化,这个是后话。3 个 NPU core 之间不是只能联合跑一个大模型,恰恰相反,它们可以各自独立执行不同的模型,也可以把其中两个或三个核心联合起来跑同一个大模型,通过 rknn-toolkit2 的 core_mask 参数控制。
这是整个方案的核心基础。既然有三个核心,最简单的做法就是一个任务绑一个核心:人物检测绑 core0、烟火检测绑 core1、垃圾分类绑 core2。这样从硬件层面就把三路人马隔离开了,理论上不存在互相抢占算力的问题。实际跑下来也确实如此,NPU 层面的并行度很高,反倒是 CPU 侧的数据搬运、预处理、后处理更容易成为瓶颈。
1.3 资源分配的核心策略:不是堆模型,是分资源
我的最终策略可以总结成一句话:每个任务都往“够用”方向压,而不是往“最强”方向配。人员入侵用 YOLOv5s(640×640),烟火检测用 YOLOv5s(416×416),垃圾分类直接用 MobileNetV3-Small(224×224),三者的模型体积和处理开销差异很大。这样安排之后,NPU 单核跑一遍的时间大致是:人员入侵 25~35ms,烟火检测 15~20ms,垃圾分类 5~10ms。三个核各自忙各自的,整体看板子的推理吞吐量可以接近 50~60 FPS 的等效算力。
但如果反过来,让三个模型都用 YOLOv5s 640,还每帧都跑,那即使 NPU 能扛住,CPU 侧的图像缩放、颜色格式转换、归一化也会成为瓶颈,而且模型之间会因为 DDR 带宽争抢而明显变慢。所以我非常不建议一上来就追求“全模型、全帧率”。
2. 模型选型与 RKNN 转换细节
2.1 每个任务选什么网络,为什么尽量统一到 YOLO 生态
先说人员入侵。我选的是 YOLOv5s,输入 640×640。人员入侵本身只是“行人检测 + 区域判定”,区域判定可以完全放在后处理:检测出人的目标框后,判断框底部中心点是否落在预设的电子围栏多边形内,用射线法几十行代码搞定,不需要模型去学“入侵”这个概念。
烟火检测我原本想用专门的烟雾火焰检测网络,后来还是换回了 YOLOv5s,输入压到 416。原因很简单:RKNN 对 YOLO 系列算子的支持已经非常成熟,转模型时几乎不用处理算子兼容问题。烟火目标的特征是颜色偏红/偏亮、边缘不规则,这些特征靠数据增强和训练数据多样性就能让 YOLOv5s 学会,没必要为了这个任务引入 Faster R-CNN 或者更重的网络。如果你的烟火样本里有大量小目标,可以在训练时把输入调回 640,或者加一层 P2 小目标检测头,但实际部署时先跑通流程再优化精度。
垃圾分类严格说是一个“检测+分类”的复合任务。我最初的构想是先检测出垃圾区域,再对每个区域做分类。后来发现对于固定机位的垃圾桶场景,直接用检测模型一阶段输出框+类别更省事。但如果你的场景里垃圾类别特别多(比如几十种),检测模型的类别头会明显变大,这时候用“轻量检测 + MobileNetV3 分类”的两级方案更划算。我最终为了部署方便,统一了推理框架,让三个模型都走同一套 rknn 推理代码。
2.2 转换前的算子检查
无论用什么网络,转 RKNN 之前必须把模型导出成 ONNX,然后在 rknn-toolkit2 里做转换。转换时我最关心的不是“能不能转成功”,而是“日志里有没有算子 fallback 到 CPU”。在 rknn-toolkit2 的转换日志里,如果出现类似W Unknown layer XXX, will fallback to CPU或I [op] YYYY use NPU这样的信息,一定要逐条确认。凡是在 CPU 上执行的算子,都会把整段推理的时延拉长,而且 NPU 和 CPU 之间还要来回搬运数据,性能损失远比想象中大。
对 YOLOv5/YOLOv8 这类模型,最常见的坑是导出 ONNX 时带了太多辅助输出,或者后处理算子(NMS)也被塞进了模型里。我的做法是导出 ONNX 时就裁掉 NMS,让模型只输出原始预测张量,后处理全部放到 NPU 外面的 CPU 上跑。RK3588 的 CPU 也不弱,做一版带置信度筛选的 NMS 完全来得及,还让模型更干净。
还有一个容易被忽略的点:ONNX 里如果用了动态维度,RKNN 转换时会有问题。我习惯在导出时把输入固定成batch=1,输入尺寸固定死,比如1x3x640x640。动态分辨率虽然也能转成多个静态 shape,但部署复杂度高,对这三个任务来说完全没必要。
2.3 量化校准集和参数怎么配
RK3588 NPU 的常见部署方式是 INT8 量化,因为 6 TOPS 的算力指的就是 INT8。模型转换时的量化配置直接影响最终精度。我整理了三个关键参数:
mean_values和std_values:必须和训练时的预处理一致。很多项目在量化后精度崩掉,查到最后发现是均值方差写错了,模型输入分布完全对不上。- 量化数据类型:rknn-toolkit2 通常默认
asymmetric_quantized-8就够了,它对大多数视觉模型效果都不错。如果精度特别敏感,再考虑混合量化。 - 校准集:这是最容易偷懒也最容易翻车的地方。校准集图片要尽量贴近真实部署场景,而不是随便从训练集里拿几张。我实际会从摄像头录像里抽帧,覆盖不同光线、不同距离、不同姿态,每类任务大概 300 张左右,多场景混合。
校准集的数量不需要很多,300 张足够,过多反而拖慢转换时间。关键是有代表性。像烟火检测,如果校准集里全是远距离小火苗,量化后模型对近距离大火的响应就不好,反过来也一样。
2.4 精度验证与“回退 CPU”的坑
转换完成后,我会用 rknn-toolkit2 的accuracy_analysis功能逐层对比量化和浮点模型的输出差异,重点看最后几层的余弦相似度。如果相似度低于 0.99,我会先怀疑校准集分布,再怀疑模型里有没有对量化特别敏感的层。比如检测头里的某些大数值输出层,可以在 config 里指定对这些层不量化或者用更高精度。
这里再强调一次“回退 CPU”的坑。哪怕最终模型精度没问题,也要检查每个算子的执行设备。我遇到过一次很隐蔽的情况:YOLOv5s 模型转换成功,精度验证也通过,但部署后单帧推理要 200ms,后来一看日志,模型里某个 Resize 算子和一个 Split 算子全部 fallback 到了 CPU,真正跑在 NPU 上的算子没几个。这类问题不在转换阶段严格把关,上线后很难查。
3. 多模型并发调度的工程实现
3.1 三核并发的入口:core_mask 用法
RKNN 在运行时通过init_runtime传入core_mask参数来决定模型跑在哪些 NPU core 上。可用的掩码大致有这些:
| core_mask 取值 | 含义 |
|---|---|
RKNN_NPU_CORE_0 | 只在 NPU core0 上执行 |
RKNN_NPU_CORE_1 | 只在 NPU core1 上执行 |
RKNN_NPU_CORE_2 | 只在 NPU core2 上执行 |
RKNN_NPU_CORE_0_1 | 在 core0 和 core1 上联合执行 |
RKNN_NPU_CORE_0_1_2 | 三个核心联合执行 |
RKNN_NPU_CORE_AUTO | 由驱动自动分配 |
我建议在多模型场景下不要用AUTO,显式给每个进程指定核心。AUTO模式在单模型时很省心,但多模型同时跑的时候,驱动不一定能理解你的业务优先级。我实际采用的做法是:人员入侵绑RKNN_NPU_CORE_0,烟火检测绑RKNN_NPU_CORE_1,垃圾分类绑RKNN_NPU_CORE_2。三种任务从初始化开始就井水不犯河水。
有一点要提醒:同一个 rknn 模型实例不能被多个线程同时inference,RKNN Python 接口本身不是线程安全的。所以我不用线程,而是直接用多进程,每个进程创建独立的 RKNN 实例,各自加载模型、各自设置 core_mask。
3.2 共享视频帧,避免三路重复解码
如果三个模型各自从摄像头拉流、各自解码,CPU 和内存带宽会很快被消耗掉,而且三路视频流时间戳还会产生偏移。正确做法是只开一个视频采集进程,把解码出来的帧放进共享内存,三个推理进程自己按需取帧。
实际项目里我用的是 Python 的multiprocessing.shared_memory,配合一个带锁的循环队列。视频采集进程把帧写成共享内存块,并维护一个frame_id计数器;推理进程拿到共享内存句柄后,只做图像数据的引用和格式转换,不再重复解码。需要注意帧的生命周期管理:一个推理进程处理慢,另一个推理进程想覆盖这块共享内存时,必须等前者释放,所以我在队列里常驻 2~3 帧缓冲,避免互相阻塞。
如果你用的是 C/C++ 部署,也可以用 DMA-BUF 或 RGA 来做零拷贝,性能会更好。Python 方案在 RK3588 上也能跑通,但对内存拷贝要特别敏感,稍有疏忽 CPU 使用率会飙升。
3.3 推理调度:优先级 + 降帧率组合拳
多进程跑起来之后,还要解决“业务优先级”的问题。三个模型都独占了一个 NPU core,但人员入侵和烟火检测是告警型任务,垃圾分类只是统计型任务。如果垃圾分类每帧都跑,即使它跑得再快,也会占用一部分 DDR 带宽,影响另外两个任务的端到端时延。
我的调度策略很简单:高优先级任务每帧都推理,低优先级任务按 N 帧取一次。人员入侵每帧都跑(25 FPS),烟火检测也是每帧都跑(15~25 FPS),垃圾分类则每 5~10 帧才跑一次(相当于 2~5 FPS)。这不会牺牲垃圾识别准确性,因为一个人丢垃圾的过程通常会持续好几秒,低频采样完全能抓到关键帧。
如果后续想让系统更智能,可以加一个“联动触发”:人员入侵检测到有人进入垃圾桶区域时,临时把垃圾分类的推理频率提上来。不过这个属于锦上添花,先把基础调度跑稳最重要。
3.4 关键代码骨架:三进程部署
下面我用伪代码把整体结构写出来,方便对照实现。这是 Python 版本的骨架,用 multiprocessing 创建三个子进程。
import multiprocessing as mp from rknn.api import RKNN def process_intrusion(frame_queue, result_queue): rknn = RKNN() rknn.load_rknn("./models/intrusion.rknn") rknn.init_runtime(core_mask=RKNN.NPU_CORE_0) while True: frame = frame_queue.get() # 预处理、推理、后处理、区域判定 dets = rknn.inference(inputs=[frame]) result_queue.put(("intrusion", dets)) def process_fire(frame_queue, result_queue): rknn = RKNN() rknn.load_rknn("./models/fire.rknn") rknn.init_runtime(core_mask=RKNN.NPU_CORE_1) while True: frame = frame_queue.get() dets = rknn.inference(inputs=[frame]) result_queue.put(("fire", dets)) def process_garbage(frame_queue, result_queue): rknn = RKNN() rknn.load_rknn("./models/garbage.rknn") rknn.init_runtime(core_mask=RKNN.NPU_CORE_2) frame_id = 0 while True: frame = frame_queue.get() if frame_id % 5 != 0: # 每5帧识别一次 frame_id += 1 continue frame_id += 1 dets = rknn.inference(inputs=[frame]) result_queue.put(("garbage", dets)) def capture_frames(shared_frames, frame_queue): # 从摄像头/RTSP取流,写入共享内存,并把帧引用放进frame_queue pass if __name__ == "__main__": frame_queue = mp.Queue(maxsize=3) result_queue = mp.Queue() p1 = mp.Process(target=process_intrusion, args=(frame_queue, result_queue)) p2 = mp.Process(target=process_fire, args=(frame_queue, result_queue)) p3 = mp.Process(target=process_garbage, args=(frame_queue, result_queue)) p0 = mp.Process(target=capture_frames, args=(shared_frames, frame_queue)) p0.start(); p1.start(); p2.start(); p3.start()实际部署时还需要在采集进程里维护共享内存的生命周期,并且把告警事件统一汇总到一个事件总线里,方便上层做联动。这个骨架只是把最核心的并发关系呈现出来。
4. 实测性能与调优清单
4.1 一份三轮并发实测数据
我在 RK3588 开发板(8GB 内存、LPDDR4x)上跑过一组比较典型的数据,环境是 Linux + rknn-toolkit2 1.6。模型全部 INT8 量化,三个进程独立绑定不同 NPU core,各跑各的输入分辨率:
| 任务 | 网络与输入 | 绑定核心 | 单帧推理耗时(实测) | 部署帧率 |
|---|---|---|---|---|
| 人员入侵 | YOLOv5s 640×640 | core0 | 28ms 左右 | 25 FPS |
| 烟火检测 | YOLOv5s 416×416 | core1 | 16ms 左右 | 25 FPS |
| 垃圾分类 | MobileNetV3-Small 224×224 | core2 | 8ms 左右 | 5 FPS(每5帧取一次) |
三个模型同时跑的时候,人员入侵的端到端时延会从单跑时的 28ms 增加 5~8ms,主要原因不是 NPU 算力不够,而是 CPU 侧的后处理和图像预处理在抢核心。总体来看,NPU 利用率大约在 60%~70%,还有余量。
如果全部模型都用 YOLOv5s 640,并且在同一个 core_mask 下跑,那单帧耗时很可能会被拉长到 100ms 以上,三个任务全部卡顿。差距就是这么大,所以“核绑定 + 分级帧率”这套组合是必须的。
4.2 调优优先级排序
如果跑完发现性能还是不够,我建议按下面的顺序做调优,而不是盲目换模型:
- 第一优先:确认每个算子都跑在 NPU 上,消除 CPU fallback。
- 第二优先:调整低优先级任务的推理频率,垃圾分类降到 1~2 FPS 都不会有问题。
- 第三优先:压缩输入分辨率。烟火检测从 640 降到 512,人员入侵从 640 降到 512,优先保证目标仍然可辨识。
- 第四优先:用 RGA 硬件做图像缩放和格式转换,把 CPU 从预处理中解放出来。
- 第五优先:检查线程/进程的 CPU 亲和性,把三个推理进程分别绑到不同的 CPU 大核上。
实测中,很多性能瓶颈其实出在预处理和后处理上。比如 YOLOv5s 的后处理需要遍历数千个候选框,在 Python 里循环很慢,我一般会用向量化的 NumPy 操作或者直接写成 C 扩展。
4.3 长期运行的稳定性与温控
RK3588 三个 NPU core 长期满载,发热非常明显。我们第一次跑满负载测试时,板子温度很快冲到 85℃ 以上,然后 NPU 开始降频,推理耗时直接翻倍。后来加了主动散热风扇,并且用 PWM 根据温度动态调速,才把温度稳定在 65℃ 左右。
如果你用的是 RK3588 的开发板,建议在系统里提前打通风扇控制逻辑,比如根据/sys/class/thermal/thermal_zone0/temp的温度值动态调整 PWM 风扇转速。这个话题和“RK3588 读取风扇转速”“PWM 风扇”这类问题经常一起出现,其实就是在设备树里配置好风扇的 PWM 引脚,然后写一个简单的温度控制循环。对于单板长时间跑三路视觉的场景,这块不能省。
另外,如果项目用了 buildroot 或自定义系统,建议把 CPU 调频策略改成performance模式,避免 CPU 动态降频干扰推理进程的实时性。NPU 本身没有独立的调频接口,但 NPU 的负载高了,CPU 调度和 DDR 频率都会受到影响,所以要整体观察。
5. 常见问题与排查实录
5.1 模型转换成功,但推理极慢
先查算子执行设备。用 rknn-toolkit2 的init_runtime时,可以打开perf_debug=True,跑一次推理后输出每个算子的执行耗时。如果看到大量算子的耗时都集中在 CPU 设备上,说明模型在转换时发生了 fallback。我遇到最多的是 YOLOv8 的输出头里某些 Gather/Slice 算子被放到 CPU,解决方法是导出 ONNX 时对检测头做裁剪,或者在模型结构里避免动态 shape 操作。
5.2 多进程 init_runtime 冲突或失败
RKNN 多进程分别初始化时,偶尔会遇到第二个进程init_runtime失败,报 NPU 上下文创建错误。这个问题在多个 RKNN 实例同时初始化的瞬间容易出现。我的处理办法是让三个子进程启动后先各自sleep0.5~1 秒,错开初始化时间窗口;或者在一个主进程里先依次完成三份 RKNN 模型的初始化,再通过 fork 创建子进程。前者改动小,后者更稳。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 第二个模型 init_runtime 报错 | 多个 RKNN 上下文同时抢占驱动 | 子进程错峰启动,间隔 0.5~1s |
| 推理结果全为 0 | 输入数据格式或归一化不一致 | 检查 mean/std 和 data_format 设置 |
| 偶发段错误 | 共享内存生命周期管理不当 | 用带引用计数的共享结构,避免提前释放 |
| 长时间运行越来越慢 | NPU 降频或内存泄漏 | 监控温度、检查推理循环中是否有对象反复创建 |
5.3 内存带宽竞争导致丢帧
三路推理同时进行时,每路都要做图像缩放、颜色空间转换、推理输入拷贝。如果全部用 CPU 做,DDR 带宽很快被打满。我用perf top查过,CPU 大量时间花在memcpy和resize上。最后的解法是把缩放和格式转换交给 RGA 硬件处理,同时把共享内存的拷贝次数降到最低。如果项目用 Python,建议对摄像头帧直接复用同一块输入缓冲区,能省一次拷贝就省一次。
5.4 量化后精度崩了
精度崩了先不要急着换模型,先做三件事:第一,确认校准集图片和真实场景分布一致;第二,检查mean_values、std_values是否和训练一致;第三,用accuracy_analysis定位第一个掉点的层。如果问题出在检测头,可以对检测头和主干分别设置量化策略,比如检测头用更高精度的量化方式,主干保持 INT8。这个操作在 rknn.config 里对应混合量化配置。
5.5 烟火检测误报多
模型层面能解决一部分,但更多是在后处理方面做时间维度的确认。火焰和烟雾在连续帧里是运动的,而路灯、红色汽车、太阳反光等干扰源相对静止。我实际应用时会给烟火检测加一个“连续 N 帧触发”机制,比如连续 3 帧都检测到才报火警,误报率能下降一大截。垃圾分类也是同样逻辑,连续两次识别到同一类垃圾才计入统计结果,避免人一走动就误判。
结尾:一点个人经验
这套方案真正落地之后,我最大的体会是:单块 RK3588 的 NPU 同时跑三个模型并没有那么难,难的是知道“哪个任务可以少跑几帧”“哪个模型必须独占核心”。资源分配和业务优先级对齐了,三个任务的问题就会自动解决。最后再分享一个小技巧:三个模型的推理输出不要各写各的告警函数,统一丢到一个事件队列里,由上层按时间戳和任务类型统一处理,这样后面接显示屏、接平台、接联动设备都会轻松很多。