之前好几个项目都要上OCR识别,检测倒是好说,真正让人头疼的是模型从Paddle环境搬到生产环境这事。PP-OCR v4和v5的精度和速度确实能打,但你不可能在每个服务上都装一套完整的Paddle全家桶,更别说还要处理一堆版本依赖。我自己在几个票据识别和单据录入项目里反复折腾过Paddle Inference、ONNX Runtime、TensorRT这几条路线,最后沉淀出来一套叫DeploySharp的轻量部署工具链,专门解决PP-OCR v4/v5从导出到落地的整个过程。这篇文章就把DeploySharp怎么用ONNX Runtime跑PP-OCR v4/v5的完整经验拆开讲清楚,包括模型导出、预处理对齐、后处理恢复、性能调参,适合正在做OCR服务化或者边缘部署的开发者参考。
1. 为什么把PP-OCR v4/v5部署在ONNX Runtime上
1.1 PP-OCR v4/v5到底强在哪里
先别急着写代码,得先搞清楚我们要部署的东西是什么。PP-OCR v4和v5并不是一个单模型,而是一条完整的文字识别管线,通常由三部分组成:文本检测模型负责定位图片里的文字区域,方向分类模型负责判断每个检测出的文本块是否倒置,文本识别模型负责把规整后的文本行图片转成字符串。三者的关系有点像流水线:检测找到大概的位置,分类把方向纠正过来,识别最后出结果。
v4版本用的是DBNet++做检测、SVTR_LCNet做识别,在速度和精度的平衡上已经做得很好。v5版本则是在训练策略、模型结构和推理效率上又优化了一轮,官方给出的数据是端到端推理速度提升22%左右,平均精度也有提升。对做实际项目的开发者来说,v5最大的吸引力不是精度那几个点,而是同样的硬件条件下能跑得更快,真机上部署更划算。
但问题来了,PP-OCR的原始模型是PaddlePaddle框架的产物,你不能让线上服务直接依赖Paddle Inference跑推理,那意味着服务器上得装Paddle、装CUDA对应版本、处理一堆动态库。更麻烦的是团队里其他同事维护的时候,光环境同步就能耗掉半天。所以我们才需要一层“转换+封装”,让模型变成通用的ONNX格式,再用ONNX Runtime来推理。
1.2 ONNX Runtime和DeploySharp的定位
ONNX Runtime是微软开源的跨平台推理引擎,相当于一个“模型运行器”。Paddle模型经过转换变成ONNX格式后,就脱离了Paddle环境,任何平台只要能跑ONNX Runtime,就能跑这个模型。DeploySharp则是我在这套思路之上整理出的一层工具链,它的作用不是替代ONNX Runtime,而是把模型转换、预处理、后处理、性能调参这些重复劳动固化下来,让你拿到PP-OCR v4/v5就能快速接进自己的服务。
DeploySharp的核心设计哲学有两条:第一,不修改模型本身,只做推理管线的编排;第二,所有预处理和后处理严格对齐PaddleOCR官方实现,避免“模型能出结果但对不上”这种尴尬情况。
1.3 部署方案选型:Paddle Inference、ONNX Runtime、TensorRT怎么选
很多新手一上来就问“哪个方案最好”,其实得看场景来定。
Paddle Inference的优势是跟Paddle模型原生兼容,算子支持最全,但环境依赖重,部署到别人的服务器上容易翻车。而且如果你跑的是GPU+TensorRT场景,Paddle Inference的配置复杂度也不低。
ONNX Runtime的优势是轻量、跨平台、支持CPU/GPU/TensorRT多种后端,Python和C++都有API,适合做服务化部署或边缘设备部署。缺点是没有Paddle自家那么多模型层面的魔法优化,但PP-OCR这种成熟模型导出成ONNX后算子都很标准,性能差距很小。
TensorRT是英伟达的加速方案,推理速度最快,适合GPU服务端高吞吐场景,但工程复杂度最高,需要显存、需要转引擎、动态shape还得配置profile。
我现在的习惯是:CPU环境直接用ONNX Runtime跑,GPU环境如果QPS要求不高也先用ONNX Runtime的CUDA ExecutionProvider顶住,等确定瓶颈在推理而非IO,再考虑上TensorRT。DeploySharp默认就把ONNX Runtime作为第一优先级,这也符合大部分项目的实际需要。
2. 环境准备与模型导出细节
2.1 前期环境清单
工具链本身不复杂,但版本要对齐,不然会在转换阶段莫名其妙卡住。我用的是一套比较保守的版本组合:
- Python 3.8到3.10之间比较稳,3.11以上某些paddle2onnx版本会有兼容问题
- paddlepaddle 2.5以上,建议2.6
- paddle2onnx 1.0.9以上
- onnxruntime 1.15以上,建议1.17
- opencv-python,负责图像处理
- numpy
安装命令很简单,但要注意从头装paddlepaddle时,如果你不需要训练,装CPU版本的paddlepaddle就够了,几百兆省下来。GPU版本只有在需要验证某些算子导出时才有必要。
2.2 导出检测、方向分类、识别三个模型
从PaddleOCR官网下载推理模型时,会分别拿到三个目录:det模型目录、cls模型目录、rec模型目录。每个目录里都有inference.pdmodel和inference.pdiparams两个文件。导出命令的套路是一样的,只是模型目录路径不同。
pip install paddle2onnx paddlepaddle paddle2onnx --model_dir ./models/ch_PP-OCRv5_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/ppocrv5_det.onnx \ --opset_version 12 \ --enable_onnx_checker True识别模型和方向分类模型只需要把model_dir和save_file换掉,命令结构完全一样。导出完成后最好用onnx库做一次检查,确认模型结构没问题:
import onnx model = onnx.load("./onnx/ppocrv5_det.onnx") onnx.checker.check_model(model) print(onnx.helper.printable_graph(model.graph))转换时最容易踩的坑是opset版本。太低的opset不支持某些动态shape算子,比如Resize在opset 10之前表现很差;我一般直接定到12,兼容性和性能都够用。如果你发现某一次转换后在ONNX Runtime里报“不支持的算子”,可以试试把opset升到13或更高,但要先确认onnxruntime版本也跟得上。
2.3 动态输入形状到底要不要固定
这是部署PP-OCR系列最纠结的问题,我展开细说。
检测模型的输入是整张图片,图片尺寸不固定,所以检测模型导出时一般保留动态shape,也就是输入的H和W都是-1。这样部署时才能喂任意尺寸的图,后处理再根据缩放比例还原坐标。
方向分类和识别模型的输入则建议固定高度、只保留宽度动态。识别模型的输入常见是高度32,宽度动态;方向分类模型则是固定高度48、宽度192,基本不动态。固定高度之后,ONNX Runtime能对卷积算子做更多优化,推理确实快一点。
如果你想让识别模型固定成[1,3,32,-1]这样的shape,可以在转换命令里加参数。不同版本paddle2onnx的写法不太一样,有的用--input_shape_dict,有的直接传--input_shape。最简单的方式是先不指定任何shape,导出后用ONNX Runtime打印一下输入,看它实际要的是什么:
import onnxruntime as ort sess = ort.InferenceSession("./onnx/ppocrv5_rec.onnx", providers=["CPUExecutionProvider"]) for inp in sess.get_inputs(): print(inp.name, inp.shape)打印结果里的shape如果显示[1,3,32,-1],那说明高度已经固定成32了,直接跑就行。如果显示[-1,3,-1,-1],那就是完全动态,你可以在服务代码里按高度32做resize后再喂进去,效果一样。
2.4 导出后的模型验证不能省
转换完了一定要做一次输出比对,不要直接接到服务上。方法很简单:准备一张带文字的测试图,分别用PaddleOCR原本的推理逻辑和ONNX Runtime推理逻辑跑一遍,对比三个阶段的输出。
我每次升级模型版本都会做这个验证。检测模型对比的是检测框坐标,方向分类模型对比的是分类置信度,识别模型对比的是最终字符串。哪一层对不上,就单独排查那个模型的预处理或后处理。这种“逐层对齐”的排查方式能帮你快速定位问题,省得盲目调参。
3. DeploySharp推理管线设计
3.1 整体架构与模块划分
DeploySharp按功能划分成四个模块:模型加载管理、预处理、推理执行、后处理。模型加载管理负责创建和复用ONNX Runtime会话;预处理负责把图片转成模型需要的张量;推理执行负责调用session.run;后处理负责把模型输出转成最终检测框和字符串。
这样拆分的好处是每个环节都可以单独测试。比如你怀疑识别精度有问题,可以只对一张文本行图片跑预处理和识别模型,输出中间结果,不用每次都要跑完整管线。
DeploySharp在初始化时会把三个模型的路径、执行提供方、线程数、字符表路径都集中到一个配置文件里,切v4和v5只需要改配置,不需要动代码。这一点在项目里价值很大,因为你很可能今天用v4跑通,明天就想着换v5试试效果。
3.2 检测模型预处理:缩放和归一化一个都不能错
检测模型的预处理是整条管线里最容易写错的地方。PaddleOCR官方用于推理的DetResizeForTest,逻辑是先按比例缩放图片,使最长边不超过960像素,然后把宽和高分别调整到32的整数倍,最后做归一化。
def det_preprocess(img, limit_side_len=960): h, w = img.shape[:2] ratio = min(1.0, limit_side_len / max(h, w)) resize_h = int(round(h * ratio / 32) * 32) resize_w = int(round(w * ratio / 32) * 32) resize_h = max(resize_h, 32) resize_w = max(resize_w, 32) img = cv2.resize(img, (resize_w, resize_h)) img = img[:, :, ::-1].astype("float32") / 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) img = (img - mean) / std img = img.transpose(2, 0, 1)[None].astype(np.float32) return img, ratio这里有三个细节必须注意。
第一,检测模型用的是ImageNet的mean和std,也就是0.485、0.456、0.406那一组,不是识别模型那组0.5、0.5、0.5。这两个预处理不一致是正常的,但不能搞混。
第二,这里只做resize,不要额外padding。很多人习惯在resize后补一圈黑边凑整,这在检测上会导致后续坐标全部偏移。PaddleOCR官方逻辑是直接把图片resize到32的整数倍,虽然会有轻微拉伸,但后处理坐标只跟缩放比例有关,反而简单。
第三,通道顺序问题。cv2.imread读进来是BGR,PaddleOCR训练时用的预处理是在BGR图基础上转RGB的,所以你在归一化之前要先把通道翻转一下,也就是第6行的img[:, :, ::-1]。
3.3 检测后处理:DBNet概率图如何还原成文本框
检测模型的输出是一张概率图,形状一般是[1,1,H/4,W/4],里面每个像素点的值代表这个位置是文字中心的概率。后处理的核心步骤是:二值化、找轮廓、计算最小外接矩形、按unclip比例外扩、坐标还原。
def det_postprocess(prob_map, ratio, thresh=0.3): prob = prob_map[0, 0] binary = (prob > thresh).astype(np.uint8) * 255 contours, _ = cv2.findContours(binary, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) boxes = [] for cnt in contours: if cnt.shape[0] < 4: continue area = cv2.contourArea(cnt) if area < 5: continue rect = cv2.minAreaRect(cnt) points = cv2.boxPoints(rect) # 这里可以按unclip系数外扩,比例通常取1.5 # 实现可参考PaddleOCR的DBPostProcess,用pyclipper对外围轮廓做二次拟合 points = points / ratio x, y = points[:, 0], points[:, 1] x = np.clip(x, 0, int(prob.shape[1] * 4)) y = np.clip(y, 0, int(prob.shape[0] * 4)) boxes.append(points.astype(np.float32)) return boxes很多人问unclip到底怎么做。简单理解,DBNet输出的概率图是缩小的,导致还原出来的框往往紧贴文字边缘,直接送去识别会切掉一部分字符笔画,所以要把框往外扩一点。PaddleOCR自己是用pyclipper对四边形做膨胀处理,效果比简单缩放四个顶点更稳。如果你不想引入pyclipper,可以直接对minAreaRect的宽高乘以1.5,再按中心点重新生成四个顶点,大多数场景也能用,只是对倾斜文本的贴合度差一些。
检测框坐标还原时,还有一个容易被忽略的点:输出概率图的宽高是输入图片的四分之一,所以用prob.shape计算原图尺寸时要记得乘4,不然clip边界会算错。
3.4 方向分类:别小看这个二分类模型
方向分类模型的作用很明确:判断裁剪出来的文本块是否需要旋转180度。虽然看起来只是二选一,但在中文识别场景里,倒置文本如果不纠正,识别结果基本就是乱码。
分类模型的预处理和识别模型一样,用0.5、0.5、0.5这组mean/std,输入尺寸固定为高48、宽192。推理输出是一个二维向量,第一维代表0度置信度,第二维代表180度置信度。
def cls_postprocess(cls_output, thresh=0.9): prob_180 = cls_output[0, 1] need_rotate = prob_180 > thresh return need_rotate这里的阈值默认是0.9,也就是说只有模型很有把握图像是倒置的,才会去旋转。这样做是为了避免误旋转,因为一旦把正着的图转180度,识别结果必错。如果你在实际项目里发现漏检倒置情况比较多,可以适当把这个阈值降到0.8,但要注意误转率也会上升。
3.5 识别模型:把文本行图片变成字符串
检测框和方向都处理完之后,需要对每个检测框做透视变换,裁出文本行图片,然后送入识别模型。
识别模型预处理的关键是:固定高度,宽度按比例缩放。比如模型要求高度32,那么一张高为40、宽为200的文本行图片,就要先按比例缩放到高32、宽160左右,然后再归一化。注意这里用的是0.5、0.5、0.5这组mean/std,不是检测那组。
推理输出是一个[1, seq_len, num_classes]的矩阵,需要做CTC解码,也就是greedy decode。最粗的做法是每列取argmax,然后把连续重复的字符合并、跳过blank,最后用字符表映射成真正的字符串。字符表来自官方文件ppocr_keys_v1.txt,里面按行记录所有字符,索引和模型输出的类别一一对应。
我这里给出一个简化版的解码函数:
def rec_postprocess(rec_output, char_list): rec_output = rec_output[0] pred_index = np.argmax(rec_output, axis=1) text = "" last = None for idx in pred_index: if idx == 0: last = idx continue if idx == last: continue text += char_list[idx - 1] last = idx return text这里idx==0对应CTC blank,实际不参与字符映射。字符表索引和模型输出类别之间错位1位,是因为模型输出在开头多了一个blank位置。不同版本导出的模型可能在末尾还会多一个padding类,这点在映射字符时尤其要注意,一旦错位,中文识别结果会出现大量乱码。
3.6 完整管线串起来
把三个模型串起来的时候,流程就是:
- 读图,转RGB,跑检测预处理
- 检测推理,得到概率图
- 检测后处理,拿到候选文本框
- 对每个文本框做透视变换,得到文本行图片
- 文本行图片传入方向分类,如果分类结果是180度,就把图片旋转180度
- 预处理后送入识别模型
- CTC解码得到字符串,按检测框顺序或自定义规则返回结果
这个流程本身不复杂,但要做到工程可用,就不能每次请求都重新创建ONNX Runtime会话,也不应该把每次推理的时间浪费在重复加载模型上。DeploySharp在初始化的时候就把三个session全部建好,之后每次请求只做张量拷贝和推理。
4. ONNX Runtime调参与性能优化
4.1 会话配置和线程数怎么给
ONNX Runtime的SessionOptions直接影响CPU推理性能。最关键的几个参数是线程数、图优化级别和内存复用策略。
import onnxruntime as ort so = ort.SessionOptions() so.intra_op_num_threads = 4 so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL so.enable_mem_pattern = True sess = ort.InferenceSession( "./onnx/ppocrv5_det.onnx", sess_options=so, providers=["CPUExecutionProvider"] )intra_op_num_threads不要盲目设成服务器核数。ONNX Runtime默认线程数是核数,但OCR服务往往是IO密集加推理密集混在一起,线程数设太大反而导致上下文切换开销过高。我自己常用的经验是设成物理核心数的一半左右,压测后再微调。比如8核机器先设4,观察QPS和延迟的拐点。
还有一个容易忽略的配置:optimized_model_filepath。开启后ONNX Runtime首次加载模型时会把图优化结果缓存到文件,下次启动直接加载优化后的模型,能省一到两次第一次推理的预热时间。对服务端常驻进程帮助不大,但对那种冷启动场景很友好。
4.2 CPU、CUDA、TensorRT执行提供方怎么选
ONNX Runtime的providers是一个列表,按顺序匹配可用的后端。比如你写:
providers = ["TensorrtExecutionProvider", "CUDAExecutionProvider", "CPUExecutionProvider"]那么有GPU且有TensorRT环境时就用TensorRT,没有就退到CUDA,再没有就退到CPU。这个设计很灵活,但也容易埋坑:很多机器上CUDA环境没配好,ONNX Runtime悄悄fallback到CPU,你以为是GPU在推理,实际速度慢得要命。所以我建议在初始化时主动打印探测结果:
print(ort.get_available_providers()) print(sess.get_providers())get_providers()返回当前session真正使用的provider列表,第一个就是实际执行后端,一眼就能看出有没有走对。
CUDA ExecutionProvider下,如果想用FP16推理,需要设置provider_options:
options = [ {"trt_fp16_enable": True, "trt_engine_cache_enable": True, "trt_engine_cache_path": "./trt_cache"}, {}, {} ]TensorRT provider对动态shape支持有些限制。检测模型输入完全动态,每次尺寸都可能变,这种情况下TensorRT的重编译开销很大,反而不如CUDA EP。所以DeploySharp默认的做法是检测模型用CUDA EP,识别模型用TensorRT EP,因为识别模型的输入宽度虽然动态,但变化范围比较可控,TensorRT的profile可以覆盖住。
4.3 FP16量化和模型精简的实际收益
FP16量化是GPU上最省事的加速手段。ONNX Runtime里CUDA EP和TensorRT EP都支持FP16,关键是模型里有没有不支持的算子,比如某些动态shape相关的Resize在FP16下可能报错。
我实际测下来,PP-OCR v5的检测和识别模型在TensorRT下开FP16,推理速度大约能提升20%到40%,精度损失在可接受范围内。但要注意,方向分类模型尺寸很小,开FP16的收益几乎可以忽略,有时候反而因为算子转换产生额外开销,所以DeploySharp对cls模型默认不开FP16。
如果连CUDA都没有,纯CPU环境想精简模型,一个思路是转成动态量化过的ONNX模型,但文本识别这类任务对精度比较敏感,动态量化后中文识别率可能掉得比较厉害,我不太推荐。CPU上第一优先级是调线程和图优化,而不是量化。
4.4 并发场景下会话复用的最佳实践
ONNX Runtime的session不是严格线程安全的,但同一个session可以在多个线程里串行执行推理,也可以创建多个session分别处理多个并发请求。这两种方式各有适用场景。
低并发场景,比如QPS不到100,一个复用session就够。高并发场景,可以按CPU核心数创建多个session,每个请求轮询分配一个session,避免锁竞争。这里要注意的是,session创建的代价不低,初始化时一次性创建好,不要每个请求new一个。
GPU场景下,多个session会占用更多显存。一般来说CUDA EP下复用单个session,内部已经通过stream处理并发,多session反而容易触发显存碎片。所以在GPU上,我倾向于一个进程一个session,用请求排队来解决并发。
5. 常见问题与排查技巧实录
5.1 推理结果和PaddleOCR对不上
这是最高频的问题。现象往往是:PaddleOCR直接跑出来的结果是对的,自己的ONNX Runtime代码跑出来的框歪了、识别错了。
第一步先检查预处理,尤其是mean/std和通道顺序。检测、识别、分类三个模型的预处理不一样,上面我已经写清楚了。第二步检查模型输出名的获取方式,不要硬编码字符串,用session.get_outputs()[0].name动态拿。第三步做逐层对齐,把每层输出打印出来和PaddleOCR的中间结果对比,定位是模型转换出了问题还是预处理出了问题。
5.2 中文识别结果乱码
乱码九成是字符表映射错位。ppocr_keys_v1.txt是按行存的字符,每行一个字符,加载时要注意大文件编码和末尾换行符。建议用readlines()后对每个元素strip()一次,再跳过可能存在的空行。另外,模型输出类别数量和字符表长度直接相关,如果发现输出类别数比字符表长度多很多,大概率模型转换时带上了额外的softmax头,需要看下实际输出维度。
还有一种情况,就是把空白字符 当成了有效文本。CTC解码时连续重复字符要合并,blank要跳过,但空格字符是真实字符,不能随便滤掉,要在字典里找到空格对应的索引。
5.3 转换报错或加载失败
paddle2onnx转换报错时,优先确认paddlepaddle和paddle2onnx的版本组合。有些报错信息看着像是模型有问题,实际是paddle2onnx版本太老,不支持新导出模型里的某个算子。升级paddle2onnx到最新版本往往能解决。
加载ONNX时报No such file or directory但文件明明存在,这种情况一般是路径里有中文或者session没有权限读文件,跟模型本身无关。还有个常见原因是ONNX Runtime版本低于模型里某个opset要求,升级runtime即可。
5.4 内存持续上涨
内存上涨最常见的原因是每次请求都重新创建session。如果确认session是复用状态,再看是不是开了一堆没必要的模型副本。还有一种情况是enable_mem_pattern配合动态shape,导致arena内存碎片越积越多。可以尝试把这个选项关掉,或者使用固定shape输入,实测能缓解一部分内存碎片问题。
GPU显存上涨通常是因为CUDA EP的arena默认不释放显存。这不是泄漏,而是ONNX Runtime为了后续推理预先保留了显存空间。如果你接受不了,可以设置arena_extend_strategy为kSameAsRequested,让显存按需申请,减少峰值占用,但运行速度会略降。
5.5 问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| 检测框整体偏移 | 后处理没按缩放比例还原坐标 | 检查ratio是否作用到位 |
| 检测框大量缺失 | 二值化阈值过高或unclip系数太小 | 调低thresh,适当增大unclip |
| 识别全是乱码 | 字符表映射错位 | 打印模型输出维度,对照字符表长度 |
| 识别输出为空白 | 模型输入宽度过小,resize比例异常 | 识别预处理时限制最小宽度 |
| 怎么调都是CPU在跑 | providers配置顺序问题 | 打印session.get_providers()确认 |
| 首次推理特别慢 | 图优化和内存模式初始化 | 用optimized_model_filepath缓存 |
6. 从DeploySharp到项目落地的几条经验
6.1 模型选择要不要追新
如果你的系统已经在用PP-OCR v4,不要急着换v5。v5精度更高速度也快,但部署代码里的一些细节可能不一样,比如识别模型的高度、某些预处理参数。DeploySharp的配置化设计就是为了让你能低风险切换。我建议先备份v4的ONNX文件,再切v5,用同一批验证图片跑A/B对比,确认关键场景没有退化再全量切。
6.2 动态batch值得做吗
ONNX Runtime支持把多个文本行图片合成一个batch送入识别模型,这样能充分利用GPU并行能力。但要注意,OCR场景里每个文本行的宽度不一致,合成batch时需要padding到相同宽度,可能浪费不少计算。我实测发现,batch为2到4时收益最大,超过8反而因为padding浪费太多导致收益下降。
实现方式并不复杂,先把多个文本行图片各自resize到固定高度和各自宽度,再按最大宽度pad成相同尺寸,拼成[N,3,H,W]的张量,一次推理。DeploySharp里默认不开启动态batch,留了开关,需要高吞吐时再打开。
6.3 最后再分享一个调参细节
所有模型参数里,最值得花时间调的是检测后处理的unclip系数和二值化阈值。不同业务场景里的文字密度和清晰度不一样,文档扫描件和手机随手拍的照片,最优参数完全不同。我习惯是先在验证集上跑一组对比,遍历thresh取0.2到0.5、unclip取1.2到2.0,找到哪个组合对当前数据集最友好,再写进配置。这个过程看起来很笨,但比事后反复调整要高效得多。部署这件事,模型是固定的,让结果产生差异的往往就是这些预处理和后处理的细节。