PaddleOCR转ONNX部署实战:从模型转换到推理优化
2026/9/19 9:30:12 网站建设 项目流程

1. 为什么要把PaddleOCR转成ONNX

做过OCR推理部署的人多半都经历过这样的场景:算法同事丢过来一个PaddleOCR的训练模型,拍着肩膀说“效果很好,你部署一下”,然后你打开服务器一看,生产环境是Java技术栈,或者是一台没有装PaddlePaddle的ARM边缘盒子,甚至是一个只支持ONNX Runtime的推理服务框架。这时候,把PaddleOCR转成ONNX就成了绕不开的一步。

PaddleOCR本身推理能力很强,PP-OCRv系列模型在中文场景下的识别精度和速度平衡做得相当不错,尤其是PP-OCRv5、PP-OCRv6这几代,检测和识别模型都做了大量结构优化。但它的推理依赖PaddlePaddle框架,这在Python环境下问题不大,一旦跨语言、跨平台,就会遇到各种麻烦。ONNX作为开放的模型交换格式,几乎成了推理部署领域的“通用货币”,ONNX Runtime在Windows、Linux、ARM、x86上都有成熟的运行时支持,Java、C++、C#、Python都能调用。把PaddleOCR转成ONNX,本质上是用一次转换的代价,换取后续部署的极大自由度。

这篇文章面向的是需要把中文OCR能力落地到实际项目里的工程师,不管你是做车牌识别、票据识别、文档数字化还是工业质检里的字符检测,只要涉及PaddleOCR的推理部署,这套流程都能直接参考。我会从模型结构拆解开始,讲到转换的具体操作、ONNX Runtime的推理代码、量化压缩,再到实际部署中踩过的坑,尽量把每个环节的“为什么”说清楚。

2. PaddleOCR模型结构拆解与转换前的准备

2.1 检测模型和识别模型是两套独立的东西

很多人刚开始接触PaddleOCR时会有一个误解,以为它是一个端到端的模型,输入图片直接输出文字。实际上PaddleOCR的推理流程是两阶段:先由检测模型(Det)找出图片中文字区域的位置,把每个文本框裁剪出来,再由识别模型(Rec)对每个文本框做文字识别。这两个模型是独立训练、独立推理的,转换ONNX时也要分别转换。

检测模型常用的有PP-OCRv5的检测网络,骨干是MobileNetV3或者ResNet的变体,输出是概率图,表示每个像素属于文字区域的概率。识别模型则是CRNN加CTC的结构,输入是裁剪后的文本行图片,输出是字符序列。理解这个结构很重要,因为转换时两个模型的输入输出形状、动态轴设置都不一样,后面会详细说。

2.2 环境准备:PaddlePaddle和Paddle2ONNX

转换工作需要在Python环境下完成,核心依赖是两个包:PaddlePaddle和Paddle2ONNX。PaddlePaddle用来加载推理模型,Paddle2ONNX负责把模型图转成ONNX格式。

安装命令很直接:

pip install paddlepaddle==2.6.0 pip install paddle2onnx==1.0.6 pip install onnx==1.15.0 pip install onnxruntime==1.17.0

这里有几个版本选择的经验。PaddlePaddle的版本要和你的模型版本匹配,如果你用的是PP-OCRv5的推理模型,建议PaddlePaddle用2.5以上。Paddle2ONNX的版本也很关键,1.0.x系列对PP-OCR系列的支持比较稳定,太老的版本可能不支持某些算子。ONNX的版本影响opset,一般用opset 11或12就够了,ONNX Runtime 1.17对这两个opset支持都很好。

如果你有GPU,可以装paddlepaddle-gpu,转换过程本身用CPU就够了,但如果你要在转换后做精度验证,GPU会快很多。不过要注意,转换出来的ONNX模型是否能用GPU推理,取决于你部署环境里的ONNX Runtime是不是GPU版本,和转换过程无关。

2.3 下载PaddleOCR推理模型

PaddleOCR官方提供了训练好的推理模型,直接下载就行。以PP-OCRv5中文模型为例:

# 检测模型 wget https://paddleocr.bj.bcebos.com/PP-OCRv5/chinese/ch_PP-OCRv5_det_infer.tar tar -xf ch_PP-OCRv5_det_infer.tar # 识别模型 wget https://paddleocr.bj.bcebos.com/PP-OCRv5/chinese/ch_PP-OCRv5_rec_infer.tar tar -xf ch_PP-OCRv5_rec_infer.tar

解压后你会看到每个目录下有四个文件:inference.pdmodelinference.pdiparamsinference.pdiparams.infoinference.pdmodel。其中.pdmodel是模型结构,.pdiparams是权重参数,.info文件在转换时用不到。这两个模型加起来大概十几兆,体积很小,非常适合部署。

注意:下载模型时确认一下你用的是哪个版本的PP-OCR,v5和v6的模型结构有差异,转换时的参数设置也不同。如果你拿的是v6模型,建议Paddle2ONNX用1.1以上的版本。

3. 动手转换:从Paddle模型到ONNX

3.1 检测模型转换的完整命令与参数解读

检测模型的转换命令如下:

paddle2onnx \ --model_dir ch_PP-OCRv5_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file det_model.onnx \ --opset_version 11 \ --enable_onnx_checker True \ --input_shape_dict "{'x': [1, 3, -1, -1]}"

逐条解释这些参数。--model_dir指向解压后的模型目录,--model_filename--params_filename分别指定结构和权重文件。--save_file是输出的ONNX文件名。--opset_version 11指定ONNX的算子集版本,选11是因为它兼容性好,ONNX Runtime和TensorRT都支持。

最关键的是--input_shape_dict。检测模型的输入是[N, C, H, W],其中N是batch size,C是通道数3,H和W是图片高宽。这里写成[1, 3, -1, -1],意思是batch固定为1,高宽动态。为什么高宽要动态?因为实际推理时输入图片尺寸各不相同,如果固定了尺寸,每张图都要resize到同一大小,会影响检测精度。设成动态后,ONNX Runtime可以根据实际输入调整。

但动态轴也有代价。某些推理后端对动态形状支持不好,比如一些NPU或特定版本的TensorRT,遇到动态轴可能需要额外配置。如果你确定部署环境输入尺寸固定,比如车牌识别场景图片都是固定角度和距离拍摄的,那可以把H和W也固定,写成[1, 3, 640, 640],这样推理速度会更快。

3.2 识别模型转换的特殊处理

识别模型的转换命令类似,但输入形状不同:

paddle2onnx \ --model_dir ch_PP-OCRv5_rec_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file rec_model.onnx \ --opset_version 11 \ --enable_onnx_checker True \ --input_shape_dict "{'x': [1, 3, 48, -1]}"

识别模型的输入高度固定为48,这是PP-OCR系列的标准输入高度。宽度设为动态,因为不同文本行的长度不一样。宽度动态是识别模型转换的关键,如果宽度也固定,长文本会被截断,短文本会补零,都会影响识别效果。

转换完成后,可以用Netron打开ONNX文件看看结构。Netron是一个模型可视化工具,直接拖进去就能看到计算图。重点检查输入输出的名字和形状,后面写推理代码时要用到。检测模型的输出通常叫sigmoid_0.tmp_0之类的名字,识别模型的输出叫softmax_0.tmp_0,不同版本可能略有差异,以Netron里看到的为准。

3.3 转换后的精度验证

转换完不能直接就用,一定要验证精度。方法是用同一张图片,分别用PaddleOCR原模型和ONNX模型推理,对比结果。

import onnxruntime as ort import numpy as np import cv2 # 加载ONNX模型 sess = ort.InferenceSession("det_model.onnx") input_name = sess.get_inputs()[0].name # 准备输入 img = cv2.imread("test.jpg") img = cv2.resize(img, (640, 640)) img = img[:, :, ::-1].astype(np.float32) / 255.0 img = np.transpose(img, (2, 0, 1))[np.newaxis, ...] # 推理 output = sess.run(None, {input_name: img}) print(output[0].shape)

把ONNX的输出和PaddleOCR原模型的输出做对比,如果数值差异在1e-4以内,说明转换没问题。如果差异很大,通常是opset版本或者算子支持的问题,可以尝试换opset版本重新转。

实操心得:转换检测模型时,如果遇到HardSwishHardSigmoid算子报错,把opset_version调到11以上通常能解决。这两个算子在MobileNetV3里很常见,opset 10以下不支持。

4. ONNX Runtime推理部署实战

4.1 Python端推理:检测+识别完整流程

先看Python端的完整推理代码,这是验证模型是否好用的最快方式。

import cv2 import numpy as np import onnxruntime as ort class PaddleOCR_ONNX: def __init__(self, det_model_path, rec_model_path): self.det_sess = ort.InferenceSession(det_model_path) self.rec_sess = ort.InferenceSession(rec_model_path) self.det_input = self.det_sess.get_inputs()[0].name self.rec_input = self.rec_sess.get_inputs()[0].name def preprocess_det(self, img): h, w = img.shape[:2] # 调整到32的倍数 new_h = int(np.ceil(h / 32) * 32) new_w = int(np.ceil(w / 32) * 32) resized = cv2.resize(img, (new_w, new_h)) # 归一化 input_data = resized[:, :, ::-1].astype(np.float32) / 255.0 input_data = (input_data - [0.485, 0.456, 0.406]) / [0.229, 0.224, 0.225] input_data = np.transpose(input_data, (2, 0, 1))[np.newaxis, ...] return input_data.astype(np.float32), (h, w), (new_h, new_w) def detect(self, img): input_data, orig_shape, new_shape = self.preprocess_det(img) output = self.det_sess.run(None, {self.det_input: input_data})[0] # 后处理:阈值化+轮廓提取 prob_map = output[0, 0] binary = (prob_map > 0.3).astype(np.uint8) * 255 contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for cnt in contours: x, y, w, h = cv2.boundingRect(cnt) if w < 3 or h < 3: continue # 映射回原图坐标 scale_x = orig_shape[1] / new_shape[1] scale_y = orig_shape[0] / new_shape[0] boxes.append([int(x*scale_x), int(y*scale_y), int((x+w)*scale_x), int((y+h)*scale_y)]) return boxes

这段代码里检测的后处理做了简化,实际PaddleOCR的后处理还包括DB(Differentiable Binarization)的阈值处理和文本框扩张,但核心逻辑就是这样。识别部分需要把检测到的每个框裁剪出来,resize到高度48,宽度按比例缩放。

def preprocess_rec(self, img): h, w = img.shape[:2] ratio = 48.0 / h new_w = int(w * ratio) resized = cv2.resize(img, (new_w, 48)) input_data = resized[:, :, ::-1].astype(np.float32) / 255.0 input_data = (input_data - 0.5) / 0.5 input_data = np.transpose(input_data, (2, 0, 1))[np.newaxis, ...] return input_data.astype(np.float32) def recognize(self, img, boxes): results = [] for box in boxes: x1, y1, x2, y2 = box crop = img[y1:y2, x1:x2] if crop.size == 0: continue input_data = self.preprocess_rec(crop) output = self.rec_sess.run(None, {self.rec_input: input_data})[0] # CTC解码 text = self.ctc_decode(output) results.append((box, text)) return results

CTC解码是识别模型后处理的核心。识别模型的输出形状是[1, T, num_classes],T是时间步数,num_classes是字符集大小。解码时取每个时间步的最大概率索引,去掉重复和blank(通常是索引0),再映射到字符。

4.2 Java端部署:ONNX Runtime Java API

很多企业项目是Java技术栈,ONNX Runtime提供了Java API,可以直接调用。Maven依赖:

<dependency> <groupId>com.microsoft.onnxruntime</groupId> <artifactId>onnxruntime</artifactId> <version>1.17.0</version> </dependency>

Java端的推理代码结构和Python类似,核心是构造OrtSessionOnnxTensor

OrtEnvironment env = OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts = new OrtSession.SessionOptions(); OrtSession session = env.createSession("det_model.onnx", opts); float[] inputData = preprocess(image); long[] shape = {1, 3, height, width}; OnnxTensor tensor = OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape); OrtSession.Result result = session.run(Collections.singletonMap("x", tensor)); float[][] output = (float[][]) result.get(0).getValue();

Java端做图像预处理比Python麻烦一些,需要用BufferedImage读取图片,手动做resize和归一化。建议把预处理逻辑封装成工具类,避免每次推理都重复写。

注意:Java端ONNX Runtime的版本要和ONNX模型opset匹配。1.17版本支持opset 11到19,如果你转换时用了更高的opset,Java端可能加载失败。

4.3 性能优化:线程数和内存配置

ONNX Runtime默认的线程数可能不是最优的。在初始化Session时,可以配置:

options = ort.SessionOptions() options.intra_op_num_threads = 4 options.inter_op_num_threads = 2 options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("model.onnx", options)

intra_op_num_threads控制单个算子内部的并行线程数,inter_op_num_threads控制算子之间的并行度。对于OCR这种模型,检测模型计算量大,intra设成4到8比较合适;识别模型相对轻量,intra设成2到4就够了。设太多线程反而会因为线程切换开销导致性能下降。

graph_optimization_level设成ORT_ENABLE_ALL会启用所有图优化,包括算子融合、常量折叠等,通常能提升10%到20%的性能。

5. 模型量化:让推理再快一倍

5.1 动态量化与静态量化的选择

ONNX模型量化主要有两种方式:动态量化和静态量化。动态量化在推理时动态计算量化参数,不需要校准数据,使用简单;静态量化需要一批校准数据来统计激活值的分布,精度通常更好,但流程复杂一些。

对于OCR模型,我一般推荐先试动态量化,因为实现简单,精度损失通常在可接受范围内。如果动态量化后精度下降太多,再考虑静态量化。

动态量化的代码:

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_input="det_model.onnx", model_output="det_model_int8.onnx", weight_type=QuantType.QUInt8 )

QuantType.QUInt8表示权重量化成8位无符号整数。量化后模型体积会缩小到原来的四分之一左右,推理速度在支持INT8的硬件上能提升2到4倍。

5.2 量化后的精度对比与调优

量化后一定要做精度对比。用同一批测试图片,分别用FP32模型和INT8模型推理,统计识别准确率的差异。如果准确率下降超过2个百分点,就需要调优。

调优的方法有几个。一是只量化部分层,比如只量化卷积层,不量化全连接层。二是用静态量化,提供校准数据集。三是调整量化时的per_channel参数,设成True会对每个通道单独计算量化参数,精度更好但速度稍慢。

quantize_dynamic( model_input="rec_model.onnx", model_output="rec_model_int8.onnx", weight_type=QuantType.QUInt8, per_channel=True, reduce_range=True )

reduce_range=True会把量化范围从0-255缩到0-127,避免某些硬件上INT8溢出,但会略微降低精度。这个参数在ARM平台上比较有用。

实操心得:识别模型量化后精度损失通常比检测模型大,因为识别模型对数值精度更敏感。如果识别模型量化后效果不好,可以只量化检测模型,识别模型保持FP32,整体速度提升也很明显。

6. 常见问题与排查技巧实录

6.1 转换报错:不支持的算子怎么办

转换时最常见的报错是“Unsupported operator”。PaddlePaddle有一些自定义算子,ONNX标准里没有对应实现。遇到这种情况,先看是哪个算子,然后查Paddle2ONNX的文档看是否支持。

如果确实不支持,有两个思路。一是升级Paddle2ONNX到最新版,新版本通常会增加算子支持。二是修改模型结构,把不支持的算子替换成等价的ONNX算子组合。比如PaddlePaddle的swish激活函数,在ONNX里可以用SigmoidMul组合实现。

6.2 推理结果乱码:字符集映射问题

PaddleOCR识别模型输出的是字符索引,需要映射到实际字符。这个映射表在PaddleOCR的代码里叫ppocr_keys_v1.txt,是一个每行一个字符的文本文件。如果映射表用错了,识别结果就会乱码。

映射表要和模型版本对应。PP-OCRv5的中文模型用的是ppocr_keys_v1.txt,包含6623个字符。如果你用的是其他语言的模型,映射表文件不一样。这个文件在PaddleOCR的GitHub仓库里可以找到,下载后按行读取,构建索引到字符的字典。

6.3 动态形状导致的推理失败

前面说了检测模型输入高宽设成动态,但有些推理后端对动态形状支持不好。如果遇到“Invalid shape”或“Dimension mismatch”的报错,可以尝试把动态轴固定。方法是在转换时指定具体的H和W,或者在推理前把输入resize到固定尺寸。

另一个常见问题是batch size。如果转换时batch设成1,推理时传了batch大于1的数据,会报错。反过来,如果转换时batch是动态的,推理时传batch 1也可以,但某些后端可能会慢。建议转换时就把batch固定成1,因为OCR推理通常一次处理一张图。

6.4 常见问题速查表

问题现象可能原因解决方法
转换时报Unsupported operator算子不被Paddle2ONNX支持升级Paddle2ONNX或替换算子
推理结果全是blank输入预处理不对检查归一化参数和通道顺序
识别结果乱码字符映射表错误使用对应版本的keys文件
推理速度慢线程数配置不当调整intra/inter_op线程数
量化后精度下降大量化范围过宽使用per_channel或静态量化
Java端加载模型失败opset版本不匹配降低opset或升级ONNX Runtime

7. 部署场景扩展与个人经验

7.1 车牌识别场景的特殊处理

车牌识别是OCR的典型应用,但和通用OCR有些不同。车牌文字排列规整,字符集小(汉字+字母+数字,总共几十个字符),可以用更小的识别模型。实际部署时,检测模型可以复用PP-OCR的检测模型,识别模型可以自己训练一个轻量级的,然后转ONNX。

车牌识别的预处理也有讲究。车牌图片通常需要先做透视矫正,把倾斜的车牌拉正,再送进识别模型。这一步可以在ONNX推理之前用OpenCV完成,不增加模型复杂度。

7.2 边缘设备部署的注意事项

在ARM边缘设备上部署ONNX模型,有几个点要注意。一是ONNX Runtime要装ARM版本,Python包名是onnxruntime,但ARM上可能需要从源码编译。二是INT8量化在ARM上收益很大,因为ARM的CPU通常有INT8加速指令。三是内存占用,边缘设备内存有限,模型量化后体积小,加载时内存占用也小。

如果边缘设备有NPU,比如某些国产芯片,ONNX模型可能需要进一步转成NPU支持的格式。这个转换通常由芯片厂商的工具链完成,ONNX作为中间格式,兼容性最好。

7.3 我踩过的几个坑

第一个坑是归一化参数。PaddleOCR检测模型的归一化用的是ImageNet的均值和方差,即mean=[0.485, 0.456, 0.406]std=[0.229, 0.224, 0.225]。识别模型用的是mean=0.5std=0.5。这两个不一样,我一开始搞混了,检测结果一直不对,排查了半天才发现。

第二个坑是通道顺序。OpenCV读进来是BGR,PaddleOCR训练时用的是RGB,所以预处理时要img[:, :, ::-1]做通道翻转。这个细节很容易忘,忘了之后检测框位置会偏,识别结果也会乱。

第三个坑是动态宽度。识别模型转换时宽度设了动态,但推理时如果宽度太小(比如小于10个像素),ONNX Runtime可能会报错。解决办法是在预处理时保证最小宽度,比如new_w = max(new_w, 16)

第四个坑是Java端的内存管理。ONNX Runtime Java API的OnnxTensorOrtSession.Result需要手动关闭,否则会内存泄漏。长时间运行的服务一定要用try-with-resources或者手动close。

7.4 后续可以扩展的方向

这套流程跑通之后,还可以做很多扩展。比如把检测和识别模型合并成一个ONNX模型,减少两次推理之间的数据拷贝。或者用ONNX Runtime的TensorRT后端,在NVIDIA GPU上进一步加速。还可以把模型转成ONNX之后,再用OpenVINO的工具链转成IR格式,在Intel平台上获得更好的性能。

另外,PaddleOCR 3.x版本对模型结构做了不少调整,转换时的参数可能和v5不一样。如果你用的是3.x的模型,建议先看Paddle2ONNX的release note,确认支持的版本范围。

我个人在实际项目中的体会是,PaddleOCR转ONNX这件事,难点不在转换本身,而在转换之后的精度验证和性能调优。转换命令就那几行,但要让模型在实际业务中稳定跑起来,需要把预处理、后处理、量化、线程配置这些环节都调对。尤其是预处理,不同版本的模型归一化参数可能不同,一定要以官方代码为准,不要凭记忆写。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询