1. 项目全貌与选型思考
1.1 DeploySharp 的定位和它解决的核心问题
DeploySharp 说到底是这么一套东西:把 PaddleOCR 训练好的 PP-OCR v4、v5 模型导出成 ONNX 静态图,然后统一丢给 ONNX Runtime 做推理,对外提供一套稳定、可配置、可观测的 OCR 识别服务或库。
我最初接触到这个方向,是因为手里的一个项目需要把中文卡证识别能力塞进一套基于容器的微服务里。当时最直接的方案是直接装 PaddlePaddle 全家桶,然后调用 PaddleOCR 的原生 Python API。但一上生产就遇到几个很现实的问题:部署包体积太大、CPU 机器上启动慢、Paddle 框架版本和业务代码之间经常因为依赖打架,最关键的是每次模型更新都要重新审视整条依赖链。后来把模型全部转成 ONNX,用 ONNX Runtime 做推理,这些问题被压到了一个比较可控的范围。
这套方案适合谁?如果你正准备做 OCR 能力的产品化封装,或者想把 PP-OCR 模型嵌到资源受限的环境里,再或者你只是想把 OCR 推理做成一个独立的、不依赖庞大框架的服务,那 ONNX Runtime 这条路值得认真看一下。本文默认你对 OCR 有基本概念,但没怎么碰过 ONNX 导出和推理,所以我尽量把每一步说透,把我踩过的坑也一并写出来。
1.2 为什么是 PP-OCR v4/v5 加 ONNX Runtime 这套组合
先解释一下模型侧的选择。PP-OCR 系列是 PaddleOCR 里最成熟的通用中文识别方案,v4 和 v5 在公开集上的表现差距不算离谱,但 v5 在文本检测的准召、长文本识别、多方向文本的稳定性上明显更收敛,模型体积还小了一圈。如果你的项目刚起步,拿 v4 mobile 练手成本最低;如果直接上生产,v5 mobile 会更省心。两个版本的模型结构差异不算特别大,所以一套部署框架完全可以同时兼容它们。
再解释推理侧。ONNX Runtime 的价值不光是多个平台能跑,更重要的是它把模型的执行计划做了深度优化。Paddle 原生推理在 CPU 上也不慢,但 ONNX Runtime 在内存占用和预热时间上通常更友好,而且它天然支持模型量化、FP16 转换、多线程调度这些生产级需求。用生活类比就是:Paddle 原生推理相当于开了一辆自带维修工具的重卡出门,稳妥但行李多;ONNX Runtime 相当于把整车拆成标准的集装箱,换什么头都能拉着跑。
这套组合最明显的优势有三点:模型文件从几百兆压到几十兆,服务启动时间肉眼可见地缩短,以及模型本身变成了一个标准产物,不再和 Paddle 训练框架绑死。后面部署过程中你可能对这点体会更深。
2. 部署前的环境准备与模型转换
2.1 从 PaddleOCR 导出 ONNX 模型的最稳路径
要把 PP-OCR 模型变成 ONNX,推荐链路是 PaddleOCR 的推理模型 -> Paddle2ONNX 转换 -> ONNX Runtime 加载。不要自己去 Paddle 训练模型里找算子手动搭,那样既不靠谱又浪费时间。PaddleOCR 发布页或项目里通常会提供训练好的推理模型包,里面包含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检测、方向分类、识别三套模型都要分别走一遍上面的流程,生成det.onnx、cls.onnx、rec.onnx。在导出时有一个很容易被忽略的细节:--opset_version不是越高越好。opset 版本太高,旧版本的 ONNX Runtime 可能不认;版本太低,有些新算子又表达不了。我实测下来选 11 到 15 之间是兼容性最好的区间,ONNX Runtime 1.15 以上的版本都能跑得动。
转换完成后我建议立刻用onnxruntime做一个最小加载测试,不要等到整条代码写完再去排查模型问题:
import onnxruntime as ort sess = ort.InferenceSession("det.onnx", providers=["CPUExecutionProvider"]) print(sess.get_inputs()[0].name, sess.get_inputs()[0].shape) print(sess.get_outputs()[0].name, sess.get_outputs()[0].shape)如果这一步能正常打出输入输出的名称和 shape,说明模型导出基本没有大问题。最常见的问题通常在后面的动态 shape 和预处理对齐上,别急着高兴。
2.2 导出过程中的关键参数与踩坑记录
转换过程中我踩过的最深的坑是动态 shape 的配置。PP-OCR 的检测模型输入尺寸是可变的,识别模型的高度是固定的(通常是 48),但宽度可以变。如果你不加处理直接用 Paddle2ONNX 转,得到的 ONNX 模型输入维度经常是[-1, 3, -1, -1]这种写法。这种写法在 ONNX Runtime 里能跑,但遇到某些算子或量化场景时会报错,所以建议在转换时直接固定或者约束动态维度。
另外,Paddle 模型里的部分前置算子(比如归一化和缩放)其实也可以留在模型内部,但我个人不建议这么做。原因很简单:ONNX Runtime 的图优化对纯卷积和纯矩阵运算最拿手,把预处理算子塞进图里不仅帮不上忙,有时候为了兼容还会引入多余的 Reshape 和 Transpose,反而拖慢推理。我更推荐的做法是预处理全部放在模型外,用 Python 或者 C++ 代码控制,逻辑更清晰,出了问题也更好排查。
还有一件事是模型输入的归一化参数。PP-OCR 的预处理要求先把图片从 BGR 转到 RGB,再除以 255,然后按mean=[0.485, 0.456, 0.406]、std=[0.229, 0.224, 0.225]标准化。这个顺序不能记反,我见过好几个同学把 RGB 和 BGR 搞反,导致识别结果整片乱掉,但检测框却一切正常,非常迷惑。后面调试的时候如果发现文本检测没问题但识别错误率奇高,第一个要查的就是通道顺序。
# 推荐导出参数组合(稳定兼容) --opset_version 11 --enable_onnx_checker True --input_shape_dict "{'x': [1, 3, -1, -1]}"input_shape_dict并不是所有版本的 Paddle2ONNX 都支持完整动态范围,如果遇到不支持的情况,可以省略这步,但在运行时通过 ONNX Runtime 的动态 shape 能力去适配。总之,导出之前先看清楚你手里的模型是检测还是识别,检测用动态 shape、识别高度固定为 48、宽度动态,这是最务实的方案。
3. 用 ONNX Runtime 串联三段式推理流程
3.1 文本检测后处理:DB 算法阈值到底怎么调
PP-OCR 的检测模型用的是 DB(Differentiable Binarization)算法,输出不是直接的文字框坐标,而是一个和原图大小相关的概率图。拿到这个概率图之后,要经过二值化、膨胀、找轮廓、计算最小外接矩形等一系列操作,才能得到真正的文本框数组。
整个检测后处理的核心阈值有三个:binary_thresh(二值化阈值)、box_thresh(得分过滤阈值)、unclip_ratio(外扩比例)。二值化阈值默认是 0.3,表示概率图上值大于 0.3 的像素被认为是文本区域;得分过滤阈值默认是 0.6,低于这个分值的候选框会被删掉;外扩比例默认 1.5,控制最后的框比实际文本区域外扩多少。
我自己的调参经验是:场景里文字小而密的情况下,把binary_thresh适当降低到 0.25 左右,能减少漏检;场景里文字稀疏但周围干扰多的情况下,把box_thresh提高到 0.7,能减少误检。unclip_ratio不建议低于 1.4,不然靠近边界的文字很容易把笔画切掉;但也不要超过 2.2,不然框会明显比文字大,影响后续识别的裁剪质量。
def decode_det_result(prob_map, ori_img_shape, binary_thresh=0.3, box_thresh=0.6, unclip_ratio=1.5): # 概率图缩放回原图尺寸 ratio_h = ori_img_shape[0] / prob_map.shape[0] ratio_w = ori_img_shape[1] / prob_map.shape[1] bitmap = prob_map > binary_thresh # 找轮廓并得到最小外接矩形 # 这一步用 OpenCV 的 findContours + minAreaRect boxes = [] for contour in contours: box = cv2.boxPoints(cv2.minAreaRect(contour)) box = clip_and_unclip(box, unclip_ratio) score = compute_box_score(contour, prob_map) if score >= box_thresh: boxes.append(box) return boxes上面这段只是示意,真正的生产代码还要处理框的顶点排序,保证每个框按照“左上、右上、右下、左下”的顺序输出,否则后面的裁剪会出问题。框的顶点排序看似小事,实际影响非常大,我建议写一个独立函数专门处理,别偷懒。
3.2 方向分类器在什么场景下必须保留
PP-OCR 的标准流程是 检测 -> 方向分类 -> 识别。方向分类器的作用是判断检测出来每个文本片是不是旋转了 180 度,如果旋转了就转回来,再送去识别。对于大多数横排中文文本,旋转 180 度会让识别结果完全乱套,所以这个分类器在通用场景下必须保留。
分类器的输出是两个类别的概率分布,类别 0 表示正常,类别 1 表示旋转 180 度。判断逻辑很简单:如果类别 1 的概率大于阈值(默认 0.9),就把图片旋转 180 度再识别。阈值为什么设这么高?因为把正常图片误判成旋转并翻转,损失比不翻转更大。宁可漏翻一些真正旋转的图,也不要频繁翻错正常的图。
实际部署时有个优化点:分类器这个模型非常小,推理只要几毫秒,但它对每一张检测框都要跑一次。如果一页纸上有几十个文本框,累计耗时也是笔不小的开销。有两个优化思路:一是把分类器置信度阈值调高,让更多低置信度的图直接走正常路线;二是对恰好为横幅(宽度远大于高度)的文本框跳过分类器,因为这种图旋转 180 度的概率本身极低。
3.3 文本识别后处理:CTCLabelDecode 的细节
识别模型的输出是一个序列概率分布,每个时间步对应一个字符的得分。要把这个输出变成字符串,需要做 CTCLabelDecode,核心步骤是:找到每个时间步上概率最大的类别索引,删除相邻重复的索引,再删除空白符索引,最后映射到字符表。
准备字符表这一步容易出问题。PP-OCR 的中文模型字符表通常是从ppocr_keys_v1.txt这类文件加载的,里面按顺序排列着空格、标点、数字、字母、汉字等几千个字符。你需要保证字符表的顺序和模型训练时完全一致。很多人在这上面栽了跟头:用得是同一个版本号,但字符表从一个旧仓库里拷的,结果识别出来的字比实际内容整体错位。
def ctc_decode(indices, score, char_list, blank_index=0): chars = [] prev = None for idx in indices: if idx != blank_index and idx != prev: chars.append(char_list[idx]) prev = idx return "".join(chars)字符表加载要注意编码问题,中文模型字符表里有空格和特殊符号,读取时建议用utf-8且不忽略空行。另外,模型的输出 shape 一般是[1, sequence_len, num_classes],NumPy 转 Python 列表之前要记得先取argmax,别把整个概率分布当成索引去查字符表。
4. 性能优化与生产级加固
4.1 线程数、执行模式和内存优化的取舍
ONNX Runtime 的性能参数主要靠SessionOptions控制。最常用的三个配置:intra_op_num_threads、execution_mode、graph_optimization_level。
intra_op_num_threads控制每个算子内部的线程数。这个参数不是越大越好,因为线程过多会带来线程切换开销,而且 OCR 流水线里多个阶段是串行的,单阶段开太多线程收益有限。我实测下来,4 到 8 个线程是大多数 CPU 上的甜点区。execution_mode可以设成ORT_SEQUENTIAL或ORT_PARALLEL,在多输入或流水线并行时用并行模式;正常情况下顺序执行反而稳。graph_optimization_level建议直接开ORT_ENABLE_ALL,ONNX Runtime 会自动做算子融合和常量折叠,通常能白赚几个百分点的性能。
so = ort.SessionOptions() so.intra_op_num_threads = 4 so.execution_mode = ort.ExecutionMode.ORT_SEQUENTIAL so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL sess = ort.InferenceSession("rec.onnx", sess_options=so, providers=["CPUExecutionProvider"])还有一个容易被忽略的内存优化点:ONNX Runtime 的输入输出要求在推理前后主动释放。如果你把 ONNX Runtime 包在一个常驻服务里,又频繁调用推理,就要特别小心输入数组的OrtValue是否被及时释放。Python 有 GC 兜底还好,C++ 场景下不主动释放,内存曲线会一路走高,最后被系统杀掉。
4.2 批处理策略与量化手段(FP16、INT8)
批处理是性能优化里最强但最麻烦的手段。PP-OCR 的识别模型支持把多张文本图片拼成一个 batch 同时推理,这样做能显著提升 GPU 或支持向量化的 CPU 利用率。但要注意,拼接 batch 时所有图片需要 padding 到相同的宽度,而空白部分在 CTC 解码时会被忽略,所以视觉上图片变宽对结果一般无影响。
批处理在 CPU 上的收益要看计算资源。比如在一台多核服务器上,4 张一批的识别耗时往往只是单张推理的 1.5 到 2 倍,吞吐量提升可观。但在低配机器上,批处理带来的内存宽带压力可能会让收益不明显。我建议把batch_size做成可配置参数,上线前用真实样本扫一遍,找一个耗时和延时的平衡点。
量化这块,如果目标设备支持 FP16,可以直接把模型转成 FP16 格式,体积减半,速度在 GPU 上提升明显。但要注意 CPU 上 FP16 不一定更快,某些 CPU 架构不擅长半精度运算。INT8 量化是 CPU 上收益最大的方案,但 ONNX Runtime 的 INT8 量化需要准备校准数据,且量化后的精度损失要单独用验证集评估,尤其是中文生僻字多的业务,不要把量化想得太简单。
量化工具和转换流程建议单独写成脚本,不要和推理代码混在一起。我之前把转换逻辑做成一个独立的工具模块,模型产物和代码彻底分离。这样切换模型版本或者重新量化时,不触碰任何业务代码,线上出问题也能快速回滚到旧模型。
4.3 模型热更新与错误兜底设计
OCR 服务跑久了,一定会面临模型更新。生产级做法是不要用代码替换模型文件,而是把模型文件放到一个独立目录,每次更新往新目录写文件,再让服务定期检查目录里的版本号,发现变化就重新创建 ONNX Runtime 的InferenceSession,旧 session 等正在进行的推理完成后再销毁。
这里有一个细节:ONNX Runtime 的InferenceSession不是线程安全的,多个线程同时调用同一个 session 的run方法可能出问题。我用的是 session 池的思路,预先创建多个 session 实例,再通过简单的循环调度把请求分散到不同 session 上。每个 session 内部串行推理,session 之间天然并行,这样既避免了线程安全问题,又实现了多核资源的充分利用。
错误兜底也是生产里必须考虑的一环。OCR 系统里有三类易错情况:检测模型什么都找不到、识别结果为空字符串、识别结果置信度极低。我的处理方法是:检测为空就直接返回“未检测到文本”,不要把空数组硬着头皮送进后续流程;识别为空或低置信度时,返回原始裁剪图给上层做人工兜底,不要静默吞掉错误。OCR 天然不是 100% 准确的,所以让上层系统知道“这个结果不可靠”比强行给一个错误答案重要得多。
5. 常见问题与排查实录
5.1 模型加载失败和 shape 不匹配
模型加载失败是部署初期最频繁的问题。现象五花八门:有的是报Invalid Handle,有的是报Node (Conv) has input size 2 != 3,还有的是直接崩溃。这类问题九成以上出在导出环节,Paddle2ONNX 的版本和 PaddleOCR 的模型版本不匹配。我的经验是:尽量用和 PaddleOCR 同期的 Paddle2ONNX 版本,不要盲目追新。
shape 不匹配的报错通常发生在你传入的输入尺寸和模型期望的输入尺寸不一致时。检测模型还好,因为它本身就是动态的;识别模型如果高度不是 48,或者通道数不是 3,一定会报错。查这个问题的最快方式是回到第 2.1 节里的最小加载测试,把sess.get_inputs()[0].shape打出来看一眼,别靠猜。
另一个容易踩的坑是颜色空间。ONNX 模型内部默认处理的图片是 RGB 顺序,你用 OpenCV 读图得到的是 BGR。直接在推理前做一个cv2.cvtColor(img, cv2.COLOR_BGR2RGB)就能解决。我见过不少同学在这个问题上反复排查好久,最后发现是通道顺序颠倒。
5.2 检测漏文字、识别乱码的定位思路
检测漏文字的排查思路要看漏在哪:如果漏的是整行,优先怀疑检测阈值太高;如果漏的是行中的某些字,优先怀疑预处理分辨率不够;如果漏的是所有文字,检查图片是不是读空了或者缩放过猛。一次性把原因找全很难,所以我会在代码里把中间结果都 dump 出来,概率图、二值化图、候选框都保存成图片,肉眼一看就能定位问题在哪个环节。
识别乱码的定位思路稍微复杂一点。先确认检测出来的框是否紧贴文字,如果框歪了或者带上了背景,乱码不奇怪。再确认预处理有没有按要求做缩放、归一化、通道转换。最后确认字符表顺序。这三个环节都正常后,再怀疑模型本身的问题。我一般会在排查时把识别模型的输入图片保存下来,人工看一下图片是不是清晰的、方向是否正确、有没有压缩失真。图像被过度压缩导致识别错误的情况在实拍证件里特别常见。
有一个让我印象深刻的案例:某次线上识别准确率突然掉了好几个点,查了半天,发现是上游服务在传图时把 JPEG 质量压缩到了 60,图像上出现了明显的块状噪声。把压缩质量调到 85 后,准确率恢复正常。这种问题代码里看不出来,但实际影响非常大。
5.3 我常用的一个小技巧:用随机样本做回归
最后分享一个我自己长期在用的方法。每次调整参数或换模型之后,我都会准备一个固定的小样本集,里面包含几十张不同类型的图:清晰印刷体、模糊票据、倾斜卡片、生僻字文本、纯英文,等等。这些样本会固定成一份清单,跑完 OCR 后把检测框数量、识别文本、整体耗时全部输出到一个日志文件里。
有了这份日志,后续任何一次改动都能对比出是变好了还是变坏了。比如调了binary_thresh之后,检测框数量从 85 变成 83,你就能立刻反应过来是漏了两个框,而不是凭感觉瞎调。这个方法不需要额外搭建复杂的评测平台,用脚本就能实现,但长期坚持下来的价值非常大。
还有一个更细节的操作:在日志里记录每个检测框的得分分布。如果所有框的得分都集中在 0.6 到 0.7 之间,说明模型对当前场景有些吃力,阈值稍微动一下就可能出现大批漏检或误检;如果得分都在 0.9 以上,说明余量充足,阈值怎么调都稳。这个分布比单个阈值更能反映模型的真实状态。
6. 两条额外的部署心得
6.1 关于模型路线的选择
如果你刚开始从零搭 OCR 服务,我的建议是先把 PP-OCRv4 mobile 版本跑通,再考虑是否升级到 v5。v4 mobile 的模型文件小、转换坑少,适合作为第一版上线;v5 的精度和稳定性更好,但导出时有些算子对 opset 版本更敏感。两条路线我都跑过,DeploySharp 里做了一层配置,可以指定当前加载哪个版本的模型目录。这样从 v4 切到 v5 只是改一个配置项,不用动任何业务代码。
6.2 上线前一定要做的三件事
第一,把超时控制加上。OCR 推理虽然大多数时候很快,但在极端大图和低配 CPU 上可能跑出几秒甚至更久。没有超时控制的服务,迟早会被一张大图拖垮。第二,把输入图片做一次尺寸上限限制,比如最长的边不超过 2000 像素。超出之后先等比缩放,不然检测模型在处理超大图时不仅慢,而且小文字容易漏。第三,把识别结果的置信度字段保留下来,哪怕是粗略的得分。这个字段现在看起来没用,等后面要做质量监控或者结果过滤时,你就知道它有多重要了。