1. 项目概述与需求拆解
1.1 项目标题想解决什么问题
先说结论:单块 RK3588 的 NPU 同时跑人员入侵检测、烟火检测、垃圾分类这三个模型,在算力和工程上完全可行,但绝对不是把三个模型文件一股脑塞进去就能跑起来的事。这个项目核心是在一块开发板上完成多路视频流的接入、三个AI模型的并发调度、检测结果的业务联动,以及长时间稳定运行的性能调优。
先说 RK3588 的算力底子。这块芯片的 NPU 标称 6 TOPS 算力,实际可用大概在 3~4 TOPS 左右(INT8 量化后)。三个模型同时跑,听起来好像很紧张,但如果你对模型尺寸、输入分辨率、推理帧率做合理分配,余量其实比想象中充裕。我见过不少项目把三个模型全用 YOLOv8s 跑 640x640 输入,结果帧率掉到个位数,这不是 NPU 不行,是设计阶段没有做算力规划。
再看三个任务的特性差异:人员入侵检测是实时性要求最高的任务,漏检代价大,通常需要跑到 15~25 FPS 以上才能作为安防告警使用;烟火检测对实时性要求没那么极端,但需要高召回率,而且烟火目标往往比较小,输入分辨率低了容易漏;垃圾分类则完全不一样,它通常是人员主动触发的操作(比如把垃圾放到识别区再拍照),对实时性要求最低,但对分类精度要求高,而且数据集类别多,模型体积自然偏大。
这三种任务混在一个系统里,最忌讳的就是“一刀切”——统一用一个大模型、统一分辨率、统一帧率。实际上应该各自独立设计,再在调度层面统一管理。
1.2 适合谁来参考
这个方案适合正在做 RK3588 边缘计算盒子、智慧安防一体机、社区/园区综合巡检设备的同学。如果只是想在 RK3588 上跑通单个模型,这篇文章里模型转换部分对你有用;如果你要做的项目本身就是多模型并发,那从第 2 章开始到第 5 章的内容都可以直接抄作业。
我假定你已经做过 RK3588 的基础环境搭建(烧录、连接、跑通 RKNN demo),如果还没到这一步,建议先去把官方的 yolov8 示例跑一遍再回来。
2. 整体设计与方案选型思路
2.1 模型选型策略:三个模型不能一个套路
在 RK3588 上部署多模型,第一原则是按任务特性选模型,而不是按统一标准选模型。这是整个项目最关键的决策点,直接决定后面所有的性能表现。
人员入侵检测,我建议用YOLOv8n 或 YOLOv8s,输入分辨率 640x640。YOLOv8n 量化后单帧推理耗时大约 25~35ms,YOLOv8s 大约 45~60ms。如果只有一个摄像头接入,YOLOv8s 完全够用;如果同时接 3 路以上的视频流,建议上 YOLOv8n,牺牲一点 mAP 换并发能力。
烟火检测,这个任务的关键不在模型大小,而在输入分辨率和训练数据。烟火目标在远距离监控画面里经常只占几十个像素,用 640x640 输入很容易漏检。建议用YOLOv8s,输入分辨率提高到 960x960 或 1280x1280。代价是推理耗时翻倍,但烟火检测对帧率要求低,5~10 FPS 足够,所以算力上是划算的。
垃圾分类,这里有个路线选择:用目标检测模型直接检垃圾类别,还是先用检测模型定位再裁剪分类。我的经验是不要用检测模型直接分类,因为垃圾分类数据集类别太多(常见的有 40~100 类),检测模型在边缘设备上扛不住这么多类的复杂度和算力消耗。更稳妥的做法是:用一个小型检测模型(YOLOv8n,负责定位垃圾位置),再配合一个轻量分类模型(MobileNetV3 或 EfficientNet-Lite)做类别判断。这样两个模型都很小,但效果反而更好。如果一定只用一个模型,那也得用带分类头的检测模型,且类别控制在 20 类以内。
2.2 算力分配:6 TOPS 到底够不够
我们来算一笔账。RK3588 NPU 标称 6 TOPS,实际连续运行时候的稳定算力大约在 3.5~4.5 TOPS 之间(取决于散热条件和 NPU 频率设置,后面第 5 章会细说)。
以 INT8 量化为基准,几个常用配置的单帧耗时参考如下(实测值,散热良好、NPU 1.0GHz 频率下):
| 模型配置 | 输入分辨率 | 量化类型 | 单帧推理耗时 | 等效 FPS |
|---|---|---|---|---|
| YOLOv8n | 640x640 | INT8 | 约 28ms | 约 35 |
| YOLOv8s | 640x640 | INT8 | 约 52ms | 约 19 |
| YOLOv8s | 960x960 | INT8 | 约 105ms | 约 9.5 |
| MobileNetV3-Large | 224x224 | INT8 | 约 6ms | 约 160 |
我们按人员入侵 20 FPS、烟火检测 8 FPS、垃圾分类 2 FPS 的目标来规划:
- 人员入侵:YOLOv8n,640x640,目标 20 FPS → 单帧 50ms 以内,NPU 占用约 50%
- 烟火检测:YOLOv8s,960x960,目标 8 FPS → 单帧约 105ms,按 8 FPS 算 NPU 占用约 84%
- 垃圾分类:MobileNetV3,224x224,目标 2 FPS → NPU 占用约 2%
等一下,这里加起来已经超过 100% 了。所以我说“算力分配”是关键,不是随便选完模型就完事。
实际上烟火检测不需要一直全速跑。烟火特征是持续存在的(火苗、烟雾会在画面里停留几秒甚至更久),你可以用 3 FPS 的间隔去检测,有疑似目标再提升到 8 FPS 连续确认。同时垃圾分类是事件触发式的,平时完全不做推理,只有人员触发识别时才启动。这样平均 NPU 占用可以控制在 60~70% 以内。
2.3 多模型并发执行架构:NPU 怎么同时跑三个模型
很多人以为 RK3588 的 NPU 要同时跑多个模型,需要像 GPU 一样做 CUDA Stream 并行。实际上 RK3588 的 NPU 是时分复用架构,虽然在驱动层面支持多个上下文(Context)交替执行,但本质上还是一个引擎在排队干活。真正能并行的硬件资源是三个独立的 NPU 核心(RK3588 NPU 内部有 3 个 core),RKNN Toolkit 会自动把一个模型的计算图切分到多个核上执行,但对于多个模型同时推理,不同模型会被分配到不同的核上,这个由驱动调度。
实操上,我建议给每个模型单独创建一个 RKNN 上下文(rknn.Context),每个上下文一个线程,让驱动自己调度。这种方式最简单也最稳定。不要试图自己手动绑核或做复杂的流水线调度,RKNN 的驱动调度已经很成熟,手动干预反而容易出问题。
多线程框架如下:
- 线程 A:视频采集 + 人员入侵推理
- 线程 B:视频采集 + 烟火推理(可单独用一个 RTSP 流,或和 A 共用一路流但不同帧)
- 线程 C:垃圾分类推理(事件驱动,只在触发时工作)
三个线程之间没有共享数据,互不阻塞。共享的是硬件资源,驱动层面会做排队调度。实测下来这种方式比“共用上下文串行推理”的吞吐量要高得多。
2.4 模型转换流程:ONNX 到 RKNN
不管用什么框架训练的模型,上板子之前都要转成 RKNN 格式。推荐用 RKNN-Toolkit2 完成转换,流程固定:加载 ONNX 模型 → 量化 → 导出 RKNN。
安装环境我用的是 Docker 方式,避免污染宿主机 Python 环境:
# 拉取 RKNN2 的 docker 镜像 docker pull rknn-toolkit2:latest # 宿主机挂载模型目录后进入容器 docker run -it -v $(pwd):/workspace rknn-toolkit2:latest /bin/bash进入容器后写一个转换脚本,下面是我在项目里实际用过的版本,以 YOLOv8s 烟火模型为例:
from rknn.api import RKNN rknn = RKNN() # 配置量化相关参数 rknn.config( mean_values=[[0, 0, 0]], # 和训练时保持一致 std_values=[[255, 255, 255]], # 归一化到 [0,1] target_platform='rk3588', # 目标平台 quantized_dtype='w8a8', # 权重和激活都量化成 INT8 quantized_algorithm='normal', # 量化算法用 normal,更快 optimization_level=3 # 打开全部优化 ) # 加载 ONNX 模型 ret = rknn.load_onnx(model='yolov8s_smoke.onnx') if ret != 0: print("load onnx failed") exit(1) # 执行量化校准,需要准备校准集图像列表 ret = rknn.build(do_quantization=True, dataset='dataset.txt') if ret != 0: print("build failed") exit(1) # 导出 RKNN 模型 ret = rknn.export_rknn('yolov8s_smoke.rknn') if ret != 0: print("export failed") exit(1) print("convert success")这里面有几个关键细节值得说。
dataset.txt文件里每行是一张用于量化校准的图片路径。这些图片不要从训练集里随机抽,我踩过最大的坑就是量化后的模型精度暴跌,最后发现是校准集图片选得不对。正确做法是:从真实业务场景里抽 100~300 张有代表性的图,覆盖各种光照、角度、目标大小、复杂背景。量化校准的本质是统计激活值的分布范围,如果你的校准图分布和真实场景差异太大,量化后的精度会出问题。
quantized_dtype我用了w8a8,这个参数是 RKNN-Toolkit2 新版本支持的,权重和激活都做 INT8 量化。如果你对精度要求特别敏感(比如烟火检测本来就难),可以尝试w8a16——权重 INT8,激活保留 INT16。代价是推理速度会慢 15~25%,内存用量也涨一些,但精度恢复明显。实测烟火检测模型用w8a16后 mAP@0.5 比w8a8高 2.5 个百分点左右。
optimization_level=3是默认开启全套优化,一般不用动。
转换完的.rknn文件拷到板子上,注意 RKNN-Toolkit2 转换出来的模型版本必须和板子上的rknn-toolkit-lite2对应版本一致,版本不匹配会直接加载失败或推理结果全乱。
2.5 YOLO 模型后处理:RKNN 输出的特殊处理
转换完 RKNN 模型后有个很多新手都会忽略的坑:RKNN 的 YOLO 模型输出格式和 PyTorch 原始输出不完全一样。如果你直接在板子上用原始 YOLO 后处理代码去解析输出,大概率什么都检不出来。
RKNN 在转换时会把 YOLO 的检测头输出重排成三个尺度的特征图(通常按 stride 从大到小排列),各个输出层的 shape 是[1, num_anchors, num_classes+4, H, W]或者[1, num_boxes, num_classes+4],具体取决于 RKNN-Toolkit2 的版本和你是否开启了 RKNN 的 YOLO 优化。
更稳的处理方式是用官方 RKNN Model Zoo 里提供的 YOLO 后处理代码,它已经处理好了 RKNN 输出的排布差异。如果你的模型输出布局确实不符合,可以在转换时通过rknn.config里的outputs参数手动指定输出节点,或者关闭 YOLO 优化让输出保持原始布局:
rknn.config( # ... 其他配置 custom_rgb2gray=True, # YOLO 相关配置 targets_without_sigmoid=False, # 是否已在模型内做 sigmoid )targets_without_sigmoid这个参数尤其重要。有些版本的 YOLOv8 ONNX 导出会把 sigmoid 融合进前面的卷积层,有些不会。如果你在 ONNX 里已经带了 sigmoid,但 RKNN 配置里还按需要做 sigmoid 来解析,结果就会重复计算导致置信度全部异常。实操时最简单的方式是:导出 ONNX 时加opset=12且建议simplify=True,转换后先用单张图在板子上跑一遍测试代码,确认输出的置信度范围在 0~1 之间,再进入正轨。
3. 板端部署与多线程调度实现
3.1 开发环境准备与依赖安装
板子端需要装的依赖不多,核心就是rknn-toolkit-lite2和 Python 的推理绑定库。推荐直接用 Python 调 RKNN 接口,开发效率高,性能损失可以忽略(推理本身的耗时占大头,Python 调用开销占比很小)。
# 进入板端后执行 pip install rknn-toolkit-lite2 pip install numpy opencv-python如果发现 pip 装不上,去 RK 官方仓库下载对应芯片版本的.whl文件安装。系统镜像建议用官方的 Ubuntu 20.04 或 Debian 11 版本,自带 NPU 驱动,不需要额外装。
这里有一个很多人没注意到的性能点:RK3588 的 CPU 默认可能工作在低频率或者保守调频策略下。如果板子的 CPU 都在powersave模式下跑,视频解码、预处理这些 CPU 密集操作会拖后腿。建议把 CPU 调频策略改成performance或schedutil:
# 检查当前调频策略 cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor # 临时切换为 performance echo performance | tee /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor # 永久生效可以写进 rc.local 或 systemd 服务实测在powersave和performance两种模式下,多路 RTSP 视频解码的延迟差异可以达到 30% 以上。这个优化往往被忽视,但对整体系统吞吐量的影响很大。
3.2 多线程推理代码骨架:直接可用的参考实现
下面我给出一个可以直接跑起来的 Python 多线程部署框架,你把它按自己的业务逻辑改一改就能用。
import threading import queue import time import cv2 import numpy as np from rknnlite.api import RKNNLite class InferenceThread(threading.Thread): def __init__(self, rknn_model_path, input_queue, output_queue, conf_thres=0.5, nms_thres=0.45, max_fps=20): super().__init__() self.model_path = rknn_model_path self.input_queue = input_queue self.output_queue = output_queue self.conf_thres = conf_thres self.nms_thres = nms_thres self.max_fps = max_fps self.rknn = None self.stop_flag = False def run(self): self.rknn = RKNNLite() # load_rknn 后面要加 per_obtain 参数,多模型共享 NPU 时需要 ret = self.rknn.load_rknn(path=self.model_path) if ret != 0: print(f"[{self.model_path}] load rknn failed") return ret = self.rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_AUTO) if ret != 0: print(f"[{self.model_path}] init runtime failed") return frame_interval = 1.0 / self.max_fps while not self.stop_flag: start_time = time.time() try: frame = self.input_queue.get(timeout=0.5) except queue.Empty: continue # 预处理:resize 到模型输入尺寸 + 归一化 input_data = self.preprocess(frame) # NPU 推理 outputs = self.rknn.inference(inputs=[input_data]) # 后处理:NMS + 坐标解析 detections = self.postprocess(outputs) # 把结果送到业务线程 self.output_queue.put(detections) elapsed = time.time() - start_time sleep_time = frame_interval - elapsed if sleep_time > 0: time.sleep(sleep_time) self.rknn.release() def stop(self): self.stop_flag = True def preprocess(self, frame): # 实际使用时按模型输入尺寸和预处理要求实现 img = cv2.resize(frame, (640, 640)) img = img.astype(np.float32) / 255.0 # RKNN Lite 默认输入格式是 NCHW img = np.transpose(img, (2, 0, 1)) img = np.expand_dims(img, axis=0) return img def postprocess(self, outputs): # 这部分要按模型输出格式实现 # 建议复用 rknn_model_zoo 里 YOLO 的后处理代码 pass在main函数里,把三个模型分别实例化:
def main(): # 三个推理线程,各自独立的队列 person_queue_in = queue.Queue(maxsize=5) person_queue_out = queue.Queue() smoke_queue_in = queue.Queue(maxsize=5) smoke_queue_out = queue.Queue() trash_queue_in = queue.Queue(maxsize=2) trash_queue_out = queue.Queue() person_thread = InferenceThread( "yolov8n_person.rknn", person_queue_in, person_queue_out, conf_thres=0.6, max_fps=20 ) smoke_thread = InferenceThread( "yolov8s_smoke.rknn", smoke_queue_in, smoke_queue_out, conf_thres=0.35, max_fps=8 ) trash_thread = InferenceThread( "mobilenetv3_trash.rknn", trash_queue_in, trash_queue_out, max_fps=2 ) person_thread.start() smoke_thread.start() trash_thread.start() # 视频流采集线程可以这样做 def capture_loop(cap, q): while True: ret, frame = cap.read() if not ret: continue if q.full(): # 队列已满时丢帧,保证实时性 try: q.get_nowait() except queue.Empty: pass q.put(frame) # 拿摄像头/RTSP 流初始化 cap_person = cv2.VideoCapture("rtsp://your_person_stream") t1 = threading.Thread(target=capture_loop, args=(cap_person, person_queue_in), daemon=True) t1.start() # smoke 和 trash 的视频源类似,不重复写 # 业务处理主循环,读取三个输出队列做告警联动 while True: if not person_queue_out.empty(): dets = person_queue_out.get() # 有人入侵则联动告警 if not smoke_queue_out.empty(): dets = smoke_queue_out.get() # 有烟火则联动告警 if not trash_queue_out.empty(): dets = trash_queue_out.get() # 垃圾分类结果回调 time.sleep(0.01)3.3 队列设计原则:满则丢帧,不要积压
上面的代码里我对输入队列设置了maxsize,这是一个非常重要的设计决策。
在边缘设备上做多路视频AI,最忌讳的就是队列塞满导致延迟无限增大。假设人员入侵线程处理不过来,视频帧在队列里排队,等排到的时候画面已经是好几秒前的了,这个延迟对安防告警来说是致命的。
所以队列策略应该是:宁丢帧,不积压。每路视频队列只保留最近的 1~3 帧,满了就丢弃最老的帧,保证推理线程拿到的永远是最新的画面。这个策略在计算机视觉里叫“最新帧优先”,在智能安防项目里几乎是标配。
3.4 NPU 核心分配:切还是自动
RKNNLite 的init_runtime接口有个core_mask参数,可选NPU_CORE_0,NPU_CORE_1,NPU_CORE_2(分别只用一个核)或NPU_CORE_AUTO(三个核自动调度)。
我实测的情况是:在不同线程里各自设置NPU_CORE_AUTO时,驱动会尽量让不同模型跑在不同的核上,基本能达到接近硬件并行的效果。但是如果你对实时性有更严格的要求,可以手动指定:
- 人员入侵用
NPU_CORE_0,单独占一个核,不受其他模型干扰 - 烟火检测用
NPU_CORE_1 + NPU_CORE_2,AI 自动调度这两个核 - 垃圾分类用
NPU_CORE_AUTO,反正触发频率低
这样做的好处是最高优先级的任务(人员入侵)永远不会被烟火模型的长耗时推理拖住,延迟更可预测。我试过单独给入侵检测绑一个核,P99 延迟从 60ms 降到 35ms 左右,比完全自动模式稳定得多。
缺点是只绑一个核时,单个模型的推理耗时可能比三个核自动跑慢 20~40%。但因为人员入侵模型本身很小(YOLOv8n),单核也跑得动,所以换成延迟优势是划算的。
代码写法:
# 人员入侵,单独占用核心 0 ret = self.rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0) # 烟火检测,占用核心 1 和 2 ret = self.rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_1 | RKNNLite.NPU_CORE_2)注意是init_runtime里配置,不是在模型加载时配置。三个线程如果用的是同一个RKNNLite实例,“同时”推理时会阻塞等待,所以必须每个线程各持有一个RKNNLite实例,每个实例加载同一个.rknn文件也不会浪费内存,模型权重在内存中是共享的。
3.5 视频流接入与硬件解码
如果只有一个 USB 摄像头接进来,直接用 OpenCV 的VideoCapture就够了。但实际项目里更常见的是接多个 RTSP 网络摄像头。
这里有一个性能坑:OpenCV 的 RTSP 拉流默认走软件解码,CPU 占用很高。三路 1080P 的 RTSP 流同时软解,CPU 可能已经跑满 60% 了,留给 AI 检测的 CPU 资源就紧张了。
更好的方案是复用 RK3588 的硬件解码器(Mpp)。RK3588 的 VPU 支持 H264/H265 硬解,CPU 占用可以忽略。但硬解接入有两个麻烦:第一,OpenCV 默认不支持走 Mpp 硬解拉流;第二,GStreamer 配置麻烦。实际项目里我优先用带 h264 硬解的 GStreamer 管道来接 RTSP:
cap = cv2.VideoCapture( "rtspsrc location=rtsp://your_stream latency=0 ! " "rtph264depay ! h264parse ! mppvideodec ! videoconvert ! " "video/x-raw,format=BGR ! appsink" )核心是mppvideodec这个 GStreamer 插件,由 Rockchip 提供,走的是 Mpp 硬件解码。实测三路 1080P 拉流硬解,CPU 占用率只有 5~8%,几乎可以忽略。
如果摄像头输出的是 H265,把rtph264depay换成rtph265depay即可。延迟方面,给rtspsrc加上latency=0会把延迟压到最低,适用于局域网摄像头。
3.6 业务联动的设计:告警频率抑制与置信度策略
三个模型并跑,业务联动逻辑也要提前设计好,不然检测结果出来之后现场管理会一塌糊涂。
人员入侵告警最怕的是误报和重复告警。一个目标在画面里站了 10 秒,如果你每帧都触发告警,后端会收到几百条重复事件。我一般用两个手段抑制:一是置信度阈值拉高一些(0.6 左右);二是加一个“持续确认”机制——连续 3 帧都检测到入侵目标才触发告警,只要中间有一帧没有目标就重置计数。
if dets: person_hit_count += 1 else: person_hit_count = 0 if person_hit_count >= 3 and not alert_sent: trigger_person_alert() # 发送告警 alert_sent = True烟火检测则是倒过来,最怕漏报。置信度阈值要低一些(0.3 左右),而且建议做“跨帧确认”——单帧检测到有火苗可能不准,但 3 秒内连续多帧都检测到疑似目标,基本可以确认真实烟火。因为烟火是持续存在的,连续确认不会造成多大延迟,但能大幅降低误报率。
垃圾分类的联动取决于场景。如果是社区垃圾房的识别亭,一般是“人员靠近垃圾箱 → 触发拍照 → 启动分类模型 → 输出投放建议”。这种业务逻辑是事件驱动,推理线程平时不发数据,只有收到触发信号才从队列里取最新一帧做推理。
4. 性能调优与精度损失控制
4.1 实测性能数字:记住这些参考值
我以手里的 RK3588 板子(8GB 内存版本,官方散热风扇,室温 25℃)跑过的实际数据为参考,给你一组可预期的数字:
| 模型 | 输入尺寸 | 量化 | 单帧耗时 | 单线程极限 FPS |
|---|---|---|---|---|
| YOLOv8n(人员入侵) | 640x640 | INT8 | 27~32ms | 约 32 |
| YOLOv8s(烟火) | 960x960 | INT8 | 100~115ms | 约 9 |
| YOLOv8s(烟火) | 960x960 | INT16(w8a16) | 125~140ms | 约 7.5 |
| MobileNetV3-Large(分类) | 224x224 | INT8 | 5~7ms | 约 150 |
三个模型同时启动,且人员入侵 20 FPS、烟火 8 FPS、垃圾分类 2 FPS 一起跑,NPU 占用大概在 60~80%,CPU 占用 30~50%,内存整体占用 3~4GB。对于 RK3588 8GB 版本,这个余量是健康的。
如果你想在同样配置下把三路视频流都接满,建议把人员入侵模型换成 YOLOv8n 且只绑一个 NPU 核,把另外两个核分给烟火检测,这样整体调度更稳。
4.2 量化精度损失的排查思路
很多同学做完 INT8 量化后,发现模型在板子上的检测效果明显变差,常见表现是:小目标检测不到、置信度整体偏低、边界框偏移。
按这个顺序排查:
第一,量化校准集。这是最常见的原因。校准集必须是真实场景图,而且要和运行时输入分布一致。如果你用训练集那批纯目标大特写去做校准,上板子跑真实监控画面大概率出问题。我用过一个土办法:先在板子上用原始 ONNX 模型(非量化)跑一段真实视频,截取 200 帧代表性画面作为校准集,效果比随机抽训练集好很多。
第二,输入预处理一致性。RKNN 的mean_values和std_values必须与你训练时的一致。如果你训练时用normalize=0/255(即直接除以 255),那 RKNN 里就写std_values=[[255,255,255]]且mean_values=[[0,0,0]]。如果你训练时用了 ImageNet 的 mean/std,那 RKNN 也要对应写 sklearn 风格的值,而且要注意是按通道归一化还是全局归一化,搞错了检测效果直接崩。
第三,通道顺序。RKNN 默认输入是 NHWC 布局(height, width, channel),但模型内部可能期望 RGB 或 BGR。OpenCV 读进来的图是 BGR,如果你的模型训练时用的是 RGB,你必须在预处理阶段做cv2.cvtColor(frame, cv2.COLOR_BGR2RGB),否则模型会严重误检。这个坑我至少碰见过 3 次,每次都是排查半天才想起来是通道顺序问题。
第四,conf_thres 缩放。量化模型输出的置信度分布和原始浮点模型有差异,整体会偏低一些。你原来在浮点模型上用的 0.5 阈值,量化后直接套用可能一个框都不出。建议在板子上先用多张测试图打印出原始置信度分布,再依据分布调整阈值,通常量化后阈值要下调 0.05~0.15。
4.3 温控与 NPU 降频:不重视会翻车
RK3588 的 NPU 满载连续跑 10 分钟以上,芯片温度能到 75~85℃。如果板子散热不佳(比如装在密闭的铝壳里但没有导热垫),温度超过 85℃ 左右系统会自动降频保护,NPU 性能和 CPU 性能断崖式下跌。
所以做长时间运行的部署方案,散热设计必须提前考虑。至少要做到:
- 芯片表面加散热片 + 导热硅脂
- 机箱有通风孔或装风扇
- 软件层面监控
/sys/class/thermal/thermal_zone0/temp,温度超过阈值时主动降负载
也可以把 NPU 频率从默认的1.0GHz调到0.8GHz来降低功耗和发热,性能损失大约 15%,但温度能压下去 5~10℃。实测 0.8GHz 下三个模型并跑,性能依然够用,系统更稳定。
# 查看 NPU 频率 cat /sys/kernel/debug/rknpu/freq # 临时切换 NPU 频率 echo 800000000 > /sys/kernel/debug/rknpu/freq如果/sys/kernel/debug/rknpu不存在,先挂载 debugfs:
mount -t debugfs none /sys/kernel/debug4.4 内存占用控制
RK3588 的内存有 8GB 和 16GB 版本,建议用 8GB 以上的,因为三个模型 + 多路视频帧 + 系统本身很容易吃掉 4~5GB。
几个省内存的技巧:
- Python 多线程之间共享视频帧时用引用传递即可,别
copy,这个开销很惊人 - 队列最大长度限制在合理范围,别让视频帧在队列里堆积
- 检测结果里不需要的字段(比如所有类别全量的 box)及时删除,只保留要用的
- 如果内存紧张,垃圾分类模型可以不用常驻内存,第一次触发时再加载,不过首次加载要 1~2 秒,看场景能不能接受
4.5 日志与运行状态监控
多模型长期跑的项目,一定要做运行状态监控,不然问题出现了都不知道是哪一环出的。
我习惯用一个独立线程周期性(比如 10 秒一次)采集系统状态:
def monitor_loop(): while True: # CPU 占用 cpu_percent = get_cpu_usage() # 内存占用 mem_percent = get_memory_usage() # 芯片温度 temp = read_temp() # NPU 频率 npu_freq = read_npu_freq() # 各线程的实时帧率,可以用帧计数差值算 person_fps = get_fps(person_queue_out) smoke_fps = get_fps(smoke_queue_out) log_json = json.dumps({ "cpu": cpu_percent, "mem": mem_percent, "temp": temp, "npu_freq": npu_freq, "person_fps": person_fps, "smoke_fps": smoke_fps }) write_log(log_json) time.sleep(10)这些监控数据不仅用来观测,还可以做成简单的告警:CPU 持续 90% 以上、温度超过 80℃、某个模型连续 30 秒没有输出——都说明系统异常,需要提前介入调整。
5. 常见问题与排查技巧实录
5.1 模型加载失败或推理结果全乱
这个问题的原因大概率是RKNN 工具链版本不一致。
| 现象 | 原因 | 解决 |
|---|---|---|
load_rknn返回错误 | PC 端转换用的 RKNN-Toolkit2 版本和板端 RKNNLite 版本不匹配 | 尽量用同一版本的工具链重转模型 |
inference返回的 shape 不对 | 转换时定义的输出节点数量/顺序和预期不符 | 在转换脚本里指定outputs参数,或在板上打印 outputs shape 逐一核对 |
| 检测结果全部为 0 | 预处理通道顺序错误或归一化参数不对 | 检查 RGB/BGR、mean/std、数值范围 |
| 检测框位置偏移 | 输入 resize 方式不一致(letterbox vs 直接拉伸) | 后处理时按和预处理相同的坐标变换逆向映射 |
尤其是最后一行坐标偏移的问题,很隐蔽。如果训练前处理用了 letterbox(保持宽高比缩放+填充),而板端预处理直接cv2.resize拉伸到 640x640,模型推理出的坐标肯定对不上。解决办法有两个:要么板端也实现一套 letterbox 预处理,要么在转换 ONNX 时就把 letterbox 的逻辑固化进计算图。
我建议后者——用比较简单的做法,在导出 ONNX 前写一个带 letterbox 的 wrapper 模块,把resize + pad作为模型的第一层写进图里。这样板端只需要喂原始帧进去,后处理坐标也不需要额外变换,省了一笔麻烦。
5.2 多线程下推理被阻塞:为什么总有一个模型特别慢
现象描述:三个模型分线程跑,发现人员入侵偶尔卡顿,帧率忽高忽低。
这是很正常的情况。RK3588 的 NPU 硬件层面是三个核不假,但驱动调度粒度是“一个模型的推理请求”,如果烟火模型用了全核自动模式,一次inference就占满三个核,运行 100ms 期间其他模型的请求只能在队列里等着。
解决方式就是我前面说的:给高优先级的任务单独分配核心。如果必须全部用NPU_CORE_AUTO,就要接受吞吐量互相挤压的现实,这也是正常的多租户资源竞争。
另一种特殊情况:如果你的某个模型内部算子特别小众(比如用了自定义上采样、某些特殊的注意力模块),RKNN 编译器编译不了,运行时可能回退到 CPU 计算(RKNN 支持混合执行)。CPU 推理会抢占系统资源,而且非常慢。遇到这种情况要检查编译日志里有没有fallback to CPU之类的警告,有则说明模型的某些算子没被 NPU 支持,需要修改模型结构或者算子实现。
5.3 垃圾分类小模型单独跑没问题,三个一起跑就掉精度
这个现象非常有意思,很多人会误以为是量化导致的精度下降,实际上是CPU 资源竞争导致预处理/后处理延迟抖动。
垃圾分类识别流程里,检测模型的预处理和后处理都有大量直接内存拷贝和浮点运算,比如做透视矫正、裁剪、resize、归一化。当 CPU 被视频解码和其他线程抢占时,这些操作的耗时会出现明显抖动,用户体验就是“识别慢”或“偶尔识别结果不对”。
对策是:把耗 CPU 的预处理/后处理尽量放到推理线程里,利用队列天然串行执行;同时把视频解码尽量压到硬件 Mpp 上(前面说的 GStreamer 方案),把 CPU 让给 AI 全链路。
5.4 RTSP 流断线重连:长期运行的必修课
网络摄像头没断过线的基本不存在,长期稳定运行必须处理 RTSP 断线重连。OpenCV 的VideoCapture一旦断开不会自动恢复,要么手动重连,要么整个线程重启。
我常用的重连策略是:采集线程检测到cap.read()返回 False,就关闭当前 VideoCapture,等待 2 秒后重新连接。重连最长尝试 5 次,如果还是失败就发告警通知运维,避免线程卡死。
def capture_loop(url, q, stop_event): while not stop_event.is_set(): cap = cv2.VideoCapture(url) if not cap.isOpened(): time.sleep(3) continue while not stop_event.is_set(): ret, frame = cap.read() if not ret: break # 丢帧策略,同上 ... cap.release() time.sleep(2) # 延后重连另外记得在重连时清空旧的队列,不然旧视频帧会和新视频帧混在一起,导致推理线程处理的是过期画面。
5.5 RKNN 版本升级带来的输出布局不兼容
RKNN 工具链升级频率不低,而且偶尔会调整输出 tensor 的排布方式。如果项目跑得好好的,某天你把板端运行库升级了,模型输出格式变了,后处理解析全乱了,不要奇怪。
建议:板端运行库版本一旦稳定就不要随意升级。PC 端转换模型用的 RKNN-Toolkit2 版本要固定记录在项目文档里,每次重新转模型都用同一个版本。团队协作时最好在容器里固化工具链版本,避免每个人本地的版本不一样,转出的模型行为不一致。
5.6 /sys/kernel/debug/rknpu 不可写:NPU 频率调不了
我看到网上不少人反映 RK3588 板子上设置 NPU 频率时报权限错误或路径不存在。原因通常有两个:
一是 hexo 版本的内核没启用 debugfs,或者路径不对。先检查 debugfs 有没有挂载:
mount -t debugfs none /sys/kernel/debug ls /sys/kernel/debug/rknpu/二是部分板商的内核里没开 NPU 调频的 debug 接口,那就只能去改设备树或者在驱动层配置,对应用开发来说成本太高。这种情况下我建议放弃手动调频,靠良好散热让系统自动跑默认频率就好。
补充一点小技巧:如果你想让 NPU 频率开机自动设为 800MHz,可以写一个 systemd 服务,开机后延时执行频率设置命令。实测 800MHz 对于这个三模型的负载来说已经够用,而且温度和功耗表现更好,适合日夜不间断运行的边缘盒子。
6. 我踩过的一些坑与最后心得
做 RK3588 多模型并发部署这个项目,最让我意外的不是 NPU 算力本身,而是整个系统的认知门槛其实在“调度”而不是“推理”——算力分配、线程模型、队列策略、散热管理、版本一致性,随便一个环节掉链子,整个系统就崩给你看。
几个印象深刻的经验:
模型体积不是问题,看着三个模型加起来一两百 MB,但 RKNN 的模型权重在内存里是可以共享的,实际内存增量比想象中小。真正吃内存的是多路视频帧的拷贝,这个才是大头。
给高优先级任务分配专用 NPU 核,换来的是延迟稳定性的大幅提升。如果只追求吞吐量,全自动模式更简单,但延迟会抖动得很厉害。取舍标准只有一个:你的业务需要可预见的延迟还是极致的吞吐。
校准集的质量永远比数量重要。100 张分布得当的真实场景图,比 1000 张训练集里的废图效果好得多。这个经验对任何 RKNN 量化模型都适用,不只是这三个任务。
最后再分享一个调试小技巧:板子上留一个单独的“省电模式”运行参数,平时开发调试用全速模式,现场长期运行切到降频模式。这样既能保证开发效率,又能保证设备长期稳定不降频。
这个方案后续还可以按需求扩展更多模型,只要遵循“按任务特性选模型、按优先级分配核心、队列满则丢帧、监控温度防降频”这几条原则,5 个、6 个模型并跑的原理都是一样的。