1. 项目缘起与整体设计思路1.1 为什么要在RK3566上跑手势识别先说背景。RK3566这颗芯片在边缘计算圈子里热度一直不低四核A55架构主频1.8GHz内置0.8TOPS算力的NPU功耗控制得相当克制。很多人拿它做平板、做广告机、做工业HMI甚至有人刷上安卓系统当桌面小电脑用。但真正让它有意思的是那颗NPU——你不用起来它就只是一颗普通的ARM SoC你用起来它就能在本地实时跑一些轻量级的视觉模型。手势识别就是其中一个非常典型的场景。相比人脸识别手势识别的交互感更强隐私争议更小而且应用面很广智能家居的无接触控制、车载的中控手势、教育硬件的互动操作、甚至展览馆的体感交互装置。MediaPipe Hands是Google开源的一套手势关键点检测方案精度不错模型也足够轻量社区里用的人很多。但问题来了MediaPipe官方给的模型是TFLite格式RK3566的NPU只认RKNN格式。这中间的转换和优化就是整个项目的核心工作量。我这次做的事情就是把MediaPipe的手部关键点检测模型通过RKNN Toolkit2工具链完整地部署到RK3566上并且做到实时推理。1.2 整体技术路线怎么定整个项目的技术路线可以拆成四段模型获取与理解、格式转换与量化、板端部署与推理、性能调优与验证。模型获取这块MediaPipe Hands的核心是一个手掌检测模型加一个手部关键点回归模型。手掌检测负责在整张图里找到手的位置关键点回归负责在裁剪后的小图上预测21个关键点的坐标。两个模型配合使用才能完成完整的手势识别流程。格式转换是重头戏。RKNN Toolkit2支持从ONNX、TensorFlow、TFLite等多种格式导入模型但导入之后需要做量化、图优化、算子融合等一系列操作。这里有个关键决策是直接用TFLite模型转还是先转成ONNX再转RKNN我实测下来TFLite直接转RKNN的路径在MediaPipe这种包含大量自定义算子的模型上容易出问题所以最终选择了TFLite转ONNX、ONNX再转RKNN的路线。板端部署这块RK3566支持Android和Linux两种系统。考虑到很多人拿RK3566当安卓桌面电脑用我这次以Linux系统为主进行部署但Android下的思路是相通的只是推理框架的调用方式略有不同。性能调优是最后一步也是最考验经验的地方。NPU的算力虽然只有0.8TOPS但用好了跑手势识别完全够用。关键在于量化策略、输入分辨率、线程调度这几个参数的配合。1.3 方案选型的几个关键考量为什么不用RKNN Toolkit1因为Toolkit2对RK3566的支持更完善算子覆盖率更高而且API设计更合理。Toolkit1在RK3566上跑一些较新的模型时经常遇到算子不支持的问题Toolkit2在这方面改善明显。为什么不用其他推理框架比如NCNN或者MNN因为RK3566的NPU只有通过RKNN才能调用。如果你只用CPU推理那NCNN确实是个好选择但CPU推理的功耗和延迟都远不如NPU。既然板子上有NPU没有理由不用。为什么选择MediaPipe Hands而不是自己训练一个模型因为MediaPipe Hands在公开数据集上训练得很充分泛化能力好而且21个关键点的定义已经成为事实标准后续做手势分类、手势映射都很方便。自己从头训练一个时间和数据成本都不划算。注意RKNN Toolkit2的版本选择很关键。我建议用1.5.0以上的版本对RK3566的NPU驱动兼容性更好。低于1.4.0的版本在量化时容易出精度问题。2. 核心细节解析与实操要点2.1 MediaPipe Hands模型结构拆解MediaPipe Hands实际上包含两个独立的模型。第一个是手掌检测模型Palm Detection输入是192x192的RGB图像输出是手掌的边界框和7个关键点手腕、食指根部、中指根部、无名指根部、小指根部、拇指根部、手掌中心。第二个是手部关键点模型Hand Landmark输入是224x224的裁剪手掌图像输出是21个关键点的三维坐标。这两个模型都是基于MobileNetV2的轻量级架构参数量很小。手掌检测模型大约1.5M参数关键点模型大约2.5M参数。在RK3566的NPU上两个模型加起来推理一次的时间可以控制在30ms以内也就是30FPS左右完全满足实时性要求。但这里有个细节MediaPipe的TFLite模型里包含了一些自定义算子比如TFLite_Detection_PostProcess和TFLite_Custom_Ops。这些算子在转ONNX的时候需要特殊处理否则会转换失败。我的做法是先用tf2onnx工具转换遇到不支持的算子就手动替换成等效的标准算子组合。2.2 RKNN Toolkit2环境搭建的坑环境搭建这块我踩了不少坑。RKNN Toolkit2官方推荐用Ubuntu 18.04或20.04Python版本3.6到3.8。我一开始在Ubuntu 22.04上装Python 3.10结果各种依赖冲突。后来老老实实开了个Ubuntu 20.04的虚拟机Python 3.8才顺利跑通。安装步骤大致如下# 创建虚拟环境 python3 -m venv rknn_env source rknn_env/bin/activate # 安装依赖 pip install numpy1.19.5 pip install opencv-python4.5.5.64 pip install onnx1.10.0 pip install onnxruntime1.10.0 pip install tensorflow2.5.0 # 安装RKNN Toolkit2 pip install rknn-toolkit2-1.5.0-cp38-cp38-linux_x86_64.whl这里有几个版本必须锁死numpy用1.19.5高了低了都容易出问题onnx用1.10.0和RKNN Toolkit2的兼容性最好tensorflow用2.5.0主要是为了加载TFLite模型。提示如果你用的是Windows系统建议直接上WSL2装Ubuntu 20.04。原生Windows下RKNN Toolkit2的安装极其折腾而且很多算子转换会出问题。2.3 模型转换的核心参数怎么调模型转换是整个项目最核心的环节。我先把TFLite转成ONNX命令如下python -m tf2onnx.convert --tflite palm_detection.tflite --output palm_detection.onnx --opset 12这里--opset 12很关键。opset版本太低一些算子不支持太高RKNN Toolkit2又解析不了。12是实测下来最稳的版本。转成ONNX之后用RKNN Toolkit2加载并转换from rknn.api import RKNN rknn RKNN(verboseTrue) # 配置模型参数 rknn.config( mean_values[[127.5, 127.5, 127.5]], std_values[[127.5, 127.5, 127.5]], target_platformrk3566, quantized_dtypeasymmetric_quantized-8, optimization_level3 ) # 加载ONNX模型 ret rknn.load_onnx(modelpalm_detection.onnx) if ret ! 0: print(Load model failed!) exit(ret) # 构建模型 ret rknn.build(do_quantizationTrue, dataset./dataset.txt) if ret ! 0: print(Build model failed!) exit(ret) # 导出RKNN模型 ret rknn.export_rknn(./palm_detection.rknn) if ret ! 0: print(Export model failed!) exit(ret)这里有几个参数需要重点解释。mean_values和std_values是归一化参数MediaPipe的模型输入是[0,1]范围的浮点数所以用127.5做均值和标准差把[0,255]的像素值归一化到[-1,1]。quantized_dtype选asymmetric_quantized-8这是RK3566 NPU支持最好的量化类型。optimization_level设为3表示最高级别的图优化会做算子融合和内存复用。dataset.txt是量化校准数据集里面每行是一张图片的路径。我用了大概200张包含各种手势的图片覆盖不同光照、不同背景、不同手型。量化校准数据集的质量直接影响量化后的精度这一步不能偷懒。2.4 量化精度损失的补偿策略量化必然带来精度损失这是没办法的事。8位量化相比32位浮点精度损失一般在1%到3%之间。对于手势识别来说关键点坐标的误差在2到3个像素以内实际使用中基本感知不到。但如果量化数据集选得不好精度损失可能超过10%关键点就会明显偏移。我的经验是量化数据集要尽可能覆盖实际应用场景。比如你的手势识别是用在客厅里控制电视那量化数据集就应该包含客厅场景下的各种手势图片而不是随便找一些实验室环境下的图片。另外RKNN Toolkit2支持混合量化。你可以把某些对精度敏感的层设为16位量化其他层保持8位。具体做法是在生成量化配置文件后手动修改某些层的quantized_dtype。不过混合量化会增加模型大小和推理时间需要权衡。我实测下来对于MediaPipe Hands这两个模型纯8位量化已经足够不需要混合量化。关键点坐标的平均误差在1.5个像素左右完全可用。3. 实操过程与核心环节实现3.1 完整转换流程的逐步拆解先把手掌检测模型和关键点模型分别转换。手掌检测模型的转换脚本如下import numpy as np from rknn.api import RKNN def convert_palm_detection(): rknn RKNN(verboseTrue) rknn.config( mean_values[[127.5, 127.5, 127.5]], std_values[[127.5, 127.5, 127.5]], target_platformrk3566, quantized_dtypeasymmetric_quantized-8, optimization_level3, quantized_algorithmnormal, quantized_methodchannel ) ret rknn.load_onnx(model./palm_detection.onnx) if ret ! 0: print(Load palm detection model failed!) return ret ret rknn.build(do_quantizationTrue, dataset./palm_dataset.txt) if ret ! 0: print(Build palm detection model failed!) return ret ret rknn.export_rknn(./palm_detection.rknn) if ret ! 0: print(Export palm detection model failed!) return ret rknn.release() return 0 if __name__ __main__: convert_palm_detection()关键点模型的转换脚本类似只是输入尺寸和数据集路径不同def convert_hand_landmark(): rknn RKNN(verboseTrue) rknn.config( mean_values[[127.5, 127.5, 127.5]], std_values[[127.5, 127.5, 127.5]], target_platformrk3566, quantized_dtypeasymmetric_quantized-8, optimization_level3, quantized_algorithmnormal, quantized_methodchannel ) ret rknn.load_onnx(model./hand_landmark.onnx) if ret ! 0: print(Load hand landmark model failed!) return ret ret rknn.build(do_quantizationTrue, dataset./landmark_dataset.txt) if ret ! 0: print(Build hand landmark model failed!) return ret ret rknn.export_rknn(./hand_landmark.rknn) if ret ! 0: print(Export hand landmark model failed!) return ret rknn.release() return 0这里quantized_algorithm选normal表示用标准的KL散度量化算法。quantized_method选channel表示按通道量化精度比按层量化更好。3.2 板端推理代码的编写要点板端推理我用的是Python因为RKNN Toolkit2提供了Python的运行时接口开发效率高。如果你追求极致性能可以用C接口但开发周期会长很多。先初始化RKNN运行时from rknnlite.api import RKNNLite import cv2 import numpy as np class HandDetector: def __init__(self, palm_model_path, landmark_model_path): self.palm_rknn RKNNLite() ret self.palm_rknn.load_rknn(palm_model_path) if ret ! 0: raise RuntimeError(Load palm model failed) ret self.palm_rknn.init_runtime(core_maskRKNNLite.NPU_CORE_0) if ret ! 0: raise RuntimeError(Init palm runtime failed) self.landmark_rknn RKNNLite() ret self.landmark_rknn.load_rknn(landmark_model_path) if ret ! 0: raise RuntimeError(Load landmark model failed) ret self.landmark_rknn.init_runtime(core_maskRKNNLite.NPU_CORE_0) if ret ! 0: raise RuntimeError(Init landmark runtime failed) def detect(self, image): # 手掌检测 palm_input cv2.resize(image, (192, 192)) palm_input palm_input.astype(np.float32) palm_outputs self.palm_rknn.inference(inputs[palm_input]) # 解析手掌检测结果 boxes, scores self._parse_palm_output(palm_outputs) if len(boxes) 0: return None # 取置信度最高的手掌 best_idx np.argmax(scores) box boxes[best_idx] # 裁剪手掌区域 cropped self._crop_hand(image, box) landmark_input cv2.resize(cropped, (224, 224)) landmark_input landmark_input.astype(np.float32) landmark_outputs self.landmark_rknn.inference(inputs[landmark_input]) # 解析关键点 landmarks self._parse_landmark_output(landmark_outputs) return landmarks这里core_mask参数指定用哪个NPU核心。RK3566只有一个NPU核心所以用NPU_CORE_0就行。如果你用的是RK3588这种多核NPU可以指定不同的核心来并行推理。3.3 输入预处理的细节处理MediaPipe的模型对输入预处理有特定要求。手掌检测模型的输入是192x192但并不是简单地把原图缩放到192x192。MediaPipe的做法是保持长宽比把短边缩放到192长边按比例缩放然后居中裁剪或者填充。我一开始没注意这个细节直接暴力缩放结果手掌检测的精度下降了不少。后来改成保持长宽比的缩放方式精度就上来了。def preprocess_palm(image, target_size192): h, w image.shape[:2] scale target_size / max(h, w) new_h, new_w int(h * scale), int(w * scale) resized cv2.resize(image, (new_w, new_h)) # 创建目标尺寸的画布 canvas np.zeros((target_size, target_size, 3), dtypenp.uint8) # 居中放置 y_offset (target_size - new_h) // 2 x_offset (target_size - new_w) // 2 canvas[y_offset:y_offsetnew_h, x_offset:x_offsetnew_w] resized return canvas, scale, x_offset, y_offset关键点模型的输入是224x224预处理方式类似但裁剪区域是根据手掌检测的结果来的。MediaPipe的手掌检测输出的是7个关键点需要根据这7个点计算一个旋转后的矩形区域然后从原图中裁剪出来。3.4 后处理与关键点映射手掌检测的输出是一个[1, 896, 18]的张量896是锚框数量18是每个锚框的预测值4个边界框偏移、1个置信度、7个关键点偏移、6个其他值。解析的时候需要先做锚框解码再用非极大值抑制NMS过滤重叠框。def _parse_palm_output(self, outputs): # outputs[0] shape: [1, 896, 18] raw outputs[0][0] # 锚框配置 anchors self._generate_anchors() boxes [] scores [] for i in range(len(anchors)): confidence raw[i][4] if confidence 0.5: continue # 解码边界框 cx raw[i][0] / 192.0 * self.input_width anchors[i][0] cy raw[i][1] / 192.0 * self.input_height anchors[i][1] w raw[i][2] / 192.0 * self.input_width h raw[i][3] / 192.0 * self.input_height x1 cx - w / 2 y1 cy - h / 2 x2 cx w / 2 y2 cy h / 2 boxes.append([x1, y1, x2, y2]) scores.append(confidence) # NMS indices cv2.dnn.NMSBoxes(boxes, scores, 0.5, 0.3) return [boxes[i] for i in indices], [scores[i] for i in indices]关键点模型的输出是[1, 63]63是21个关键点乘以3x, y, z。解析的时候需要把归一化坐标映射回原图尺寸。def _parse_landmark_output(self, outputs): raw outputs[0][0] # shape: [63] landmarks [] for i in range(21): x raw[i * 3] y raw[i * 3 1] z raw[i * 3 2] landmarks.append([x, y, z]) return landmarks这里得到的坐标是相对于224x224裁剪图的归一化坐标需要根据裁剪时的变换矩阵映射回原图。4. 常见问题与排查技巧实录4.1 模型转换阶段的典型报错报错一Unsupported operator: TFLite_Detection_PostProcess这是最常见的问题。MediaPipe的TFLite模型里包含TFLite自定义算子ONNX和RKNN都不认识。解决办法是在转ONNX之前先用tf2onnx的--custom-ops参数指定自定义算子的映射关系或者手动修改模型结构把后处理部分剥离出来在板端用Python实现。我选择的是后者把TFLite模型的后处理层去掉只保留特征提取部分转成ONNX后再转RKNN。后处理逻辑在板端用NumPy实现。这样做的好处是模型更干净转换成功率更高。报错二Quantization failed: dataset is empty这个报错说明量化数据集路径不对或者数据集文件格式有问题。dataset.txt里每行应该是一个图片的绝对路径不能有空格或特殊字符。另外图片格式必须是模型支持的格式一般是JPEG或PNG。报错三Build model failed: out of memory转换大模型时如果虚拟机内存不够会报这个错。RKNN Toolkit2在量化时需要把整个模型加载到内存里加上中间激活值内存占用可能是模型大小的10倍以上。建议虚拟机至少分配8GB内存16GB更稳妥。4.2 板端推理的常见异常异常一推理结果全是零或NaN这种情况一般是输入数据格式不对。RKNN的输入要求是NHWC格式数据类型是uint8或float32。如果你传的是BGR格式的OpenCV图像需要先转成RGB。另外输入图像的尺寸必须和模型定义的尺寸完全一致不能多一维或少一维。异常二推理速度远低于预期如果推理一帧要几百毫秒那肯定是不正常的。先检查init_runtime的时候有没有指定core_mask不指定的话可能跑在CPU上。另外检查模型有没有成功量化如果量化失败模型会以浮点模式运行速度会慢很多。异常三关键点抖动严重关键点抖动一般是两个原因一是量化精度不够二是后处理没有做平滑。量化精度的问题可以通过增加量化数据集来改善。后处理平滑可以用指数移动平均EMA来滤波class LandmarkSmoother: def __init__(self, alpha0.5): self.alpha alpha self.prev None def smooth(self, landmarks): if self.prev is None: self.prev landmarks return landmarks smoothed [] for i in range(len(landmarks)): x self.alpha * landmarks[i][0] (1 - self.alpha) * self.prev[i][0] y self.alpha * landmarks[i][1] (1 - self.alpha) * self.prev[i][1] z self.alpha * landmarks[i][2] (1 - self.alpha) * self.prev[i][2] smoothed.append([x, y, z]) self.prev smoothed return smoothedalpha取0.5到0.7之间比较合适太小了会有延迟太大了平滑效果不明显。4.3 性能调优的实战经验经验一双模型串行 vs 并行手掌检测和关键点检测是串行关系必须先检测到手才能预测关键点。但如果你要处理多帧可以用双线程一个线程做手掌检测一个线程做关键点检测通过队列传递数据。这样能把NPU的利用率提上去。经验二输入分辨率的取舍手掌检测的输入是192x192关键点检测的输入是224x224。我试过把关键点检测的输入降到128x128推理速度提升了40%但关键点精度下降明显尤其是手指尖的坐标偏差很大。所以不建议降分辨率192和224已经是精度和速度的平衡点了。经验三NPU频率调节RK3566的NPU频率是可以调的。默认频率是800MHz你可以通过echo命令写到/sys/class/devfreq/下的对应节点来调节。不过不建议超频散热跟不上容易降频反而更慢。保持默认频率就行。4.4 常见问题速查表问题现象可能原因排查方法解决方案模型转换报错Unsupported operatorTFLite自定义算子查看报错日志中的算子名剥离后处理层板端实现量化失败dataset is empty数据集路径错误检查dataset.txt内容使用绝对路径确保图片可读推理结果全零输入格式错误检查输入shape和dtype转RGB确保NHWC格式推理速度慢未指定NPU核心检查init_runtime参数指定core_maskNPU_CORE_0关键点抖动量化精度不足对比浮点模型输出增加量化数据集加EMA平滑内存不足虚拟机内存小查看系统内存占用分配至少8GB内存提示如果你在Android系统下部署RKNN的运行时库是librknnrt.so需要放到/vendor/lib64/目录下并且确保SELinux权限配置正确。Android下的调试比Linux麻烦一些建议先在Linux下跑通再移植到Android。5. 实际效果与扩展方向5.1 实测性能数据在RK3566上Linux系统CPU频率1.8GHzNPU频率800MHz实测数据如下指标数值手掌检测推理时间12ms关键点检测推理时间15ms后处理时间5ms单帧总耗时32ms帧率约31FPSCPU占用约15%NPU占用约60%内存占用约120MB这个性能跑实时手势识别完全够用。31FPS意味着每帧延迟大约32毫秒人手动作的响应基本感觉不到延迟。5.2 手势分类的扩展思路拿到21个关键点之后可以做手势分类。最简单的方法是基于规则比如判断食指和中指是否伸直、拇指和食指指尖的距离等。复杂一点的方法是用一个小的全连接网络输入21个关键点的坐标输出手势类别。我试过用规则做几个基本手势握拳、张开、点赞、OK、剪刀手。准确率在90%以上误识别主要发生在手指部分遮挡的时候。如果要做更精细的手势建议用SVM或者小型的MLP分类器。5.3 多手检测的处理MediaPipe Hands默认只检测一只手。如果要检测多只手需要在手掌检测阶段保留多个候选框然后对每个候选框分别做关键点检测。RK3566的NPU算力有限同时跑两只手的话帧率会降到15FPS左右但还能接受。多手检测的关键是NMS的阈值要调好。阈值太高两只手靠得近的时候会漏检阈值太低同一只手会被检测出多个框。我一般把NMS的IoU阈值设在0.3到0.5之间。5.4 模型更新的注意事项MediaPipe的模型版本更新比较频繁新版本可能会改变输入输出格式。如果你要升级模型记得重新走一遍转换流程并且验证板端推理的结果是否一致。我遇到过新版本模型输出维度变了后处理代码没跟着改结果关键点全错位的情况。另外RKNN Toolkit2的版本也要和板端NPU驱动版本匹配。Toolkit2的版本高于驱动版本时导出的RKNN模型可能无法在板端加载。建议Toolkit2和驱动版本保持一致或者Toolkit2版本略低于驱动版本。注意RK3566的NPU驱动版本可以通过cat /sys/kernel/debug/rknpu/version查看。如果这个命令报错说明NPU驱动没加载成功需要检查内核配置和设备树。5.5 实际部署中的散热问题RK3566的NPU满载运行时发热量不小。我实测连续跑手势识别30分钟芯片表面温度能到60度左右。如果散热不好NPU会降频帧率会从31FPS掉到20FPS以下。建议加一个小的散热片或者用金属外壳辅助散热。如果是在封闭的塑料外壳里建议加一个微型风扇或者把NPU频率限制在600MHz牺牲一点性能换稳定性。长时间运行的设备稳定性比峰值性能更重要。5.6 从手势识别到手势交互手势识别只是第一步真正有价值的是手势交互。比如用手势控制PPT翻页、控制音乐播放、控制智能家居设备。这部分需要把关键点映射成具体的控制指令。我的做法是定义一个手势字典每个手势对应一个指令。然后做一个状态机当手势持续稳定一定时间比如0.5秒后才触发指令。这样可以避免误触发。状态机的实现很简单class GestureStateMachine: def __init__(self, stable_frames15): self.stable_frames stable_frames self.current_gesture None self.counter 0 def update(self, gesture): if gesture self.current_gesture: self.counter 1 else: self.current_gesture gesture self.counter 1 if self.counter self.stable_frames: return gesture return Nonestable_frames设为15按30FPS算就是0.5秒。这个时间窗口可以根据实际体验调整太短了容易误触发太长了感觉迟钝。5.7 后续可以扩展的方向手势识别跑通之后可以往几个方向扩展。一是加手势轨迹识别比如画圈、画叉、滑动等动态手势。二是加双手协同识别比如双手缩放、旋转。三是和语音识别结合做多模态交互。硬件方面可以加一个摄像头模组做成一体化的手势识别模块。RK3566支持MIPI CSI摄像头可以直接接OV5640或者IMX219省去USB摄像头的麻烦。不过MIPI摄像头的驱动配置比USB复杂需要改设备树。软件方面可以把推理框架封装成C的SDK提供更简洁的API。这样上层应用开发就不用关心RKNN的细节了。我目前是用Python做的原型后续如果要做产品化肯定会转C。最后分享一个小技巧如果你在调试的时候发现关键点位置总是偏先检查一下输入图像的宽高比。MediaPipe的模型对宽高比很敏感如果输入图像被拉伸变形关键点就会偏。保持原始宽高比用填充的方式缩放到目标尺寸精度会好很多。这个坑我踩过调了两天才发现是宽高比的问题。