ONNX 与 RKNN 详解:从模型格式到 RK3568 部署实战
做边缘计算和嵌入式 AI 的同学,应该都被同一个问题折磨过:模型在电脑上跑得好好的,一搬到板子上就各种报错,要不算子不支持,要不精度对不上,要不 NPU 压根不干活,纯靠 CPU 硬扛。我自己在 RK3568 上折腾部署也踩了一整年的坑,从最开始的 PyTorch 模型一路导出、转换、量化、调试,到最终在板子上跑出稳定帧率,整个过程走下来,最大的体会是:部署这事,七分在格式转换,三分在板端调试。所以这篇我重点把 ONNX 和 RKNN 这两个格式聊透,再带大家完整走一遍 RK3568 的部署流程,全是实际操作中总结出来的经验,不是那种照抄文档的搬运文。
这篇内容适合谁?如果你的工作涉及边缘计算、NPU 推理、嵌入式 Linux 开发,或者你正准备把 YOLO、OCR、分类模型这类视觉模型搬到瑞芯微平台上,那这篇文章基本就是为你准备的。我会把 ONNX 这个“通用交换格式”为什么存在、RKNN 这个“NPU 专属格式”是怎么设计出来的讲清楚,再给出一套可以直接照着做的 RK3568 部署方案,从模型转换到板端调用,一站式搞定。
1. 为什么部署要先搞定模型格式:从训练框架到 NPU 之间的“翻译官”
先说一个很多人忽略的事实:PyTorch 训练出来的模型,跟 NPU 能直接执行的模型,中间隔着一条巨大的鸿沟。训练框架关心的是怎么高效算梯度、怎么灵活搭建网络结构;而 NPU 关心的是怎么把算子在专用硬件上跑得最快、怎么让数据在有限的内存带宽里流动得最顺畅。这两者的诉求天然冲突,所以必然需要一个中间层来做转换。
1.1 ONNX:通用交换格式是怎么诞生的
ONNX(Open Neural Network Exchange)最初是微软和 Facebook 联合推出来的,目标很纯粹:让模型能在不同框架之间自由流动。你拿 PyTorch 训好的模型,导出成 ONNX,再导入到 TensorRT、OpenVINO、ONNX Runtime 这些推理引擎里跑,完全没问题。打个比方,ONNX 就像是模型界的通用语言,不管你原来是说法语的 PyTorch 还是说德语的 TensorFlow,统一翻译成英语,大家就都能交流了。
但注意,ONNX 本身并不是一个“部署格式”,它更多是一个“交换格式”。它的设计目标是表达完整的计算图,而不是追求极致的运行效率。所以你会看到,ONNX 模型通常包含大量冗余的算子组合,比如 Conv 后面的 BatchNorm 在推理时可以融合成单个 Conv,但 ONNX 里它俩往往是分开的两个节点。也就是说,ONNX 解决的是“能不能转换”的问题,而不是“转换后快不快”的问题。
1.2 RKNN:瑞芯微 NPU 专属格式的设计逻辑
RKNN 是瑞芯微(Rockchip)专门为自家 NPU 设计的模型格式,全称叫 Rockchip Neural Network,后缀通常是.rknn。它跟 ONNX 最大的区别在于:RKNN 格式的文件里不光包含了网络结构,还包含了针对特定 NPU 硬件优化过的算子调度信息、权重布局、量化参数等。
RK3568 上的 NPU 算力是 0.8 TOPS(INT8 精度),虽然不是顶尖水平,但在同价位的 SoC 里性价比相当能打。NPU 要高效运行,必须把模型的算子映射到硬件单元上,权重还得按硬件要求的排列方式重新排布,这个过程只有 RKNN 格式能承载。所以你不能直接把 ONNX 丢给 RK3568 的 NPU 跑,必须先经过 rknn-toolkit2 工具链转换。
1.3 格式转换链路全景
整个部署链路可以概括成一句话:训练框架 → ONNX → RKNN → NPU 运行。中间每一步都有各自的坑位,别以为导出了 ONNX 就万事大吉了。我见过不少新手把 PyTorch 模型导出成 ONNX 后直接拿去转 RKNN,结果在转换环节爆出一堆算子不支持的报错,源头其实就是导出 ONNX 的时候埋下了雷。
所以正确的做法是,每一步都验证清楚再往下走。导出 ONNX 后用 ONNX Runtime 跑一遍,对比 PyTorch 输出是否一致;转成 RKNN 后先用模拟器评估精度和性能,确认没问题再烧到板子上。这个多级验证的习惯能帮你省掉至少一半的排查时间。
2. ONNX 模型核心细节解析:搞懂算子你就掌握了转换的主动权
很多人觉得 ONNX 就是一个文件格式,直接torch.onnx.export导出完事,根本不关心里面是什么。等转换 RKNN 报错的时候再一个个算子去查,效率极低。我建议你花半小时把 ONNX 的结构看明白,后面省下的时间远超这半小时。
2.1 ONNX 文件结构拆解
ONNX 文件本质上是一个 protobuf 序列化的文件,核心包含三部分:
- 计算图(Graph):描述了数据从输入到输出的完整流动过程
- 算子节点(Node):图中的每个操作,比如 Conv、Relu、Add、Concat、Resize 等
- 权重与常量(Initializer):Conv 的卷积核权重、BN 的均值和方差等参数,以张量形式存储在模型内部
你可以用 Netron 打开 ONNX 文件,直观地看到整个网络结构。对于排查算子支持情况、检查输入输出 shape、确认模型分支结构,Netron 就是最好的工具,没有之一。我在 RKNN 转换报错时,第一件事永远是打开 Netron 看对应算子周围连接了哪些节点,再决定是改模型结构还是换算子实现。
2.2 动态 shape 与静态 shape 的取舍
ONNX 导出时可以设置动态维度(比如 batch 维度为动态),也可以全部固定。在 RK3568 部署场景下,我的强烈建议是:尽量用固定 shape。原因很简单,NPU 擅长处理固定尺寸的输入,内存预分配更容易,算子融合优化空间更大。动态 shape 虽然灵活,但 NPU 上往往需要额外的内存拷贝或算子重编译,性能会打折扣。
我曾经有个项目,输入尺寸设计成动态的 (1, 3, -1, -1),转 RKNN 时反复报 shape 推导错误,最后改成固定 640x640 后一次通过,推理速度还提升了 15% 左右。如果实际场景确实需要动态分辨率,可以考虑在预处理阶段做 letterbox 填充到固定尺寸,而不是把动态交给 NPU。
2.3 int8 量化基础:为什么边缘设备绕不开量化
RK3568 NPU 的 0.8 TOPS 算力是基于 INT8 计算的,FP16 算力只有一半不到,FP32 基本只能靠 CPU 硬跑。所以想在 RK3568 上获得最优性能,int8 量化几乎是必选项。
量化的本质是用 8 位整数来近似表示 32 位浮点数,核心是确定一个缩放尺度(scale)和偏移(zero point),把浮点数值域映射到 [-128, 127] 或 [0, 255] 的整数范围。rknn-toolkit2 支持两种量化方式:训练后量化(PTQ)和量化感知训练(QAT)。对于大多数场景,PTQ 用几百张代表性图片做校准就能达到不错的精度,实现成本低,是首选方案。QAT 精度更高,但需要修改训练代码,成本高,只有在 PTQ 精度损失无法接受时才考虑。
3. rknn-toolkit2 转换全流程实操:环境、脚本、量化一个都不能少
工具链的版本一定要选对,这是 RKNN 部署里最容易踩的坑。RK3568 对应的工具链是 rknn-toolkit2,注意不是 rknn-toolkit 1.x,两者针对的芯片平台完全不同。我用的是 1.5.0 版本,配合板端的 librknnrt 1.5.0,整体稳定,推荐新区块链直接用这个组合。
3.1 环境准备与安装
rknn-toolkit2 提供 Python 包,支持 Ubuntu 18.04/20.04 x86_64 环境。我的建议是单独创建一个虚拟环境,避免跟其他深度学习库的依赖冲突。安装方式很简单:
# 创建虚拟环境 conda create -n rknn python=3.8 conda activate rknn # 安装 rknn-toolkit2,从 GitHub 仓库获取 whl 包 pip install rknn_toolkit2-1.5.0-cp38-cp38-linux_x86_64.whl # 验证安装 python -c "from rknn.api import RKNN; print('rknn-toolkit2 installed')"注意,rknn-toolkit2 的依赖里有 numpy、opencv-python、onnx 等,如果服务器本来就装了不同版本的这些库,建议用 virtualenv 或者 conda 隔离,我实测下来混装很容易出现奇奇怪怪的 ctypes 报错。
3.2 编写转换脚本并理解关键参数
下面这个脚本是我项目里一直在用的最小可运行版本,它的完整链路是:读入 ONNX → 设置输入预处理 → 量化 → 生成 RKNN 模型 → 用模拟器评估输出。每一步都有详细注释,你可以直接复制改路径就能用:
from rknn.api import RKNN # 1. 创建 RKNN 对象,verbose=True 会打详细信息,排查问题时建议打开 rknn = RKNN(verbose=True) # 2. 配置模型输入预处理 # mean_values 和 std_values 必须与训练时保持一致,不然精度一定会崩 # target_platform 指明目标芯片,这里是 rk3568 rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3568' ) # 3. 加载 ONNX 模型 ret = rknn.load_onnx(model='./yolo11s.onnx') assert ret == 0, "ONNX 模型加载失败,检查路径和模型格式" # 4. 构建 RKNN 模型,do_quantization=True 表示进行 int8 量化 # dataset.txt 里每行写一张量化校准图片的路径,200张左右比较合适 ret = rknn.build(do_quantization=True, dataset='./dataset.txt') assert ret == 0, "模型构建失败" # 5. 导出 RKNN 模型文件 ret = rknn.export_rknn('./yolo11s.rknn') assert ret == 0, "RKNN 模型导出失败" # 6. 用模拟器跑一遍,确认精度和中间输出是否正常 rknn.release()这段脚本里,最容易出问题的是rknn.config中的 mean_values 和 std_values。这两个参数定义了输入图像的归一化方式,必须跟你训练时用的一致。我用 PyTorch 训练时正常做法是x / 255归一化到 0~1,所以这里就设置 mean=[0,0,0], std=[255,255,255]。如果你训练时用了 ImageNet 的 mean=[0.485, 0.456, 0.406] 和 std=[0.229, 0.224, 0.225],那这里也要对应设置,否则量化后的模型输出会跑偏得非常离谱,看起来像“精度崩了”,其实只是预处理没对齐。
3.3 量化数据集的准备方法
量化数据集的准备是 RKNN 转换中最容易被忽视的环节。dataset.txt里的每一行是量化校准用的图片路径,这些图片用于统计激活值的分布,决定量化的 scale 和 zero point。如果这张表的图片太少、内容太单一,比如全是纯黑色的背景,量化后的模型对真实场景的图片输出会明显偏差。
我的经验是,从训练集和验证集中随机抽取 200~500 张图片,尽量覆盖不同亮度、不同目标大小、不同背景。图片分辨率不需要跟模型输入完全一致,工具会自动做缩放处理。另外,图片格式最好是 jpg 或 png,不要用带 alpha 通道的图片,避免通道数不一致导致报错。
如果你发现量化后精度掉得厉害,可以尝试多准备一些与真实场景分布一致的图片,或者调整量化策略,rknn-toolkit2 从 1.4.0 版本开始支持quantized_algorithm、quantized_method这些高级量化参数,默认算法不够理想时可以手动调。
4. RK3568 部署硬件环境与 NPU 使用要点
模型转好了,接下来就是把它真正放到板子上跑起来。这部分内容比较杂,我挑重点讲:硬件规格、跑模型的方式、还有 RK3568 调试中绕不开的设备树问题。
4.1 RK3568 与 RK3588 的规格对比
很多人在选型时会纠结 RK3568 还是 RK3588,这里简单列个对比表,方便你对照自己的项目需求:
| 项目 | RK3568 | RK3588 |
|---|---|---|
| CPU | 4 核 Cortex-A55,主频 2.0GHz | 8 核,4 个 Cortex-A76 + 4 个 Cortex-A55 |
| NPU 算力 | 0.8 TOPS INT8 | 6 TOPS INT8 |
| 内存接口 | LPDDR4/LPDDR4X | LPDDR4X/LPDDR5 |
| 视频编解码 | 4K 60fps 解码,1080P 编码 | 8K 30fps 解码,8K 30fps 编码 |
| 定位 | 性价比、中低端边缘设备 | 旗舰、多路视频 / 复杂模型 |
如果你的项目做的是单路视频流目标检测、OCR、简单分类,RK3568 绝对够用,性价比极高。如果要做多路视频分析、大模型推理,那就老老实实选 RK3588。在 RK3568 上做部署时,目标帧率一般定位在 10~30 FPS 左右(视模型复杂度而定),如果想跑更大的模型就得对模型结构做裁剪或者深度优化。
4.2 板端运行时 librknnrt 与推理接口
模型部署到板端后,还需要安装 librknnrt.so 运行时库,以及 rknn-toolkit2 对应的 Python API(板端叫 rknn-toolkit-lite),或者在 C 环境通过 C API 调用。我们的项目大多用 C++ 写业务逻辑,所以这里给一个 C API 的最小示例,仅供参考:
#include "rknn_api.h" // 加载模型 FILE *fp = fopen("yolo11s.rknn", "rb"); fseek(fp, 0, SEEK_END); size_t model_size = ftell(fp); fseek(fp, 0, SEEK_SET); void *model_data = malloc(model_size); fread(model_data, 1, model_size, fp); fclose(fp); // 初始化 RKNN 上下文 rknn_context ctx; rknn_init(&ctx, model_data, model_size, 0, NULL); // 查询输入输出信息 rknn_input_output_num io_num; rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num)); // 设置输入 rknn_input inputs[1]; inputs[0].index = 0; inputs[0].type = RKNN_TENSOR_UINT8; inputs[0].size = 640 * 640 * 3; inputs[0].fmt = RKNN_TENSOR_NHWC; inputs[0].buf = img_data; rknn_inputs_set(ctx, 1, inputs); // 推理 rknn_run(ctx, NULL); // 获取输出 rknn_output outputs[1]; outputs[0].want_float = 1; rknn_outputs_get(ctx, 1, outputs, NULL); // 后处理 + 释放 rknn_outputs_release(ctx, 1, outputs); rknn_destroy(ctx);需要注意,输入数据的摆放格式(NHWC 还是 NCHW)必须跟转换时的一致。我默认使用 NHWC,因为摄像头采集的帧基本都是 HWC 排列,省去一次通道重排,推理延迟能降低 1~2ms。
4.3 设备树调试与系统适配
RK3568 的板端调试绕不开设备树(Device Tree),如果用的是正点原子、野火这类开发板,官方出厂系统一般已经适配好大部分外设。但如果你是自己画的板子,或者需要外接摄像头(比如 OV5695、OV8858),设备树就得自己调。
设备树调试中最典型的问题是摄像头 I2C 地址冲突、MIPI 通道配置错误、时钟频率不匹配。我调试 OV5695 时踩过一个大坑:MIPI 虚拟通道没配对,导致画面花屏。检查了一遍发现是设备树中>import torch from ultralytics import YOLO # 加载训练好的模型 model = YOLO('yolo11s.pt') # 切换到 eval 模式,固定网络权重 model.model.eval() # 构造一个固定尺寸的虚拟输入,ONNX 导出时用它来追踪计算图 dummy_input = torch.randn(1, 3, 640, 640) # 导出 ONNX 文件 torch.onnx.export( model.model, dummy_input, 'yolo11s.onnx', opset_version=12, input_names=['images'], output_names=['output0'], dynamic_axes=None # 固定 shape,不设动态维度 ) print("ONNX 导出完成")
几个关键点:opset_version 建议用 12 或 13,瑞芯微工具链对这两个版本的支持最成熟;dynamic_axes 这里设置为 None,也就是固定输入尺寸为 (1, 3, 640, 640),原因在 2.2 节已经说过。导出完以后,用 Netron 打开看一眼,确认输出节点的 name 和 shape 是否符合预期,这一步能提前发现网络被错误折叠或冗余节点残留的问题。
5.2 ONNX 转 RKNN:从转换到量化全流程
导出 ONNX 后,进入 RKNN 转换环节。按照 3.2 节的模板脚本,我加上了量化校准和模拟器验证,形成一个完整版:
from rknn.api import RKNN import numpy as np rknn = RKNN(verbose=True) # 配置输入预处理,YOLO 模型训练时通常用 0~1 归一化 rknn.config(mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3568') # 加载 ONNX 模型 ret = rknn.load_onnx(model='./yolo11s.onnx') assert ret == 0 # 构建 RKNN 模型并量化 ret = rknn.build(do_quantization=True, dataset='./dataset.txt') assert ret == 0 # 导出 RKNN 文件 ret = rknn.export_rknn('./yolo11s.rknn') assert ret == 0 # 用模拟器验证输出 # 这里构造一张真实图片作为输入,检查输出 shape 是否合理 img = cv2.imread('test.jpg') img = cv2.resize(img, (640, 640)) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 新建 RKNN 对象加载模型做模拟推理 rknn_sim = RKNN() rknn_sim.load_rknn('./yolo11s.rknn') rknn_sim.init_runtime() outputs = rknn_sim.inference(inputs=[img]) print("模拟器输出:", outputs[0].shape) rknn_sim.release() rknn.release()一个常见的坑:量化数据集里的图片应该和实际推理场景尽可能一致。比如你的产品是工业质检,目标都是固定在流水线上的零件,那校准图片就应该用流水线的实拍图,而不是训练集里的网络图片。如果校准图片和实际场景差异太大,量化后的模型就会对真实场景“失明”。
5.3 板端推理实测与性能优化
把生成的 yolo11s.rknn 拷贝到 RK3568 板子上,用 C++ 或者 Python 调用推理。实测下来,640x640 输入的 YOLO11s,在 RK3568 上 int8 量化后的 NPU 推理时间大约在 30~45ms,换算下来约 22~33 FPS,这个性能在同级别芯片里表现算相当不错的。
如果实测帧率不达标,可以从三个方向优化:
- 输入尺寸降级。比如从 640x640 降到 512x512,推理时间可能直接减少一半左右,代价是精度小幅下降,需要实验评估。
- 后处理移到 NPU 之外。RKNN 的推理输出是原始张量,NMS 等后处理如果放在 NPU 计算图上会增加耗时,建议在 CPU 上做后处理,只把模型的骨干检测头部署到 NPU 上。
- 打开 rknn.config 里的性能优化选项。比如开启
enable_cfg_preprocess、合理设置quantized_dtype为asymmetric_quantized-8可以提高部分算子的效率。
// 板端推理时间测试示例 struct timespec start, end; clock_gettime(CLOCK_MONOTONIC, &start); rknn_run(ctx, NULL); rknn_outputs_get(ctx, 1, outputs, NULL); clock_gettime(CLOCK_MONOTONIC, &end); double cost_ms = (end.tv_sec - start.tv_sec) * 1000.0 + (end.tv_nsec - start.tv_nsec) / 1000000.0; printf("NPU inference time: %.2f ms\n", cost_ms);一般来说,NPU 推理时间比较稳定,不会像 CPU 那样受负载影响明显。如果你发现推理时间波动很大,首先检查板子上的 CPU 频率有没有被降频、内存是否被其他进程占用,这些外部因素对 NPU 的调用延迟影响很大。
6. 常见问题与排查技巧实录:把踩过的坑一次性讲完
最后用一个专门的章节整理我在 RK3568 部署过程中遇到的高频问题。这些问题在官方文档里未必有详细描述,但实际项目里几乎绕不开。
6.1 算子不支持与版本兼容问题
RKNN 工具链对 ONNX 算子的支持不是 100% 的,尤其是 Transformer 结构里的 LayerNorm、Gelu、矩阵乘法相关的新算子,老版本工具链可能直接报Unsupported operator之类的错误。我的处理顺序是:
- 升级 rknn-toolkit2 到最新版本,通常能解决 80% 的算子支持问题
- 如果还是不支持,看报错信息中具体是哪个算子,回到 PyTorch 侧把该算子替换为等价实现
- 实在不行,把该算子的计算留在 CPU 上,通过 RKNN 的分段部署能力(分割成多个模型)实现
实际项目中,修改模型结构的优先级高于升级工具链,因为工具链的迭代速度往往赶不上模型结构的发展速度。
6.2 量化后精度显著下降怎么办
量化精度崩塌的原因通常有三个:
- 预处理参数不匹配(mean/std 设置错误)
- 校准数据集分布与真实场景差异过大
- 模型本身对量化敏感,尤其是检测小目标、分割任务
排查时先做“无量化对照实验”:把do_quantization设为 False,转一个 fp16 或 fp32 版模型,在板端对比测试精度。如果不量化的模型精度没问题,量化后崩了,那问题就锁定在量化环节;如果未量化就崩,那问题在 ONNX 导出或预处理阶段。这个对照思路能帮你快速定位问题所在。
如果确认是量化精度问题,优先尝试增加校准数据量,或使用 QAT 重新训练。部分模型对量化敏感是因为网络中存在大数值范围的中间激活,可以在导出 ONNX 前对模型添加 clip 或 relu 约束,把激活值限制在合理范围内。
6.3 推理性能瓶颈定位
NPU 推理时间正常,但整体帧率上不去,那瓶颈大概率在前后处理。比如图像缩放、颜色空间转换、NMS 等操作如果在 CPU 上低效实现,很容易拖后腿。我这里有一个性能开销分配的经验法则:
- 模型推理(NPU):约 30~45ms
- 图像预处理(CPU):约 5~10ms
- 后处理 NMS(CPU):约 3~8ms
如果预处理超过 15ms,就该检查代码是不是在逐像素循环操作了,应该用 OpenCV 的矩阵操作或者 RGA 硬件加速。RK3568 内置 RGA 模块,专门用来做缩放和格式转换,使用 RGA 可以把 resize 从 5ms 降到不到 1ms,强烈推荐。
6.4 设备树、系统与周边适配的几个陷阱
除了模型本身,板级的问题也很常见。我在 RK3568 上调过不少外设,把最容易踩的坑列成一张速查表,方便你排查:
| 现象 | 大概率原因 | 排查思路 |
|---|---|---|
| NPU 调用失败 / 空闲 | 板端 rknn_server 未启动或版本不匹配 | 检查 librknnrt 版本与工具链是否一致 |
| 画面花屏 / 颜色异常 | MIPI 虚拟通道或>
|