NanoTrack 这类轻量级单目标跟踪模型,在 PC 端用 PyTorch 跑起来很舒服,但真要落到 RK3588 这种边缘计算板子上,中间那条转换链路能把人折腾到怀疑人生。我前前后后在这块板子上折腾过好几个视觉模型,NanoTrack 的转换算是比较有代表性的一类——它同时包含卷积主干、互相关运算和多个分支输出,比单纯的分类网络麻烦不少。这篇内容就是把我从环境搭建、模型导出、ONNX 修正、RKNN 量化到板端推理的完整过程摊开来讲,重点放在那些官方文档不会写、但实际一定会踩的坑上。不管你是刚拿到 RK3588 开发板的新手,还是已经跑通过几个模型想再啃一个硬骨头的朋友,都能从里面找到能直接抄的步骤和判断依据。
1. 先搞清楚 NanoTrack 到底特殊在哪
1.1 它不是普通的单输入单输出网络
很多人第一次转 RKNN 失败,根本原因不是工具用错了,而是没搞明白自己手里的模型结构。NanoTrack 属于 Siamese 类跟踪器,推理时至少需要两路输入:一个是模板帧(template),一个是搜索帧(search)。模板帧通常来自视频第一帧里框出来的目标区域,搜索帧是后续每一帧里待搜索的大图。网络内部会对这两路特征做互相关(depthwise cross-correlation),再输出分类分支和回归分支的结果。
这就带来几个直接后果。第一,模型导出 ONNX 时必须保证两路输入都被正确保留,不能因为某一路在 traced 过程中被常量折叠掉。第二,RKNN 工具链对多输入模型的支持虽然存在,但量化时的校准数据要同时覆盖两路输入,否则量化误差会集中在其中一路,导致跟踪漂移。第三,板端推理时两路输入的尺寸往往不一样,模板帧一般是 127x127,搜索帧是 255x255 或 287x287,这要求你在 RKNN 配置里分别处理,不能想当然地用一个输入尺寸糊弄过去。
我见过有人直接把 NanoTrack 当成单输入模型去转,结果 ONNX 里只剩一个输入节点,转出来的 RKNN 在板子上跑出来的结果完全对不上。所以第一步永远是先用 Netron 打开导出的 ONNX,肉眼确认输入输出节点数量和形状。
1.2 RK3588 的 NPU 对算子有脾气
RK3588 搭载的 NPU 算力在同类板子里算不错的,但它不是万能算子接收器。RKNN 工具链在转换时会把不支持的算子回退到 CPU 执行,或者直接报错。NanoTrack 里比较敏感的几个点包括:互相关操作如果被表达成非常规的卷积形式,可能不被识别;某些 reshape 或 transpose 的组合会触发维度推断失败;还有 sigmoid、softmax 这类激活在量化后精度损失比较明显。
我的经验是,在导出 ONNX 之前就先把模型里的算子尽量往 RKNN 友好列表上靠。比如把一些可以合并的 transpose 提前在 PyTorch 里做掉,把动态 shape 全部固定成常量。这些改动在 PC 端精度几乎无损,但能让后面的转换顺畅很多。
1.3 量化方式的选择直接决定成败
RKNN 支持非量化、混合量化和全整型量化几种模式。NanoTrack 如果做全整型量化,分类分支的置信度输出会变得很粗糙,跟踪框容易跳。我实测下来比较稳的方案是:主干和互相关部分做 int8 量化,最后的分类和回归头保留 float16。这样既拿到了 NPU 的加速,又保住了输出精度。当然这需要你在转换脚本里手动指定混合量化层,后面会详细讲怎么配。
2. 环境搭建:别在版本问题上浪费一整天
2.1 PC 端 PyTorch 环境的取舍
转换工作是在 PC 上完成的,不是板子上。你需要一个能跑 PyTorch 导出 ONNX 的环境,同时还要装 RKNN-Toolkit2。这两个东西对 Python 版本和依赖库版本都有要求,最容易冲突的地方是 numpy 和 protobuf。
我目前比较稳的组合是 Python 3.8 或 3.9,PyTorch 1.13 到 2.0 之间,onnx 1.14 左右,RKNN-Toolkit2 用 1.6.0 以上版本。用 conda 建独立环境,不要和系统 Python 混在一起。命令大概是这样:
conda create -n nanotrack_rknn python=3.9 conda activate nanotrack_rknn pip install torch==2.0.1 torchvision==0.15.2 --index-url https://download.pytorch.org/whl/cpu pip install onnx==1.14.0 onnxruntime==1.15.1 pip install rknn-toolkit2==1.6.0这里特意装 CPU 版 PyTorch,因为导出 ONNX 不需要 GPU,装 CUDA 版反而会引入一堆没必要的依赖。如果你机器上已经有 GPU 环境,也可以直接用,但要注意 CUDA 版本和 PyTorch 版本的匹配,别为了省事导致导出时报奇怪的错。
注意:RKNN-Toolkit2 对 protobuf 版本很敏感,如果安装后 import 报错,先检查 protobuf 是不是被其他包升级到了 4.x,把它降到 3.20.x 通常能解决。
2.2 板端运行环境的准备
板子这边需要的是 RKNN Runtime,不是 Toolkit。RK3588 出厂固件里一般已经带了 librknnrt.so,但版本可能比较老。你要做的是确认板子上运行库的版本和 PC 端 Toolkit 的版本匹配。版本不匹配时,PC 上转出来的 rknn 模型在板子上加载会直接报错,错误信息往往很含糊,只说什么 init runtime failed。
检查板端版本的方法:
adb shell cat /usr/lib/librknnrt.so | grep -a "librknnrt version"如果版本太老,就从 SDK 里找到对应版本的运行库替换上去。替换后记得 reboot,不然动态库缓存可能还是旧的。
另外板子上要准备好 Python 的 rknn 运行时包,或者用 C++ 接口。我一般先用 Python 接口快速验证模型能不能跑通,确认没问题再上 C++ 做性能优化。Python 接口安装:
pip install rknn_toolkit_lite2-1.6.0-cp39-cp39-linux_aarch64.whl这个 whl 包在 SDK 的 runtime 目录里能找到,注意架构是 aarch64,别下成 x86 的。
2.3 两套环境之间的文件传输
PC 和板子之间传模型文件,最省事的是 adb push。前提是板子已经开了 adb 调试。连接命令:
adb devices adb push nanotrack.rknn /data/如果 adb 连不上,先确认板子的 USB 调试模式打开了,或者用网络 adb。网络 adb 的步骤是先串口登录板子,设置 adb tcpip 端口,然后 PC 上 adb connect 板子 IP。这套流程在 RK3588 上很成熟,网上教程也多,不展开。
3. 从 PyTorch 到 ONNX:导出只是开始
3.1 导出前的模型改造
拿到 NanoTrack 的官方代码后,不要直接 torch.onnx.export 就完事。先做几件事:
第一,把模型切到 eval 模式,并且确认所有 BN 层都处于推理状态。第二,把输入尺寸固定死,模板帧 127x127,搜索帧 255x255,不要留动态维度。第三,检查 forward 函数里有没有 Python 层面的控制流,比如 if 判断或者循环,这些在 tracing 时会被固化,可能导致导出后的计算图和实际推理不一致。
NanoTrack 官方实现里,互相关部分有时会用 F.conv2d 配合分组卷积来实现。这种写法本身没问题,但要确认分组数在导出后仍然正确。我遇到过导出后分组卷积的 group 参数丢失,变成普通卷积,导致输出通道数翻倍。解决办法是在导出前把互相关模块单独测一遍,用固定输入对比 PyTorch 输出和 ONNX Runtime 输出,误差在 1e-4 以内才算过。
导出脚本的核心部分:
import torch from model import NanoTrack model = NanoTrack() model.load_state_dict(torch.load("nanotrack.pth", map_location="cpu")) model.eval() template = torch.randn(1, 3, 127, 127) search = torch.randn(1, 3, 255, 255) torch.onnx.export( model, (template, search), "nanotrack.onnx", input_names=["template", "search"], output_names=["cls_out", "reg_out"], opset_version=11, do_constant_folding=True, dynamic_axes=None )opset 选 11 是比较保险的,RKNN 对 11 和 12 支持都还行,再高可能遇到算子版本不兼容。dynamic_axes 设为 None 是为了强制固定 shape,避免后面 RKNN 转换时维度推断出问题。
3.2 用 ONNX Runtime 做第一轮验证
导出完别急着转 RKNN,先用 ONNX Runtime 跑一遍,和 PyTorch 的输出对比。这一步能帮你把大部分导出问题挡在门外。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("nanotrack.onnx") t = np.random.randn(1, 3, 127, 127).astype(np.float32) s = np.random.randn(1, 3, 255, 255).astype(np.float32) onnx_out = sess.run(None, {"template": t, "search": s}) torch_out = model(torch.from_numpy(t), torch.from_numpy(s)) for i, (o1, o2) in enumerate(zip(onnx_out, torch_out)): diff = np.abs(o1 - o2.detach().numpy()).max() print(f"output {i} max diff: {diff}")如果某个输出的误差超过 1e-3,就要回去查对应的算子。常见原因是某个自定义操作在导出时被简化错了,或者 padding 方式不一致。定位方法是用 Netron 看 ONNX 图,找到可疑节点,然后在 PyTorch 里单独构造该模块的输入输出做对比。
3.3 ONNX 图的清理和简化
ONNX 导出后往往带一堆冗余节点,比如恒等变换、多余的 cast、可以合并的 transpose。这些节点在 PC 上无所谓,但在 RKNN 转换时会增加算子映射的复杂度,甚至触发不支持算子。
用 onnx-simplifier 做一轮清理:
pip install onnx-simplifier python -m onnxsim nanotrack.onnx nanotrack_sim.onnx简化后再用 Netron 打开,确认输入输出没变,关键算子还在。有时候简化会把一些看似冗余但实际影响精度的节点干掉,所以简化后一定要重新跑一遍 ONNX Runtime 验证,对比简化前后的输出差异。
我一般会保留简化前后的两个版本,如果简化版转 RKNN 出问题,就退回未简化版,手动删掉几个明显多余的节点再试。这种笨办法在工具链不完善的时候反而最有效。
4. RKNN 转换与量化:参数配错等于白干
4.1 转换脚本的骨架
RKNN-Toolkit2 的转换流程分几步:创建 RKNN 对象、配置模型、加载 ONNX、构建、导出。每一步都有坑,我先把完整脚本贴出来,再逐段解释。
from rknn.api import RKNN rknn = RKNN(verbose=True) rknn.config( mean_values=[[0, 0, 0], [0, 0, 0]], std_values=[[255, 255, 255], [255, 255, 255]], target_platform="rk3588", quantized_dtype="w8a8", quantized_algorithm="normal", optimization_level=3 ) ret = rknn.load_onnx( model="nanotrack_sim.onnx", inputs=["template", "search"], input_size_list=[[1, 3, 127, 127], [1, 3, 255, 255]] ) if ret != 0: print("load onnx failed") exit(ret) ret = rknn.build(do_quantization=True, dataset="calib_dataset.txt") if ret != 0: print("build failed") exit(ret) ret = rknn.export_rknn("nanotrack.rknn") if ret != 0: print("export failed") exit(ret)4.2 归一化参数必须和训练时一致
config 里的 mean_values 和 std_values 是最容易被忽略的地方。NanoTrack 训练时如果用的是 ImageNet 的均值和方差,你这里就要填对应的值,而不是想当然填 0 和 255。填错了不会报错,但板端推理结果会整体偏移,表现为跟踪框一直往某个方向飘。
判断方法很简单:在 PC 端用同样的预处理跑一遍 PyTorch 模型,记录输出范围,然后在板端跑 RKNN 模型,对比输出范围。如果量级差很多,先查归一化参数。
我一般会把预处理做成一个独立函数,PC 端和板端共用同一套逻辑,只是板端把归一化交给 RKNN 内部做,这样能减少不一致的可能。
4.3 校准数据集的制作
量化校准需要一批有代表性的输入数据。NanoTrack 有两路输入,所以校准集里每条样本要包含一对 template 和 search。数量上 100 到 200 对就够了,太少会导致量化参数估计不准,太多则浪费时间。
制作校准集的思路是从验证视频里抽帧,按跟踪逻辑生成模板和搜索区域。如果嫌麻烦,也可以用随机裁剪的方式生成近似分布的数据,但效果会打折扣。我的做法是拿一段真实视频,跑一遍 PC 端跟踪,把每帧实际送入网络的 template 和 search 保存下来,这样校准数据分布和推理时完全一致。
校准集文件是一个文本文件,每行一对路径:
./calib/000_template.npy ./calib/000_search.npy ./calib/001_template.npy ./calib/001_search.npy ...注意这里用的是 npy 文件而不是图片,因为 RKNN 的 dataset 支持直接读 npy,省去了解码和预处理的步骤,也避免了图片格式带来的精度损失。
4.4 混合量化的配置方法
前面提到全 int8 量化会让输出头精度下降,解决办法是在 build 时指定哪些层不量化。RKNN-Toolkit2 支持通过 hybrid_quantization 配置,但更直接的方式是用 quantized_dtype 配合 layer 级别的配置。
实际操作中,我会先用全 int8 转一版,在板端测精度,如果跟踪框抖动明显,再改成混合量化。混合量化的配置需要在 config 里加:
rknn.config( ..., quantized_dtype="w8a8", quantized_algorithm="normal", custom_string="config hybrid", hybrid_quantization=True )然后在 build 之前调用 rknn.hybrid_quantization_step1 生成配置文件,手动编辑里面哪些层用 float,再 step2 完成构建。这个过程比较繁琐,但效果确实比全 int8 好。我实测下来,把最后两层卷积和输出层设为 float16,跟踪成功率能提升十几个百分点。
4.5 转换失败的常见报错与定位
RKNN 转换报错信息往往很简短,比如 "build failed" 后面跟一个错误码。这时候要看 verbose 日志,里面会指出是哪个算子不支持或者哪个维度推断失败。
几个我踩过的典型问题:
- 维度不匹配:ONNX 里某个 reshape 的目标 shape 是动态计算的,RKNN 推断不出来。解决办法是在导出 ONNX 前把 shape 固定成常量。
- 算子不支持:比如某些版本的 RKNN 不支持 GridSample 或特定的 interpolate 模式。解决办法是换一种等价实现,或者把该部分留在 CPU 上跑。
- 量化校准失败:校准数据里有 NaN 或者数值范围异常。检查 npy 文件,确保没有脏数据。
遇到报错不要慌,先把 verbose 日志完整读一遍,定位到具体算子,再决定是改模型还是改配置。大部分问题都能通过调整 ONNX 图解决,实在不行就放弃那部分算子的 NPU 加速,让它回退 CPU。
5. 板端推理:跑通只是第一步
5.1 Python 接口快速验证
模型推到板子上后,先用 Python 接口跑一遍,确认能加载、能推理、输出形状对。
from rknnlite.api import RKNNLite import numpy as np rknn = RKNNLite() ret = rknn.load_rknn("nanotrack.rknn") ret = rknn.init_runtime(core_mask=RKNNLite.NPU_CORE_0) template = np.load("template.npy") search = np.load("search.npy") outputs = rknn.inference(inputs=[template, search]) for i, o in enumerate(outputs): print(f"output {i} shape: {o.shape}, range: [{o.min():.3f}, {o.max():.3f}]")core_mask 指定用哪个 NPU 核心。RK3588 有三个 NPU 核心,可以单独用也可以组合用。单模型推理一般用 CORE_0 就行,多模型并行时才需要分配不同核心。
如果加载时报 "init runtime failed",九成是运行库版本不匹配。回到 2.2 节检查 librknnrt.so 版本。
5.2 输出后处理的对齐
RKNN 输出的分类和回归结果,需要经过后处理才能变成跟踪框。这部分逻辑必须和 PC 端完全一致,否则会出现"模型输出对但框不对"的情况。
后处理一般包括:对分类分支做 sigmoid,对回归分支做解码,然后按得分排序取最高分的位置,再映射回原图坐标。我在板端实现时,会把 PC 端的后处理代码原封不动搬过来,只把输入从 PyTorch tensor 换成 numpy array。这样能最大程度保证一致性。
有一个细节要注意:RKNN 量化后的输出数值范围和 float 模型不完全一样,sigmoid 的输入可能偏大或偏小。如果发现置信度普遍偏低,可以在后处理里加一个温度系数做微调,或者干脆把分类头改成不量化。
5.3 性能实测与瓶颈分析
RK3588 上 NanoTrack 的单帧推理时间,我实测在 15 到 25 毫秒之间,取决于是否开启全部 NPU 核心以及输入分辨率。这个速度跑实时跟踪(30 FPS 以上)是够的,但前提是后处理不能太慢。
用 Python 接口时,后处理和内存拷贝的开销可能比推理本身还大。如果发现整体帧率上不去,先用 time 模块分段计时,看时间花在哪。常见瓶颈包括:numpy 的 sigmoid 在大数组上比较慢、坐标映射用了 Python 循环、每帧都重新分配内存。
优化方向:把 sigmoid 换成查表或者用定点近似,坐标映射向量化,预分配输入输出 buffer 复用。这些改动能让整体帧率提升 30% 以上。
5.4 多核 NPU 的分配策略
如果你的板子上同时跑多个模型,比如 NanoTrack 加一个检测模型,可以把它们分配到不同的 NPU 核心上,避免互相抢占。RKNNLite 的 core_mask 支持 CORE_0、CORE_1、CORE_2 以及组合。分配原则是计算量大的模型独占一个核心,小模型共享另一个核心。
不过要注意,多核并行时内存带宽是共享的,如果两个模型都吃带宽,实际加速比可能达不到预期。我一般先用单核跑,确认性能满足需求就不折腾多核;实在不够再考虑分配。
6. 那些让我返工三次的坑
6.1 模板帧和搜索帧的预处理不一致
这个问题很隐蔽。PC 端跟踪代码里,模板帧的预处理可能是 resize 加归一化,搜索帧是 crop 加 resize 加归一化,两者用的插值方式还不一样。导出 ONNX 时这些预处理不在图里,所以不影响转换,但板端推理时如果你自己实现的预处理和 PC 端不一致,输入分布就变了,跟踪效果直接崩。
我的解决办法是把预处理也做成一个独立模块,PC 端和板端用同一份代码逻辑,只是板端用 numpy 实现。写完后用同一张图分别跑 PC 和板端,对比送入网络的 tensor 数值,误差在 1e-3 以内才算对齐。
6.2 量化后互相关层的输出饱和
互相关层的输出动态范围比较大,int8 量化后容易饱和,表现为分类得分集中在 0 或 1 附近,失去区分度。我试过几种缓解方法:一是把互相关层设为不量化,但这样会损失不少速度;二是调整校准算法,从 normal 换成 kl 散度;三是在互相关后加一个可学习的缩放,但这个改动需要重新训练。
最终我采用的是混合方案:互相关层保持 int8,但在校准集里增加一些困难样本(目标被遮挡、背景杂乱的帧),让量化参数更好地覆盖大动态范围。这个改动不需要重新训练,效果也比较明显。
6.3 板端内存不足导致推理中断
RK3588 板子内存一般有 4GB 或 8GB,跑单个 NanoTrack 绰绰有余。但如果你的系统里还跑了其他服务,或者输入分辨率调得很大,可能会遇到内存分配失败。表现是推理跑几帧后突然报错退出。
排查方法是监控推理过程中的内存占用。如果持续增长,说明有内存泄漏,检查是不是每帧都新建了 RKNN 输入输出数组而没有释放。RKNNLite 的 inference 接口每次调用会返回新的数组,如果在一个循环里不断累积引用,内存就会涨。解决办法是复用输入 buffer,输出用完即弃。
6.4 版本升级带来的意外
RKNN-Toolkit2 从 1.5 升到 1.6 时,我发现同一个 ONNX 转出来的模型精度变了。查下来是量化算法默认参数改了。这种问题没有太好的预防办法,只能是在升级工具链后重新跑一遍精度验证,确认没问题再上板子。
我的习惯是每个项目固定一套工具链版本,把版本号写在 README 里,换机器或换人接手时照着装。这样能避免"在我机器上好好的"这类问题。
7. 从跑通到好用还差什么
7.1 跟踪逻辑的板端适配
模型转换只是把网络搬到了板子上,真正的跟踪逻辑还需要在板端实现。包括:第一帧初始化模板、后续帧生成搜索区域、根据上一帧结果确定搜索中心、处理跟踪失败时的重置策略。这些逻辑在 PC 端可能依赖 OpenCV 的某些函数,板端要确认这些函数可用,或者用等价实现替换。
我一般会把跟踪逻辑写成纯 numpy 加少量 OpenCV 的形式,减少对特定库版本的依赖。搜索区域的生成用简单的仿射变换就能搞定,不需要复杂的图像处理。
7.2 长时间运行的稳定性
跑几分钟和跑几小时是两回事。长时间运行要关注:内存是否稳定、NPU 是否过热降频、跟踪失败后能否自动恢复。RK3588 的 NPU 在持续高负载下会发热,如果散热不好,频率会降,推理时间变长。解决办法是加散热片,或者在跟踪逻辑里加空闲帧,让 NPU 有机会喘口气。
跟踪失败恢复策略也很重要。当分类得分低于阈值时,不要直接放弃,可以扩大搜索区域再试几帧,或者用上一帧的模板做一次重检测。这些策略在 PC 端容易实现,板端要注意计算量,别让恢复逻辑把帧率拖垮。
7.3 精度与速度的最终权衡
到这一步,你手里应该有几个版本的模型:全 int8 的、混合量化的、可能还有不量化的。选哪个上生产,取决于你的场景。如果追求极致速度,全 int8 加小分辨率输入;如果追求精度,混合量化加原始分辨率。我的建议是先用混合量化版跑一段时间,收集实际场景下的跟踪成功率,再决定要不要为了速度牺牲精度。
实测数据供参考:全 int8 在 RK3588 单核上约 12ms 一帧,混合量化约 18ms,不量化约 35ms。跟踪成功率方面,混合量化比全 int8 高约 15%,和不量化差距在 5% 以内。所以混合量化是性价比最高的选择。
7.4 后续可以继续折腾的方向
如果这套流程跑通了,还可以往几个方向扩展。一是把 NanoTrack 换成其他跟踪器,比如基于 Transformer 的轻量跟踪模型,转换流程大同小异,但算子适配要重新做。二是做多路视频同时跟踪,利用 RK3588 的多核 NPU 并行处理。三是把整个 pipeline 做成 C++ 服务,减少 Python 开销,进一步压榨性能。
我个人在实际操作中的体会是,模型转换这件事,工具链的坑远多于模型本身的坑。把环境版本锁死、把每一步的输出都验证一遍、把校准数据做扎实,这三点做到了,大部分问题都能提前暴露。剩下的就是耐心,遇到报错别急着换方案,先把日志读透,往往答案就在里面。