PP-OCRv5 ONNX部署以及手搓方案
1、为啥要ONNX格式部署
把话题拉回到机器视觉领域,据我所知,主流浪潮是使用C#做应用开发语言。ONNX格式自然也就十分的受欢迎。大家不用安装庞大的paddlepaddle框架,也不需要用python写个识别算法。一切都用onnxruntime库搞定,省心省事省力!十分妙哉。
此外还有一些好处,比如配合INT8等量化技术,ONNX模型体积缩小,推理速度提升,而在精度损失极小;如果对跨平台有要求,ONNX也丝毫不逊色,各大操作系统它都支持,硬件上也会有惊喜:在Intel CPU上用OpenVINO加速,在NVIDIA GPU上用TensorRT加速,在高通或瑞芯微的NPU上也能高效运行。
2、来自RapidOCR的快速部署
RapidOCR就是为了解决PaddleOCR的部署困境。它整体框架如下:

使用它,配合onnxruntime库,很少的代码就可以完成识别的工作。下面是一个简单的例子:
from rapidocr_onnxruntime import RapidOCR
from pathlib import Path
det_model = Path("models/PP-OCRv5_server_det_onnx.onnx")
rec_model = Path("models/PP-OCRv5_server_rec_onnx.onnx")
cls_model = Path("models/PP-OCRv5_server_cls_onnx.onnx")
dict_file = Path("models/ppocrv5_dict.txt")
engine = RapidOCR(
Det_model_path=str(det_model),
Rec_model_path=str(rec_model),
Rec_char_dict_path=str(dict_file),
Rec_rec_img_shape=[3, 48, 320],
Cls_model_path=str(cls_model) if cls_model.exists() else None
)
img_path = "../pic002.png"
result, elapse = engine(img_path)
if result:
det_time, cls_time, rec_time = elapse
total_time = sum(elapse)
print(f"检测耗时: {det_time:.2f}ms")
print(f"分类耗时: {cls_time:.2f}ms")
print(f"识别耗时: {rec_time:.2f}ms")
print(f"总耗时: {total_time:.2f}ms")
for line in result:
box = line[0]
text = line[1]
score = float(line[2])
print(f"文字: {text}, 置信度: {score:.4f}")
else:
print("未识别到文字")
在GPU的加持下,识别效果如下:
检测耗时: 0.41ms
分类耗时: 0.08ms
识别耗时: 0.57ms
总耗时: 1.06ms
文字: 五、中文识别, 置信度: 0.8509
文字: 六、高级应用场景, 置信度: 0.8788
文字: 5.1, 置信度: 0.6642
文字: 结构化数据提取, 置信度: 0.7905
可以看到,检测和识别耗时还是非常多的。原生的Paddle框架在GPU条件下,整体识别是在100毫秒内的。同时,它的置信度也不高。这都是推动我去手搓一个ONNX部署方案的原因。
3、手搓一个ONNX部署方案
手搓一个方案,它有几个好处:一是它会强迫让我们理解原理,出了问题能定位到根本原因;二是生产环境往往需要特定的前后处理逻辑,我们在很多场景下要做到按需定制;三是通过手搓,可以去掉不需要的功能,还可以做一些性能优化。
3.1 整体模块设计
之前在讲解OCR系统时提到过,所有OCR系统都可以划分为三个核心模块:文本检测、方向分类和文本识别。如下示意图所示。
┌─────────────────┐
│ 图片输入 │
└────────┬────────┘
↓
┌─────────────────┐
│ 文本检测器 │ ← 找到文字在哪里
│ (TextDetector) │
└────────┬────────┘
↓
┌─────────────────┐
│ 方向分类器 │ ← 判断文字是否颠倒
│ (TextClassifier) │
└────────┬────────┘
↓
┌─────────────────┐
│ 文本识别器 │ ← 把图片变成文字
│ (TextRecognizer) │
└────────┬────────┘
↓
┌─────────────────┐
│ 结构化输出 │
│ [{box, text, score}] │
└─────────────────┘
代码结构大致如下:
class InferenceSessionManager:
"""统一管理ONNX Runtime会话"""
class TextDetector:
"""文本检测器"""
class TextClassifier:
"""方向分类器"""
class TextRecognizer:
"""文本识别器"""
class OCR:
"""OCR主入口"""
3.2 文本检测器
检测器的职责是输入一张图片,输出多个文本框的坐标。PP-OCR的检测模型基于DB(Differentiable Binarization)算法:输出不是直接的文本框,而是一个概率图(每个像素是文字的概率),需要通过后处理把概率图转换成文本框。
3.2.1 预处理设计
预处理的目标是把任意尺寸、任意格式的输入图像,转换成一个符合模型输入要求的标准化张量。
通常会经过图像尺寸处理、颜色空间转换、归一化和标准化等几个步骤。
class TextDetector:
def __init__(self, model_path, use_gpu=True):
self.session = InferenceSessionManager.create_session(model_path, use_gpu=use_gpu)
self.input_name = self.session.get_inputs()[0].name
self.output_names = [o.name for o in self.session.get_outputs()]
def resize_image(self, img, max_side_len=960):
"""保持宽高比,限制最大边长,并对齐到32的倍数"""
h, w = img.shape[:2]
resize_w = w
resize_h = h
# 限制最大边长(控制推理时间)
if max(resize_h, resize_w) > max_side_len:
ratio = float(max_side_len) / max(resize_h, resize_w)
resize_h = int(resize_h * ratio)
resize_w = int(resize_w * ratio)
# 对齐到32的倍数(模型有5次下采样,2^5=32)
resize_h = resize_h if resize_h % 32 == 0 else (resize_h // 32 + 1) * 32
resize_w = resize_w if resize_w % 32 == 0 else (resize_w // 32 + 1) * 32
img = cv2.resize(img, (resize_w, resize_h))
return img, (resize_h / h, resize_w / w)
def preprocess(self, img):
"""图像预处理:转RGB、归一化、标准化"""
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img, ratio = self.resize_image(img)
# 归一化到[0,1]
img = img.astype(np.float32) / 255.0
# 标准化(ImageNet的均值和标准差)
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
# 转换维度:HWC -> CHW,并添加batch维度
img = np.transpose(img, (2, 0, 1))
img = np.expand_dims(img, axis=0)
return img, ratio
3.2.2 后处理设计
后处理的核心任务是把模型的概率图输出(每个像素属于文本区域的概率)转换成真实的文本框坐标,并还原到原始图像尺寸。
设计思路可以看这个流程图:
模型输出 (1, H, W)
↓
【标准化】去除多余维度 → (H, W)
↓
【二值化】preds > 0.3 → mask (0/1掩码)
↓
【连通域标记】ndimage.label → 带标签的掩码
↓
【遍历每个区域】
├─ 获取像素坐标
├─ 过滤小区域(<10像素)
├─ 计算轴对齐矩形框
├─ 过滤小面积(<100像素)
├─ 精确扩张(unclip)
└─ 存储有效框
↓
【排序】按y坐标为主,x坐标为辅
↓
【坐标还原】除以缩放比例 → 原图坐标
↓
输出文本框列表
代码实现:
def postprocess_optimized(self, pred, ratio_w, ratio_h, thresh=0.3, unclip_ratio=1.6):
preds = pred[0]
# 处理不同维度的输出
if len(preds.shape) == 4:
preds = preds[0]
if len(preds.shape) == 3:
preds = preds.squeeze(0)
# 二值化
mask = (preds > thresh).astype(np.uint8)
# 使用连通域分析(C实现,非常快)
labeled_mask, num_features = ndimage.label(mask)
boxes = []
for i in range(1, num_features + 1):
ys, xs = np.where(labeled_mask == i)
# 过滤太小的区域(可能是噪声)
if len(ys) < 10:
continue
# 快速计算最小外接矩形
x_min, x_max = xs.min(), xs.max()
y_min, y_max = ys.min(), ys.max()
# 用轴对齐矩形(比minAreaRect快很多)
# 如需精确旋转框,可以调用cv2.minAreaRect
box = np.array([
[x_min, y_min],
[x_max, y_min],
[x_max, y_max],
[x_min, y_max]
], dtype=np.float32)
# 检查面积
area = (x_max - x_min) * (y_max - y_min)
if area < 100:
continue
# 精确unclip(基于周长扩张)
box_unclip = self.unclip_accurate(box, unclip_ratio)
# 再次检查面积
unclip_area = cv2.contourArea(box_unclip)
if unclip_area < 100:
continue
boxes.append(box_unclip)
# 按y坐标排序(从上到下)
if boxes:
boxes = self.sorted_boxes(boxes)
# 还原到原始图像尺寸
boxes = [[(int(p[0] / ratio_w), int(p[1] / ratio_h)) for p in box] for box in boxes]
return boxes
def unclip_accurate(self, box, unclip_ratio):
"""
精确的unclip实现,基于面积-周长比
原理:DB算法的输出是收缩后的文本框,需要向外扩张恢复原始大小
扩张距离 = unclip_ratio * 面积 / 周长
"""
area = cv2.contourArea(box)
perimeter = cv2.arcLength(box, True)
if perimeter == 0 or area < 1:
return box
d = unclip_ratio * area / perimeter
# 向中心方向扩张
center = np.mean(box, axis=0)
direction = box - center
norm = np.linalg.norm(direction, axis=1, keepdims=True)
norm[norm == 0] = 1
direction = direction / norm
new_box = box + direction * d
return new_box.astype(np.float32)
def sorted_boxes(self, boxes):
"""按y坐标排序,y相近时按x排序"""
if len(boxes) == 0:
return boxes
boxes = sorted(boxes, key=lambda x: (x[0][1], x[0][0]))
return boxes
3.3 方向分类器
识别时对文字的方向是很敏感的,不是正向的文字,识别率非常低。所以才有了方向分类器。
我们的方向分类器的功能包括:判断每个文本框的文字是正常方向还是颠倒的;将颠倒的文本框旋转180度,使其变为正常方向,为后续的文字识别模块提供正向输入。
有方向分类器参与的流程:
原始图片
↓
【检测模型】→ 文本区域坐标
↓
【裁剪】根据坐标裁剪出文本框
↓
【方向分类器】← 这里!
├─ 正常 → 直接送入识别
└─ 颠倒 → 旋转180度后送入识别
↓
【识别模型】识别文字内容
↓
输出识别结果
代码实现:
class TextClassifier:
def __init__(self, model_path, use_gpu=True):
self.session = InferenceSessionManager.create_session(
model_path,
use_gpu=use_gpu,
gpu_mem_limit=1 * 1024 * 1024 * 1024 # 分类器只需1GB
)
self.input_name = self.session.get_inputs()[0].name
self.output_names = [o.name for o in self.session.get_outputs()]
def preprocess(self, img):
"""固定尺寸160x80,分类任务不需要高分辨率"""
img = cv2.resize(img, (160, 80))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
img = img.astype(np.float32) / 255.0
# 使用ImageNet标准化参数
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 = np.transpose(img, (2, 0, 1))
img = np.expand_dims(img, axis=0)
return img
def __call__(self, img_list):
"""对每个裁剪图判断是否需要旋转180度"""
if len(img_list) == 0:
return img_list, [], []
results = []
for img in img_list:
img_input = self.preprocess(img)
pred = self.session.run(self.output_names, {self.input_name: img_input.astype(np.float32)})[0][0]
label = np.argmax(pred) # 0:正常, 1:需要旋转
score = float(pred[label])
results.append((label, score))
# 对需要旋转的图片进行180度旋转
img_list = [cv2.rotate(img, cv2.ROTATE_180) if label == 1 else img
for img, (label, _) in zip(img_list, results)]
labels = [label for label, _ in results]
scores = [score for _, score in results]
return img_list, labels, scores
3.4 文本识别器
文本识别是OCR中最复杂的模块,主要挑战有:文本行长度不一致 → 无法直接batch;CTC解码需要处理blank和重复字符;宽度变化大,需要动态调整。
识别流程:
# 输入:裁剪的文字图片
img = Image.open("chinese_text.jpg") # 24x240
# 1. 预处理
preprocessed = recognizer.preprocess(img)
# 尺寸:48x256(高度固定48,宽度对齐到256)
# 归一化:[-1, 1]范围
# 2. 模型推理
pred = session.run(...)
# 输出:pred[0] 形状 (64, 6625)
# 64个时间步,每个步6625个类别的概率
# 3. CTC解码
preds_idx = [0, 123, 123, 0, 456, 0, 0, 456, 789, 0, ...]
解码后:text = "中国好"
# 4. 返回结果
result = ("中国好", 0.883)
具体实现:
class TextRecognizer:
def __init__(self, model_path, dict_file, use_gpu=True):
self.session = InferenceSessionManager.create_session(
model_path,
use_gpu=use_gpu,
gpu_mem_limit=2 * 1024 * 1024 * 1024
)
self.input_name = self.session.get_inputs()[0].name
self.output_names = [o.name for o in self.session.get_outputs()]
# 加载字典
with open(dict_file, 'r', encoding='utf-8') as f:
self.characters = [line.strip() for line in f]
self.characters = ['blank'] + self.characters # blank是CTC的特殊字符
self.target_height = 48 # 模型固定高度
def preprocess(self, img):
"""预处理:固定高度48,动态宽度"""
h, w = img.shape[:2]
ratio = self.target_height / h
new_w = int(w * ratio)
# 确保宽度是32的倍数
new_w = max(new_w, 32)
new_w = new_w if new_w % 32 == 0 else (new_w // 32 + 1) * 32
# 限制最大宽度
if new_w > 1600:
new_w = 1600
img = cv2.resize(img, (new_w, self.target_height))
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
# 归一化到[-1, 1](识别模型使用这个范围)
img = img.astype(np.float32) / 255.0
mean = np.array([0.5, 0.5, 0.5], dtype=np.float32)
std = np.array([0.5, 0.5, 0.5], dtype=np.float32)
img = (img - mean) / std
img = np.transpose(img, (2, 0, 1))
img = np.expand_dims(img, axis=0)
return img
def postprocess(self, pred):
"""
CTC解码:将模型输出转换为文本
规则:
1. 跳过blank字符(索引0)
2. 连续重复的字符只保留一个
"""
preds = pred[0]
preds_idx = np.argmax(preds, axis=2)[0] # 每个位置最可能的字符索引
preds_prob = np.max(preds, axis=2)[0] # 对应的置信度
text = ''
scores = []
last_char = None
for i, pred_idx in enumerate(preds_idx):
if pred_idx != 0: # 不是blank
if pred_idx != last_char: # 与上一个不同才添加
text += self.characters[pred_idx]
scores.append(preds_prob[i])
last_char = pred_idx
if len(scores) > 0:
avg_score = sum(scores) / len(scores)
else:
avg_score = 0.0
return text, avg_score
def __call__(self, img_list):
"""逐张识别(先这样...)"""
if not img_list:
return []
results = []
for img in img_list:
img_input = self.preprocess(img)
pred = self.session.run(self.output_names, {self.input_name: img_input.astype(np.float32)})
text, score = self.postprocess(pred)
results.append((text, score))
return results
4、试试效果
性能统计
============================================================
图片读取: 0.54 ms ( 0.2%)
文本检测: 13.03 ms ( 4.0%)
图片裁剪: 0.04 ms ( 0.0%)
方向分类: 13.31 ms ( 4.1%)
文本识别: 298.93 ms ( 91.7%)
------------------------------------------------------------
总耗时: 325.86 ms
============================================================
识别到 6 个文本区域:
------------------------------------------------------------
1. [0.989] 五、
2. [1.000] 中文识别
3. [0.957] 六、
4. [1.000] 高级应用场景
5. [0.993] 5.1
6. [1.000] 结构化数据提取
虽然没有纯paddle识别的速度快,但是咱们比RapidOCR又快又准啊!这样可还行?
5、最后
为了在GPU下推理,我们不单单要安装onnxruntime-gpu(1.25.1),还要安装cuda相关的软件:
1、cuDNN 9.22.0:https://developer.nvidia.com/cudnn-downloads?target_os=Windows&target_arch=x86_64&target_version=11&target_type=exe_local
2、CUDA Toolkit cuda_12.8: https://developer.download.nvidia.cn/compute/cuda/12.8.0/local_installers/cuda_12.8.0_571.96_windows.exe
哈哈,感觉又把部署的难度提升了…
有需要源码文件的同学,请联系我!
更多推荐


所有评论(0)