客户把需求拍在桌上那一刻,我第一反应是“这块板子怕是扛不住”:单块RK3588,6 TOPS的NPU,要同时跑人员入侵、烟火检测、垃圾分类。先不说模型精度,光是在一块板卡上把三个视觉任务长期稳定跑起来,就够折腾一阵。但真把算力账算清、把调度逻辑理顺之后,结论反而乐观:不但能跑,还能在8GB内存版本上留出足够余量,继续接一路甚至两路摄像头。这篇文章把这套从模型选型、RKNN转换到多模型并发的完整过程写出来,给准备在RK3588上做多任务边缘AI的朋友做个参考。
1. 先算账:6 TOPS的NPU到底能同时喂饱几个模型
1.1 RK3588的NPU算力到底是什么规格
先说一个最容易误解的地方:RK3588标称的6 TOPS,指的是INT8量化下的峰值算力,不是你想怎么用都能拿到的通用算力。实际项目中,推理框架、内存带宽、数据搬运、解码单元都会分走性能,实测下来能把峰值的一半稳定用出来已经算优化得不错。所以我个人习惯把这6 TOPS当成3 TOPS的有效算力来做方案规划,这样后面算帧率预算时不容易翻车。
RK3588的NPU是三个核心,支持INT4、INT8、INT16精度,频率最高能到1GHz。单看数据确实亮眼,但它不是独立显卡那样的大规模并行单元,而是需要把输入数据从DDR搬进NPU内部缓存,算完再搬回来。所以内存带宽和缓存命中率对实际吞吐的影响,往往比峰值算力更大。这也是为什么有些人拿到板子跑同一个模型,帧率却比论坛上别人测的低一截——DDR频率配置、散热条件、同时解码几路视频,都会直接影响NPU表现。
1.2 三个任务每次推理要花掉多少毫秒
回到三个任务本身。模型选型不同,单帧消耗差好几倍。我最终确定下来的方案,前提是做了“目标场景拆分”,不让一个模型去包打天下:
- 人员入侵:用YOLOv8n,输入缩到416x416,单次NPU推理大约16ms;
- 烟火检测:烟火目标普遍偏小,早期火苗可能只有几十个像素,我用YOLOv5s,输入保持640x640,单次约45ms;
- 垃圾分类:因为摄像头固定在某个垃圾桶位,用MobileNetV3-small做纯分类,输入160x160,单次3到5ms。
这个过程里我做了大量对比测试,同样的模型在板子上跑出来的时间会因为量化方式、输入尺寸、NPU负载产生挺大波动。上面这些数是“低负载、1GHz NPU、良好散热”下的参考值。如果环境温度高导致降频,后面会专门讲。
1.3 算清楚帧率预算:为什么不用每帧都跑全部任务
直接算一笔账:如果25fps视频每一帧都跑这三个模型,每秒需要25×(16+45+4)=1625ms,远超NPU一秒1000ms的处理能力,这条路从数学上就堵死了。但视觉项目没人规定每个任务都要跑满帧率,人员入侵不需要每帧都产出告警,烟火从出现到蔓延是相对缓慢的过程,垃圾更不用24小时持续扫。
我把策略调成:
- 人员入侵:每3帧跑一次,大约8fps,单路视频每秒NPU占用约128ms;
- 烟火检测:每6帧跑一次,大约4fps,单路视频每秒NPU占用约180ms;
- 垃圾分类:事件触发,只有检测到有人靠近垃圾桶并离开后才采样一次,每秒最多占几毫秒。
这样单路视频一整秒的NPU占用大概300ms左右,整体负载只有三成。如果你只做一两路摄像头,那绰绰有余;到三四路的时候,才需要考虑真正意义上的错峰调度。
算完这笔账,我心里就踏实了。剩下的问题不再是“能不能跑”,而是“怎么把这三件事在同一个进程里安排得明明白白”。
2. 三个任务分开选模型:目标差异决定了路线差异
2.1 入侵检测的本质:大目标、高召回、人与规则结合
人员入侵的核心不是“识别人”,而是“识别到人之后判断这个人做了什么”。所以模型部分只需要把人体框稳定地标出来,真正的报警逻辑放在规则层:是否进入ROI区域、是否越界、是否逗留超时。这样模型任务被大幅简化,召回率反而更容易保证。
我用YOLOv8n并在自己的数据上重新训练过。数据集主要覆盖三种场景:白天强光、夜间红外、人员部分被遮挡的情况。道路监控和工地场景差别很大,最好用目标现场的素材做fine-tune。YOLOv8n在416输入下对中远距离人体有不错的表现,但如果是想检测很小的目标,就需要切成512或640输入,推理耗时会上浮,项目里要对这个平衡有预期。
入侵检测最容易翻车的点不是模型漏检,而是误报。树枝晃动、光影变化、动物经过,都可能产生人体框。规则层一定要加“连续N帧检出且框位移小于阈值”的确认机制,或者用轻量跟踪把同一目标的轨迹串起来,再做跨线判断。否则甲方第二天就会给你打电话说“你们系统被一只猫触发了一晚上报警”。
2.2 烟火检测的本质:小目标、半透明、昼夜差异巨大
烟火检测是这三个任务里最憋屈的,因为“烟”和“火”的目标属性差异非常大。火焰有自己的纹理和颜色分布,算是有形状的目标;高温烟气则半透明、无固定纹理、边缘模糊,跟云、水汽、汽车尾气经常长得差不多。
我在烟火模型上最终选YOLOv5s,保持640输入,因为目标尺度小,下采样太大容易直接丢掉。数据方面,白天场景要收集阳光反射、红色灯光、晚霞等强负样本;夜晚场景要收集路灯、车灯、LED屏幕等干扰源。有条件的话做白天和夜晚两个模型或者加昼夜切换逻辑,因为同一个模型在两种光照下很难同时做好。烟火判定我只信多帧确认:连续3帧都有检出,并且中间停检不能超过5帧,才触发报警。单帧闪烁的火花不值得报,但连续帧出现的火焰纹理必须及时推给值班人员。
2.3 垃圾分类的本质:固定ROI加轻量分类,别硬上检测
垃圾分类这个任务,刚拿到需求时很多人第一反应是用YOLO检测垃圾桶里的瓶子、纸箱、易拉罐。但在真实部署里,垃圾桶位置固定,镜头角度固定,还经常遇到反光、遮挡、光线变化。直接做检测会引入大量背景干扰,模型还得学“什么是垃圾”和“垃圾在哪儿”,算力浪费严重,效果还未必好。
我把它拆成两步:第一步在画面里固定一个ROI框,框住垃圾桶口区域;第二步由调度器触发,把ROI裁剪后送进MobileNetV3-small做四分类:可回收、厨余、有害、其他。这个方案对摄像头安装位置有要求,但绝大多数垃圾房、垃圾亭的监控角度都满足条件。MobileNetV3在160x160输入下板端推理只要几毫秒,模型文件也小得多,这让整个系统的资源预算一下子轻松了不少。
从结果看,固定ROI加分类比端到端检测模型更稳也更省钱。这个思路其实可以复制到很多类似的场景:只要相机不动,就别让模型去学“定位”,把定位交给几何规则,模型只负责“识别”。
3. ONNX转RKNN:模型转换里的深坑与量化校准
3.1 环境与版本:工具链和板端驱动必须一一对应
模型训练好了,下一步就是转成RKNN格式。这个阶段我踩过最大的坑,不是模型本身,而是工具版本错配。rknn-toolkit2是跑在PC上的转换工具,板端则通过librknnrt.so加载RKNN模型。两者版本必须匹配,否则会出现rknn_init失败、甚至初始化成功但推理结果全是乱框的诡异问题。
建议先确认板子固件里SDK的版本号,再去下载对应版本的rknn-toolkit2。官方一般会提供版本对应表。我自己习惯在x86的Docker环境里跑转换,Python版本用3.8或3.10,避免本机环境把依赖搞乱。还有个老生常谈的点:转换前先对ONNX跑一遍onnxsim,很多莫名其妙的op不支持问题都能在简化之后消失。
3.2 从PyTorch导出ONNX并完成RKNN转换
转换脚本本身不难,难的是你知不知道每行参数在干什么。我的基础流程是这样:
from rknn.api import RKNN rknn = RKNN(verbose=True) # mean/std 配 0 和 255,含义是把输入归一化到 0~1 # 这样模型内部就不再额外做减均值除方差,量化精度通常会更好看 rknn.config( target_platform='rk3588', mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]] ) rknn.load_onnx(model='yolov8n_sim.onnx') rknn.build(do_quantization=True, dataset='calibration_dataset.txt') rknn.export_rknn('yolov8n.rknn')导出时有个容易被忽略的点:如果PyTorch模型包含动态shape或者带有大量自定义前处理逻辑,导出ONNX后要先固定shape,再把前处理剥离出去。RK3588的NPU喜欢静态shape,动态shape会让内存规划非常难受,性能也会打折。
3.3 量化校准集到底该放什么图
do_quantization=True的时候,校准集直接决定模型精度。校准集不是越多越好,而是覆盖度越高越好。我给三个模型分别准备了校准集:
- 人员入侵:白天、夜晚、逆光、远距离、部分遮挡各取一些,总共大概300张;
- 烟火检测:近火远火、灶台炊烟、路边摊烟雾、晚霞、车灯,300到500张;
- 垃圾分类:不同时段、不同光线下的垃圾桶ROI截图,150张左右就够,因为分类网络本身简单。
校准集图片推荐直接从现场视频里抽帧,而不是用训练集的典型样本,因为现场的实际数据分布更接近部署环境。抽帧时用一个脚本每隔几秒存一帧,覆盖从早到晚的完整光线变化,校准出来的量化精度会比你随便找的图片好一大截。
3.4 转换中的常见报错和量化掉点修复
转换阶段我遇到过三类问题:
一是“op not support”或者“op version mismatch”。这种优先用onnxsim简化,再不行就查一下该op对应的RKNN版本是否支持,有时升级工具链版本就能解决。
二是量化后精度明显掉。比如mAP掉了5个点以上,首先怀疑校准集分布和真实场景差距大,重新准备校准集;其次可以打开混合量化,把某些敏感层单独指定成fp16,虽然推理时间会涨一点,但精度损失能控制在1%以内。
三是转换后模拟器能用、上板不能用。这类问题基本都是板端librknnrt版本和PC端工具链不匹配,直接替换板端运行库或升级固件,别在代码层面硬调。
4. 多模型调度器:常驻权重、优先级队列、事件触发
4.1 为什么不推荐“三个进程各跑一个模型”
很多人拿到这种需求,第一反应是写三个Python进程,或者干脆开三个Docker容器,一个跑入侵一个跑烟火一个跑垃圾分类。这套方案最省事,但不是最优解。
首先,RK3588的NPU只有一个,多个进程同时提交rknn_run时,驱动要在不同上下文之间做任务切换,不仅排队时间不确定,还可能出现其中一个进程把NPUHAL锁占满,另一个模型长时间得不到调度的情况。其次,三个进程各自解码、各自预处理,同一路视频会被重复处理三次,CPU和DDR带宽白白浪费。最后是运维层面,三个进程崩溃任何一个都很难监控,日志还分散。
我的做法是反过来:一个常驻进程,内部管理三份模型权重,用同一个调度器统一分配NPU时间。模型上下文在启动时全部rknn_init好,运行期间绝不销毁重建,避免模型加载带来的秒级卡顿。
4.2 调度器的结构与代码骨架
调度器说到底就是三件事:优先级、帧间隔、事件触发。我用Python写过一版验证逻辑,写得很直白:
class MultiTaskScheduler: def __init__(self): self.person = RKNNModel('yolov8n.rknn') self.fire = RKNNModel('yolov5s_fire.rknn') self.trash = RKNNModel('mobilenetv3_trash.rknn') self.frame_cnt = 0 def on_frame(self, frame, timestamp): self.frame_cnt += 1 # 优先级1:人员入侵,每3帧一次 if self.frame_cnt % 3 == 0: self.run_person(frame) # 优先级2:烟火检测,每6帧一次 if self.frame_cnt % 6 == 0: self.run_fire(frame) # 优先级3:事件触发,人员离开垃圾ROI后采样 if self.trash_trigger.is_ready(timestamp): crop = self.roi.extract(frame) self.run_trash(crop) def run_person(self, frame): img = rga_resize(frame, 416) boxes = self.person.infer(img) self.rule_engine.on_person_boxes(boxes)这里的关键是所有推理共享同一个帧循环,串行提交给NPU。我不用多线程去并发跑rknn_run,因为串行提交反而能减少任务切换开销,单次推理时间更稳定。实际测试下来,三个模型轮流跑,就算某一次两个任务挤在同一帧,整体延迟也只增加几个毫秒,完全在可接受范围。
4.3 用规则引擎把三个任务串起来
调度器只解决“什么时候跑哪个模型”,真正让系统智能起来的,是模型之上那层规则引擎。
人员入侵模型出框后,规则引擎判断框是否进入ROI、是否越线、是否逗留超过15秒。烟火模型出结果后,多帧确认器负责去抖。垃圾分类则直接复用入侵模型的结果当作“触发器”:当人员框进入垃圾桶ROI区域、随后离开超过2秒,调度器才去采集垃圾图像并跑分类模型。这样垃圾模型一天可能只被触发几百次,而不是7x24小时空转。
这个串行触发逻辑,让三个任务不是简单“同时跑”,而是按业务因果关系协作,算力效率一下子上来了。之前担心的“NPU负载不够”问题,在这种设计下基本消失。
5. 别让预处理和后处理拖后腿:硬解码、RGA与低成本后处理
5.1 解码环节:RKMPP硬解基本链路
调度器设计好了,还得保证图像送到NPU之前别让CPU累死。如果视频流是RTSP或者本地H264文件,不要用OpenCV的VideoCapture去软解。RK3588自带的RKMPP硬解模块就是干这个的,1080p视频解码大概只要几毫秒,CPU占用几乎可以忽略。
我用的是MPP解码,拿到的帧是NV12格式的dma_buf。这里有个关键习惯:不要把解码后的NV12转成JPEG或者BMP再去做后续处理,直接把这个buffer传给RGA做缩放和格式转换。每多一次拷贝,都是在浪费内存带宽和CPU缓存。
5.2 缩放与颜色转换:RGA替代OpenCV
模型需要的输入是RGB,而且尺寸从416到640不等。如果每帧都先用OpenCV做resize再做cvtColor,A76核心会被大量消耗,尤其在多任务并行的场景下,CPU很可能比NPU先成为瓶颈。
RGA是Rockchip的硬件2D图像处理单元,一次调用可以同时完成缩放、颜色空间转换、裁剪。我之前测试过同样的720p转416x416 RGB,OpenCV大概要6到8毫秒,RGA基本在2毫秒以内。在调度器里,我维护了两份预处理缓存:一份416的RGB给入侵,一份640的RGB给烟火;ROI裁剪到160给垃圾分类。同一帧只需要做两次RGA缩放,而不是每个模型各做一次。
5.3 后处理与线程池:别让CPU成为新瓶颈
NPU跑得快,但模型输出的原始张量还得做decode、NMS、阈值过滤。如果这部分用Python循环去写,CPU占用会瞬间飙到300%以上。我最后把三个模型的后处理全部下沉到C++实现,并放到一个4线程的线程池里跑。YOLOv8n的输出decode加NMS一次大约2到3毫秒,YOLOv5s大约3到4毫秒,MobileNet分类就更快了,完全可以忽略。
这里还有个容易被忽略的细节:RKNN在推理时,输出内存如果复用不当,每次rknn_outputs_get都可能发生拷贝。我通常直接用RKNN的零拷贝接口,把输出buffer固定住,后处理线程只读取数据,不额外分配大内存。长期跑下来,内存碎片也少很多。
5.4 端到端延迟和整板负载实测
在RK3588、8GB内存、1GHz NPU、良好散热的条件下,单路1080p视频源做三任务部署,端到端数据大概是:
| 阶段 | 耗时 |
|---|---|
| RKMPP硬解1080p H264 | 3 ~ 5ms |
| RGA缩放+颜色转换到416/640 | 2 ~ 3ms |
| 入侵模型NPU推理 | 16 ~ 20ms |
| 烟火模型NPU推理 | 40 ~ 50ms |
| 后处理decode+NMS | 3 ~ 4ms |
| 规则引擎判断 | 小于1ms |
也就是说,从一帧到达解码器,到入侵告警或烟火告警输出,端到端大约30到35毫秒。整机CPU占用大概在30%到40%,NPU平均负载30%左右,内存常驻约1.2GB。在这个状态下,再接一路视频压力也不大。
6. 一次温度墙引起的性能骤降:完整排查链路复盘
6.1 线上症状:跑了四十分钟后报警变迟钝
东西部署到现场后,客户反馈说系统刚开始一切正常,跑了大概四十分钟,入侵报警的响应越来越慢。一开始我也怀疑是网络问题,但本地看画面延迟并不高,就是模型出框的时间变长了。我们在代码里给每次rknn_run打了耗时日志,发现同一个推理任务,冷启动时只要16ms,跑一段时间后涨到了25ms、28ms,甚至接近40ms。
这不是模型变笨了,是硬件在“自我保护”。RK3588的NPU、CPU、DDR共享散热模组,温度一高,SoC会自动降频。尤其NPU长时间满负载跑,温度墙很快就到。
6.2 逐步排查:从进程状态一路查到温度墙
排查链路是这样的:
第一步,先排除内存泄漏。在板子上连续跑free -m,发现内存占用稳定,排除持续缓存增长的问题。
第二步,看CPU占用。用top看有没有失控的进程,结果CPU占用正常,没有哪个进程在空转吃核。
第三步,打npu推理时间戳。发现涨的是NPU耗时,而不是预处理或后处理,问题锁定在NPU侧。
第四步,查温度。读取/sys/class/thermal/thermal_zone*/temp,发现已经到80度以上。再查NPU当前频率,通过debugfs看到频率从1GHz掉到666MHz左右,问题实锤了。
温度墙导致频率下降,推理耗时自然上涨。这也是很多边缘设备项目都会遇到的坑:开发时环境开着空调,板子裸奔测试一切正常;到了夏天阳光直射的机柜里,没有风扇,性能直接砍半。
6.3 修复方案:散热、降载、保优先级
解决分了三个层次:
第一层是治本,加主动散热。把PWM风扇接上,通过/sys/devices/platform/pwm-fan/hwmon/hwmon0/fan_target设置目标温度,夏天转速调到70%以上。实测加风扇后温度稳在55度左右,NPU保持1GHz不再掉频。
第二层是治标,软件降载。如果现场结构限制装不了风扇,就把烟火检测降到每8帧跑一次,入侵检测降到每4帧跑一次,以减少NPU持续负载。效果是响应稍微变慢,但至少不会性能雪崩。
第三层是保重点。调度器里加一个温度状态判断,当SoC温度超过阈值时,主动丢弃低优先级的垃圾分类任务,保证入侵和烟火始终处于最高响应级别。这套机制上线后再也没接到客户“报警变慢”的投诉。
6.4 同类坑的预判:版本不匹配和NPU任务堆积
跑多模型项目还容易碰到的两个坑,我顺便列在这。
一个是版本不匹配。有次把新模型带过去,rknn_init直接报错,查了半小时发现是板端librknnrt.so版本太老。建议每次在板上部署之前,先确认librknnrt.so版本和PC端rknn-toolkit2版本一致。
另一个是任务堆积。调度器的队列如果消费速度小于生产速度,会把无效帧一直留在队列里,导致“越跑越慢”的假象。我在每个队列里加了最大长度限制,队列满了直接丢最老的帧,保证当前帧永远是最新的画面。
所以如果你打算在RK3588上同时跑多个NPU任务,我的建议是:把散热方案和调度策略写进第一版设计文档,而不是等项目上线了再补。我这次就是被现场温度墙逼着从头补了这一课,过程相当酸爽。先把这些底子打好,后面哪怕增加一路摄像头、多跑一个模型,都不会慌。