PaddleOCR转ONNX部署实战:从模型导出到跨语言推理优化

PaddleOCR转ONNX部署实战:从模型导出到跨语言推理优化 中文OCR的推理部署一直是个让人又爱又恨的活儿。PaddleOCR 的识别精度在开源方案里属于第一梯队但它的推理框架依赖比较重装完 PaddlePaddle 再加 PaddleOCR动辄几个G的依赖放到生产环境里尤其是边缘设备或者 Java 服务里经常水土不服。ONNX 就不一样了它是一个通用的模型交换格式一次导出Python、C、Java、C# 都能跑运行时还特别轻。把 PaddleOCR 的模型转成 ONNX等于给中文 OCR 装上了一双能跑遍各种平台的腿。这篇内容就是围绕这个转换和部署的完整链路展开的从模型导出、ONNX 推理代码编写到量化压缩、跨语言部署的注意事项都会讲到。不管你是做后端服务、桌面工具还是嵌入式设备上的文字识别这套流程都能直接拿去用。1. 为什么要把 PaddleOCR 折腾成 ONNX1.1 PaddleOCR 原生推理的痛点在哪PaddleOCR 本身提供了paddleocr这个 Python 包装完之后调几行代码就能识别用起来确实方便。但方便是给开发和测试阶段的一旦要上生产问题就来了。首先是依赖体积PaddlePaddle 的 GPU 版本装完轻松超过 2GBCPU 版本也不小如果你的服务本身很轻量为了一个 OCR 功能拖进来这么大一个依赖运维同学看了想打人。其次是部署环境的限制PaddlePaddle 对 Python 版本、CUDA 版本、cuDNN 版本都有比较严格的要求服务器上如果已经跑了别的深度学习服务版本冲突几乎是必然的。再就是跨语言的问题PaddleOCR 官方主要支持 Python 推理虽然也有 C 的部署方案但编译门槛不低Java 生态里更是几乎没有官方支持。我遇到过最典型的一个场景是一个 Java 写的后台管理系统需要做营业执照的 OCR 识别。团队一开始想用 PaddleOCR 的 Python 服务单独部署一个推理接口Java 那边通过 HTTP 调用。这个方案能跑通但多了一个服务就多了一份运维成本而且 HTTP 调用的延迟在并发高的时候会成为瓶颈。后来改成 ONNX 之后Java 直接用 ONNX Runtime 加载模型在同一个进程里完成推理延迟从几十毫秒降到了个位数部署也简单了很多。1.2 ONNX 到底解决了什么问题ONNX 的全称是 Open Neural Network Exchange翻译过来就是开放神经网络交换格式。你可以把它理解成深度学习模型界的 PDF——不管你是用什么框架训练的模型只要导出成 ONNX 格式任何支持 ONNX 的运行时都能加载和推理。这个特性带来的好处是连锁反应式的。第一运行时选择多了。ONNX Runtime 支持 Python、C、C#、Java、JavaScript 等多种语言还有针对不同硬件的加速版本比如 CPU 上用 OpenMP 加速GPU 上用 CUDA 或 TensorRT 加速。第二依赖轻了。ONNX Runtime 的 CPU 版本安装包只有几十兆跟 PaddlePaddle 完全不是一个量级。第三优化空间大了。ONNX 模型可以用工具做量化把 FP32 的权重压缩成 INT8模型体积能缩小到原来的四分之一推理速度还能提升不少精度损失通常在可接受范围内。对于中文 OCR 来说ONNX 还有一个隐性好处PaddleOCR 的模型结构相对固定导出成 ONNX 之后模型的计算图就冻结了不会因为框架版本升级导致推理结果变化。这在生产环境里很重要稳定性比什么都强。1.3 哪些场景适合走 ONNX 这条路不是所有场景都值得折腾 ONNX。如果你只是本地跑个脚本做实验直接用 PaddleOCR 的 Python 包最省事。但以下几种情况转 ONNX 的收益会非常明显Java 或 C# 后端服务需要在业务进程内直接做 OCR不想额外维护 Python 服务。边缘设备部署比如工控机、树莓派、安卓设备存储和算力都有限ONNX Runtime 的轻量优势很关键。高并发推理ONNX Runtime 的线程管理和内存复用做得比较好配合量化模型单机吞吐能提升不少。需要模型加密或固化ONNX 模型可以进一步转成其他格式方便做模型保护。反过来说如果你需要频繁调整模型结构、做在线学习或者依赖 PaddleOCR 的一些高级预处理功能那还是留在原生框架里更合适。2. 从 PaddleOCR 到 ONNX 的模型导出全流程2.1 环境准备与版本选择的坑导出 ONNX 模型需要装paddle2onnx这个工具但版本匹配是个大坑。PaddlePaddle、PaddleOCR、paddle2onnx 三者的版本必须对得上否则导出的时候各种报错。我踩过的坑包括paddle2onnx 版本太新导致不认旧版 Paddle 的算子PaddleOCR 版本太旧导致模型结构跟导出脚本对不上等等。经过多次实测下面这组版本组合是比较稳的组件推荐版本说明PaddlePaddle2.5.x 或 2.6.xCPU 版本即可导出不需要 GPUPaddleOCR2.7.x这个版本的模型结构比较稳定paddle2onnx1.0.x跟 Paddle 2.5 配合良好ONNX1.14.x运行时和工具链都兼容ONNX Runtime1.16.x 或更高推理用CPU 版本即可安装命令大概是这样pip install paddlepaddle2.6.0 pip install paddleocr2.7.0.3 pip install paddle2onnx1.0.5 pip install onnx1.14.1 pip install onnxruntime1.16.3注意如果你之前装过其他版本的 Paddle 相关包建议先pip uninstall干净再装避免残留的依赖冲突。我遇到过因为旧版 paddle2onnx 残留导致导出脚本报AttributeError的情况排查了半天才发现是版本没清干净。2.2 检测模型和识别模型要分开导出PaddleOCR 的推理流程分两步先用检测模型找出文字区域再用识别模型把区域里的文字读出来。这两个模型是独立的导出 ONNX 的时候也要分开处理。很多人第一次转的时候只导了识别模型结果发现没有检测框识别根本没法用。检测模型的导出命令paddle2onnx --model_dir ./ch_PP-OCRv4_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./det_model.onnx \ --opset_version 11 \ --enable_onnx_checker True识别模型的导出命令paddle2onnx --model_dir ./ch_PP-OCRv4_rec_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./rec_model.onnx \ --opset_version 11 \ --enable_onnx_checker True这里的--opset_version 11是个关键参数。ONNX 的算子集版本决定了模型能用哪些算子版本太低可能不支持某些操作版本太高又可能导致运行时兼容性问题。实测下来 opset 11 对 PaddleOCR 的模型支持最好ONNX Runtime 1.16 也能完美加载。模型文件可以从 PaddleOCR 的官方模型库下载也可以在你第一次运行 PaddleOCR 的时候自动下载到本地缓存目录。缓存目录一般在~/.paddleocr/下面找到对应的inference文件夹就行。2.3 导出后的模型验证不能省导出完成之后千万别直接拿去部署一定要先验证一下 ONNX 模型能不能正常推理结果跟原生 PaddleOCR 是否一致。验证的方法很简单用 ONNX Runtime 加载模型跑一张测试图片对比输出。import onnxruntime as ort import numpy as np import cv2 # 加载检测模型 det_session ort.InferenceSession(det_model.onnx) det_input_name det_session.get_inputs()[0].name # 准备输入 img cv2.imread(test.jpg) img cv2.resize(img, (960, 960)) img img.astype(np.float32) / 255.0 img img.transpose(2, 0, 1)[np.newaxis, ...] # 推理 det_output det_session.run(None, {det_input_name: img}) print(检测输出形状:, det_output[0].shape)如果输出形状跟预期一致说明模型导出没问题。如果报错或者输出形状不对大概率是 opset 版本或者输入尺寸的问题。识别模型的验证类似但要注意识别模型的输入是裁剪后的文字区域图像高度固定为 48 像素宽度按比例缩放。提示验证的时候最好用同一张图片分别跑 PaddleOCR 原生推理和 ONNX 推理对比识别结果。如果文字内容完全一致说明转换是无损的。如果有个别字符差异可能是浮点精度的问题一般不影响使用。3. ONNX 推理代码的完整实现3.1 检测模型的前处理与后处理ONNX 模型只负责计算前处理和后处理都得自己写。检测模型的前处理包括缩放图片到模型要求的尺寸通常是 32 的倍数、归一化、维度转换。后处理则是把模型输出的概率图转换成实际的文本框这一步比较复杂涉及到阈值筛选、连通域分析、文本框扩张等操作。先看前处理def preprocess_det(image, limit_side_len960): h, w image.shape[:2] ratio 1.0 if max(h, w) limit_side_len: ratio limit_side_len / max(h, w) resize_h int(h * ratio) resize_w int(w * ratio) # 确保是 32 的倍数 resize_h max(int(round(resize_h / 32) * 32), 32) resize_w max(int(round(resize_w / 32) * 32), 32) resized cv2.resize(image, (resize_w, resize_h)) # 归一化 img resized.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406]) std np.array([0.229, 0.224, 0.225]) img (img - mean) / std img img.transpose(2, 0, 1)[np.newaxis, ...].astype(np.float32) return img, ratio后处理的核心是把概率图二值化然后找连通区域再根据连通区域拟合出矩形框。PaddleOCR 用的是 DBDifferentiable Binarization算法后处理逻辑在paddleocr的源码里有参考实现但直接搬过来会比较重。我一般用 OpenCV 的findContours做一个简化版def postprocess_det(output, ratio, thresh0.3, box_thresh0.5): prob_map output[0, 0, :, :] binary (prob_map thresh).astype(np.uint8) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for cnt in contours: if cv2.contourArea(cnt) 10: continue rect cv2.minAreaRect(cnt) box cv2.boxPoints(rect) box box / ratio boxes.append(box.astype(np.int32)) return boxes这个简化版在大多数场景下够用但如果图片里的文字排列很密集或者有倾斜文字可能不如官方后处理准确。如果对精度要求高建议把 PaddleOCR 的后处理代码抽出来单独用。3.2 识别模型的动态宽度处理识别模型比检测模型麻烦一点因为文字区域的宽度是不固定的。PaddleOCR 的识别模型支持动态宽度输入但导出 ONNX 的时候如果没处理好可能会把宽度固定死。导出时可以通过设置动态轴来解决paddle2onnx --model_dir ./ch_PP-OCRv4_rec_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./rec_model.onnx \ --opset_version 11 \ --input_shape_dict {x: [1, 3, 48, -1]}这里的-1表示宽度是动态的。导出之后ONNX 模型的输入形状会显示为[1, 3, 48, width]推理的时候可以传入任意宽度的图像。识别的前处理是把每个检测框里的图像裁剪出来缩放到高度 48宽度按比例缩放然后归一化def preprocess_rec(image, box): # 裁剪 x_min int(box[:, 0].min()) x_max int(box[:, 0].max()) y_min int(box[:, 1].min()) y_max int(box[:, 1].max()) crop image[y_min:y_max, x_min:x_max] # 缩放到高度 48 h, w crop.shape[:2] ratio 48.0 / h new_w int(w * ratio) resized cv2.resize(crop, (new_w, 48)) # 归一化 img resized.astype(np.float32) / 255.0 img (img - 0.5) / 0.5 img img.transpose(2, 0, 1)[np.newaxis, ...].astype(np.float32) return img识别的后处理是把模型输出的概率序列解码成文字。PaddleOCR 的识别模型输出的是每个时间步的字符概率需要用 CTC 解码。CTC 解码的逻辑不复杂就是取每个时间步概率最大的字符然后去掉重复字符和空白符def ctc_decode(output, char_dict): indices np.argmax(output, axis2)[0] result [] prev -1 for idx in indices: if idx ! prev and idx ! 0: result.append(char_dict.get(idx, )) prev idx return .join(result)字符字典可以从 PaddleOCR 的模型目录里找到一般是一个ppocr_keys_v1.txt文件每行一个字符行号就是索引。3.3 把检测和识别串起来的完整推理函数把上面的步骤串起来就是一个完整的 OCR 推理流程class ONNXPaddleOCR: def __init__(self, det_path, rec_path, keys_path): self.det_session ort.InferenceSession(det_path) self.rec_session ort.InferenceSession(rec_path) self.char_dict self.load_keys(keys_path) def load_keys(self, path): with open(path, r, encodingutf-8) as f: keys [line.strip() for line in f.readlines()] return {i 1: k for i, k in enumerate(keys)} def __call__(self, image): # 检测 det_input, ratio preprocess_det(image) det_output self.det_session.run(None, {self.det_session.get_inputs()[0].name: det_input}) boxes postprocess_det(det_output[0], ratio) # 识别 results [] for box in boxes: rec_input preprocess_rec(image, box) rec_output self.rec_session.run(None, {self.rec_session.get_inputs()[0].name: rec_input}) text ctc_decode(rec_output[0], self.char_dict) results.append(text) return results这个类可以直接拿去用初始化的时候传入三个文件路径就行。实测下来在 CPU 上单张图片的推理时间大概在 200 到 500 毫秒之间具体取决于图片里文字的数量和长度。4. 量化压缩与性能优化实战4.1 INT8 量化到底能带来多少提升ONNX 模型默认是 FP32 精度权重占 4 个字节。量化成 INT8 之后权重只占 1 个字节模型体积直接缩小到四分之一。推理速度方面CPU 上通常能提升 1.5 到 2 倍GPU 上提升更明显。但量化不是没有代价的精度会有一定损失尤其是对中文这种字符类别多、字形复杂的场景。我实测过 PaddleOCR 的识别模型量化前后的对比指标FP32 模型INT8 量化模型变化模型体积约 10MB约 2.5MB缩小 75%CPU 单张推理时间约 80ms约 45ms提升约 44%识别准确率98.2%97.5%下降 0.7%准确率下降 0.7% 听起来不多但如果你的场景对精度要求极高比如金融票据识别这个损失可能就不能接受了。所以量化之前一定要在自己的测试集上验证一下看看精度损失是否在可接受范围内。4.2 用 ONNX Runtime 做动态量化的步骤ONNX Runtime 提供了量化工具可以做动态量化不需要校准数据集用起来比较方便from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_inputrec_model.onnx, model_outputrec_model_int8.onnx, weight_typeQuantType.QInt8 )动态量化的原理是在推理时动态计算激活值的缩放因子权重的缩放因子则是预先算好的。这种方式对 LSTM 和全连接层的效果比较好对卷积层的效果一般。PaddleOCR 的识别模型里有很多卷积层所以动态量化的加速效果可能没有预期那么明显。如果想要更好的量化效果可以用静态量化但需要准备一批校准数据from onnxruntime.quantization import quantize_static, CalibrationDataReader class OCRCalibrationReader(CalibrationDataReader): def __init__(self, image_paths): self.image_paths image_paths self.index 0 def get_next(self): if self.index len(self.image_paths): return None img cv2.imread(self.image_paths[self.index]) input_data preprocess_rec(img, np.array([[0, 0], [100, 0], [100, 48], [0, 48]])) self.index 1 return {x: input_data} quantize_static( model_inputrec_model.onnx, model_outputrec_model_int8_static.onnx, calibration_data_readerOCRCalibrationReader(calibration_images), quant_formatQuantFormat.QDQ )静态量化的校准数据最好用真实场景的图片数量在 100 到 500 张之间比较合适。太少会导致缩放因子估计不准太多则浪费时间。4.3 推理会话的配置优化ONNX Runtime 的推理会话有很多可配置的参数调好了能明显提升性能。下面这几个参数是我常用的options ort.SessionOptions() options.intra_op_num_threads 4 options.inter_op_num_threads 2 options.graph_optimization_level ort.GraphOptimizationLevel.ORT_ENABLE_ALL options.execution_mode ort.ExecutionMode.ORT_SEQUENTIAL session ort.InferenceSession(model.onnx, options, providers[CPUExecutionProvider])intra_op_num_threads控制单个算子内部的计算线程数inter_op_num_threads控制算子之间的并行度。这两个值不是越大越好一般设置成 CPU 物理核心数比较合适。graph_optimization_level设为ORT_ENABLE_ALL会启用所有图优化包括算子融合、常量折叠等对性能有正面影响。如果服务器有 GPU可以把 provider 换成CUDAExecutionProvider但要注意 CUDA 版本和 ONNX Runtime 版本的匹配。GPU 推理的延迟通常比 CPU 低一个数量级但显存占用和初始化时间会增加。注意在多线程环境下使用同一个 InferenceSession 是线程安全的ONNX Runtime 内部做了并发控制。但如果你自己管理 session 池要注意每个 session 都会占用一份模型内存不要创建太多。5. 跨语言部署的注意事项5.1 Java 环境下的 ONNX Runtime 集成Java 用 ONNX Runtime 需要引入 Maven 依赖dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version1.16.3/version /dependencyJava 的 API 跟 Python 略有不同但核心逻辑是一样的。创建 session、构造输入张量、执行推理、解析输出OrtEnvironment env OrtEnvironment.getEnvironment(); OrtSession.SessionOptions opts new OrtSession.SessionOptions(); OrtSession session env.createSession(rec_model.onnx, opts); float[] inputData preprocess(image); long[] shape {1, 3, 48, width}; OnnxTensor inputTensor OnnxTensor.createTensor(env, FloatBuffer.wrap(inputData), shape); OrtSession.Result result session.run(Collections.singletonMap(x, inputTensor)); float[][][] output (float[][][]) result.get(0).getValue();Java 这边最容易踩的坑是内存管理。ONNX Runtime 的 Java API 底层是 JNI 调用创建的 Tensor 和 Result 对象需要手动关闭否则会导致内存泄漏。建议用 try-with-resources 或者显式调用close()。另一个坑是输入数据的布局。Python 里 numpy 的 transpose 操作在 Java 里需要手动实现要特别注意维度顺序是 NCHW 还是 NHWC。我见过有人因为维度搞反了推理结果全是乱码。5.2 C 部署的编译与链接C 部署 ONNX Runtime 需要下载预编译的库文件然后在 CMake 里配置链接find_package(onnxruntime REQUIRED) target_link_libraries(your_app onnxruntime::onnxruntime)C 的性能是最好的但编译配置比较繁琐。Windows 上要注意运行时库的匹配MT 还是 MDLinux 上要注意 glibc 版本。如果目标部署环境的 glibc 版本比较旧建议在低版本系统上编译或者用静态链接。C 的推理代码跟 Java 类似核心是Ort::Session和Ort::Value的使用。输入张量的创建需要自己管理内存建议用std::vectorfloat来存储数据然后传给Ort::Value::CreateTensor。5.3 移动端和嵌入式设备的适配在安卓上ONNX Runtime 提供了 Android 的 AAR 包可以直接集成到 Android Studio 项目里。但安卓设备的算力有限建议用 INT8 量化模型并且把输入图片的分辨率控制一下不要太大。树莓派这类设备上ONNX Runtime 的 ARM 版本可以正常跑但要注意散热和功耗。我实测过树莓派 4B 跑 PaddleOCR 的 ONNX 模型单张图片大概 1 到 2 秒做低频的 OCR 任务够用高频的话还是得上算力更强的设备。嵌入式设备上还有一个常见问题是内存。ONNX Runtime 加载模型后会占用一部分内存推理过程中还会分配临时缓冲区。如果设备内存紧张可以通过SessionOptions限制内存使用或者把模型拆成检测和识别两个进程分别加载。6. 实际部署中踩过的坑与排查思路6.1 识别结果乱码的几种可能原因乱码是 PaddleOCR 转 ONNX 之后最常见的问题原因可能有以下几种第一种是字符字典不对。PaddleOCR 的识别模型输出的是字符索引需要跟字典文件对应。如果字典文件的编码不对或者行号跟训练时不一致解码出来的就是乱码。排查方法是打印几个索引值看看对应的字符是不是预期的。第二种是输入图像的归一化参数不对。检测模型和识别模型的归一化参数是不一样的检测模型用的是 ImageNet 的均值和方差识别模型用的是 0.5 和 0.5。如果搞混了模型输出会完全乱掉。第三种是 CTC 解码的逻辑有问题。CTC 解码要去掉重复字符和空白符如果空白符的索引搞错了或者去重逻辑写反了结果就会乱。PaddleOCR 的空白符索引通常是 0但不同版本的模型可能不一样最好确认一下。第四种是模型的输出维度理解错了。识别模型的输出形状是[batch, time_steps, num_classes]如果把它当成[batch, num_classes, time_steps]来处理解码结果肯定不对。6.2 检测框偏移或漏检的调试方法检测框偏移通常是因为前处理和后处理的缩放比例不一致。前处理的时候把图片缩放了后处理的时候要把坐标还原回去。如果还原的时候用错了比例框就会偏。漏检的原因可能是阈值设得太高。检测模型的输出是概率图概率大于阈值的像素才被认为是文字区域。如果阈值设成 0.5 甚至更高一些模糊的文字就会被漏掉。我一般把阈值设在 0.3 左右然后在后处理里用框的置信度再做一次筛选。还有一种情况是图片的对比度太低文字跟背景的区分度不够。这种情况下可以在前处理里加一个直方图均衡化增强对比度之后再送给模型。6.3 性能不达预期的优化方向如果推理速度比预期慢可以从以下几个方向排查先看是不是用了 FP32 模型。量化成 INT8 之后速度会有明显提升尤其是 CPU 上。如果还没量化先做量化试试。再看线程配置是否合理。intra_op_num_threads设得太小会导致并行度不够设得太大又会导致线程切换开销。建议从 CPU 核心数开始调逐步找到最优值。然后看输入图片的尺寸。检测模型的输入尺寸越大推理越慢。如果图片本身不大没必要把输入尺寸设成 960设成 640 甚至 480 都能用速度会快很多。最后看是不是每次都重新创建了 session。创建 session 的开销很大应该在整个应用生命周期内复用同一个 session。如果每次推理都新建 session性能会差很多。6.4 模型版本升级后的兼容性检查PaddleOCR 的模型版本更新比较频繁从 PP-OCRv3 到 v4 再到 v5模型结构都有变化。升级模型之后导出 ONNX 的脚本可能需要调整后处理逻辑也可能需要改。我的做法是维护一个版本对照表记录每个模型版本对应的导出参数、输入尺寸、归一化参数、字典文件。升级的时候先在小规模测试集上验证确认精度和速度都符合预期之后再全量替换。另外ONNX 模型的 opset 版本也要注意。新模型可能用到了更高版本的算子如果 opset 设得太低导出会失败。遇到这种情况把 opset 提高到 12 或 13 试试但要注意运行时的兼容性。7. 一些让部署更顺滑的实用建议模型文件的管理建议用版本号命名比如det_v4_20240101.onnx这样回滚的时候不会搞混。配置文件里把模型路径、字典路径、阈值参数都抽出来不要硬编码在代码里。如果是在容器里部署建议把 ONNX Runtime 的库文件和模型文件放在同一个镜像层里减少镜像体积。CPU 版本的 ONNX Runtime 镜像可以做到 200MB 以内比 PaddlePaddle 的镜像小很多。日志方面建议把每次推理的耗时、检测到的文本框数量、识别出的文字长度都记录下来。这些数据在排查问题和做性能优化的时候很有用。最后如果你的场景对精度要求特别高可以考虑用多个模型做集成比如同时跑 PP-OCRv4 和 PP-OCRv5 的识别模型对结果做投票。这样精度会有所提升但推理时间也会翻倍需要根据实际情况权衡。我在实际项目里用这套方案部署过好几个 OCR 服务从 Java 后台到安卓端都有整体稳定性比直接依赖 PaddlePaddle 好很多。唯一需要注意的是量化模型的精度验证这一步不能省否则上线之后发现识别率下降就麻烦了。