把Paddle文本检测识别模型转为ONNX,这活儿我前前后后折腾过好多次,每次都会遇到点新问题。先给不熟的朋友解释一下:PaddleOCR这套工具里通常包含文本检测和文本识别两个模型,检测模型负责找出图片里的文字区域,识别模型负责把区域内容转成字符串。平时在Paddle框架里跑没问题,但真正做服务端部署、接入C++/Java项目,或者想用ONNX Runtime做轻量加速,就会考虑把模型转成ONNX格式。这篇文章就把从PaddleOCR模型到ONNX的完整链路讲清楚,包括环境准备、模型导出、转换命令、推理验证,以及那些文档里不常写明白的坑,适合正在做OCR落地或者准备改造现有OCR组件的开发者参考。
1. 为什么要把Paddle模型转成ONNX
1.1 原框架部署的尴尬
PaddleOCR本身是很好用的,检测、识别、方向分类一条龙,效果也不错。但一旦到了生产环境,问题就来了。最直接的一点:要跑PaddleOCR的预测服务,服务端基本得安装整套PaddlePaddle框架,哪怕只是CPU推理,几个依赖库装下来也有不少体积,GPU版本更大。如果你已经在用别的推理方案,比如ONNX Runtime、TensorRT、OpenVINO,再为OCR单独引入一套Paddle环境,运维成本会明显增加。
更麻烦的是跨语言调用。很多Java服务、C++服务、Go服务并不想直接通过Python进程调Paddle。虽然Paddle有对应的预测库,但编译版本、依赖库、ABI兼容性都是坑。这时候ONNX的价值就出来了:它就是一个开放模型格式,支持方很多,ONNX Runtime也覆盖了主流语言,转换后可以直接嵌入现有服务。
我个人的看法是:PaddleOCR训练和调模型时用Paddle没问题,但“训练”和“部署”之间的格式搬运越标准化越好。ONNX就是那条常见的搬运通道。
1.2 转换不是重新训练,但也不是简单改后缀
模型转换本质上做的事情,是把Paddle的计算图和权重“翻译”成ONNX的计算图和权重。Paddle2ONNX这个工具负责把Paddle算子逐个映射到ONNX算子,同时做一些常数折叠、维度推断之类的优化。所以转换后模型的权重数值不会变,原则上精度损失可以忽略。
但“原则上”三个字是有原因的。ONNX的算子集有版本,Paddle的算子结构和ONNX算子不一定一一对应。有些Paddle自定义算子没法直接映射,工具会拼算子来实现,拼出来的行为如果边界处理不严谨,极少数情况下输出会和你预想有一点点差异。另外,如果ONNX模型里存在不支持的动态shape,推理时也可能报错。这就是为什么转ONNX说简单也简单,一条命令能搞定;说麻烦也麻烦,真遇到算子兼容和动态轴问题,你会花不少时间调试。
转换之前心里最好有个预期:不管怎么转,你需要验证ONNX输出和原模型输出是否一致,而不是转完就跑。
2. 动手前的准备:环境、版本和模型导出
2.1 环境清单与版本兼容
先列一下我常用的环境。我是用Python 3.9做测试的,你可以根据自己的环境调整,但版本尽量保持一致,尤其是Paddle和Paddle2ONNX的匹配关系。
pip install paddlepaddle==2.5.2 pip install paddle2onnx==1.0.8 pip install onnxruntime==1.16.0 pip install paddleocr==2.7.0需要注意,Paddle2ONNX对Paddle版本不是无脑兼容。早期Paddle2ONNX 0.x版本只能适配Paddle 1.x,现在主流是Paddle 2.x配paddle2onnx 1.x,转换稳定很多。ONNX Runtime版本建议装1.10以上,对opset 11和12支持都很好。
环境最好放在虚拟环境或者Docker里,不要直接污染生产环境。我吃过一次亏,把Paddle装到业务服务器上,结果和已有的TensorRT版本冲突,排查了半天。
2.2 从训练产物到推理模型
很多人以为手里有.pdparams权重文件,就可以直接paddle2onnx转换。不是的。Paddle2ONNX转换的输入一般是Paddle的“推理模型”,也就是包含inference.pdmodel和inference.pdiparams两个文件的目录。
如果是从官方下载的推理模型,这个结构已经有了。如果是自己训练出来的模型,需要先把训练产出的权重导出为推理模型。PaddleOCR仓库里给了现成脚本,大概是这样:
git clone <PaddleOCR仓库地址> cd PaddleOCR python tools/export_model.py \ -c configs/det/ch_PP-OCRv4_det_cml.yml \ -o Global.pretrained_model=./output/det/best_accuracy \ Global.save_inference_dir=./inference/det识别模型同理,换成对应的rec配置文件即可。export_model.py做的事情是把训练过程中附加的优化器状态、损失函数、训练专用算子去掉,合并参数并生成固定结构的推理模型。这个步骤我强烈建议你自己跑一遍,因为训练版本和官方发布版可能不完全一致,导出后你才知道真实的输入输出节点名。
2.3 一个绕不开的关键参数:opset
转换指令里必须有--opset_version,我建议默认用11。opset是ONNX算子集的版本号,版本太低会导致某些算子没法表示,比如PaddleOCR里常见的grid_sampler、layer_norm等,opset太低就不行。版本太高又可能让你的目标推理环境不认,比如线上还在用ONNX Runtime 1.7,你转个opset 15的模型,可能加载都会报错。
所以我的策略很简单:先按11试,能用就不升级;遇到报错说某个算子需要更高版本,再递增到12或13。不要一上来就转opset 17,那是给自己找麻烦。
3. 核心实操:检测模型和识别模型怎么转ONNX
3.1 文本检测模型(DB)转换命令与细节
转换之前,先确认推理模型目录结构:
inference/det/ ├── inference.pdmodel └── inference.pdiparams然后执行:
paddle2onnx \ --model_dir ./inference/det \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/det.onnx \ --opset_version 11 \ --enable_onnx_checker True--enable_onnx_checker True会调用ONNX官方检查器帮你验证生成模型结构,建议一直开着。如果检查器报错,通常说明模型结构有问题,后面推理也会出幺蛾子。
这里有一个容易忽略的点:PaddleOCR检测模型默认导出时输入shape可能是固定的,比如[1,3,960,1600]。转换后的ONNX会把960和1600作为固定尺寸。如果你希望输入尺寸可变,Paddle2ONNX提供了--input_shape_dict参数,可以自定义输入维度:
paddle2onnx \ --model_dir ./inference/det \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/det.onnx \ --opset_version 11 \ --input_shape_dict '{"x": [1,3,960,1600]}'从经验看,动态shape不是转个命令就能完全解决的,因为检测模型内部有些算子对输入大小敏感。如果项目里图片尺寸范围很大,我建议先固定尺寸把流程跑通,再考虑动态化,不要一开始就陷进动态轴的坑里。
检测模型转完ONNX后,输出的并不是最终的文本框坐标,而是一个概率图(shape一般是[1,1,H,W])。DB算法的后处理,比如阈值二值化、轮廓查找、多边形裁剪,都在模型之外。这一点必须提前知道,不然你拿到ONNX输出会发现和PaddleOCR给的“框”完全对不上。
3.2 文本识别模型(CRNN/SVTR)转换命令与细节
识别模型的转换命令基本一样,只是输入尺寸和模型目录不同。以常见的PP-OCR识别模型为例,输入shape一般是[1,3,48,320],高度48、宽度320:
paddle2onnx \ --model_dir ./inference/rec \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/rec.onnx \ --opset_version 11 \ --input_shape_dict '{"x": [1,3,48,320]}' \ --enable_onnx_checker True转换完成后,建议立刻打印一下输入输出节点名:
import onnxruntime as ort sess = ort.InferenceSession('rec.onnx', providers=['CPUExecutionProvider']) for inp in sess.get_inputs(): print('input:', inp.name, inp.shape) for out in sess.get_outputs(): print('output:', out.name, out.shape)识别模型的输出一般是[1, 序列长度, 字典大小],比如[1, 40, 6625],其中40是序列长度,6625是字符类别数。后面解码需要用字典文件ppocr_keys_v1.txt,这个文件在PaddleOCR仓库里能找到,里面第一行通常是blank符号。
3.3 方向分类模型到底要不要转
方向分类(cls)模型很小,作用是对检测到的文本区域做顺逆时针判断,如果旋转了180度就翻转回来,提升识别准确率。
我的建议是:优先转换,后续按场景取舍。因为cls的推理成本很低,一个模型单次前向几乎可以忽略,但它对拍照、倾斜、扫描件的识别结果改善明显。如果做的项目是UI截图、标准文档扫描这类方向固定的图片,可以去掉cls模型,省一次调用。不过为了流程完整,我一般还是把cls转出来:
paddle2onnx \ --model_dir ./inference/cls \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/cls.onnx \ --opset_version 11 \ --enable_onnx_checker True方向分类模型输出一般就是两个类别的概率,一个表示正,一个表示旋转180度。判断方式比较直接,识别概率大的类别是positive就不操作,是negative就把区域旋转回来。
4. 转完不是终点:ONNX Runtime推理与前后处理
4.1 和PaddleOCR对齐的图片预处理
模型转成ONNX后,输入必须是模型训练时见过的数据分布。PaddleOCR里的预处理一般包括缩放、归一化、RGB通道转换和减均值除方差。我见过不少新手转完ONNX后,随便把图片resize一下填进去,识别效果崩盘,然后怀疑转换出问题,实际上就是预处理不一致。
以检测模型为例,标准做法大概是这样:将图片缩放到模型输入尺寸内,保持宽高比,剩余区域用0填充,然后除以255,再用ImageNet的均值和标准差做归一化。注意PaddleOCR的预处理是RGB模式,OpenCV读图是BGR,必须先用cv2.cvtColor转通道。
一个参考实现:
import cv2 import numpy as np def det_preprocess(img, input_size=(960, 1600)): h, w = img.shape[:2] ratio = min(input_size[0] / h, input_size[1] / w) nh, nw = int(round(h * ratio)), int(round(w * ratio)) resized = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR) canvas = np.zeros((input_size[0], input_size[1], 3), dtype=np.float32) canvas[:nh, :nw, :] = resized canvas = cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB) canvas = canvas / 255.0 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) canvas = (canvas - mean) / std canvas = canvas.transpose(2, 0, 1)[None] return canvas.astype(np.float32)识别模型的预处理略有不同,一般会把高度固定成模型规定的尺寸(比如32或48),宽度按比例缩放并补齐到指定宽度。这些细节最好对照PaddleOCR的配置文件确认,不同版本模型可能不一样。
4.2 检测后处理:概率图怎么变成文本框
ONNX检测模型输出的是概率图,要得到最终文本框,需要自己实现DB后处理。最简单的流程:
- 对输出概率图做
sigmoid(如果原模型输出不是归一化的话)。 - 按阈值(比如0.3)生成二值图。
- 用OpenCV的
findContours找到连通区域。 - 对每个轮廓做多边形近似,如果希望输出矩形框,直接用
minAreaRect。
这里用到的阈值、扩展比例(unclip_ratio)和PaddleOCR默认配置有关,如果发现检测框偏大偏小,多半是后处理参数没对齐。
我可以给一个很简化的代码结构:
import cv2 import numpy as np def db_postprocess(prob_map, thresh=0.3): binary = (prob_map > thresh).astype(np.uint8) contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for cont in contours: rect = cv2.minAreaRect(cont) box = cv2.boxPoints(rect) boxes.append(box) return boxes真实项目里还需要对轮廓点排序、去除面积过小的噪点、做透视变换等,但整体思路就是这样。
4.3 识别后处理:CTC解码与字典映射
识别模型输出矩阵后,要用CTC贪心解码把索引序列变成文本。核心逻辑是:取每一步概率最大的索引,移除相邻重复的索引,再丢弃blank符号。
示例代码:
def ctc_decode(pred, char_list, blank=0): pred = np.squeeze(pred, axis=0) # [seq_len, num_classes] pred_idx = pred.argmax(axis=-1) prev = -1 result = [] for idx in pred_idx: idx = int(idx) if idx != blank and idx != prev: result.append(char_list[idx]) prev = idx return ''.join(result)字典加载时要注意编码,ppocr_keys_v1.txt是UTF-8,我遇到过用错系统编码导致读出来乱码的问题。另外字典第一行一般是blank,所以索引0对应空白符号。不同模型的blank位置可能不同,保险起见输出节点shape里看一眼睛dict大小,再对比字典长度。
5. 转换路上的坑:问题排查与避坑记录
5.1 常见报错与解决方案速查表
我把实际操作中遇到比较多的几类情况整理成了表格,表格里的坑基本覆盖了90%的转换问题。
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
| 转换时报“some ops are not supported” | Paddle2ONNX版本太旧,或opset版本不够 | 先升级paddle2onnx到1.0.8以上,再提高opset版本 |
| 转换成功,但ONNX Runtime加载报错 | 模型结构损坏,或用了不兼容的onnx Runtime版本 | 开启--enable_onnx_checker True,或升级onnxruntime到1.10以上 |
| 推理输出全是NaN | 输入预处理不对,归一化参数或通道顺序不一致 | 检查减均值除方差、BGR/RGB转换 |
| 推理结果和Paddle输出差异大 | 后处理参数不一致,不是模型问题 | 对比预处理和后处理配置,不要只怀疑ONNX |
| 输入尺寸报错 | 动态shape或固定shape和实际输入不匹配 | 用input_shape_dict统一shape,或手动改ONNX动态轴 |
| 找不到输入名“x” | 模型版本不同,输入节点可能叫别的名字 | 打印onnxruntime的get_inputs()确认节点名 |
| cls 模型输出无法判断旋转 | 没有对齐类别顺序 | 检查两边类别索引,Paddle推理和ONNX推理分别打印输出对比 |
5.2 精度一致性验证的几个实用判断方法
半途转换完,第一件事不是急着接服务,而是做一致性验证。我自己的流程是:同一张输入图,分别用Paddle的推理接口和ONNX Runtime跑一次前向,比较中间输出和最终输出的误差。
简单脚本:
import numpy as np paddle_out = ... # 从Paddle前向拿到的结果 onnx_out = session.run(None, {'x': input_tensor})[0] print(np.max(np.abs(paddle_out - onnx_out))) print(np.allclose(paddle_out, onnx_out, atol=1e-5))建议用三张以上的图片做对比,分别覆盖小目标文字、长文字、复杂背景。如果allclose通不过,先看预处理,再看算子的数值误差阈值。ONNX和Paddle在算子实现上会有少量浮点误差,只要相对误差在1e-4左右,一般不会影响OCR效果。
5.3 我踩过的几个值得说的细节坑
第一个坑是“固定shape”带来的连锁反应。早期我图省事,转换时全部用默认shape,后来发现图片尺寸稍微大一点,预处理resize就会导致文字被压扁。OCR识别非常吃宽高比,压扁之后识别率肉眼可见地下降。所以后面我在转换识别模型时会仔细确认输入尺寸和实际应用场景匹配。
第二个坑是“动态shape”并不是万能。我曾经试着把检测模型的宽高都设成动态,结果转出来的ONNX在推理时不断报内存相关的错误。后来我用固定shape加多档尺寸,比如960x1600、1280x1344,按图片比例选一档。这个方案在工程上很稳。
第三个坑是部署时忘了把字典文件和模型放一起。模型在本地测试没问题,上线后接口返回空,Debug了很久发现是字典读取路径问题。字典文件虽小,却是识别链路里不可缺少的一部分,部署时最好把字典内容随配置一起打包。
还有一个比较隐蔽的问题:PaddleOCR的图片预处理虽然看起来差不多,但不同版本配置差别很大。比如旧版识别模型的高度可能是32,新版换成48,如果你从网上复制了一份旧代码,跑新模型很容易出现输入shape对不上的报错。所以一切以你手中模型的配置为准,外部代码只参考,不盲抄。
6. 平时我是怎么组织这些转换脚本的
顺手分享一个习惯:把转换过程写成一个脚本放到项目里,不要只存命令在终端记录里。脚本里把Paddle2ONNX版本、opset版本、输入shape、输出目录都固化下来。这个东西看起来简单,但哪天你要升级模型或者同事接手,它能让流程非常省心。
我一般会在项目里建一个convert_onnx目录,里面结构是:
convert_onnx/ ├── export.sh ├── convert_det.sh ├── convert_rec.sh ├── convert_cls.sh └── validate_onnx.pyvalidate_onnx.py专门负责加载ONNX并跑一致性验证。每次转换新版本模型后,跑一遍验证脚本,确认没有回归再合入部署流程。这样长期维护的成本会低很多。
另外,ONNX Runtime本身的线程数、模型session的复用,在生产环境也需要注意。不要在请求里反复创建InferenceSession,应启动时创建一个全局的session,或者用线程池。OCR识别如果并发高,CPU推理建议开OrtSessionOptions设置合理的线程数,避免线程切换损耗。
这些细节不属于模型转换本身,但每次转换后最终都要面对,所以提前想好会轻松很多。
我在实际使用中最大的体会是:Paddle转ONNX并不是一个黑盒操作,你可以把它完全当黑盒跑命令,也能一路往里深入调算子。普通项目里,做好环境匹配、固定shape、预处理对齐和输出验证这四件事,转换基本不会出大问题。真正花时间的,往往不是转换本身,而是后处理里那些从PaddleOCR搬过来但没人帮你写清楚的细节。如果后续还想在GPU上跑得更快,ONNX模型还可以继续转成TensorRT引擎,核心的预处理和后处理流程是不用变的,这也是当初选择ONNX路线的一个好处。