1. 手势识别模型上板这件事,到底难在哪

把训练好的手势识别模型塞进 RK3588 开发板跑起来,听起来就是"转换一下格式"的事,但真正动过手的人都知道,这条链路里埋的坑比想象中多得多。我前后在 RK3588 上部署过三四个手势模型,从最早的 TFLite 直接怼,到后来老老实实走 ONNX 中转再转 RKNN,中间踩过的坑足够写一篇长文。这篇就把整个流程拆开讲清楚,包括为什么这么选、每一步在干什么、参数怎么定、出问题怎么查。

先说清楚这个项目的定位: 手势模型转换到 RK3588 ,核心目标是把一个在 PC 上训练好的手势识别模型(通常是分类网络,比如识别"握拳、比耶、OK、点赞"这几类手势),经过格式转换和量化,最终在 RK3588 的 NPU 上以 RKNN 格式高效推理。它解决的是"模型在服务器上跑得好好的,一到嵌入式板子上就慢成幻灯片甚至跑不起来"这个典型问题。适合谁看?做过一点深度学习训练、手里有 RK3588 板子、想把视觉模型真正落地到边缘设备的开发者。如果你连 ONNX 是什么都还没概念,建议先补一下模型导出的基础,再回来看这篇会更顺。

RK3588 这颗芯片的 NPU 算力是 6 TOPS,三个核心,支持 INT8/INT16 混合量化,纸面参数很漂亮。但纸面归纸面,实际能不能把这 6 TOPS 用起来,取决于你的模型转换得干不干净、量化做得合不合理、前后处理有没有拖后腿。我见过太多人模型转过去了,结果推理一帧要 200ms,最后发现瓶颈根本不在 NPU,而在 CPU 上做图像预处理。所以这篇不只是讲"怎么转",更要讲"怎么转得好"。

整个链路我习惯这样组织: 训练框架导出 → ONNX 中转 → RKNN 转换 → 板端部署验证 。中间还会穿插量化校准、算子兼容性检查、前后处理对齐这几个关键环节。下面按这个顺序,一层一层往下拆。

2. 整体方案设计与格式选型思路

2.1 为什么不让 TFLite 直接上,非要绕一圈 ONNX

很多人第一反应是:我模型本来就是 TFLite 的,RKNN 工具链不是也号称支持 TFLite 吗,直接转不就完了?理论上可以,但实操里我强烈建议走 ONNX 中转,原因有三个。

第一, 算子覆盖度 。RKNN Toolkit 对 TFLite 的支持是"能用但挑食",尤其是一些自定义算子或者较新的 TFLite 算子版本,转换时经常报 Unsupported op 。而 ONNX 作为中间格式,生态最全,绝大多数主流算子都能被 RKNN 正确识别。第二, 调试友好度 。ONNX 有 Netron 这种可视化工具,能一层一层看结构、看输入输出 shape、看权重分布,出问题定位快得多。TFLite 虽然也能看,但工具链成熟度差一截。第三, 量化校准的灵活性 。走 ONNX 路线,你可以用 RKNN 自带的混合量化工具做逐层量化分析,哪些层适合 INT8、哪些层必须保留 FP16,一目了然。

所以标准链路是: TFLite → ONNX → RKNN 。TFLite 转 ONNX 用 tf2onnx 这个工具,一行命令的事,但要注意算子版本对齐。

2.2 量化策略:INT8 还是 FP16,这不是拍脑袋决定的

RK3588 的 NPU 对 INT8 的支持是最好的,算力也是按 INT8 标的。但手势识别这类任务有个特点: 类别之间的差异有时候很细微 ,比如"比耶"和"比心"手指角度差一点点,量化太狠就容易混淆。我的经验是分情况:

  • 如果模型本身参数量小(比如 MobileNetV2 级别的 backbone),且类别数少(5 类以内),直接上 INT8 全量化 ,精度损失通常在 1% 以内,速度能快 2-3 倍。
  • 如果模型较大或者类别细粒度很高,建议用 混合量化 ,把分类头(最后几层全连接)保留 FP16,前面的特征提取层用 INT8。这样精度基本不掉,速度损失也不大。

量化校准集的准备是个容易被忽视的点。 校准集不是随便找几张图就行 ,它要能代表你实际部署场景的数据分布。我一般会从训练集里抽 200-500 张,覆盖所有类别、各种光照、各种手势角度。校准集质量直接决定量化后的精度,这一步偷懒,后面精度掉了都找不到原因。

2.3 输入尺寸和预处理,别在源头埋雷

RK3588 的 NPU 对输入尺寸没有硬性限制,但有个原则: 尽量用 2 的幂次或者 8 的倍数 。比如 224x224、256x256、320x320 这种。为什么?因为 NPU 内部是按块计算的,尺寸不规整会导致 padding 浪费算力。我实测过 224x224 和 225x225,后者推理时间反而更长,就是这个道理。

预处理这块, 归一化参数必须和训练时完全一致 。训练时用的 mean/std 是多少,板端就要用多少。我见过有人训练用 ImageNet 的 mean/std,部署时忘了改,结果模型输出全是乱的。这个坑很隐蔽,因为模型不报错,只是结果不对。

3. 核心转换环节的细节拆解

3.1 TFLite 转 ONNX:算子版本对齐是命门

这一步用 tf2onnx 完成,命令看起来简单:

python -m tf2onnx.convert \
  --tflite gesture_model.tflite \
  --output gesture_model.onnx \
  --opset 13

但这里有个关键参数 --opset 。 opset 版本要和 RKNN 工具链支持的版本对齐 。RKNN Toolkit2 目前对 opset 11-13 支持最好,opset 太高(比如 17、18)会出现算子映射失败。我一般固定用 13,稳。

转换完第一件事,用 Netron 打开 ONNX 模型,检查三样东西:输入输出节点名字、输入 shape、最后一层是不是 softmax。 输入输出节点名字后面写 RKNN 配置要用到 ,如果名字是 serving_default_input:0 这种带冒号的,建议用工具重命名成干净的 input 、 output ,避免后面配置时踩坑。

还有一个高频问题:TFLite 转出来的 ONNX 有时候会带一些冗余的 Transpose 或者 Reshape 节点,这些节点在 NPU 上可能不支持或者效率低。如果发现模型开头结尾有这类节点,可以在转换时加 --inputs-as-nchw 参数,或者转换后用 onnx-simplifier 清理一遍:

python -m onnxsim gesture_model.onnx gesture_model_sim.onnx

3.2 ONNX 转 RKNN:配置文件的每个参数都有讲究

RKNN 转换的核心是一个 Python 脚本,配置项看着多,但真正影响结果的就那么几个。我把关键配置列出来:

from rknn.api import RKNN

rknn = RKNN(verbose=True)

# 均值方差,必须和训练一致
rknn.config(
    mean_values=[[127.5, 127.5, 127.5]],
    std_values=[[127.5, 127.5, 127.5]],
    target_platform='rk3588',
    quantized_dtype='asymmetric_quantized-8',
    quantized_algorithm='normal',
    optimization_level=3
)

# 加载 ONNX
ret = rknn.load_onnx(model='gesture_model_sim.onnx')
if ret != 0:
    print('Load failed')
    exit(ret)

# 构建,指定量化校准集
ret = rknn.build(do_quantization=True, dataset='calibration.txt')
if ret != 0:
    print('Build failed')
    exit(ret)

# 导出
ret = rknn.export_rknn('gesture_model.rknn')

这里几个参数值得展开说。 mean_values 和 std_values 是预处理参数,RKNN 会在推理时自动帮你做归一化,所以板端代码里就不用再重复做了。但注意, 这里的归一化是作用在 0-255 的原始像素上的 ,如果你板端喂进去的已经是归一化过的数据,就会重复归一化,结果全错。

quantized_algorithm 有两个选项: normal 和 mmse 。 normal 是标准的 KL 散度校准,速度快; mmse 是最小均方误差校准,精度略高但慢。我一般先用 normal 跑一遍看精度,如果掉得厉害再换 mmse 。

optimization_level 设 3 是最高优化,会做一些算子融合和内存复用,能提升推理速度。但偶尔会引入 bug,如果转换后结果不对,可以降到 1 试试。

3.3 量化校准集怎么准备才靠谱

校准集文件 calibration.txt 就是一个纯文本,每行一张图片的路径:

./calib/gesture_01.jpg
./calib/gesture_02.jpg
...

图片数量我一般控制在 200-500 张。太少(比如 50 张)量化参数估计不准,太多(比如 2000 张)转换时间会很长,收益也不大。 关键是分布要均匀 :每个类别至少 30 张,光照要有明有暗,手势角度要有正有侧。

有个细节:校准图片的尺寸不用和模型输入尺寸一致,RKNN 会自动 resize。但 resize 的方式要和板端一致 ,否则校准和实际推理的分布对不上。我一般校准集就直接用和板端相同的预处理流程生成,保证一致性。

3.4 转换后的精度验证,别跳过这一步

RKNN 工具链提供了模拟器,可以在 PC 上先验证转换后的模型精度,不用上板:

rknn.init_runtime()
outputs = rknn.inference(inputs=[test_img])

把测试集跑一遍,对比 ONNX 原始模型的输出。 如果精度掉超过 3%,就要回头查量化配置 。常见原因:校准集分布不对、某些层不适合 INT8、预处理参数写错。这时候可以用 rknn.accuracy_analysis 做逐层精度分析,定位是哪一层掉得厉害。

4. 板端部署与推理验证实操

4.1 板端环境准备:Python 还是 C++

RK3588 板端跑 RKNN 模型有两条路:Python 的 rknnlite 和 C++ 的 rknn_api 。 Python 适合快速验证,C++ 适合最终产品 。我一般先用 Python 跑通,确认模型没问题,再移植到 C++。

Python 环境准备:

pip install rknn-toolkit-lite2

注意版本要和 PC 端的 RKNN Toolkit2 对应,版本不匹配会报 model version mismatch 。我踩过这个坑,PC 端用 1.6.0 转的模型,板端用 1.4.0 的 runtime 加载,直接报错。 版本号一定要对齐 。

C++ 环境需要交叉编译 librknnrt.so ,这个库在 RKNN Toolkit2 的 runtime 目录里能找到,直接拷贝到板子的 /usr/lib 下即可。

4.2 推理代码的关键环节

Python 端推理的核心代码:

from rknnlite.api import RKNNLite
import cv2
import numpy as np

rknn = RKNNLite()
rknn.load_rknn('gesture_model.rknn')
rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0)

img = cv2.imread('test.jpg')
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = cv2.resize(img, (224, 224))
img = np.expand_dims(img, axis=0)

outputs = rknn.inference(inputs=[img])
pred = np.argmax(outputs[0])

这里有个 大坑 : core_mask 参数。RK3588 有三个 NPU 核心, NPU_CORE_0 、 NPU_CORE_1 、 NPU_CORE_2 ,还有 NPU_CORE_0_1_2 表示三核一起用。单模型推理用单核就够了,多模型并行才需要多核。我见过有人无脑用三核,结果因为调度开销反而更慢。

另一个坑是 输入数据的 layout 。RKNN 默认接受 NHWC 格式,但如果你转换时用了 --inputs-as-nchw ,板端就要喂 NCHW。这个一定要和转换配置对齐,否则结果全乱。

4.3 前后处理的性能陷阱

模型推理本身可能只要 5-10ms,但前后处理如果写得糙,整个流程能拖到 50ms 以上。 图像 resize 是最耗时的环节 ,用 OpenCV 的 cv2.resize 在 CPU 上做,224x224 大概要 2-3ms,如果输入是 1080p,光 resize 就要 10ms 以上。

优化思路有两个:一是用 RK3588 的 RGA(Raster Graphic Acceleration)硬件做 resize,速度能快 5-10 倍;二是把 resize 和颜色转换合并成一步,减少内存拷贝。RGA 的用法在 RK3588 的 MPP 文档里有,C++ 接口是 im2d 系列函数。

后处理如果是分类任务,就是取 argmax,开销可以忽略。但如果是检测任务(比如手势检测框),NMS 就是大头,建议用 NPU 或者 RGA 加速。

4.4 实测性能数据参考

我在 RK3588 上实测过一个 MobileNetV2 backbone 的手势分类模型(224x224,5 类),数据如下:

环节 耗时
图像 resize(CPU) 3.2ms
颜色转换 1.1ms
NPU 推理(INT8,单核) 4.8ms
后处理 0.3ms
合计 9.4ms

换成 RGA 做 resize 后,resize 环节降到 0.6ms,整体降到 6.8ms,帧率从 106fps 提升到 147fps。这个提升在实时手势交互场景里是很可观的。

5. 常见问题排查与避坑经验

5.1 转换阶段的高频报错

报错一: Unsupported op: XXX

这是最常见的。原因是 ONNX 里有 RKNN 不支持的算子。解决思路:先用 Netron 找到这个算子,看能不能用等价算子替换。比如 HardSwish 不支持,可以手动拆成 HardSigmoid + Mul 。或者升级 RKNN Toolkit 版本,新版本通常支持更多算子。

报错二: Quantize failed at layer XXX

量化失败,通常是这一层的权重分布太极端(比如有极大的离群值)。解决办法:把这一层加入 hybrid_quantization 列表,保留 FP16。配置方式:

rknn.config(
    ...
    hybrid_quantization=[['layer_name', 'float16']]
)

报错三: Model version mismatch

PC 端和板端 RKNN 版本不一致。查版本:

# PC 端
python -c "from rknn.api import RKNN; print(RKNN().version)"
# 板端
python -c "from rknnlite.api import RKNNLite; print(RKNNLite().version)"

两个版本号必须一致,不一致就重装对应版本。

5.2 精度掉点的排查路径

精度掉了别慌,按这个顺序查:

  1. 先确认 ONNX 模型本身精度正常 。用 onnxruntime 跑一遍测试集,如果 ONNX 就掉点了,问题在 TFLite 转 ONNX 这一步。
  2. 确认预处理参数一致 。训练、ONNX、RKNN 三处的 mean/std 必须完全相同。
  3. 检查校准集 。换一批更有代表性的校准图重转。
  4. 逐层精度分析 。用 rknn.accuracy_analysis 定位问题层,把问题层改成 FP16。

我遇到过一次精度掉了 15%,最后发现是校准集里全是白天的图,而测试集有大量夜间图,量化参数估计偏了。换校准集后精度恢复到 1% 以内。

5.3 板端运行时的坑

坑一:内存不足 。RK3588 板子如果只跑 Ubuntu 桌面版,内存占用很高,留给 NPU 的连续内存可能不够。建议用 server 版系统,或者调整 CMA 内存大小。在 uboot 里改 cma 参数,一般设 512MB 够用。

坑二:多线程推理冲突 。RKNN runtime 不是线程安全的,多个线程同时调用 inference 会崩。要么加锁,要么每个线程独立初始化一个 RKNNLite 实例。

坑三:模型加载慢 。第一次 load_rknn 会做很多初始化,可能要 1-2 秒。建议在程序启动时加载一次,后续复用实例,不要每次推理都重新加载。

5.4 常见问题速查表

现象 可能原因 解决方向
转换报 Unsupported op 算子不支持 替换算子或升级工具链
量化失败 权重离群值 该层改 FP16
精度掉 >5% 校准集/预处理问题 换校准集、核对参数
板端加载报版本错 版本不匹配 对齐 PC 与板端版本
推理结果全乱 layout 或归一化错 核对 NHWC/NCHW 和 mean/std
推理速度慢 前后处理拖累 用 RGA 加速 resize
多线程崩溃 runtime 非线程安全 加锁或独立实例

6. 几个让我印象深刻的实操心得

第一个心得关于 校准集的"代表性" 。我一开始图省事,直接从训练集里随机抽了 300 张,结果量化后精度掉了 8%。后来分析发现,训练集里某个类别样本特别多,随机抽导致校准集里这个类别占比过高,其他类别的量化参数估计不准。改成 按类别均匀采样 后,精度恢复到 1% 以内。所以校准集不是越多越好,而是要 分布均衡 。

第二个心得关于 混合量化的粒度 。一开始我整层整层地加 FP16,后来发现其实可以更细,RKNN 支持按算子粒度指定。比如一个 Conv + ReLU 的块,只把 Conv 保留 FP16, ReLU 还是 INT8,这样精度和速度的平衡更好。不过这个需要对着 Netron 一层一层看,比较费时间,适合对精度要求极高的场景。

第三个心得关于 板端调试的效率 。一开始我每次改代码都要重新交叉编译、scp 到板子、运行,一轮下来好几分钟。后来改成 先在 PC 上用 RKNN 模拟器验证逻辑 ,确认没问题再上板,效率高很多。模拟器的推理结果和板端基本一致,只有速度不一样,所以逻辑验证完全够用。

第四个心得关于 版本管理 。RKNN 工具链版本更新挺频繁,不同版本之间行为可能有差异。我现在的习惯是: 项目开始时锁定一个版本,整个项目周期不升级 。升级带来的收益往往抵不上重新验证的成本。如果非要升级,一定要把整个链路重新跑一遍精度验证。

最后说一个容易被忽视的点: 模型的输入输出节点名字 。RKNN 转换时如果不指定,会用 ONNX 里的原始名字。如果原始名字带特殊字符(比如冒号、斜杠),板端代码里引用会很不方便。建议在 ONNX 阶段就用 onnx 的 Python API 重命名成干净的 input 、 output ,后面省很多事。

import onnx

model = onnx.load('gesture_model.onnx')
model.graph.input[0].name = 'input'
model.graph.output[0].name = 'output'
onnx.save(model, 'gesture_model_renamed.onnx')

这套流程我前后在三个项目里用过,从手势识别到人脸关键点,链路基本通用。核心就一句话: 格式转换是体力活,量化校准是技术活,板端调优是经验活 。三样都做到位,模型才能真正在 RK3588 上跑得又快又准。

Logo

聚焦RDK与具身智能,汇集机器人开发教程、开源案例和技术交流

更多推荐