去年年中接了一个边缘设备上做人脸识别的项目,老板指定要用 RK3588,模型用 FaceNet。说实话,当时脑子里第一个念头是“这不就是装个环境,导个模型,跑个推理吗”,真正动手之后才发现,从 PyTorch 到 RKNN 这条链路上到处都是坑,而且每个坑都长得不一样:有算子兼容的、有归一化配置的、有量化精度塌方的,还有板子端版本对不上导致模型加载失败的。
这篇文章就是把我在 RK3588 上从零部署 FaceNet 的完整过程记录下来。FaceNet 的核心思想是“把一张人脸映射到一个向量空间,同类人脸的向量距离近,不同人脸的距离远”,部署时最终落到板子上就是两件事:一个能跑出 128 维(或 512 维,取决于你用的网络结构)embedding 的推理程序,和一个能算相似度、做比对的后续链路。这篇文章适合正在做边缘端人脸识别、准备把 PyTorch 模型往 Rockchip NPU 上迁移的开发者参考,尤其是第一次碰 RKNN 工具链的人。
1. 动手之前先理清楚:模型结构、部署流程图和 RK3588 的算力边界
1.1 确认你手里的是哪种 FaceNet
FaceNet 在 PyTorch 生态里最常用的开源实现是timesler/facenet-pytorch,它用的是 Inception ResNet V1 作为 backbone,输出维度默认是 512,也可以用embedding_size=128重新定义。我这边用的就是官方预训练权重的 512 维版本,因为下游比对逻辑是复用一套现成的向量检索服务,512 维对检索库来说压力不大,就没动结构。
这里提醒一句:别只看名字叫 FaceNet 就直接拿来部署,先确认三件事。
- 输入尺寸是多少?facenet-pytorch 默认是 160x160,有些仓库是 112x112(比如基于 mobilefacenet 的)。
- 预处理方式是什么?常见有两种:
(x / 255 - 0.5) / 0.5和(x - 127.5) / 128,这两种在 RKNN 里对应的mean_values和std_values参数配置完全不同。 - 检测和对齐用的是哪套?FaceNet 本身不检测人脸,只负责特征提取,前面必须接 MTCNN 或 RetinaFace 做人脸框检测和关键点对齐。这一部分在 RK3588 上也要一起部署。
1.2 部署流程图:从 PyTorch 到 RKNN 要走五步
整个链路在动手前一定要画清楚:
- 在 x86 主机上准备一个干净的 Python 环境,装好 PyTorch、onnx、onnxruntime、rknn-toolkit2。
- 把训练好的 FaceNet PyTorch 权重导出为 ONNX。
- 用 onnx-simplifier 对导出的 ONNX 做算子化简,并用 onnxruntime 验证输出一致性。
- 用 rknn-toolkit2 把 ONNX 转为 RKNN 格式,这一步可以选择是否量化。
- 把生成的
.rknn文件部署到 RK3588 板子上,通过 RKNN Runtime 调用 NPU 推理,完成 embedding 提取。
这五步里面,第 1 步看似废话,实际上最容易出问题的是版本兼容。第 4 步是坑最多的地方,第 5 步则是“看起来跑通了但结果就是不对”的高发区。
1.3 RK3588 的 NPU 到底适合跑什么
RK3588 内置的 NPU 标称算力是 6 TOPS(INT8),支持 INT4、INT8、INT16、FP16 混合精度,这个数字在边缘设备里属于中上水平。实测对 160x160 输入的 Inception ResNet V1 来说,纯 NPU 推理单帧耗时大概在 10 到 20 毫秒这个量级,具体取决于你的量化方式和主线频率。如果你用 FP16,耗时会比 INT8 高一些;如果你在板子上用 CPU 跑同一个模型,耗时可能要 400 到 500 毫秒。
所以结论很直接:FaceNet 这种计算量不小的模型,在 RK3588 上一定要上 NPU,而 NPU 要走通,就逃不掉 RKNN 工具链和量化这两个绕不开的坎。
提示:不要在脑子里默认“NPU 什么算子都能跑”,Rockchip 的 NPU 对算子是有一套支持列表的。FaceNet 里面的某些结构,比如 Inception ResNet V1 的某些 block,可能会在转换时碰到算子映射问题,后面章节我会具体展开。
2. 环境搭建中最容易翻车的版本对齐问题
2.1 x86 主机上安装 rknn-toolkit2
rknn-toolkit2 是 Rockchip 提供的模型转换、仿真、量化工具,跑在你的开发机上,不是跑在板子上。安装方式很简单,用 conda 创建一个 Python 3.8 或 3.10 的环境,然后pip install rknn-toolkit2-x.x.x-cp38-cp38-linux_x86_64.whl。但这一步有几个暗坑:
- Python 版本必须对得上包名。rknn-toolkit2 的 wheel 包是按 Python 版本发布的,cp38 的包装在 3.8 环境,cp310 就装在 3.10,别用 3.11 强行装,大概率依赖冲突。
- 宿主机依赖:rknn-toolkit2 依赖
onnx==1.8.1或相近版本,如果你环境里装了一个很新的 onnx 1.14,装 rknn-toolkit2 可能会因依赖冲突直接失败。我后来是单独建了一个 conda 环境专门给 RKNN 用,不和其他项目混。 - 转换工具版本和板子端 Runtime 版本必须对齐。这是我踩了很久才意识到的问题:如果你用 rknn-toolkit2 1.6.0 转换出来的模型,拿到板子上的 RKNN Runtime 1.5.0 去加载,可能报错,也可能不报错但行为异常。我的建议是,直接查对应版本的 release note,确保 toolkit 和 lite runtime 版本一致。
我最终用的组合是:rknn-toolkit2 1.6.0 + Python 3.8 + Ubuntu 20.04 开发机,板子上用配套的rknn-toolkit2-litePython 包。
2.2 板端环境的准备
RK3588 板子上(我用的是一块基于 RK3588 的 ARM 开发板),烧录 Ubuntu 系统后,需要确认两件事:
- 系统里是否已经有
/usr/lib/librknnmrt.so,这就是 NPU 的 runtime 库。 /usr/share/rknn-toolkit-lite目录下是否有对应版本的 Python wheel 包。
如果没有,需要从 Rockchip 官方资料库里下载对应版本的rknn-toolkit2-lite并安装。安装后可以用一段最简单的代码验证板端 NPU 是否可用:
from rknnlite.api import RKNNLite rknn_lite = RKNNLite() ret = rknn_lite.load_rknn('/path/to/model.rknn') ret = rknn_lite.init_runtime() print('NPU runtime initialized:', ret)这一步如果把整条链路比作盖房子,就是打地基。地基没打好,后面什么模型转换、精度验证都无从谈起。
2.3 为什么不建议在板子上直接做转换
有些人图省事,直接在 RK3588 上跑 rknn-toolkit2 的完整版做模型转换,实际上并不推荐。完整版 rknn-toolkit2 在板子上跑,一是依赖库很多,容易污染系统环境;二是模型量化过程需要跑大量校准数据,在板子上跑效率很低。正确的操作是:在 x86 主机上转换、量化、生成 .rknn 文件,板子上只安装轻量的 rknn-toolkit2-lite 做加载和推理。
3. PyTorch 导出 ONNX 时的关键操作和常见坑
3.1 导出前先冻结模型结构
FaceNet 这类模型在 PyTorch 里通常是以nn.Module形式存在的。导出 ONNX 之前,一定要把模型切到 eval 模式,并且把model.classify这类分类头关掉。faceNet-pytorch 仓库里的模型有一个classify参数,如果为 True,输出的就是分类 logits 而不是 embedding,导出时一定要确保这个开关是 False:
import torch from facenet_pytorch import InceptionResnetV1 model = InceptionResnetV1(pretrained='vggface2', classify=False) model.eval() dummy_input = torch.randn(1, 3, 160, 160) torch.onnx.export( model, dummy_input, 'facenet.onnx', opset_version=11, input_names=['input'], output_names=['output'], dynamic_axes=None ) print('ONNX exported.')这里有几个参数需要说明:
opset_version建议用 11,rknn-toolkit2 对 opset 11 的支持最成熟。用太高的 opset(比如 17、18)可能会导致转换时出现未知算子。dynamic_axes我特意设为 None。虽然动态尺寸很诱人,但在 RKNPU 上动态 shape 支持有限,而且推理速度可能反而变慢。既然 FaceNet 的输入尺寸在设计时就固定为 160x160,那就老老实实静态导出。dummy_input的 batch size 设为 1。RKNN 转换时如果你用 batch 1 导出,后面推理时也最好用 batch 1,不要想着批量推理,NPU 上 batch 推理的效率提升不一定明显,还会增加内存占用。
3.2 用 onnx-simplifier 化简,提前拆掉容易出问题的算子
PyTorch 导出的 ONNX 往往带有很多冗余的 shape 操作、恒等映射、Transpose 等。这些算子在 ONNX Runtime 上跑没问题,但在 RKNN 转换时可能被判定为“不支持的算子”,或者会让转换后的模型在 NPU 上多出很多无用的 CPU 算子,拖慢速度。
我用onnx-simplifier做了一次化简:
pip install onnx-simplifier python -m onnxsim facenet.onnx facenet_sim.onnx化简之后,最好打开 ONNX 文件看一眼算子列表:
import onnx model = onnx.load('facenet_sim.onnx') ops = {node.op_type for node in model.graph.node} print(ops)我这边化简后的算子集合大概包括 Conv、Relu、BatchNormalization、MaxPool、AveragePool、AdaptiveAvgPool、Reshape、Gemm、Add、Mul 等。这些都是 RKNN 的熟面孔,基本不会出大问题。如果看到像GridSample、TfIdfVectorizer这类冷门算子,基本就是模型结构不合适,需要提前处理。
这一步特别重要,一定不要省。因为有相当一部分 ONNX 转 RKNN 报错,最后追根究底都是因为原始 ONNX 里残留了一些逻辑上存在但计算上冗余的算子。
3.3 导出后用 onnxruntime 验证输出
在转 RKNN 之前,先用 onnxruntime 跑一遍导出的 ONNX,和 PyTorch 的输出做一个对比。这个验证能排除“模型没导出好”的干扰因素,让你后面在 RKNN 上遇到精度问题时,能明确知道问题出在哪个环节。
对比方法很简单:同一个输入,分别用 PyTorch 和 onnxruntime 推理,计算两者输出向量的余弦相似度和欧氏距离。正常情况下,因为浮点计算的微小差异,余弦相似度应该接近 1.0(比如 0.9999 以上),欧氏距离应该非常小。如果差异很大,一定是导出过程出了问题,先解决这个再往下走。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession('facenet_sim.onnx') ort_output = sess.run(None, {'input': dummy_input.numpy()})[0] torch_output = model(dummy_input).detach().numpy() cos_sim = np.dot(ort_output.flatten(), torch_output.flatten()) / ( np.linalg.norm(ort_output) * np.linalg.norm(torch_output) ) print('cosine similarity:', cos_sim)4. ONNX 转 RKNN:核心配置项和算子兼容问题
4.1 转换前的输入输出梳理
ONNX 转 RKNN 不是简单地把文件格式换一下,而是要告诉转换工具“这个模型的输入长什么样、预处理怎么算、输出是什么”。这一步配置错了,后面所有工作都白做。
FaceNet 在 PyTorch 里的预处理是:
x = (x / 255 - 0.5) / 0.5对应到 RKNN 的 config 里,mean_values和std_values要理解成:input = (img / 255 - mean) / std。所以:
from rknn.api import RKNN rknn = RKNN() rknn.config( mean_values=[[0.5, 0.5, 0.5]], std_values=[[0.5, 0.5, 0.5]], target_platform='rk3588', optimization_level=3 ) ret = rknn.load_onnx(model='facenet_sim.onnx') ret = rknn.build(do_quantization=False)这里target_platform='rk3588'是必须写的。不写的话默认可能是 rk3568 或其他平台,生成的模型在 RK3588 上也能加载,但算子映射方式和性能调优可能不是最优的。
注意 PyTorch 的通道顺序:ONNX 里输入一般是 NCHW(1, 3, 160, 160),而 RKNN 内部处理时会切换成 NHWC,这些是工具自动做的,不需要你手动转。但如果你在预处理里把 RGB 和 BGR 搞反了,后面人脸识别效果会非常差,而且不太好排查。整个流程里,我建议从图片解码开始就统一用 RGB 顺序,在 RKNN 推理传入前保持和 PyTorch 训练时一致。
4.2 量化 vs 不量化:第一次先跑通
我在第一次转换时直接把do_quantization设为 False,也就是先导出一个 FP16 精度的 RKNN 模型,把整条链路跑通、输出对了,再做 INT8 量化。这个顺序是我强烈推荐的:先跑通,再优化。
do_quantization=False时,RKNN 会生成一个 FP16 精度的模型。FP16 在 RK3588 NPU 上是可以直接跑的,精度损失比 INT8 小得多。但代价是模型体积更大、推理速度更慢。这个阶段我们不在乎速度,只要结果对。
转换完成后,导出.rknn文件:
rknn.export_rknn('facenet_fp16.rknn')同时可以用 RKNN 自带的模拟器(模拟器跑在 x86 上,模拟 NPU 推理)先看一眼输出是否正常。模拟器跑通了,再放板子上。
4.3 算子不支持怎么办
转换过程中最常见的报错是类似这样:
E LoadONNX: Cannot find a valid rknn for the current op: xxx遇到这种报错,我的排查顺序是:
- 看报错里的算子名是什么。如果是卷积、池化、激活这一类,大概率是参数组合不被支持,比如
dilation > 1的卷积在某些版本上映射得不好。 - 回到 PyTorch 模型结构里,找到这个算子对应的代码位置。用 ONNX 的节点名定位,或者在导出 ONNX 时把每个 tensor 的 name 打印出来。
- 对模型结构做等效替换。比如某些自定义的
AdaptiveAvgPool2d(1)可以手动替换成AvgPool2d+Reshape,某些F.interpolate换成固定size的Upsample。 - 替换后重新导出 ONNX、重新 onnxsim、重新转换。
Facenet 的 Inception ResNet V1 整体结构不算复杂,一般不会遇到太难的算子问题。我最开始遇到过torch.split导出的Split算子在某些版本上映射效率低的问题,后来通过结构上直接改成多个 slice 拼起来解决了,但这个属于特例,不同模型遇到的情况不同。
4.4 模拟器推理和真机推理的差异
rknn-toolkit2 提供模拟器,可以在 x86 上模拟 NPU 推理。模拟器跑通的模型,放在板子上大概率能跑通,但不能保证数值完全一致。我遇到过模拟器输出正常、真机输出 NaN 的情况,最后发现是板子上的 RKNN Runtime 版本和 toolkit 版本不一致导致的。
所以这里有个经验:
模拟器只是用来验证链路通不通,最终精度验证必须放在真机上做。而且最好从最开始就在板子上留好一个“模型输出对比”的测试脚本,用同一张测试图,对比 ONNX Runtime 的输出、RKNN 模拟器的输出、RKNN 真机的输出,这样一旦出问题,能立刻定位是哪个环节坏了。
5. int8 量化后精度崩塌:原因定位和解决办法
5.1 “不量化正常,int8 量化后数值不动”的现象
如果你在rknn.build(do_quantization=True)之后,发现模型在真机上输出变成了一堆恒定值,或者和输入完全无关,这说明量化过程出了问题。我这边遇到的情况是:FP16 模型输出的 embedding 和 ONNX Runtime 基本一致,但 INT8 量化后,输出的 512 维向量所有维度都趋近于同一个值。
这个问题的本质,是量化时激活值范围估算出了问题。RKNN 在做 INT8 量化时,需要跑一批校准数据来统计每一层的激活值分布,然后根据 min/max 把 float 映射到 int8。如果校准数据不够有代表性,或者某些层的激活值分布特别不均匀,量化参数就会算偏,最终结果就是模型在量化后“表情呆滞”,输出失去区分度。
5.2 量化校准数据集不是越多越好
我在第一次量化时犯了一个典型错误:为了省事,拿了很多不同场景的图片做校准,但没有注意图片的质量和内容分布。结果就是模型在测试集上输出非常差,比随机向量还离谱。
后来我重新整理了校准数据集,遵循这几个原则:
- 使用和真实部署场景一致的数据。如果你的摄像头装在室内门禁,就多放室内光照下人脸图片;如果装在室外闸机,就放室外自然光下人脸图片。不要拿了一堆 ImageNet 风格图片来做 FaceNet 的校准。
- 数量不用多,但要覆盖人脸姿态、光照的多样性。一般 200 到 500 张就足够了,甚至 100 张做得好的效果也明显好于 1000 张乱选的。
- 校准图片要经过同样的预处理。也就是说,图片要先经过 MTCNN 检测和对齐,裁出 160x160 人脸区域,再喂给 RKNN 的量化器。如果你拿原始大图直接校准,量化器看到的分布和真实推理时完全不同,量化参数肯定不准。
校准数据准备的过程,我建议单独写一个脚本,把“检测人脸-对齐-裁切-缩放-acetime”这一套流程固定下来,确保后续无论做什么实验,校准数据的处理方式都是统一且可复现的。
RKNN 的build接口支持传入一个 dataset 文件:
rknn.load_onnx(model='facenet_sim.onnx') ret = rknn.build(do_quantization=True, dataset='dataset.txt')dataset.txt的每一行是一个经过预处理的图片路径,或者是你手动提取的 npy 文件路径。我更推荐直接用.npy文件,因为这样可以确保传给 RKNN 的数据和你用 PyTorch 验证时的输入完全一致,不经过任何图片解码差异的干扰。
5.3 诊断量化问题的三板斧
如果你做完校准,量化后输出的 embedding 和 FP16 还是有明显差距,我一般按下面的顺序排查:
- 逐层对比:RKNN 提供了
rknn.accuracy_analysis接口,可以对比原始模型和量化模型每一层的输出误差。跑一次这个分析,很快就能定位到是哪一个 layer 的量化误差最大。
rknn.accuracy_analysis(inputs=[test_input], output_dir='./accuracy_analysis')检查敏感层清单:如果误差集中在某些层,看一下这些层的算子类型。卷积层的权重通常对量化不敏感,但一些
Concat、Add、BatchNormalization层如果处理不好,会放大误差。FaceNet 里 Inception 模块有大量的Concat,有时候问题就出在 concat 之前某个分支的激活值范围特别大。对应到模型结构,决定是否混合量化:如果误差来自模型最后几层,而对整体识别率影响不大,可以直接把敏感的子模块在 RKNN 里设置为不量化(保留 FP16)。Rockchip 的混合量化配置方式相对简陋,一般是在构建时通过
custom_quantize_layers来指定某些层走 FP16。
rknn.config( custom_quantize_layers=['module.last_linear'], # ... )这个方法在实际项目中经常用来救急。它的原理很简单:把影响最大的几层保留高精度,其他层继续 INT8,这样既保住了精度,模型体积和推理速度的损失又比较小。
5.4 量化后的精度验收标准
量化完不是看一眼输出“差不多”就算完了。我的验收流程是:
- 找 100 到 200 张真实场景下的人脸图,跑完全流程(检测、对齐、embading)。
- 同类人脸两两计算余弦相似度,得到类内相似度分布;异类人脸两两计算余弦相似度,得到类间相似度分布。
- 对比 FP16 模型和 INT8 模型在相同测试集上的相似度分布重叠程度。如果两个分布的重叠面积明显变大,说明量化已经影响到了实际识别效果,需要回去调整校准数据或混合量化策略。
如果只是追求“跑通”,量化后也能出数值,但识别率掉了 3 到 5 个点,很多人可能觉得无所谓。但在真实门禁、闸机场景下,3 个点的识别率差异可能就决定了项目验收过不过,所以精度验证一定不能省。
6. 在 RK3588 板子上跑通完整推理:代码细节和调用流程
6.1 RKNN Lite 接口的使用
板子上用的推理接口是RKNNLite,比完整版轻量很多。加载模型、初始化 runtime、推理的代码也不复杂:
from rknnlite.api import RKNNLite import numpy as np import cv2 rknn_lite = RKNNLite() rknn_lite.load_rknn('facenet_int8.rknn') rknn_lite.init_runtime() # 读取图像,统一为 RGB,resize 到 160x160 img = cv2.imread('face.jpg') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (160, 160)).astype(np.float32) # RKNN 的输入,默认接受 NHWC,同时支持传入未归一化的 0-255 数值, # 归一化由 mean_values/std_values 自动完成 input_data = np.expand_dims(img, axis=0) outputs = rknn_lite.inference(inputs=[input_data]) embedding = outputs[0].flatten()这个接口非常简洁,但有几个细节决定成败:
- 输入通道顺序:
cv2.imread读出来是 BGR,要手动转成 RGB,或者把 RKNN config 里的 mean_values 顺序调整成 BGR 对应的值。我吃了这个亏,第一次在板子上跑出来的 embedding 在比对时效果极差,排查了很久才发现是通道顺序搞反了。这里统一建议:在代码层直接转成 RGB,和 PyTorch 训练时的输入保持一致,这是最不容易出错的做法。 - 输入尺寸:RKNN 模型导入时自带输入 shape,如果你传一个 200x200 的图片进去,会被直接 resize 到 160x160,这个 resize 是 RKNN runtime 内部做的。但为了减少额外的精度损失,最好在喂给 RKNN 之前自己用 OpenCV 处理好尺寸和数据类型。
- 数据类型:
float32是安全的。如果你传uint8,RKNN 也会处理,但为了统一,建议在外部转好 float32。
6.2 前面要接检测和对齐:MTCNN 在 CPU 上跑
FaceNet 只做特征提取,实测在 RK3588 上如果直接用原始摄像头画面喂进去,效果会非常差。部署时必须前置一个人脸检测和对齐模块。
我当时在板子上用的是 MTCNN,通过 facenet-pytorch 自带的实现:
from facenet_pytorch import MTCNN mtcnn = MTCNN(image_size=160, margin=0, keep_all=False, thresholds=[0.6, 0.7, 0.7], device='cpu') face_tensor = mtcnn(img_rgb)MTCNN 这块我在 RK3588 上是用 CPU 跑的,没有移植到 NPU 上。实测单帧人脸检测加对齐大概耗时 120 到 180 毫秒,在门禁场景下完全够用。如果你对速度要求更高,可以考虑用 RetinaFace 的简化版,或者把检测模型也转成 RKNN 上 NPU,但这就是另一个工程了。
有一点要注意:MTCNN 返回的对齐后人脸图像,内部实现里已经做了归一化((x / 255 - 0.5) / 0.5)并转成了 PyTorch Tensor。所以如果你用 MTCNN 的输出直接喂到 RKNN,需要先把它转回 0-255 范围的 RGB 图像,再让 RKNN 的 mean/std 配置去处理,或者你调整 RKNN 的 mean/std 配置,让输入匹配 MTCNN 的输出。
我的做法是:在 MTCNN 前后加一个转换函数,统一以“RGB uint8 图像”作为整个识别链路的数据接口。
6.3 embedding 比对和后处理
拿到 512 维的 embedding 后,后续比对逻辑很直接。我用的是余弦相似度:
def cosine_similarity(a, b): return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))阈值的选择没有固定标准,需要在自己的数据集上调。我见过有些项目用欧氏距离,有些用余弦相似度,二者效果差异不大,但要注意:FACE-NET 开源仓库在训练时做的是 L2 归一化 + 三元组损失,所以理论上用欧氏距离判定更符合原论文的设计。实际项目里怎么选,取决于你的 embedding 在库里的分布。我在自己的数据集上跑下来,余弦相似度阈值定在 0.68 到 0.72 之间效果不错,不同的摄像头和光照条件差异会很大,上线前一定要采集真实环境数据做阈值标定。
一个完整的比对流程大致是:
- 摄像头取帧。
- MTCNN 检测到人脸并框出来。
- 对齐并裁剪出 160x160 人脸图。
- RKNN 推理得到 embedding。
- 在本地人脸库 / 特征库里检索最相似的向量。
- 相似度超过阈值,判定为同一人;否则拒识或进入注册流程。
6.4 性能实测:int8 和 fp16 的对比
我在自己的 RK3588 板子(8G 内存、标准散热片、系统负载较低时)上跑了几个版本的模型,统计了从输入图像到输出 embedding 的单次推理耗时,结果如下:
| 模型版本 | 推理耗时(ms) | 模型体积(MB) | 与 PyTorch 输出的余弦相似度 |
|---|---|---|---|
| FP16(不量化) | 32~40 | 约 110 | 0.9992 |
| INT8(校准良好) | 12~18 | 约 30 | 0.9921 |
| INT8(校准糟糕) | 12~18 | 约 30 | 0.47 |
从表里可以清楚看到,校准质量对量化模型的影响是断崖式的。校准好了,精度损失在 0.7% 以内;校准不好,输出基本不能用。
如果把 MTCNN 的 CPU 耗时加进来,整条链路的帧率大概在 4 到 6 FPS 左右,对我们这种轻交互的门禁场景来说是够用的。
7. RKNN 模型优化和后续可以扩展的方向
部署跑通之后,还有几个方向可以做,我的经验是每一步都能带来实际收益。
7.1 第一件事:跑一次 accuracy_analysis,把精度损失的账算清楚
在部署一切之前,强烈建议在 toolkit 端跑一次完整的accuracy_analysis,它需要你放一组和真实场景一致的输入数据进去,然后针对每一层输出一个“量化前后差异”的报告。这个报告是后续判断“要不要上混合量化、哪一层需要保留 FP16”的依据。
报告里如果一个模型的平均余弦相似度在 0.99 以上,基本可以无脑用 INT8;如果掉到 0.95 以下,就需要检查是校准数据问题还是模型对该任务特别敏感。
7.2 尝试更轻量的骨干网络替代
FaceNet 的 Inception ResNet V1 在边缘设备上虽然能跑,但算力开销不小。如果在精度允许的范围内,可以换成 MobileFaceNet 这类轻量网络,甚至直接使用已有人脸识别模型如 MobileFaceNet + ArcFace 的组合。RKNN 部署方式完全一样,只是导出 ONNX 时输入输出尺寸可能要调整。实测 MobileFaceNet 在 RK3588 上能做到单帧纯 NPU 推理 5 到 10 毫秒,而且识别精度并不一定比 FaceNet 差太多,特别是在受限场景下。
7.3 批量特征库的构建
人脸识别项目一般不会只有一个注册用户。随着用户增加,embedding 库会变大,简单的线性扫描会越来越慢。我这边目前是直接用 numpy 做向量化批量距离计算,库容量在 1 万以下时毫无压力。如果库继续膨胀,可以试试用 SQLite 存特征 + 预先对 embedding 做 PCA 降维到 128 维,检索效果和速度都能得到改善。
7.4 进一步做工程化的方向
部署完成后,我做的最有价值的一件事,是把整个推理封装成一个 HTTP 服务,通过局域网把摄像头采集到的每一帧发送到服务端做识别。这样摄像头端只需要负责采集和推流,RK3588 板子专注做人脸特征提取和比对,架构解耦之后,后续升级模型或增加摄像头数量都会方便很多。
如果内存允许,还可以考虑在 RKNN runtime 初始化时同时加载两个模型,一个人脸检测模型跑 NPU,一个 FaceNet 特征模型跑 NPU,这样整条链路都不依赖 CPU 上的 MTCNN,瓶颈会小很多。这也是我下一步打算做的事。
说到底,RK3588 部署 FaceNet 这条路,模型转换只是最前面的 30%,后面关于量化、预处理、阈值标定和工程集成的功夫才是决定项目能不能真正落地的关键。希望这篇记录能帮你少走一些弯路。