上个月帮客户做一批基于RK3588的边缘识别终端,OCR模块要识别设备铭牌上的型号序列号。最开始在服务器上用PaddleOCR的PP-OCRv4跑得很顺,一到嵌入式平台就卡壳:Paddle Inference在ARM板的部署依赖太多,算子支持得逐个验证,交叉编译一堆未知符号等着处理。折腾了两天后我换了个思路,把PP-OCRv4的三个子模型全部导出成ONNX,再分别接到RK3588的CPU和NPU上,流程一下子清晰了很多。这篇东西就是把这条“PP-OCRv4 → ONNX → RK3588/RKNN”的路线完整记录下来,适合正在做OCR边缘设备、想把PaddleOCR移植到ARM盒子或RK3588平台的工程师参考。
1. 为什么是ONNX:PP-OCRv4在嵌入式部署中的绕路方案
1.1 PP-OCRv4本身并不难跑,难的是离开服务器
PP-OCRv4是PaddleOCR里比较成熟的版本,检测端用的是可微二值化DBNet框架,识别端是SVTR系列演进出的HGNet骨干网络,整条pipeline按“文本检测 → 方向分类 → 文本识别”三段式组织。在x86服务器上,直接用PaddlePaddle的Python接口跑demo非常轻松,下载官方权重,加载模型,一张图三五十毫秒就出结果。
但部署到嵌入式平台就是另一回事。Paddle Inference和Paddle Lite对ARM架构有支持,可实际用起来有两个绕不开的问题。第一,Paddle生态和自家推理库绑定得比较紧,模型格式很难直接吃进其他推理引擎;第二,RK3588这种平台的算力核心是NPU,而NPU能识别的模型格式是RKNN,Paddle模型必须绕一大圈才能转过去,中间只要有一个算子不支持就白忙活。我在第一块板子上尝试直接用Paddle Lite加载成模型,结果光编译环境就折腾了一天,后面又遇到几个ARM上未注册算子的报错,果断放弃了这条路线。
1.2 ONNX为什么能做“翻译官”
ONNX是一个开放的模型交换格式,作用很像中间翻译官。训练框架用Paddle、PyTorch或者TensorFlow导出成ONNX后,下游推理引擎只要能读ONNX就能跑,不需要关心模型原来是从哪个框架来的。PP-OCRv4在PaddleOCR框架里虽然是Paddle原生格式,但官方提供了paddle2onnx转换工具,三步就能把检测、分类、识别三个子模型全部导成ONNX。
更关键的是,ONNX能同时接入RK3588的两条推理通道。一条是ONNX Runtime,作为通用推理引擎直接跑在ARM的CPU上,部署最快;另一条是用RKNN-Toolkit2把ONNX转成RKNN格式,喂给NPU算力跑。也就是说,导出ONNX这一步做完,后面无论是快速验证、CPU兜底还是NPU加速,路径全打通了。
1.3 三条路线放在一起看
我整理了一张路线对比,能直观看出为什么最终选择ONNX作为中间层:
| 部署路线 | 模型格式 | 开发成本 | 性能表现 | 适合场景 |
|---|---|---|---|---|
| Paddle Inference/Lite | Paddle原生格式 | 高,依赖库多,嵌入式编译麻烦 | CPU上中等,NPU基本用不上 | 纯Linux x86服务端或对框架生态依赖很强的项目 |
| ONNX Runtime | ONNX | 低,导出后直接加载 | 纯CPU推理,性能取决于板子 | 原型验证、跨平台PoC、业务跑通阶段 |
| ONNX → RKNN | RKNN | 中,需做转换和量化 | NPU加速,性能最优 | RK3588量产设备、视频流实时OCR |
我最终选择的是“先导ONNX,再按需求选ORT或RKNN”这个策略。这样做的好处是中间产物只有一份,换平台时不用重新导出模型,只需要在目标平台上换推理后端。
2. 导出前的准备:环境版本、模型权重与输入规范
2.1 版本组合先固定下来,别用最新
很多人一上来就装最新版PaddlePaddle和最新版paddle2onnx,结果导出报错后完全不知道是谁的锅。PaddleOCR的版本迭代非常快,而paddle2onnx对新导出的模型结构不一定完全兼容,这一块我建议直接用经过验证的稳定组合:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| PaddlePaddle | 2.5.x 或 2.6.x | 训练和导出用同一套,避免算子差异 |
| paddle2onnx | 1.0.5 或 1.0.9 | 1.x系列比较稳,2.x换了参数形式,习惯用1.x |
| PaddleOCR | release/2.7 或 release/2.8 | 这两个分支包含PP-OCRv4的完整权重和配置 |
| onnxruntime | 1.13+ | 开发机做校验用,版本旧一点没关系 |
| RKNN-Toolkit2 | 1.6.x 或 2.x | 与RK3588固件配套,选对应版本 |
有一点要提醒:paddle2onnx在1.0.x和2.x的CLI参数差异很大,网上很多教程混用导致命令不生效。我在写转换脚本时统一用的是1.0.x的参数风格,如果你手头是2.x版本,记得先执行paddle2onnx --help看下实际参数名。
2.2 权重来源与目录结构
PP-OCRv4的官方推理模型可以从PaddleOCR仓库的release文档里下载,需要准备三份:
- 文本检测模型:
ch_PP-OCRv4_det_infer.tar - 方向分类模型:
ch_ppocr_mobile_v2.0_cls_infer.tar - 文本识别模型:
ch_PP-OCRv4_rec_infer.tar
解压后每个目录里有两个核心文件:
ch_PP-OCRv4_det_infer/ ├── inference.pdmodel # 模型结构 ├── inference.pdiparams # 模型参数 └── inference.pdiparams.info有人会问,为什么方向分类还是v2.0的?因为PP-OCRv4在方向分类这个环节上沿用MobileNetV3小模型已经足够,官方没有单独出v4版分类模型,这条信息在PaddleOCR文档里明确写过,部署时不要去找不存在的ch_PP-OCRv4_cls_infer。
下载完权重,我建议先在开发机上用PaddleOCR的预测接口把官方demo跑通一次,记录一张测试图的识别结果。这张结果图后面有大用,所有导出、转换、量化后的模型都要以它作为基准去做对比,确保每一步没有引入精度损失。
2.3 三个子模型的输入规范
PP-OCRv4的检测、分类、识别是三个独立模型,输入输出各不相同。导出前先把各自的输入规范搞清楚,后面写预处理和后处理时才不会乱。
| 模型 | 输入形状 | 说明 |
|---|---|---|
| 检测det | [1, 3, H, W],H/W可变 | 通常按长边960缩放,宽高最好保持原图比例 |
| 方向分类cls | [1, 3, 48, 192] | 固定输入,宽高不能乱动 |
| 识别rec | [1, 3, 48, W],W可变 | 高固定48,宽度根据文本行宽度缩放,建议最大不超过320 |
预处理必须和PaddleOCR源代码保持一致。以检测模型为例,PaddleOCR的NormalizeImage是在/255之后按ImageNet的均值方差做的标准化,具体是mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]。而且PaddleOCR读取图像后统一转成RGB顺序,如果用OpenCV的imread读图,默认是BGR顺序,必须加一步cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。
这个细节看起来不起眼,但我在实际调试中遇到过一次全图框位置完全错乱的问题,最后查下来就是颜色通道顺序反了。对于OCR这种对像素值极其敏感的模型来说,RGB/BGR的顺序差异是致命的。
3. 检测、方向分类、识别三个子模型的ONNX导出与校验
3.1 导出命令与参数选择
三个模型的导出方式完全一样,只是model_dir和save_file不同。以检测模型为例:
paddle2onnx \ --model_dir ./ch_PP-OCRv4_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx_models/ch_PP-OCRv4_det.onnx \ --opset_version 11 \ --enable_onnx_checker True这里有几个参数值得解释。opset_version我建议选11,这是RKNN-Toolkit2和ONNX Runtime兼容性最稳妥的版本,调成13或更高的话,某些算子会被拆得更碎,反而增加RKNN转模型时的工作量。enable_onnx_checker True会在导出后自动校验ONNX结构合法性,等于做了一遍初步体检。
方向分类模型:
paddle2onnx \ --model_dir ./ch_ppocr_mobile_v2.0_cls_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx_models/ch_ppocr_mobile_v2.0_cls.onnx \ --opset_version 11 \ --enable_onnx_checker True识别模型:
paddle2onnx \ --model_dir ./ch_PP-OCRv4_rec_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx_models/ch_PP-OCRv4_rec.onnx \ --opset_version 11 \ --enable_onnx_checker True3.2 导出后的立即校验
导出完成后先别急着转RKNN,在开发机上用ONNX Runtime做一轮输入输出核对,确认模型没有“导出成功但结构损坏”的问题。
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("./onnx_models/ch_PP-OCRv4_det.onnx") for inp in sess.get_inputs(): print("input:", inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print("output:", out.name, out.shape, out.type)正常的话,det模型的输入名一般是x,输出是一个四维的Tensor,后面接Sigmoid概率图;rec模型输出是[1, 133, ?],133代表字典字符数加空格等符号;cls模型输出是[1, 2]或类似两个类别的概率。如果输出维度明显不对,比如rec模型输出维度变成了[1, 100, ?],那说明模型版本和字典不匹配,必须回头检查权重来源。
校验完结构,再喂一张真实图片做一次推理。这一步不求后处理,只求输出数值在合理范围内。比如检测模型的概率图输出应该在0到1之间,如果出现大量负值或NaN,大概率是预处理没对齐。
3.3 onnxsim化简的必要性
导出的ONNX通常会带很多训练框架留下的冗余结构,比如多余的Identity节点、固定shape的Shape节点、常量操作等。这些节点在ONNX Runtime上跑没问题,但喂给RKNN-Toolkit2转换时很容易变成“不支持算子”。
我的经验是先用onnxsim做一次图优化。安装很简单:
pip install onnxsim然后对三个模型分别执行:
python3 -m onnxsim \ ./onnx_models/ch_PP-OCRv4_det.onnx \ ./onnx_models/ch_PP-OCRv4_det_sim.onnxonnxsim会把常数折叠掉、删除无用节点,最后输出的模型图更干净。转RKNN时,sim后的模型成功率比原始ONNX高出不少。如果你的onnxsim遇到某些特殊算子处理不了,还有一个备用方案是用onnxruntime.tools的onnx_model_editor手动删节点,但操作成本高,优先用onnxsim。
化简后的模型建议再用Netron打开看一眼,确认三个输入节点和输出节点都正常。Netron是网页工具,把onnx文件拖进去就能看到图结构,这一步对后续定位算子问题非常有帮助。
4. RK3588上的两条部署路线:ONNX Runtime直跑与RKNN NPU加速
4.1 先说结论:什么时候选哪条
RK3588是一颗8核处理器,4个A76大核加4个A55小核,NPU算力标称6 TOPS,同时带RGA硬件加速和强大的多媒体能力,做边缘OCR设备相当合适。但“算力6 TOPS”指的是INT8模式下的峰值,如果程序只跑CPU,根本发挥不出这块板子的价值。
我在项目里的实际分工是这样的:
- 原型验证阶段:板端直接用ONNX Runtime加载sim后的ONNX模型,把整个OCR业务流跑通,包括拍照、预处理、推理、后处理、结果返回。这阶段可能一天就完成,目的是确认整条链路逻辑没问题。
- 性能达标阶段:把三个模型全部转成RKNN格式,拆分到NPU上跑,同时处理好CPU和NPU之间的数据拷贝,追求端到端时延降到最低。
如果应用场景对时延不敏感,比如每秒只处理一两张图,CPU的ONNX Runtime方案其实完全够用,还能省掉RKNN转换的工作量。反过来,如果要做视频流连续识别或并发处理多路图像,就必须上NPU。
4.2 路线A:ONNX Runtime直接推理
在RK3588的Ubuntu系统上安装ONNX Runtime非常方便:
pip install onnxruntimeRK3588的Ubuntu系统是aarch64架构,只需要下载arm64变体即可。如果板端是精简系统没有Python环境,也可以下载官方发布的C库版本,通过C/C++接口调用,但开发速度会慢一些。
用ONNX Runtime加载之前导出的sim模型:
import onnxruntime as ort import cv2 import numpy as np sess = ort.InferenceSession("ch_PP-OCRv4_det_sim.onnx", providers=["CPUExecutionProvider"]) img = cv2.imread("test.jpg") img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img_resized = cv2.resize(img_rgb, (640, 640)) img_norm = img_resized.astype(np.float32) / 255.0 img_norm = (img_norm - np.array([0.485, 0.456, 0.406])) / np.array([0.229, 0.224, 0.225]) img_nchw = np.transpose(img_norm, (2, 0, 1))[np.newaxis, ...].astype(np.float32) outputs = sess.run(None, {"x": img_nchw})实测下来,在RK3588上纯用CPU跑PP-OCRv4的检测模型,640×640输入大概在90到150毫秒这个量级,具体和电源策略、CPU调度都有关系。单个识别模型在48×320输入下大概在20到40毫秒。整条流水线下来一般要200毫秒以上。对于原型验证来说够了,但离实时识别还差得远。
4.3 路线B:ONNX转RKNN
要发挥NPU算力,必须在开发机上用RKNN-Toolkit2把ONNX转成RKNN格式。RKNN-Toolkit2是运行在x86开发机上的工具,转换完成后将.rknn文件拷贝到板端,用RKNNLite加载推理。
开发机上安装RKNN-Toolkit2我建议直接按官方文档用Docker镜像,能省掉不少环境依赖的麻烦。转换脚本核心部分:
from rknn.api import RKNN rknn = RKNN(verbose=True) # 设置目标平台和量化级别 rknn.config(target_platform="rk3588", optimization_level=3) # 加载ONNX模型 ret = rknn.load_onnx( model="./onnx_models/ch_PP-OCRv4_det_sim.onnx", input_size_list=[[1, 3, 640, 640]] ) if ret != 0: raise RuntimeError("load onnx failed") # 先不量化,跑一次全精度转换 ret = rknn.build(do_quantization=False, dataset="./dataset.txt") if ret != 0: raise RuntimeError("build failed") # 导出RKNN模型 rknn.export_rknn("./rknn_models/ch_PP-OCRv4_det.rknn")这里必须强调input_size_list的用途。PP-OCRv4的检测模型在Paddle框架里是动态shape,但RKNN转换时,如果ONNX模型里还有动态维度,转换器会自动fallback到CPU算子,NPU完全用不上。因此转RKNN之前,要么在ONNX图上把shape固定死,要么通过input_size_list指定固定尺寸。
我最终固定的检测输入是1×3×640×640,识别输入是1×3×48×320。这样虽然会损失一部分大图检测的动态灵活性,但换来的是NPU上的流畅加速,值得。
转换完成后,把rknn文件拷贝到板端,用RKNNLite推理:
from rknnlite.api import RKNNLite rknn_lite = RKNNLite() rknn_lite.load_rknn("./rknn_models/ch_PP-OCRv4_det.rknn") rknn_lite.init_runtime(core_mask=RKNNLite.NPU_CORE_0_1) outputs = rknn_lite.inference(inputs=[img_nchw])4.4 两条路线的结果对齐
同一张测试图,用ONNX Runtime和RKNN分别推理,输出结果可能会有细微差异,特别是在某些激活函数或归一化算子上,NPU的定点计算和CPU浮点计算不完全一致。我的习惯是先把两边的输出都存成npy,然后逐元素比较误差,确保误差在可接受范围内。
如果差异过大,优先检查两点:一是预处理是否完全一致,二是是否存在算子被映射成了低精度模式。尤其注意RKNN模型输入数据的布局是NCHW,和ONNX Runtime保持一致,不要在板端想当然改成NHWC。这个错误我在早期调试时踩过一次,输出全乱,排查了好几个小时才定位到是数据摆布问题。
5. int8量化:把NPU算力真正吃到嘴里的关键一步
5.1 为什么RK3588上的部署绕不开量化
RK3588的NPU之所以标6 TOPS,指的就是INT8算力。模型保持FP32精度转成RKNN虽然能跑,但NPU会以较低效率执行,内存占用也高。以检测模型为例,FP32的ONNX模型转成RKNN后会明显变大,推理时还有额外内存开销。而做完INT8量化后,模型体积缩到约四分之一,推理速度明显提升,精度损失通常又在一个可接受范围内。
特别是在OCR这种场景里,检测模型和识别模型同时跑,如果不量化,NPU上同时承载两个大模型会比较吃力,量化后就从容很多。
5.2 校准数据集怎么准备
RKNN量化并不是简单把权重转成INT8,它需要一批真实图片做校准,统计每层激活值的分布范围,然后确定量化尺度。这一步非常关键,直接影响量化后的精度。
rknn.build里的dataset参数指向一个txt文件,每行写一张图片的路径:
./calib_imgs/001.jpg ./calib_imgs/002.jpg ./calib_imgs/003.jpg ...我的建议是准备200张左右代表真实业务的图片,覆盖不同光照、不同字体大小、不同背景复杂度。如果做的是设备铭牌识别,就多拍一些金属反光面、贴纸产品标牌、塑料外壳上的丝印;如果做的是文档扫描,就多准备打印体和手写体混合的数据。千万不要随便拿ImageNet图片凑数,那种图片和OCR场景的像素分布差异太大,量化完很容易掉点。
另外,校准图片喂给模型前也要走相同的预处理,包括缩放、归一化。RKNN-Toolkit2在量化的时候会自动读取图片,但我不确定它的预处理是否和PaddleOCR一致,稳妥做法是自己先把图片处理好再喂给dataset。
5.3 量化后精度验收与精度补救手段
量化后的模型必须在真实测试集上做一轮对比。我的验收方法是抽50到100张业务图片,分别用FP32的RKNN模型和INT8的RKNN模型跑一遍OCR,对比识别正确率和检测框的IoU。如果识别正确率下降超过1%到2%,或者出现明显漏检,就要想办法补救。
补救手段有几个:
- 提高校准数据的质量:加入更多与业务场景接近的样本,重新统计激活分布。
- 混合精度:RKNN-Toolkit2提供了部分算子保持FP16/FP32的能力,对精度损失大的敏感层做保留。
- 调整后处理阈值:量化后某些低置信度检测框会消失,可以把检测的后处理阈值适当调低,找回一部分召回率。
下表是我在项目里的典型模型大小对比(以检测模型为例):
| 模型版本 | 大小 | 说明 |
|---|---|---|
| FP32 ONNX | 约100%基准 | 原始导出大小 |
| FP32 RKNN | 略小于ONNX | 转换后格式紧凑 |
| INT8 RKNN | 约25%大小 | 量化后,显存和带宽压力都小 |
实际数据会因模型结构有浮动,但大致是这个比例。量化后识别模型的输出概率分布通常会比原始模型更“尖锐”,后处理时要注意不要设置太高阈值,否则容易把置信度0.6左右的正确结果滤掉。
6. 移植调试中真正值得记录的坑
6.1 预处理对不上:输出全是无效框
这是很多人第一次移植时最容易踩的坑。导出ONNX后,在开发机上用Paddle原版推理结果正常,但用自己写的ONNX Runtime推理脚本一跑,框全乱了,或者干脆没有有效框。
根本原因就是预处理没对齐。PaddleOCR的预处理链路包含Resize → Normalize → Transpose三个环节,而Normalize用的不是简单的除255,是ImageNet的mean/std标准化。如果只做了缩放到0到1就喂给模型,输出特征分布和训练时完全不同,自然出不来框。
我的排查经验是:先用最简单的方式打印模型输入前的像素值,和PaddleOCR源码中同一张图预处理后的像素值做逐像素对比。只要两个值一致,模型输出基本就不会差。
6.2 字典错位:识别结果一页乱码
识别模型输出的是每个字符的类别索引,要把索引映射回实际字符需要字典文件ppocr_keys_v1.txt。如果字典文件和模型版本不匹配,识别结果就会是一串乱码或错误字符。
这类问题隐蔽在导出后的模型里很难看出来。我遇到过一种情况:识别模型输出维度看起来正常,字符索引范围也对,但实际印出的结果和图上文字完全对不上。后来发现是PaddleOCR不同版本之间调整过字典顺序,我用了一个旧版字典。
解决方案很简单:每个推理模型目录或权重包发布时,官方会附带对应版本的字典,必须使用和模型同一版本号的字典文件,不要跨版本混用。另外,如果启用了use_space_char选项,字典末尾会多一个空格符号,索引匹配时也要把这个偏移考虑进去。
6.3 ONNX转RKNN不支持的算子
RKNN-Toolkit2的算子支持虽然在持续扩充,但遇到比较新或比较特殊的算子还是会有报错。我在转PP-OCRv4识别模型时,就撞到过一次Unsupported op的报错,定位到的节点是动态shape场景下生成的Gather和Resize相关算子。
排查思路是从报错日志中找到具体算子名,再回到ONNX图里看这个算子能不能化简。大部分情况下,先用onnxsim简化模型就能干掉一大半不支持的节点。如果简化后还报错,就考虑把动态维度固定下来,比如把无限制的输入高度从-1改成48,宽度从-1改成固定值。
还有一招是升级RKNN-Toolkit2版本,新版本对ONNX的兼容性会好一些。注意升级后要回归验证一次模型精度,因为新版本的算子融合策略可能会变,输出结果有微小差异是正常的。
6.4 固定shape导致的坐标回映射错误
RKNN模型固定为640×640输入后,检测出的框坐标是在缩放后的图像坐标系里的。把框画回原图时,必须根据原图和固定输入之间的缩放比例做回映射。
这个比例不是简单的640 / original_width,因为PaddleOCR在检测预处理时用的是保持长宽比的resize,如果原图是1920×1080,缩放后图像并不会充满640×640的四周,剩余空间通常用0来填充。这意味着坐标回映射还要考虑padding的偏移量。
我的做法是记录三个信息:原图尺寸、缩放后尺寸、padding偏移,然后按线性映射把检测框坐标换算回原图坐标。调试时直接在图上画框验证,框和文字边界贴合就算正确。
6.5 NPU内存对齐与并发调用
RKNNLite在板端如果长时间运行,偶尔会出现内存分配失败或malloc failed的报错。这类问题通常和输入数据的对齐要求有关。NPU处理时对输入tensor的宽高有一定对齐要求,我实际遇到的是宽度必须是16的整数倍,最保险的写法是在预处理阶段直接把图像resize到16的倍数再喂给NPU。
另外,如果应用里同时跑多个OCR任务,不要频繁初始化RKNNLite实例,最好启动时加载一次,后续复用同一个实例做推理。我在一个并发项目里遇到内存持续上涨,排查到最后发现是每次请求都重新load_rknn又没释放,改成全局单例之后内存稳定了下来。
6.6 版本锁定的建议
最后一个建议不是具体代码,而是工程习惯。RKNN-Toolkit2、PaddleOCR、paddle2onnx、ONNX Runtime这几个工具的版本一旦验证通过,就必须在项目文档里固化下来。我在做第二个设备时升级了一次RKNN-Toolkit2,结果转换出的模型在旧固件上无法加载,回退版本才解决。
建议把所用的版本号、转换脚本、校准图片集、三个sim后的ONNX模型、三个rknn模型全部归档到一个独立目录,作为项目的基线文件保存。后续接新平台或者换新需求,都从这个基线出发,避免每次重新摸索组合兼容性。
以上这些坑,都是我在RK3588平台上真正踩过的。如果重新做一次移植,我会把“先对齐预处理,再验证模型输出,最后做量化”这个顺序执行得更彻底,因为很多看似诡异的模型输出问题,根子上都是前处理或后处理环节的参数偏差。把PP-OCRv4转成ONNX再上RK3588这件事,本质是拿一个开放中间格式换来了整个工具链的选择权,这一步选对了,后面板端适配的路就顺了。