前阵子接了个需求要把一套基于 Paddle 框架训练的文本检测识别模型迁到另一套推理服务里那套服务统一用 ONNX 做模型分发。当时第一反应是“转个 ONNX 而已工具链都有应该不麻烦”真正动手才发现检测模型、识别模型各自的输入输出规格、动态维度、预处理参数、后处理阈值每个环节都可能让转换结果在“能跑”和“跑得对”之间差出十万八千里。这篇文章就把我亲手走完的 Paddle 文本检测识别模型转 ONNX 全过程拆开讲包括为什么要转、转换前要核对哪些东西、转换命令和参数怎么选、转完之后怎么验证以及部署阶段容易踩的坑。适合两类人第一次做模型转换的新手可以照着完整走一遍已经被各种报错卡住的人可以直接跳到第三节和第五节找思路。1. 为什么好好的模型要费劲转成 ONNX其实 OCR 的文本检测识别模型不是一个模型而是两个一个负责在整张图里定位文字区域业内通常叫检测模型Detector常见结构类似 DBNet输出的是文字区域的概率图另一个负责把裁出来的文字区域图变成字符串通常叫识别模型Recognizer常见结构类似 CRNN 或 SVTR输出的是字符序列。两个模型在训练时是独立的推理时串在一起先检测找到文本框再把每个框抠出来送识别。Paddle 框架把这套东西做得比较完整中文识别效果好所以很多业务场景都直接用 Paddle 的预训练模型作为起点。但真到了生产部署环节问题就来了Paddle 的推理依赖完整的框架运行环境这是个不小的体积而且很多服务端的模型分发体系不认 Paddle 自己的模型格式。ONNX 作为一种开放的模型交换格式最大的优势就是“一次转换到处部署”——只要目标环境有 ONNX Runtime就能加载同一份模型文件。1.1 检测模型和识别模型的结构差别决定了转换策略不同文本检测模型大多是像素级别的分割结构。以典型的 DBNet 链路为例输入是一张三通道图比如 [1, 3, 736, 1280]输出通常包含一个概率图probability map和一个阈值图threshold map后续通过可微二值化逻辑把两个图融合成最终的文本区域。有的推理模型会把这两个输出都保留有的只保留概率图。转换前先搞清楚这一点特别重要——你要是后处理脚本里默认只取第一个输出但 ONNX 模型实际上有两个输出取值逻辑就得跟上。识别模型则完全是另一套思路。输入是固定高度的文字行图比如 [1, 3, 32, width]宽度可以变。输出有两个典型的形态如果是带 CTC 解码的结构输出是 [1, T, num_classes] 的概率分布再在外部做 argmax 和去重如果是结构里自带了 softmax 和 argmax输出可能是 [1, T] 的索引序列。这两种形态在转换后做的事完全不一样不能照搬官方 demo 的后处理。1.2 ONNX 真正解决的生产问题不只是“换个格式”我实际转完再部署后体会到的收益主要有四条这里用表格直接对比对比维度Paddle 原生推理转 ONNX 后运行环境必须装完整 Paddle 框架及配套 CUDA/算子库只需 ONNX Runtime体积小得多跨语言集成Python/C 为主其他语言支持较弱C/Java/C#/Rust 等都有现成绑定推理加速框架自带算子融合但与外部工具链耦合深可用 ONNX Runtime、OpenVINO、TensorRT 等后端模型分发各框架格式混用维护成本高统一 ONNX一套文件多端消费在异构部署场景下收益更明显。比如服务端主程序是 Java边缘端又是 ARM Linux如果维护两套框架推理逻辑光踩环境依赖的坑就够呛。转成 ONNX 之后服务端用 ONNX Runtime 的 Java API边缘端用 C API模型文件是同一份。另一个很实际的好处是ONNX Runtime 支持通过 ExecutionProvider 机制切换到不同硬件加速后端底层怎么优化不用自己管。当然也不是说转成 ONNX 就一定更好。Paddle 自家的推理库在某些算子和硬件上有深度定制转出去反而可能损失这部分优化所以动手前先跑一下验证确认精度和速度都能接受再决定是否真正替换。2. 动手转换前这几项准备比最后那条命令更关键转换 Paddle 的文本检测识别模型很多人第一反应是直接敲 paddle2onnx 命令。我的建议是先别急把环境、模型文件、输入输出规格三件事核对完后面会省掉大量反复试错的时间。2.1 环境和版本翻车重灾区paddle2onnx 这个工具本身依赖 Paddle 框架转换时会用框架去加载模型然后解析图结构。所以版本匹配是第一关。我用下来比较稳的组合是Paddle 2.5.x paddle2onnx 1.0.9 onnxruntime 1.16.x onnx 1.14.x。如果你的 Paddle 版本更高paddle2onnx 也建议用对应新版本避免出现“这个算子模板不存在”之类的报错。强烈建议用单独的虚拟环境来操作别直接装在系统 Python 里python3 -m venv ocr_convert_env source ocr_convert_env/bin/activate pip install paddlepaddle2.5.2 paddle2onnx1.0.9 onnxruntime1.16.0 onnx1.14.0为什么要强调虚拟环境因为转换过程中可能要降级或升级某些依赖比如 onnx 的版本、numpy 的版本这些一旦和系统里其他项目冲突排查起来非常痛苦。我见过有人在转换时因为 numpy 2.x 和旧版 onnx 不兼容白白折腾了半天。装完之后先确认版本对得上import paddle import paddle2onnx import onnxruntime print(paddle.__version__) print(paddle2onnx.__version__) print(onnxruntime.__version__)如果 import paddle2onnx 时报错找不到共享库多半是 Paddle 版本和编译环境不匹配优先检查是不是装了 CPU 和 GPU 两个版本的 Paddle 混在一起。2.2 模型文件选型推理模型和训练模型不是一回事Paddle 的 OCR 模型库下载页面通常分成“训练模型”和“推理模型”。做转换时一定要选推理模型也就是带着 inference 字样的那套。这两者的区别一句话说清训练模型里还保留着损失函数、优化器状态等训练相关节点结构复杂且变量名不固定推理模型是剥离了这些东西、经过融合固化后的版本只保留前向推理需要的算子。下载下来的推理模型目录一般是这样的结构my_ocr/ ├── det/ │ ├── inference.pdmodel │ └── inference.pdiparams └── rec/ ├── inference.pdmodel └── inference.pdiparamsinference.pdmodel是模型结构文件inference.pdiparams是权重参数文件。有的还会有inference.pdiparams.info可以忽略。这里有个很关键的检验步骤拿到模型后先用 Paddle 推理功能把整个 OCR 流程跑通一次拿到一个“基准结果”。这个基准结果后面要用来和 ONNX 版本做对比。千万别跳过这步——我就是因为偷懒直接转换结果转完后发现结果不对又花了一轮功夫去定位到底是我预处理写错了还是模型转换有问题非常被动。2.3 转换前先看清模型的输入输出规格这一节是最容易被忽略的。很多人拿到模型就转转完拿到 ONNX 文件才开始猜输入叫什么、shape 是多少。其实在转换前用 Paddle 自己就能把输入输出规格打出来import paddle det_model paddle.jit.load(./my_ocr/det/inference) print(det inputs:, det_model.inputs) print(det outputs:, det_model.outputs) rec_model paddle.jit.load(./my_ocr/rec/inference) print(rec inputs:, rec_model.inputs) print(rec outputs:, rec_model.outputs)输出里会显示类似下面这样的信息检测模型输入名xshape[-1, 3, -1, -1]类型float32检测模型输出名可能是sigmoid_0.tmp_0或save_infer_model/scale_0.tmp_1之类shape 可能是[-1, 1, -1, -1]识别模型输入名xshape[-1, 3, 32, -1]识别模型输出名softmax_0.tmp_0shape[-1, -1, num_classes]看到-1就说明模型保存的是动态维度。paddle2onnx 对这种情况经常需要你主动指定输入 shape否则转出来的 ONNX 在某些推理后端上要么加载失败要么性能稀烂。我一般会拿一张真实业务图片测出最常用的尺寸比如检测模型用[1, 3, 960, 960]识别模型用[1, 3, 32, 320]然后在转换时通过--input_shape_dict固化下来。具体怎么传下一节说。此外还要记录预处理用的均值和标准差以及模型训练时的通道顺序RGB 还是 BGR。这些信息决定了 ONNX 推理脚本里怎么写预处理如果只是照着别处的代码抄很容易在颜色空间上翻车。3. paddle2onnx 转换命令实测参数、报错与解决所有准备工作做完真正敲命令反而简单。单独一个模型一条命令就能转完。这里以检测模型为例。3.1 一条最标准的转换命令假设我已经进入虚拟环境并且当前目录下就是刚才那个my_ocr目录cd my_ocr mkdir -p onnx paddle2onnx \ --model_dir ./det \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/det.onnx \ --opset_version 11 \ --enable_onnx_checker True \ --input_shape_dict {x:[1,3,960,960]}参数含义整理成一张表方便照着填参数作用说明--model_dir推理模型所在目录目录里必须同时有 pdmodel 和 pdiparams--model_filename模型结构文件名不传时可能默认找不到文件--params_filename参数文件名同上--save_fileONNX 文件输出路径注意目标目录要提前创建好--opset_versionONNX 算子集版本建议先用 11兼容性最好--enable_onnx_checker转换结束后做 ONNX 合法检查建议始终打开--input_shape_dict手动指定输入 shape形如{x:[1,3,960,960]}如果一切正常终端会打印出类似“转换成功”的日志然后给出 ONNX 模型的输入输出摘要。转换完成后可以用 onnx 库快速确认import onnx model onnx.load(./onnx/det.onnx) onnx.checker.check_model(model) print(model.graph.input) print(model.graph.output) print(nodes:, len(model.graph.node))看到checker通过并且输出节点数是一个合理范围检测模型一般在几百个节点说明这步没问题。3.2 op 集版本怎么选为什么默认先用 11ONNX 每次升级都会引入新的算子paddle2onnx 转换时会把 Paddle 算子映射成 ONNX 算子。如果目标部署环境的 ONNX Runtime 版本较老选择的 opset 版本太高部署端可能不认识某些新算子反之opset 版本太低一些复杂算子映射不进去转换阶段就报错退出。实际使用中我基本遵循两个原则默认从 opset 11 开始试。这个版本被绝大多数 ONNX Runtime 支持兼容面广。如果转换报“某些算子需要更高 opset”再逐步升到 12、13。升级到 13 以上前先确认部署端的 onnxruntime 版本不低于 1.10 左右。有一个判断技巧转换完成后直接看 ONNX 文件里的算子类型。如果出现ScatterND、Einsum这类相对较新的算子说明 opset 被拉高了部署端也要相应匹配。3.3 转换失败时我会按这个顺序排查paddle2onnx 的报错信息有时候写得很隐晦一堆 C 栈。我总结了一套固定排查顺序按步骤走比瞎猜快得多。第一步确认模型文件正确。最常见的问题其实是下载成了训练模型或者 pdmodel 和 pdiparams 不匹配。先跑一下paddle.jit.load能不能加载加载不了就别继续了。第二步看是不是输入 shape 问题。如果报错里有“input dimension”或“shape”字样大概率是动态维度没处理好。加上--input_shape_dict再试。第三步尝试调整 opset。有些算子只在特定 opset 下才能完成映射可以试试 11/12 两个版本。第四步升级 paddle2onnx 版本。但不要一下升到最新。我用某些新版本转某些结构反而比旧版更容易报错。如果旧版没解决就在小版本范围内微调。第五步打开--enable_dev_version True。这个参数会启用开发版算子实现覆盖到更多 Paddle 特有算子。转换完成后一定要用真实图片做精度对比因为开发版实现历史上出过精度问题。排查示例识别模型转换时报Unsupported ops根据报错提到的一个名为某特殊算子的名字我直接确认这是 opset 11 表达不了的新结构于是把 opset 改成 13成功解决问题。但转完发现部署端是旧运行时于是又把模型简化、部分算子手动替换最终固定在了 12。4. 转完之后先跑通 ONNX Runtime 推理再做精度对比转换成功不等于可以上线。我建议按三步走先在 Python 里用 ONNX Runtime 把单模型跑通再对比 Paddle 推理结果最后串联检测和识别做全链路验证。4.1 用 ONNX Runtime 加载模型并完成第一次推理以检测模型为例写一个最小可用的推理脚本import cv2 import numpy as np import onnxruntime as ort det_session ort.InferenceSession(./onnx/det.onnx, providers[CPUExecutionProvider]) input_name det_session.get_inputs()[0].name output_names [out.name for out in det_session.get_outputs()] img cv2.imread(./sample.jpg) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 缩放让最长边等于 960短边按比例 h, w img.shape[:2] scale 960.0 / max(h, w) new_w, new_h int(w * scale), int(h * scale) img_resized cv2.resize(img, (new_w, new_h)) # 归一化并转成 CHW img_resized img_resized.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) img_resized (img_resized - mean) / std img_resized img_resized.transpose(2, 0, 1) # 在目标尺寸上做 padding保证能凑成图片长宽可被 32 整除 img_input np.zeros((1, 3, 960, 960), dtypenp.float32) img_input[0, :, :new_h, :new_w] img_resized outputs det_session.run(output_names, {input_name: img_input}) prob_map outputs[0] print(prob_map shape:, prob_map.shape, value range:, prob_map.min(), prob_map.max())这里有一个非常容易踩的问题ONNX Runtime 推理脚本里的预处理必须和 Paddle 训练时的预处理完全一致包括是否按 255 归一化、mean/std 是哪个、resize 策略是保持长宽比还是直接拉伸、是否会 pad 到 32 的倍数。差一点点检测框可能都会偏移几个像素识别结果更是会直接变字。识别模型的脚本结构类似区别在于输入高度固定为 32宽度按文字行长度等比缩放但通常不超过 960输出是一个概率矩阵需要做 argmax 去重 映射到字符表才能得到文本。如果模型输出已经是索引序列则省去 argmax 步骤但仍要做 CTC 去重。4.2 同一张图的输出差异怎么看跑通之后干第一件正事拿一张图分别用 Paddle 推理和 ONNX Runtime 推理对比输出。最简单的方法是用 numpy 对比两个输出之间的最大绝对差def max_array_diff(arr_paddle, arr_onnx): arr_paddle np.array(arr_paddle) arr_onnx np.array(arr_onnx) return np.max(np.abs(arr_paddle - arr_onnx)) diff max_array_diff(paddle_prob_map, onnx_prob_map) print(max abs diff:, diff)只要两个输入完全一致这个差值通常在1e-3到1e-5之间。这个误差来源于浮点运算顺序不同不算是 bug。但如果差值到了1e-1这个量级基本可以断定是预处理不一致或者输入的 shape 不匹配或者模型转换时算子映射出了问题。我的建议是准备三张图像做对比——一张干净截图、一张带复杂背景的实拍图、一张横向文字和竖向文字都有的混合图。不要只测一张因为某些偏差只在特定图像尺寸或文字方向下才暴露。4.3 检测 识别全链路验证才算真正完成转换单模型跑通之后把检测模型和识别模型串起来写一个简单的 OCR 流程原图进检测模型得到概率图。用 DB 后处理拿到文本区域轮廓。注意 unclip_ratio、box_thresh 等参数要和 Paddle 原 pipeline 一致。对每个文本框做坐标变换裁剪出文字行图。文字行图缩放成识别模型需要的尺寸送入识别模型。对识别输出做 CTC 解码得到字符串。每一步都要打印中间结果的 shape 和几个关键值方便和 Paddle 版本对齐。我实际过程中遇到最多的问题出现在第三步和第四步之间裁剪图在 Paddle 里的方向是正的但我转 ONNX 后自己写的脚本里少了旋转变换导致识别模型输入全是旋转过的文字结果自然全部乱码。全链路验证通过后再拿一批多样化的测试图跑一遍统计检测框数量和识别正确率和转换前 Paddle 的结果对比。只要关键指标没有明显回落这次转换才算真正完成。5. 部署时绕不开的坑动态维度、预处理对齐与模型瘦身最后这部分写部署阶段真正会卡住人的细节。模型在 Python 里跑通只是第一步进入服务端、边缘端之后问题会换一副面孔出现。5.1 动态 Shape 在服务端的代价我在第三节提到用--input_shape_dict固定输入尺寸。固定尺寸的好处是部署端简单推理性能稳定但代价是每张图都要 resize 到同一个大小不同分辨率的图会被拉伸检测效果可能下降。如果想让 ONNX 模型保留动态维度paddle2onnx 转换时不需要指定--input_shape_dict但 ONNX Runtime 在 CPU 上对动态 shape 的支持并不友好每次换一批新尺寸内部可能要重新分配内存多线程场景下甚至有概率出现不稳定。更推荐的折中方案是按业务实际图片尺寸划分几个档位比如 640、960、1280转换几个静态 shape 版本部署时按图选模型。这种方式兼顾了性能和适配度我已经用在一个模拟项目上效果比单一动态模型稳得多。另外如果目标是 TensorRT 这类后端绝大多数情况下更要求静态 shape动态 shape 虽然支持但容易触发重新编译、耗时抖动。建议能固定就固定。5.2 预处理后处理必须逐行对齐少一行都不行很多人在模型转换后跑出的结果不对最后发现根本不是模型的问题而是预处理脚本写错了。OCR 的检测和识别两个模型的预处理参数是不同的不能共用一套。阶段检测模型识别模型输入尺寸最长边缩放常见 960可考虑 pad 到 32 倍数高度固定 32宽度等比缩放并限最大宽度均值常见 [0.485, 0.456, 0.406]常见 [0.5, 0.5, 0.5] 或按模型文档方差常见 [0.229, 0.224, 0.225]常见 [0.5, 0.5, 0.5] 或按模型文档通道顺序注意是 RGB 还是 BGR同左后处理要点二值化阈值、box_thresh、unclip_ratioCTC 解码、字符集映射这张表不是让照着填而是提醒你以模型文档为准。不同版本的 OCR 预训练模型预处理参数确实会有差异。正确做法是先翻一遍模型文档里给的预处理代码把每个参数记录成配置项再写转换后的推理脚本。我把这套参数统一放到了一个配置结构里后面无论是 Python 验证还是 C 部署都能复用同一份定义减少两端漂移。5.3 输出裁剪、格式瘦身与算子兼容转出来的 ONNX 文件不一定“干净”。检测模型有时会输出两个概率图其中只有一个是后处理真正要用的识别模型的输出也可能是带 softmax 的概率矩阵而服务端只需要 argmax 后的索引。多余的输出不仅浪费带宽和显存还可能在部分后端上带来额外的开销。有两个常用手段用简化工具移除多余节点比如 ONNX Simplifier 这类社区工具。它会把常量折叠、算子融合模型体积通常会小一些。如果简化工具不够稳就手动编辑 ONNX Graph。把不用的输出节点裁剪掉同时清理对应的计算路径。这一步需要细心改完后一定要重新跑精度对比。算子兼容方面最隐蔽的问题是某些算子在 ONNX Runtime 旧版本上被标记为“实验性算子”虽然能加载但可能走的是 fallback 实现性能和精度都和预期不同。我的处理方式是转换后查看 ONNX 文件里的算子集合逐个和部署环境的 ONNX Runtime 支持列表做比对遇到可疑算子优先调整 opset 或改模型结构来规避而不是硬着头皮上。整套流程走完之后我最深的体会是模型转换这种事功夫都在转换之外。把一个模型转成 ONNX本质上是在做一次“语义搬运”——必须把原框架里的输入输出规格、预处理参数、后处理逻辑、动态维度信息完整搬过去任何一个环节没对齐最后都会在业务结果上暴露出来。最后分享一个实用习惯每次转完模型我都会在项目里留一个“版本对照说明”记录 Paddle 版本、paddle2onnx 版本、opset 版本、固定 shape、预处理参数这五件事。下次再转同系列的模型直接照着一份说明跑能省掉大量排查时间。