
把Paddle文本检测识别模型转为ONNX这活儿我前前后后折腾过好多次每次都会遇到点新问题。先给不熟的朋友解释一下PaddleOCR这套工具里通常包含文本检测和文本识别两个模型检测模型负责找出图片里的文字区域识别模型负责把区域内容转成字符串。平时在Paddle框架里跑没问题但真正做服务端部署、接入C/Java项目或者想用ONNX Runtime做轻量加速就会考虑把模型转成ONNX格式。这篇文章就把从PaddleOCR模型到ONNX的完整链路讲清楚包括环境准备、模型导出、转换命令、推理验证以及那些文档里不常写明白的坑适合正在做OCR落地或者准备改造现有OCR组件的开发者参考。1. 为什么要把Paddle模型转成ONNX1.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 paddlepaddle2.5.2 pip install paddle2onnx1.0.8 pip install onnxruntime1.16.0 pip install paddleocr2.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. 核心实操检测模型和识别模型怎么转ONNX3.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、宽度320paddle2onnx \ --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), interpolationcv2.INTER_LINEAR) canvas np.zeros((input_size[0], input_size[1], 3), dtypenp.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], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.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, thresh0.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, blank0): pred np.squeeze(pred, axis0) # [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, atol1e-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路线的一个好处。