基于PP-OCRv6的ONNX中文OCR部署实践

📅 发布时间:2026/10/3 4:39:56
基于PP-OCRv6的ONNX中文OCR部署实践
简介这是一份基于PP-OCRv6的ONNX模型实现的图片文字检测与识别Python源码包面向需要在离线或本地环境下快速集成OCR能力的开发者尤其适合已有一定Python基础、希望直接调用推理接口而不必深入PaddlePaddle训练流程的用户。资源内置文字检测与识别两个tiny版ONNX模型配合清晰的Python脚本可直接完成中文、英文、手写体、竖排等常见场景的文本抽取同时附带benchmark与校验脚本既能评估模型推理性能也能对比ONNX Runtime与PaddleX的识别结果方便排查环境与精度差异。整个压缩包共25个文件包含Python源码、ONNX模型、测试图片、Markdown说明文档以及YAML配置等整体仅8.97MB轻量易部署代码要求Python 3.10及以上、onnxruntime 1.23.2及以上。目前已有156人学习浏览适合快速搭建OCR推理Demo也可作为二次开发的基线工程利用自带测试图片与说明文档快速上手。1. 一套开箱即跑的 PP-OCRv6 ONNX 中文 OCR 方案到底解决了什么你把一张带文字的照片丢给电脑希望在 200ms 内拿到每行文字的内容和坐标这就是图片文字检测识别要干的事。标题里这套基于 PP-OCRv6 的 ONNX 模型搭配 Python 源码是我这几年在 OCR 落地里最常用的一种交付形态不用装 PaddlePaddle 全家桶只要一个 onnxruntime 就能跑Windows、Linux、ARM 板子都能用非常适合做私有化部署和嵌入式原型。适合谁读被项目要求“离线识别身份证、票据、屏幕截图”的工程师想绕开百度云 OCR 收费接口的独立开发者以及刚接触 OCR 但不想看一堆论文、只想先把模型跑起来的 Python 选手。这套东西能解决的核心需求就一句话用 ONNX 把 PP-OCRv6 的检测和识别能力搬到自己的 Python 进程里而且每一步都能自己调、自己改。2. 为什么是 PP-OCRv6 ONNX模型结构和落地的三条硬道理2.1 PP-OCRv6 的检测-识别两段式结构先粗后细PP-OCRv6 延续了 PP-OCR 家族的两段式架构检测Detection 识别Recognition。检测模型本质是一个基于 DBNetDifferentiable Binarization的像素分割网络输入一张图输出每个像素是否为文字的概率图再通过可微二值化和膨胀恢复出多边形文本框。识别模型则是经典的 CRNN 结构卷积层提特征双向 LSTM 建模序列最后接 CTC 解码输出字符序列。两段分开的好处是任意一张图先定位“在哪有字”再裁剪出来逐块识别。这也决定了 ONNX 导出的时候要拆成两个文件一个 det.onnx一个 rec.onnx而不是一个端到端的大模型。日常做图片文字检测识别的时候这个结构最大的坑在于检测是全局的识别是局部的。如果检测框偏了识别再好也白搭。所以落地时要把检测模型当作主角花最多时间调它的阈值和后处理参数识别模型只要保证字典对齐基本就能稳定输出。标题里的源码包通常就是把这两个 onnx 模型和 Python 推理脚本打包在一起让你不用自己从 PaddleOCR 导出。2.2 ONNX 模型从哪来Paddle 推理模型转 ONNX 的常见路径虽然标题直接给了 onnx 模型但你总有一天要自己导出。常见做法是先训练或下载 PP-OCRv6 权重然后用 PaddleOCR 提供的 export 脚本导出推理模型再用 paddle2onnx 工具把 inference model 转成 onnx。流程大概是# 1. 克隆 PaddleOCR 并安装依赖 git clone https://github.com/PaddlePaddle/PaddleOCR.git cd PaddleOCR pip install -r requirements.txt # 2. 下载 PP-OCRv6 中文检测和识别权重 # 假设你已经放在 pretrain 目录下 # 3. 导出检测推理模型 python tools/export_model.py \ -c configs/det/ch_PP-OCRv6_det_cml.yml \ -o Global.pretrained_model./pretrain/ch_PP-OCRv6_det_cml \ Global.save_inference_dir./inference/det # 4. 导出识别推理模型 python tools/export_model.py \ -c configs/rec/PP-OCRv6/ch_PP-OCRv6_rec.yml \ -o Global.pretrained_model./pretrain/ch_PP-OCRv6_rec \ Global.save_inference_dir./inference/rec # 5. 转 ONNX paddle2onnx \ --model_dir ./inference/det \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./models/det.onnx \ --opset_version 11 paddle2onnx \ --model_dir ./inference/rec \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./models/rec.onnx \ --opset_version 11这段命令的关键在--opset_version。ONNX Runtime 对 opset 11 以上支持比较稳如果导出时报某些算子不支持先改成 12 或 13 试试反之如果部署端是老旧设备比如用 onnxruntime 1.4那别超过 12。save_inference_dir导出的东西包含 pdmodel、pdiparams这是 Paddle 的格式paddle2onnx 会把它们转换成一个独立的 onnx 文件。转换完后建议用onnx.checker.check_model验一下很多“第一版跑不通”的问题都出在导出这一步。2.3 选 ONNX 而不是 Paddle Inference 的三条落地理由第一个理由摆脱 Python 版本绑架。Paddle Inference 或者 PaddleOCR 的推理依赖 PaddlePaddle装 CPU 版还好装 GPU 版时 CUDA、cuDNN、Paddle 三者的版本经常互相打架每次换机器都要重来一遍。ONNX 只需要pip install onnxruntime同一个 onnx 文件在 x86、ARM、Windows 上都能跑模型文件本身不绑定任何 Python 包。第二个理由更容易嵌入到已有服务。如果团队服务端是 Java 或者 GoPaddle 的跨语言推理接口不够顺手而 ONNX 有各种语言的 Runtime SDKC/C#/Java 都能直接加载。第三个理由量化与硬件加速路径更宽。onnx 文件可以很方便地转成 ncnn、rknn 等边缘端格式也可以直接用 onnxruntime 做 int8 量化这对后续把模型塞进手机或开发板是刚需。Paddle 的部署工具链虽然也在补但 ONNX 的生态明显更通用。3. 用 Python 把 ONNX 模型跑起来从预处理到 CTC 解码的完整脚本3.1 最小依赖与模型文件摆放这套方案不需要 PaddlePaddle只需要四个包onnxruntime、numpy、opencv-python、pyclipper。pyclipper用来做检测框的膨胀shapely也常被用到但很多轮子版本对 Python 3.10 不友好我一般用pyclipper的PyclipperOffset代替 shapely 的简化。如果你拿到的是标题那个 .7z 包解压后常见的文件组织方式是ocr_onnx/ ├── models/ │ ├── det.onnx │ └── rec.onnx ├── ppocr_keys_v1.txt └── ocr.pyppocr_keys_v1.txt是识别字典每行一个字符包含中文、英文、数字和标点。如果你的包里的字典文件名不一样一定要打开看内容是不是按行分割的字符表如果少了这一行后面的 CTRL 解码完全对不上。默认字典大概有 6623 个字符这个数字会在解码时用到。3.2 检测模型预处理与后处理拿到文本框坐标检测模型的输入是一个[1, 3, 736, 1216]的归一化图像输出是[1, 1, 736, 1216]的概率图。常见预处理是等比例缩放图片直至最长边不超过 960很多包默认是 960然后补零到 32 的倍数再除以 255 并做一个(mean0.485, std0.229)的归一化。这里有个血泪经验很多初学者只做了img / 255忘了减均值除方差结果概率图全糊一个框都检测不出来。下面是一个最小可用的检测预处理函数import cv2 import numpy as np def det_preprocess(img, target_size960, stride32): h, w img.shape[:2] # 等比例限制最长边 scale min(target_size / max(h, w), 1.0) new_w int(w * scale / stride) * stride new_h int(h * scale / stride) * stride resized cv2.resize(img, (new_w, new_h)) # 归一化 img_norm resized.astype(np.float32) / 255.0 img_norm (img_norm - np.array([0.485, 0.456, 0.406])) / \ np.array([0.229, 0.224, 0.225]) img_norm img_norm.transpose(2, 0, 1) img_norm np.expand_dims(img_norm, axis0).astype(np.float32) return img_norm, scale注意这里scale要保留下来后处理把检测框映射回原图时要乘回去。检测后处理的核心是对概率图做阈值二值化找到连通域然后用cv2.minAreaRect求最小外接矩形再把四个顶点用pyclipper向外扩张unclip_ratio倍。常见的后处理代码片段import pyclipper def boxes_from_prob(prob_map, img_shape, scale, thresh0.3, unclip_ratio1.6): h, w prob_map.shape binary (prob_map thresh).astype(np.uint8) * 255 contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for cnt in contours: area cv2.contourArea(cnt) if area 4: continue rect cv2.minAreaRect(cnt) box np.int0(cv2.boxPoints(rect)) # 向外扩张 offset pyclipper.PyclipperOffset() offset.AddPath(box, pyclipper.JT_MITER, pyclipper.ET_CLOSEDPOLYGON) expanded offset.Execute(pyclipper.PyclipperScalar(unclip_ratio)) if not expanded: continue # 坐标映射回原图 pts np.array(expanded[0], dtypenp.float32) pts[:, 0] / scale pts[:, 1] / scale # 转成矩形并排序 boxes.append(pts.astype(np.int32)) return boxes这段代码里thresh就是概率图的置信度阈值unclip_ratio控制检测框向外膨胀的程度。对于印刷体1.5到1.8之间比较稳对于歪斜严重的场景可以适当调大到2.0。area 4的过滤是为了剔除噪声点如果你发现小符号漏检把这个值调成 1。3.3 识别模型预处理与 CTC 解码把裁剪图变成字符串识别模型的输入是[1, 3, 48, 320]的归一化图像输出是[1, 40, 字典长度]的概率序列。预处理相对简单把从检测框裁剪出的文字行图片缩放到高度 48宽度等比例但不超过 320如果不足 320 则补零。注意这里不能用cv2.resize直接拉伸否则长宽比失真会让识别率崩掉。裁剪图像时还要做一个仿射变换把倾斜的文本框拉正这在 PaddleOCR 里叫get_rotate_crop_image。我这里给一个简化版本def rec_preprocess(crop_img, rec_h48, max_wh_ratio20.0, char_len6623): h, w crop_img.shape[:2] # 高度固定宽度按比例缩放 ratio w / h if ratio max_wh_ratio: ratio max_wh_ratio new_w int(rec_h * ratio) resized cv2.resize(crop_img, (new_w, rec_h), interpolationcv2.INTER_LINEAR) # 补零到统一宽度 320 target_w min(320, max(new_w, 8)) padded np.zeros((rec_h, target_w, 3), dtypenp.float32) padded[:, :new_w, :] resized / 255.0 padded (padded - np.array([0.485, 0.456, 0.406])) / \ np.array([0.229, 0.224, 0.225]) padded padded.transpose(2, 0, 1) padded np.expand_dims(padded, axis0).astype(np.float32) return paddedCTC 解码是识别里最容易翻车的一环。模型输出每个时间步对每个字符的概率你需要用贪心或 beam search 取每步最大值然后合并重复字符再用字典去掉blank一般对应索引len(char_list) - 1。一个简单可靠的解码函数def ctc_decode(rec_probs, char_list): # rec_probs: (seq_len, char_num) seq_len rec_probs.shape[0] preds np.argmax(rec_probs, axis1) # 合并重复 chars [] for i, idx in enumerate(preds): if i 0 and idx preds[i - 1]: continue if idx len(char_list): continue chars.append(idx) # 去掉 blank 字符通常最后一个字符 result [] for idx in chars: if idx len(char_list) - 1: continue result.append(char_list[idx]) return .join(result)这个解码假设 blank 是字典最后一个字符如果你用的字典里 blank 不在末尾需要改成if char_list[idx] blank之类的判断。实际项目里很多人的解码结果全是乱码都是因为 blank 判断写错了。3.4 完整串联检测 识别一次性搞定把上面的函数拼起来就是一个最小可用的 OCR 类。我习惯先加载模型再用一个__call__方法完成全流程import onnxruntime as ort class PaddleOCRv6ONNX: def __init__(self, det_path, rec_path, dict_path): self.det_session ort.InferenceSession(det_path, providers[CPUExecutionProvider]) self.rec_session ort.InferenceSession(rec_path, providers[CPUExecutionProvider]) with open(dict_path, r, encodingutf-8) as f: self.char_list [line.strip() for line in f.readlines()] self.char_list.append( ) # 获取输入输出名 self.det_input_name self.det_session.get_inputs()[0].name self.rec_input_name self.rec_session.get_inputs()[0].name def __call__(self, img): det_input, scale det_preprocess(img) prob self.det_session.run(None, {self.det_input_name: det_input})[0][0, 0, :, :] boxes boxes_from_prob(prob, img.shape, scale) results [] for box in boxes: # 裁剪并拉正 crop four_point_transform(img, box) rec_input rec_preprocess(crop) rec_probs self.rec_session.run(None, {self.rec_input_name: rec_input})[0][0] text ctc_decode(rec_probs, self.char_list) results.append({box: box.tolist(), text: text}) return results这段代码里的four_point_transform是透视变换可以用cv2.getPerspectiveTransform实现作用是把任意四边形转成水平矩形。别偷懒直接按最小外接矩形裁剪那样倾斜文字会被裁掉一截。整个流程跑通后你对“检测识别”这件事就有了手感先处理检测框再逐块缩放识别最后解码。这也是所有 PP-OCR 系模型 onnx 部署的通用骨架。4. 参数怎么调四个必动参数与一套可视化验证方法4.1 det_thresh检测置信度阈值低了出框多高了漏字这个参数直接决定每个像素被判为“文字”的标准。默认0.3对清晰印刷体一般没问题。如果你发现市面上的截图文字明明很清楚却检测出一堆乱串的小框或者文字被切成了好几块先别急着改代码把thresh调到0.4到0.5试试。反过来如果图片模糊阈值太高会导致概率图没有一个像素能超过阈值最后一张图一个文字框都找不到这时候调低到0.2甚至0.15。我有个习惯第一次跑新图集先做一个thresh扫描从 0.1 到 0.6 每隔 0.05 出一张可视化结果图肉眼挑一张最稳的。这个参数是整个 OCR 效果最敏感的旋钮值得花半小时专门测。4.2 unclip_ratio检测框外扩系数影响识别裁剪能不能包全unclip_ratio控制检测框向外膨胀的倍率。小于 1 会收缩大于 1 会扩张。对大多数场景1.5到1.8是甜点区。如果识别结果经常漏掉最后一个字或第一个字比如“中华人民共和国”被识别成“中华人民共国”多半是这个参数太小边框把首尾字符的像素切掉了一部分调大到2.0通常能解决。但如果调得过大两个相邻文本行会黏在一起识别结果变成一行混排的乱串。注意这个参数和thresh一起作用时是乘性关系阈值低检测框区域大外扩大裁剪区域更大。两者要配合调不能只动一个。4.3 识别动态宽度与 max_wh_ratio长文本行别硬压到 320识别模型固定高度48宽度是动态的。源码里通常会设一个最大宽度320超过这个宽度的文本行会强行压缩长文本的字符会被压扁识别率显著下降。比如一张发票上的“开户银行及账号”这一行宽度可能超过 600直接压到 320 就完蛋。常见做法是分两批先按 320 宽度推理如果检测框宽度超过某个阈值就横向切成两段分别识别或者把模型输入宽度调大比如640甚至960。但你手里的 rec.onnx 输入维度是固定形状的话调大宽度需要重新导出模型。所以多数源码包会建议你在检测后按文本框宽度做切分。参数max_wh_ratio在预处理里限制最大长宽比改这个值不能本质解决固定输入宽度的问题只是让过长的行不被暴力拉伸。4.4 batch 与可视化验证真正判断参数好坏的标准是画图调参不画图等于盲人摸象。我每次跑完一张测试图一定会把检测框画回原图并保存绿色框是检测模型输出的原图坐标黄线是裁剪区域。对比绿框与实际文字位置立刻能看出是thresh过高导致漏框还是unclip_ratio过小导致切字。验证代码很简单def visualize(img, results, save_path): vis img.copy() for res in results: pts np.array(res[box], dtypenp.int32) cv2.polylines(vis, [pts], True, (0, 255, 0), 2) cv2.putText(vis, res[text], tuple(res[box][0]), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1) cv2.imwrite(save_path, vis)此外识别置信度不能直接从 raw softmax 里读因为 CTC 解码后的置信度是多个时间步概率的乘积会随文本长度变小。要评价识别好坏不如直接对比字符级准确率对一张已知文字内容的截图统计识别结果错字、漏字、多字数量。这一步很土但比盯着一坨概率数值靠谱得多。如果你要批量验证建议搞一个二十张的测试集每张带 json 标注跑一次出一份指标表这样后面调任何参数都有据可依。5. PP-OCRv6 ONNX 部署避坑指南五条血泪经验5.1 onnxruntime 安装后无法导入报 C 库版本错误现象pip install onnxruntime成功但import onnxruntime直接抛OSError: libgomp.so.1: cannot open shared object file。原因onnxruntime 的 wheel 依赖系统中的libgomp在精简版 Docker 或某些 CentOS 7 上没有这个库。解决先执行apt-get install -y libgomp1或yum install -y libgomp如果是 ARM 板子要装onnxruntime-arm64而不是 x86 的包。另一个常见坑是同时装了onnxruntime和onnxruntime-gpu两个包共享命名空间import 时会随机选一个强烈建议只在虚拟环境里保留一个。5.2 检测框坐标错位画出来完全不在文字上现象检测框能画出来但画在原图上的位置明显偏移有的偏上有的偏下且差距不固定。原因预处理里做 resize 时记录了scale但在后处理时scale被覆盖成了识别阶段的缩放比例或者放大回原图时用了coords * 2之类的魔法数字。解决在所有函数返回时明确把检测阶段的scale传下去更稳妥的做法是记录原始分辨率用box * (orig_w / resize_w)计算。调试办法是打印一张检测框叠加图看偏移方向如果框整体偏右说明 x 坐标缩放算错了如果框位置是对的但尺寸偏小说明 unclip 倍数没生效。5.3 识别结果全是 ├ 这类乱码或者粘着重复字符现象检测正常但 OCR 输出的字符串像乱码或每个字符重复一次比如“你好”变成“你你你好好好”。原因字典文件没有正确加载char_list里每行多读了换行符或者没加 blank 占位导致 CTC 解码时索引错位。解决打开字典文件确认每行只有一个字符读取时用line.strip()然后确认char_list的长度和模型输出维度一致——检查rec_session.run返回的数组形状如果是(seq_len, char_num)那么char_num应该等于len(char_list)如果不一致在char_list末尾补一个空白字符计算索引。还有一种情况模型的输出维度是(1, char_num, seq_len)你按(seq_len, char_num)去解码概率全转置了解码自然错处理方法是先确认模型输出维度再 reshape 成(seq_len, char_num)。5.4 中文识别率低尤其是行内英文字母被吞现象大段中文识别效果不错但遇到夹杂的英文数字比如身份证号、车牌号识别结果缺字符或乱拼。原因PP-OCRv6 的识别模型默认字典包含中英数字但 ONNX 导出时如果输入宽度被固定成 320过长的混合文本会被压缩字符特征糊成一团。解决在检测后处理阶段检查文本框的长宽比当width / height 10时把文本框等分成两段或多段分别送识别。另一个常见因素是预处理里的interpolation对细长文本使用cv2.INTER_CUBIC比cv2.INTER_LINEAR更能保留笔画边缘代价是耗时增加你可以拿一组真实样本对比一下。5.5 int8 量化后模型体积变小识别率直接垮掉现象把 det.onnx 转成 int8 量化后体积从 20MB 降到 6MB但图片检测不出任何框。原因onnxruntime 的 dynamic int8 量化默认使用对称量化如果原模型每个输出层的数值范围差异很大简单量化会让边缘激活值全部溢出。解决要么用 per-channel 量化并提供一个校准数据集来收集激活范围要么只量化 det 模型、保留 rec 为 fp32。我踩过一次这个坑最后方案是 rec 保持 fp32det 用 per-channel 量化精度损失控制在 1% 以内。如果你手里的 .7z 里提供了 int8 版本和 fp32 版本优先用 fp32 跑通整个流程再在真机上测量化模型的精度不要直接上 int8。6. 最后一招把 ONNX 推理加速到能用的三个技巧当你的服务端需要每秒处理几十张截图时单个推理循环无法满足。第一招是动态 batch。单个图片包含多个检测框识别阶段通常有 5 到 20 个裁剪图你可以把它们按宽度降序排列分组填充到同一 batch 里用ort.IOBinding或直接传[batch, 3, 48, 320]的输入。前提是你手里的 rec.onnx 允许动态 batch一般导出的模型都带1维动态轴不确定的话把rec_session.get_inputs()[0].shape打印出来看是否含-1。第二招是换onnxruntime-gpu。把 providers 改成[CUDAExecutionProvider, CPUExecutionProvider]检测和识别的耗时通常能降一半以上。但注意 GPU 版第一次调用会初始化 CUDA context前两张图很慢预热逻辑要写在启动流程里。第三招是我现在最常用的技巧给输入图像做多级缩放。很多图片里文字区域只占画面一角直接整体送进检测模型会浪费算力。先做一次粗略检测把检测框包围盒外围扩展 10% 截取为 ROI然后只对这一块重新跑一次检测与识别既提高了小字识别率又降低了计算量。这个做法在截图类任务里效果明显但千万别用于身份证这类本身体积就很大的场景二次裁剪反而会丢失边界文字。这三招做完CPU 上单图耗时能从 400ms 压到 150ms 左右GPU 上能到 50ms。我的习惯是每次改完参数后固定三张难度不同的图测速度和字准率记到表格里防止某次优化把效果调回去了。这些都是我用过的真东西希望帮到你。本文还有配套的精品资源点击获取