最近在做一个OCR识别模块的迁移部署,目标平台不方便直接跑完整的PaddlePaddle推理环境,我得把文本检测和文本识别模型统一转成ONNX,再交给跨平台推理引擎去执行。整个过程比我预想的复杂一些——模型本身的转换不算太难,真正花时间的是版本搭配、动态尺寸处理、转换后的精度对齐这些边角问题。前前后后折腾了近两天,总算把Paddle OCR的检测模型和识别模型都成功转成了ONNX格式,并在ONNX Runtime里跑通了完整的端到端OCR流程。这篇东西把整个过程中的关键步骤、参数含义、踩过的坑都整理了出来,给还在做模型迁移的朋友当个参考。
1. 为什么要把Paddle文本检测识别模型转成ONNX
1.1 部署场景下的实际需求
OCR模型在资源受限或环境固定的平台上部署时,ONNX Runtime和TensorRT是两类常见选择,这些推理引擎更习惯读取ONNX格式。PaddlePaddle原生推理格式是.pdmodel加.pdiparams,在本地或者有完整框架的服务器上跑没问题,但一旦到了嵌入式设备、容器、Web端或者异构计算单元上,再装一个完整的Paddle深度学习框架就很不现实。ONNX作为一个开放、中立的模型交换格式,几乎能被所有主流推理引擎识别,把它作为中间格式,转换一次就能服务多个后端,这是做迁移的核心动机。
还有一个容易被忽略的点:模型转换之后,推理过程和训练框架彻底解耦了。训练和场景迭代继续用Paddle,部署环节统一走ONNX,研发和工程之间的分工清晰了,发布流程也轻了。我参与的项目里,模型一般只在训练集群上跑Paddle,到了生产环境的服务节点,所有深度学习模型统一走ONNX Runtime,方便统一治理、监控和灰度。这个前提下,把Paddle OCR的两个模型转成ONNX不是可选优化,而是上线路径上必须完成的一步。
1.2 PP-OCR模型结构回顾
Paddle的OCR系列模型里,文本检测模型基于DBNet结构,整个网络由Backbone、FPN和DB Head组成。输入是经过resize和归一化后的图像张量,输出是文字区域对应的概率图。注意这里的输出已经经过sigmoid激活,但真正的后处理,比如阈值二值化、连通域分析、文本框构建,都在模型外部由Python或C++代码完成。好在这个Head里的算子大多是卷积、上采样、加法和sigmoid,都属于ONNX原生支持的标准算子,所以检测模型整图导出基本没有障碍。
文本识别模型一般用CRNN结构,输入是检测模型裁剪出来的文本行图片,经过固定高度resize后送入网络,输出是每个时间步在字符表上的logits序列,之后再用CTC解码得到最终文本。这一链路里同样不包含复杂的自定义算子,模型内部就是卷积、LSTM/GRU、全连接和softmax/LogSoftmax的组合。因此识别模型也是完全可以导出的。对paddle2onnx来说,只要算子图中没有特殊自定义op,转换成功率就很高,PP-OCR系列的检测和识别模型恰好都属于这一类。
1.3 转换工具的选型逻辑
行业内有一种做法是用ONNX手动搭建同结构网络,再把训练好的权重逐层复制。这种做法在小模型上可行,但OCR检测和识别网络动辄几十上百层,且存在多分支和重复使用参数的情况,手动搭建不仅慢,还极易在复制权重时出错,排查起来难度很大。相比之下,paddle2onnx是Paddle生态的官方转换组件,能够解析训练框架的算子图,再映射到ONNX标准算子上,自动完成绝大多数情况下的算子翻译。它的误差基本集中在少数算子翻译策略上,可控性高得多,这也是一线工程里选择它的主要原因。
2. 环境准备与版本搭配
2.1 版本对应关系是第一个坑
paddle2onnx和PaddlePaddle版本之间的对应关系非常严格,不是随便装最新版就能顺畅使用。转换工具需要读取模型中的算子信息,如果Paddle版本太新,paddle2onnx没有及时适配,会在解析阶段报出一些奇怪的错误;如果paddle2onnx版本太旧,又可能无法识别新版本模型里的新算子。我这次使用的版本组合是经过多轮测试后确定下来的:
| 组件 | 版本 | 说明 |
|---|---|---|
| Python | 3.8.18 | 建议用3.8或3.10,兼容性最稳 |
| PaddlePaddle | 2.5.2 | 训练/推理框架本体 |
| paddle2onnx | 1.2.4 | 官方转换组件 |
| onnx | 1.14.1 | ONNX格式处理库 |
| onnxruntime | 1.16.3 | 转换后推理验证 |
这个组合在PP-OCR的mobile系列模型上实测非常稳定。如果你的Paddle版本不同,最好自查一下版本兼容矩阵再安装,能省掉大量莫名其妙的报错时间。我在第一次尝试时就是用了最新版的Paddle,结果paddle2onnx在解析某个算子的属性时直接崩溃,后来回退版本才解决问题。版本问题一定要放在第一步解决,否则后面排查起来非常痛苦。
2.2 安装与基础验证
安装本身不复杂,几个pip命令基本就能搞定:
pip install paddlepaddle==2.5.2 pip install paddle2onnx==1.2.4 pip install onnx onnxruntime装完先跑一下版本确认:
paddle2onnx --version能正常输出版本号就说明工具可用。另外建议顺带装一个onnxruntime,后面转换完做推理验证必须用。还有一个小习惯:每次转换前确认当前Python环境里的onnx和paddle2onnx与Paddle版本匹配,避免环境里历史版本干扰。曾经遇到过一个问题,环境里同时存在两个版本的onnx,Python import时加载了旧版,导致onnx模型修改时莫名报错,清理环境后一切正常。
2.3 准备推理模型文件
PaddleOCR的推理模型下载后通常是这样的目录结构:
inference/ ├── det_model/ │ ├── inference.pdmodel │ └── inference.pdiparams └── rec_model/ ├── inference.pdmodel └── inference.pdiparams这里的inference.pdmodel是模型结构文件,inference.pdiparams是权重参数文件。有些模型会多一个inference.pdiparams.info,那个只是参数说明信息,转换时用不到。自己训练的模型也可以用paddle.jit.save导出成同样格式,关键是最终得到这两个文件即可,转换工具对训练和导出方式不做额外限制。这步完成后,就可以开始实际的转换操作了。
3. 文本检测模型转换实操
3.1 命令行的基本用法
paddle2onnx支持命令行和Python API两种方式。命令行适合快速转换,Python API适合嵌入到自动化流程里。命令行转换文本检测模型,我使用的完整命令是:
paddle2onnx \ --model_dir ./inference/det_model \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./det_model.onnx \ --opset_version 11 \ --enable_onnx_checker True--model_dir指向存放推理模型的目录,--model_filename和--params_filename分别指定结构文件和参数文件名。--save_file是输出ONNX文件的路径。--opset_version指定ONNX算子集的版本,一般11或12就够用。不建议追求最新opset,部分目标部署引擎对高版本算子集的支持不完整,反而容易在推理阶段报错。--enable_onnx_checker True会在生成之后自动跑一遍ONNX模型合法性检查,相当于转换完成后自我体检,建议一直开着。第一次转换时我漏掉了这个参数,后来在别的机器上加载模型时报出结构问题,就是因为缺少了这一步校验。
3.2 动态shape的处理
文本检测的输入图像宽高在推理时是不固定的,实际传入的图像会经过resize到一定尺寸,但不同图像的长宽比差异可能很大。如果模型输入被固定成某个shape,部署时就会很被动。paddle2onnx在多数情况下会保留模型里的动态维度,但有时导出的模型输入仍是固定尺寸,需要额外处理。
检测模型的输入通常声明为[1, 3, 736, 736]这样的固定shape。虽然Paddle推理引擎可以容忍动态输入,但ONNX模型如果固定了,换一张不同宽高的图就会直接报错。解决方法是转换后手动修改输入维度,我用的是onnx库的Python API:
import onnx from onnx import helper model = onnx.load("./det_model.onnx") input = model.graph.input[0] input.type.tensor_type.shape.dim[0].dim_param = "batch" input.type.tensor_type.shape.dim[2].dim_param = "H" input.type.tensor_type.shape.dim[3].dim_param = "W" onnx.save(model, "./det_model_dynamic.onnx")这样输入张量的维度就变成了[batch, 3, H, W]的动态形式。要注意,动态维度越多,推理引擎在内存分配和图优化上的开销就越大。我一般只把H和W设置为动态,batch维度保持固定,因为线上推理大多是单张图片请求,batch固定为1不仅在性能上更优,还可以让ONNX Runtime保留更多图优化机会。如果确认后续有批量推理需求,再把batch也设成动态。
3.3 转换后的快速验证
转换完成后,先用ONNX Runtime加载一下,确认模型能正常推理。验证脚本我习惯放在转换流程的最后一步:
import onnxruntime as ort import numpy as np sess = ort.InferenceSession("./det_model_dynamic.onnx") input_name = sess.get_inputs()[0].name output_name = sess.get_outputs()[0].name print("input:", sess.get_inputs()[0].shape, sess.get_inputs()[0].type) print("output:", sess.get_outputs()[0].shape, sess.get_outputs()[0].type) x = np.random.rand(1, 3, 640, 640).astype(np.float32) result = sess.run([output_name], {input_name: x})[0] print("输出shape:", result.shape)这段代码能正常执行,输出shape符合预期,就说明模型结构、输入输出定义都没问题。这一步建议在转换后立即执行,因为只要转换工具和模型本身有偏差,错误信息就能第一时间暴露出来。我在识别模型转换时遇到过一个问题,模型能成功保存,但推理时输出维度与预期不一致,就是因为没有及时验证,直到接入全流程才暴露,排查时又花了额外时间。
3.4 与Paddle推理结果的核对
跑通只是第一步,还需要和Paddle推理结果做对比。可以先用Paddle跑一次前向,保存输出,再用ONNX Runtime用完全相同的输入跑一次,比较两组输出的差异。文本检测模型的输出是概率图,浮点误差一般很小。我会从最大误差、平均误差两项指标来评估,要求绝对误差在1e-4以内,超过这个量级就需要排查输入预处理或模型权重加载是否出了问题。
pred_paddle = ... # paddle推理得到的概率图 pred_onnx = ... # onnxruntime推理得到的概率图 diff = np.abs(pred_paddle - pred_onnx) print("最大误差:", diff.max(), "平均误差:", diff.mean())实测经验是,如果只是浮点舍入差异,最大误差在1e-5到1e-4之间;如果出现明显差异,大概率是推理模式不同造成的,比如Paddle推理时开启了TensorRT加速或某些融合优化选项,导致数值规律和ONNX Runtime不一致。这时可以先关闭Paddle的加速选项再对比,优先保证逻辑一致。
4. 文本识别模型转换实操
4.1 识别模型的独特点
文本识别模型和检测模型在转换流程上基本一致,但有两个特性需要特别留意。第一,识别模型的输入高度一般固定为32或者48,宽度是动态的;第二,识别模型的输出是未经解码的logits序列,shape通常为[1, T, C],其中T是时间步数,C是字符类别数。验证时不能只看shape,还要看logits的数值分布是否和Paddle输出一致,因为logits的微小偏差会直接影响到CTC解码后的文本结果。
识别模型转换命令和检测模型几乎一样:
paddle2onnx \ --model_dir ./inference/rec_model \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./rec_model.onnx \ --opset_version 11 \ --enable_onnx_checker True转换后,如果发现输入shape固定,同样可以用onnx库把宽度维度改成动态。我一般只改宽度,高度保持固定,因为识别模型在整条OCR链路中对输入高度的约束本来就强,改了反而容易出问题。宽度动态则能适配不同长度的文本行图片,这个在实际业务里非常关键。
4.2 识别模型的输出验证
识别模型的输出是未经CTC解码的logits,验证时同样需要和Paddle输出对比。关键是最后一维对应字符类别数,不直接对应文本内容,要看到最终识别文本,还得接上CTC解码和后处理脚本。单独验证模型时,比较logits的一致性就足够说明模型转换没问题。
# 假设rec_model.onnx已经加载 logits_onnx = sess.run(None, {input_name: img_tensor})[0] # 与Paddle推理得到的logits_paddle对比 print("logits最大误差:", np.abs(logits_onnx - logits_paddle).max())如果最大误差在1e-4以内,基本可以认为识别模型转换成功。如果误差偏大,不要急着怀疑转换工具,先确认输入图像预处理是否一致。我在实际验证时遇到过输入图像归一化参数漏写的问题,导致logits差异达到了0.1量级,排查后修正归一化均值方差,误差立刻降到1e-5以内。
4.3 在OCR全流程里验证识别效果
更贴近实际的验证方式,是把转换后的检测模型和识别模型直接嵌入到完整OCR流程里。流程大概是:原始图像进入检测模型得到概率图,经过二值化和连通域分析等后处理得到文本框,裁剪出文本行图片,再进入识别模型得到logits,最后用CTC解码得到文本。每个环节的输入输出格式都要和Paddle原版保持一致,尤其是检测输出经过后处理后,任何微小偏移都可能影响框的位置,进而影响后续识别效果。
我用包含印刷体、手写体和倾斜文本的测试图做了端到端对比。转换前后的检测框位置基本一致,识别出的文本内容也高度吻合,个别手写体样本因为预处理差异出现单字符波动,属于正常范围。调整后处理里的阈值参数后,识别效果恢复到原有水平。这轮验证做完,我的心才真正放下,因为模型级对比再一致,都不如端到端效果来得可靠。
5. 转换后的精度验证细化
5.1 预处理一致性是不可妥协的底线
很多人做完模型转换后,发现精度对不上,第一反应是转换工具出问题了,但实际排查下来,大部分情况是输入预处理不一致。PaddleOCR在推理时会对图片做标准化,归一化用的均值、方差非常关键。比如ImageNet风格的归一化,均值通常是[0.485, 0.456, 0.406],方差是[0.229, 0.224, 0.225],一旦在ONNX Runtime下漏掉这一步,模型输出的概率图就会出现明显偏移。所以在做精度对比时,一定要复用Paddle推理时的预处理代码,至少保证resize方式、归一化参数、通道顺序完全一致。
5.2 误差的量化标准
端到端工程里的误差判断,不能只看是否识别出相同文字。一个比较可靠的方法是把误差拆到两个阶段分别评估:检测阶段看概率图浮点误差,识别阶段看logits浮点误差,最后再看整个OCR结果的字符级一致率。概率图和logits用绝对误差、平均误差来衡量,文本结果可以用编辑距离或字符精确率来评估。实际操作中,我把ONNX结果和Paddle结果做逐字段比对,OCR结果整体一致率在99%以上,就认为转换精度符合上线要求。
5.3 性能和稳定性观察
转换后模型在ONNX Runtime上的运行时间、显存占用和稳定性,和Paddle推理有差异是非常正常的。建议在上线前做一个简单的压力测试,连续跑几千张图,观察是否有偶发的异常输出和内存增长。ONNX Runtime在CPU上对PP-OCR系列的推理速度通常够用,GPU环境下可以额外启用CUDAExecutionProvider甚至TensorRTExecutionProvider,开启后性能往往还有提升。但启用加速时要注意算子支持和精度变化,半精度模式和动态尺寸同时开启时容易出现数值抖动,需要反复测试后才能确定参数。
5.4 典型对比流程简表
| 对比项 | Paddle推理 | ONNX Runtime推理 |
|---|---|---|
| 检测输入shape | [1,3,H,W],H/W动态 | 需手动设定动态H/W |
| 检测输出 | 概率图[1,1,H,W] | 概率图[1,1,H,W] |
| 识别输入shape | [1,3,32,W] | 高度固定,宽度动态 |
| 识别输出logits | [1,T,C] | [1,T,C] |
| 预处理 | resize+标准化 | 必须保持一致 |
| 后处理 | DB后处理+CTC解码 | 直接复用原逻辑 |
这张表我通常会挂在项目文档里,每次做模型升级或者环境变更后,就按照表里的项目逐项检查一遍,避免遗漏。
6. 常见问题与避坑实录
6.1 版本不匹配导致转换报错
我最初的转换尝试里,在一个全新环境中装了最新版Paddle和paddle2onnx,结果转换一开始就报解析类错误,提示某个算子属性无法处理。后来查明是paddle2onnx尚未适配最新版Paddle引入的新算子属性。解决办法很简单,把两个工具的版本对齐到release note中验证过的组合即可。版本对应关系是paddle2onnx使用中最容易忽视的细节,建议在项目文档里单独写明验证过的转换环境版本,避免团队成员更新依赖后同一个坑反复踩。
6.2 动态shape和算子优化之间的冲突
在某些版本组合下,如果输入的所有维度都设为动态,ONNX Runtime会放弃部分图优化,推理速度反而下降。我的处理方式是尽量保持batch固定,只让H和W动态,这样既满足多尺寸输入需求,又能让运行时保留大多数优化机会。如果实际业务中图片尺寸范围很窄,还可以把输入做成固定尺寸并配合自适应resize,卷积和循环网络的优化效果会更明显。这个经验和模型本身无关,但直接影响上线后的推理延迟,值得提前评估。
6.3 遇到不支持的算子怎么办
转换过程中最头疼的就是遇到paddle2onnx尚未映射的算子。处理思路是能拆则拆,能降则降。先在模型层面看能不能把这个算子移动到后处理里;如果不行,就尝试把相关分支在Paddle里单独导出为ONNX,再拼接成一个完整的图;实在不行,也可以考虑替换模型结构。比如把识别模型里的双层双向LSTM替换成能映射的GRU实现,这通常会损失一点精度,但多数场景下影响不大。好在PP-OCR系列的检测和识别模型使用的算子都很常规,实际遇到这种极端情况不多。
6.4 检测输出与原版有细微差异
转换后的检测模型,概率图整体差异不大,但某些具体位置的数值与Paddle输出存在万分之一量级的偏差,最终二值化结果偶尔会在文字边缘处多几个像素或少几个像素。这类问题通常是浮点运算顺序不同导致的,属于正常现象,调整二值化阈值做一点补偿就能解决。如果概率图差异超过0.01,那就得按照前面讲的方法检查输入预处理了,大概率是归一化参数或者resize方式不一致。
6.5 ONNX Runtime的线程和性能配置
ONNX Runtime默认并行线程数根据机器自动选择,在部分容器环境里可能不理想。可以通过session_options.intra_op_num_threads和inter_op_num_threads手动设置。我遇到过在一个4核容器里默认线程数过高,推理延迟反而变大,把线程数调到2之后性能恢复正常。这是一个与模型转换本身无关,却会影响最终上线效果的问题,顺带提出来,建议在部署阶段一并测试。
6.6 官方模型与自训练模型的差异
官方下载的推理模型结构比较固定,转换起来很顺利;自己训练的模型只要导出格式正确,过程也一样。唯一要留意的是,如果训练时使用了自定义的动态图Layer,导出后可能残留一些自定义节点,转换前需要对模型做一层梳理,确保结构清晰、命名规范。paddle2onnx虽然能处理多数情况,但复杂自定义算子多了之后,依然可能出现无法映射的问题。遇到这种情况,优先检查模型结构里是否存在自定义算子,再决定是修剪计算图还是人工补充转换逻辑。
这次转换让我最大的体会是,模型转换这件事,工具只能解决大部分问题,剩下的小部分都藏在版本、shape、预处理和对齐细节里。先把环境版本固定好,再把动态shape处理干净,最后认真做一次精度回归,转换成功率其实非常高。最后再给一个实用建议:转完ONNX之后,把转换命令、验证脚本和对比结果都留档保存。后期模型升级时,有了一套可复现的流程,再转换就只是几分钟的事情。希望这篇记录能帮到你。