1. 项目背景与整体设计思路
做 PP-OCR 系列开源项目的念头,最早来自一个很具体的业务需求:公司内部有一套文档管理系统,每天要处理大量扫描件和截图,需要把里面的文字识别成可检索的文本。团队当时面临两个选择:一是直接用现成的云端 OCR API,但数据出域这一关就过不了;二是在内网自建一套 OCR 服务,用 PaddleOCR 官方推理库跑,但若干生产环境是信创机器,甚至有的机器连 Python 环境都没有。
这就逼着我去思考一个问题:假设在极端受限的条件下,OCR 推理还能不能跑起来?带着这个问题,我先后做了 5 个 PP-OCR 相关项目,从最简单的 OpenCV DNN 部署开始,逐步走向 TensorRT 加速,最后干脆自己写了纯 C 和纯 Java 的推理引擎。这篇文章把这 5 个项目的完整思路、关键代码片段、踩过的坑、以及每个方案背后的“为什么”都摊开来讲。
先说结论:PP-OCR 这个模型架构非常适合做工程化裁剪和手工推理实现。它不像一些结构化模型那么依赖复杂算子,检测模型的主干 ResNet 加上 DBNet 头,识别模型是 MobileNetV3 主干加 CRNN 头,整个计算图拆开来看,全是卷积层、BatchNorm 层、ReLU 激活、池化和全连接这类基础算子。也就是说,只要你愿意,每一个算子都有办法用 C 或者 Java 手写实现。
我做的 5 个项目是这样分布的:
| 序号 | 项目 | 推理框架 | 适用场景 |
|---|---|---|---|
| 1 | PP-OCR OpenCV DNN 推理 | OpenCV 4.x + ONNX | 快速原型、跨平台、无 Python 依赖 |
| 2 | PP-OCR TensorRT 推理 | TensorRT 8/10 + ONNX | GPU 服务器、高吞吐并发 |
| 3 | 纯 C 语言推理引擎 | 自研、零依赖 | 嵌入式设备、信创环境 |
| 4 | 纯 Java 推理引擎 | 自研 + 矩阵运算封装 | Android、Spring Boot 服务 |
| 5 | 多语言引擎统一封装 | 以上四者的交叉验证 | 模型对照、精度回归、性能 Benchmark |
这篇文章面向的读者是有一定图像处理基础、想深入理解 OCR 推理原理、或者需要在受限环境中部署 OCR 能力的开发者。我会把从模型格式转换到算子实现再到性能调优的完整链路都过一遍,保证你看完能直接照着做。
2. PP-OCR 模型结构与推理拆解
2.1 检测、识别、方向分类三段式架构
PP-OCR 系列从 v2 到 v4 核心思路没变:先用检测模型在整张图上找到文字行的位置,再用方向分类器判断文字方向是否需要旋转,最后把每个文字行区域裁剪下来送入识别模型。
三段式拆分的最大好处是模块化。检测模型只需要判断像素点属于文字还是背景,识别模型只需要处理一个矩形区域内的字符序列。任何一段出现精度问题时,可以单独替换其中一个模型,不必整个流程推倒重来。比如项目里遇到竖排文字识别率低,我就只换了方向分类器的阈值参数,检测和识别模型完全不用动。
检测模型用的是 DBNet(Differentiable Binarization)结构。它和传统基于分割的文字检测的思路不同:传统方法是从分割概率图得到文本区域之后再去找边界,而 DBNet 在训练时额外学习一个阈值图,用概率图减阈值图再做二值化得到近似边界。这个设计对推理来说非常友好,因为阈值图在推理时可以直接用一个固定值代替,省掉了一个分支。
识别模型是典型的 CRNN 结构:MobileNetV3 负责提取图像特征,输出序列特征图;双向 LSTM 建模序列上下文;最后接一个全连接层加 CTC 解码输出字符序列。
我在最初设计推理引擎时,把模型拆成了三个独立 ONNX 文件:det.onnx、cls.onnx、rec.onnx。每个模型的输入输出我都单独验证过,排查问题时就能按模块隔离。
2.2 ONNX 格式:工程化的关键一环
要把 Paddle 格式的 PP-OCR 模型搞到 OpenCV 或者自研引擎上跑,首先要导成 ONNX 格式。这里我踩过的最大的坑是:PP-OCR 官方模型的 ONNX 导出不能直接在 PaddleOCR 仓库里用--save_onnx一个参数搞定,不同的版本导出逻辑也不同。
我推荐用 Paddle2ONNX 工具:安装好之后,执行类似这样的命令:
paddle2onnx --model_dir ./inference/ch_PP-OCRv4_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./onnx/det.onnx \ --opset_version 11 \ --enable_onnx_checker True识别模型和方向分类器模型同理。导出之后,用 Netron 打开看一眼输入输出节点的名字和维度:
- det 模型输入:
x,形状[1,3,640,640],float32 - det 模型输出:
save_infer_model/scale_0.tmp_1,形状[1,1,640,640],是这个输入尺寸下对应的概率图 - rec 模型输入:
x,形状[1,3,48,320] - rec 模型输出:
softmax_12.tmp_0,形状[1,40,6625],40 是序列长度,6625 是字符集大小
这里必须强调一点:ONNX 导出的动态轴问题。PaddleOCR 的官方模型导出的 ONNX 可能输出dynamic_axes是动态的,但 OpenCV 的 DNN 模块对动态 shape 支持一直不太稳定。我试过把 dynamic axes 保留,结果 OpenCV 推理时报Assertion failed,折腾了半天后退回到固定 shape 方案:所有模型都统一用固定输入尺寸。检测模型固定 640x640,识别模型固定 48x320。这样做损失了一点灵活性,但换来的是部署时的绝对稳定。
注意:如果你打算做的 OCR 服务可能要处理不同分辨率的图片,建议在预处理阶段先做等比缩放再 padding 到固定尺寸,而不要试图让模型支持任意尺寸输入,后者会带来一连串工程问题。
2.3 预处理细节决定推理精度
OCR 模型的预处理看起来简单,实际上每个细节都会影响最终精度。PP-OCR 官方推理代码里的预处理包括:读图、BGR 转 RGB、缩放、归一化、HWC 转 CHW、加 batch 维度。
我这里以 OpenCV 的blobFromImage函数为例说明一下:
blob = cv2.dnn.blobFromImage( img, # 原始 BGR 图像 scalefactor=1.0 / 255.0, # 缩放到 [0,1] size=(640, 640), # 固定输入尺寸 mean=(0.5, 0.5, 0.5), # 均值 swapRB=False, # 先自己转成 RGB crop=False)这里有一个经典失误点:PP-OCR 的归一化方式是先除以 255 再减均值 0.5,而不是 OpenCV 默认的(像素 - mean) / scale。如果你直接写blobFromImage(img, 1.0/255.0, (640,640), (0.5,0.5,0.5)),OpenCV 内部实际做的是(img * scalefactor - mean),也就是先归一化再减均值,这跟 Paddle 推理里的先减均值再乘以 scale 的结果是不一样的。
正确的换算公式:Paddle 里是(x / 255 - 0.5) / 0.5,对应到blobFromImage要写成:
blob = cv2.dnn.blobFromImage( img_rgb, 1.0, (640, 640), mean=(127.5, 127.5, 127.5), scalefactor=1.0 / 127.5, swapRB=False, crop=False)也就是把/255和(x - 0.5)/0.5合并成(x - 127.5) / 127.5。这个细节我一开始没注意,结果同样的模型在 Paddle 上识别率有 90 分,到了 OpenCV 上只剩下 70 分,而且检测框明显偏大偏碎,排查了很久才发现是预处理归一化的差异。
另外检测模型的输入要保持宽高比。官方 PP-OCR 检测模型在推理时会对图像做 limit_side_len 处理,把长边限制在 960 以内,然后按比例缩放短边。如果用 OpenCV 直接拉伸到 640x640,原本瘦长的文字会变形,检测精度会大幅下降。我在项目里做的是:先按原始宽高比把长边缩放到 960 以内,再 padding 到 32 的倍数。因为 ResNet 主干有 32 倍下采样,输入尺寸必须是 32 的整数倍才不会出现算子维度错误。
2.4 后处理:DBNet 的阈值与膨胀
检测模型的输出是一张概率图,要把它变成文字框,需要经历:二值化 → 找轮廓 → 计算最小外接矩形 → 按比例放大矩形 → 过滤多余框。
DBNet 推理时,直接用固定阈值 0.3 对概率图做二值化。但要注意,概率图输出后需要先做一次1 / (1 + exp(-x))的 sigmoid 操作把值映射到 0~1,然后才做阈值比较。很多 OpenCV 部署教程在这里会漏掉 sigmoid,直接把原始 logit 和 0.3 比较,导致检测结果几乎全灭。
检测出文字区域后,还有一个重要的后处理细节:box 的坐标需要放大回原图尺寸。因为输入模型前做了缩放和 padding,输出坐标是相对于输入图像的,需要记录下缩放比例和 padding 偏移量,在拿到文本框坐标后反算回原图。
我在第一个项目里写过一个脏代码:直接拿固定比例往回乘,遇到 padding 不为零的图片时全部错位。后来统一用一个结构体保存ratio_h、ratio_w、offset_x、offset_y,每次推理都动态计算。类似这样的逻辑:
def decode_points(pred_map, scale_h, scale_w, pad_w, pad_h): # pred_map 是模型输出的 640x640 概率图 # 先做二值化,然后 cv2.findContours 找轮廓 boxes = [] for contour in contours: rect = cv2.minAreaRect(contour) box = cv2.boxPoints(rect) box[:, 0] = (box[:, 0] - pad_w) / scale_w box[:, 1] = (box[:, 1] - pad_h) / scale_h boxes.append(box) return boxes3. 五个项目逐个拆解
3.1 项目一:OpenCV DNN 部署 PP-OCR
这个项目是所有后续工作的地基。目标只有一个:在不依赖 PaddlePaddle 框架的情况下,用 OpenCV 的 DNN 模块完成 PP-OCR 的完整推理链路。
OpenCV DNN 模块的优点是跨平台、零 Python 依赖、推理链路可以完全用 C++ 写。缺点是性能上限不高,尤其是 CPU 推理时,OpenCV 的卷积实现远不如 Paddle Inference 优化得极致,实测下来在同样一台 i7 机器上,OpenCV 比 Paddle Inference 慢约 30%~50%。但对原型验证和中小并发场景来说完全够用。
完整流程:
- 用上节提到的命令导出三个 ONNX 模型。
- 编写预处理函数:读图、转 RGB、缩放、归一化、转 CHW。
- 检测模型推理,拿到概率图。
- 后处理概率图,得到文本框。
- 根据文本框裁剪文字行区域,送方向分类器判断是否需要旋转。
- 旋转后送识别模型,得到 softmax 输出。
- 用 CTC 解码得到最终文本。
OpenCV C++ 里的核心代码逻辑是:
cv::dnn::Net det_net = cv::dnn::readNetFromONNX("det.onnx"); cv::Mat blob = cv::dnn::blobFromImage(im, 1.0, cv::Size(640, 640), cv::Scalar(127.5, 127.5, 127.5), false, false); det_net.setInput(blob); cv::Mat pred = det_net.forward(); // pred 形状: [1,1,640,640]这里我遇到一个很隐蔽的 OpenCV 版本问题:在 OpenCV 4.5.5 以前的版本,某些 ONNX 算子(比如hard_swish)不被支持,导致加载模型直接报错。PaddleOCR 的 MobileNetV3 里大量使用了 hard_swish,所以我建议直接用 OpenCV 4.6.0 以上版本,最好用 4.8 或 4.9。如果公司有较老的 Ubuntu 系统需要编译 OpenCV,可以用 CMake 关掉不需要的模块来减少编译时间,但 DNN 模块一定要保留。
OpenCV 的 DNN 推理还有一个潜在问题:它默认用 OpenCL 做后端加速,但有部分老显卡的 OpenCL 驱动有 bug,会导致输出全是 NaN。这时候可以强制切回 CPU 后端:
det_net.setPreferableBackend(cv::dnn::DNN_BACKEND_OPENCV); det_net.setPreferableTarget(cv::dnn::DNN_TARGET_CPU);我在项目文档里给用户推荐的首选配置就是DNN_BACKEND_OPENCV + DNN_TARGET_CPU,稳定优先。
识别模型的推理类似,但预处理不同:识别模型输入尺寸是[1,3,48,320],输入图片需要先把高度统一缩放到 48,宽度按比例缩放但不超过 320。超过 320 时再降采样,不足时 padding 到 320。宽度方向的比例信息要记录下来,因为 CTC 解码后的字符序列需要按比例换算回原始宽度上的位置。
3.2 项目二:TensorRT 加速推理
如果说 OpenCV 项目解决的是“有没有”,TensorRT 项目解决的就是“快不快”。
PP-OCR 的识别模型是典型的计算密集模型,在纯 CPU 上识别一行 10 个字的文字大约需要 40~80ms;在 TensorRT FP16 模式下,一张 GTX 1070 跑同样的推理只需要 2~4ms,性能差距接近 20 倍。
TensorRT 的优化手段主要有两个层面:一是将网络图中的卷积、BN、ReLU 等层融合成单一算子,减少 kernel launch 次数和中间数据读写;二是支持 FP16 和 INT8 精度推理,用较低的精度换取更快的计算速度。
但 TensorRT 部署有一个非常折磨人的问题:版本与显卡架构的兼容性。我做这个项目的时候用的是一台配了 GTX 1070 的老机器,而当时最新的 TensorRT 10.x 对 Pascal 架构(GTX 10 系列)的支持已经不像 TensorRT 8 那么完善。GTX 1070 的算力是 6.1,TensorRT 10.x 官方虽然还支持,但 build engine 时如果开启 FP16,某些层会自动回退到 FP32,加速效果打折扣。如果你的显卡是 GTX 10 系列,建议直接用 TensorRT 8.5 LTS 版本;而如果你用的是 RTX 30/40 系列,TensorRT 10.x 的优化则更激进。
TensorRT 推理的完整流程:
- 将 ONNX 模型转成 TensorRT engine。这一步有两种方式:用
trtexec命令行工具,或写 C++/Python 代码调用OnnxParser。
trtexec --onnx=rec.onnx \ --saveEngine=rec_fp16.trt \ --fp16 \ --workspace=1024加载 engine,创建 context,分配显存 buffer。
将预处理后的输入数据拷贝到 GPU 显存,执行推理,再把输出拷回内存。
这里关键的一步是:ONNX 模型的输入输出 shape 必须固定。TensorRT 虽然支持动态 shape(设置 optimization profile),但第一次 build engine 时会为每个 shape 生成专门的计算图,动态 shape 会导致 engine 更复杂、耗时更久。对于 PP-OCR 这种固定输入尺寸的模型来说,直接用固定 shape 是最优解。
TensorRT 的一个大坑是:engine 文件与 TensorRT 版本、GPU 型号强绑定。在一台机器上 build 出来的 engine,换到另一台不同型号的 GPU 上大概率加载失败。我吃过一次亏:在开发机 RTX 3090 上 build 好的 engine 拷到生产机的 T4 上,反序列化直接报错could not find engine implementation。正确的做法是:写一个初始化函数,如果本地没有 engine 缓存文件,就现场从 ONNX 构建,否则直接加载缓存文件。这样构建一次之后后续启动就很快。
还有一点:TensorRT 的 INT8 量化模式需要校准数据集,不能用随机数据做校准,否则精度会稀碎。OCR 模型对文字区域的置信度非常敏感,INT8 量化后我实测识别精度下降了约 3 个百分点,尤其在模糊字体和生僻字上明显变差。如果业务对准确率要求高,建议只用 FP16,不要上 INT8。
3.3 项目三:纯 C 语言自研推理引擎
这个项目是我个人认为技术含量最高的一个。前面两个项目不管怎样都有现成框架兜底,纯 C 语言写推理引擎意味着:所有算子要从零实现,模型参数要自己解析,内存要自己管理,后处理要自己写。
为什么会有这种需求?最早是有一个嵌入式 OCR 项目,目标平台是一个只有 128MB 内存、没有操作系统支持的 ARM 核,跑不了 Linux,更不可能装 PaddlePaddle 或者 OpenCV,所有东西必须静态编译进一个可执行文件里。
纯 C 推理引擎的总体结构分三层:
第一层是模型解析层。需要定义一个最小化的参数文件格式。我不直接解析 ONNX 的 protobuf(在 C 环境里解析 protobuf 的成本太高),而是提前在 PC 端把 ONNX 模型里的卷积权重、BN 系数、全连接权重全部导出成裸的二进制文件。
导出方式可以写个 Python 脚本:
import onnx from onnx import numpy_helper model = onnx.load("rec.onnx") for initializer in model.graph.initializer: arr = numpy_helper.to_array(initializer) arr.astype(np.float32).tofile(f"weights/{initializer.name}.bin")第二层是算子实现层。PP-OCR 模型里涉及的核心算子其实只有 5 类:
conv2d:卷积,重点优化 im2col 和 GEMM 的映射batch_norm:推理时 BN 可以和前一层卷积合并,节省一次遍历relu/hard_swish:激活函数max_pool/avg_pool:池化lstm:双向 LSTM 是识别模型最麻烦的部分
这里要重点讲一下卷积和 BN 融合。PaddleOCR 的 MobileNetV3 每个卷积后面几乎都跟着一个 BN 层。训练时 BN 有均值、方差、缩放系数、偏移量四个参数,但推理时这四个参数可以合并到卷积的权重和偏置里。公式推导是这样的:
先算卷积输出,再过 BN:
y = gamma * (x_conv - mean) / sqrt(var + eps) + beta把(x_conv - mean) / sqrt(var + eps)展开,令:
w' = w * gamma / sqrt(var + eps) b' = (b - mean) * gamma / sqrt(var + eps) + beta卷积层的权重从w变成w',偏置从b变成b',就可以省掉 BN 层的计算。
合并之后,模型里大量“卷积 + BN + ReLU”的组合就变成一个带偏置的卷积再加一个 ReLU。在 C 语言里,这个融合可以写成:
for (int oc = 0; oc < out_channels; oc++) { for (int oh = 0; oh < out_h; oh++) { for (int ow = 0; ow < out_w; ow++) { float sum = bias[oc]; for (int ic = 0; ic < in_channels; ic++) { for (int kh = 0; kh < ksize; kh++) { for (int kw = 0; kw < ksize; kw++) { // 累加 } } } out[oc][oh][ow] = sum > 0 ? sum : 0; // ReLU } } }简单粗暴,但能跑。当然如果追求极致性能,应该用 im2col 把卷积转成矩阵乘法再调用 GEMM 函数。我在嵌入式平台上用的是最简方案:让每个卷积算子支持 3x3 步长 1 的滑动窗口优化,把它从六重循环拆成带行缓冲的滑窗算法,配合 NEON 指令做 SIMD 并行,性能提升了接近 4 倍。
第二层里另一个头疼的是 LSTM。双向 LSTM 每一步的输入是序列特征,循环依赖很强,没法直接并行。我当时的优化思路是把手写的 LSTM 单元里的矩阵乘法全部向量化。PP-OCR 识别模型的 LSTM 隐藏层大小是 96,输入特征维度是 96,一共 4 个门,每个门需要做一次[96, 96]的矩阵乘,四个门拼成一个[384, 96]的大矩阵乘,一次性算出来,再加 bias 和激活。这样一个时间步就是 1 次大矩阵乘 + 若干向量加法,效率比逐门计算高很多。
第三层是内存管理。嵌入式平台内存紧张,我实现了一个简单的内存池:把输入、中间特征、输出全部分配在预先申请好的大块内存里,算子之间通过偏移量复用内存。这个做法类似 TensorRT 的 workspace 机制,好处是运行过程中完全不需要动态 malloc/free,内存使用量可预测。实测下来整个识别模型的内存峰值控制在 36MB 以内。
纯 C 引擎的精度验证方法也值得一提。开发时我在 PC 上把模型每一层的输出都 dump 成二进制文件,然后在 C 引擎里跑同样的输入,逐层对比浮点误差。要求是相对误差小于 1e-4,因为 C 代码里用了float类型,经过十几层卷积后累计误差会放大,如果误差超阈值就说明某个算子实现有 bug。
这个项目做完之后我最大的感触是:框架帮你封装掉了太多细节,手写一遍推理引擎后,对模型的每层结构、每个参数的作用都有了更深的理解,后面再调精度时效率高得多。
3.4 项目四:纯 Java 自研推理引擎
Java 项目是这 5 个项目里商业价值最高、也是让我最纠结的一个。
纠结的点在于:到底要不要用 JNI 调 C++?如果不调 JNI,纯 Java 实现的性能能不能接受?最后的结论是:纯 Java 不加 JNI,硬写。理由有二:一是目标环境是 Android 和非标准 JDK 的服务端,JNI 跨平台编译太麻烦;二是 Java 经过 JIT 预热之后,矩阵乘法的性能在现代硬件上并没有想象中那么差,只要避开自动装箱和频繁的小对象创建,纯 Java 跑 MobileNetV3 级别的模型是可行的。
Java 推理引擎的核心设计:
- 使用一维
float[]数组存储所有张量,避免多维数组的引用开销 - 手动实现 im2col + GEMM,GEMM 内部用三层循环加缓存友好的分块策略
- LSTM 部分用
float[]拼装门控矩阵,一次性计算四个门 - 不依赖第三方线性代数库
一个很重要的优化点:Java 的 JIT 编译器对float[]的循环有较强的自动向量化能力,前提是循环内不要有数组越界检查逃逸、不要有复杂的分支语句。我写过一版用java.nio.FloatBuffer来存张量的实现,结果因为 ByteBuffer 的 get/put 方法调用开销较大,性能反而不如原始数组。后来统一回到float[],简单直接。
推理性能实测:在一台 4 核的服务器上,纯 Java 引擎跑一次识别推理(输入 48x320)约 18ms。这个性能不如 C 或者 TensorRT,但也足够支撑每 QPS 约 50 的服务场景,而且还占了“无任何第三方依赖”这个巨大的优势。
Java 引擎里额外实现了一个便捷功能:支持从 Java SPI 加载不同的模型版本。我把检测、识别、方向分类三个模型文件打包进 resources,通过一个OcrEngineFactory选择用哪个模型组合,模型之间通过接口解耦。这样如果后续换了新的 PP-OCR v5 模型,只需要新增一个模型参数配置类,不用改推理逻辑。
3.5 项目五:多语言引擎统一封装与交叉验证
第 5 个项目其实是一个工程整合项目。C 引擎、Java 引擎、OpenCV 部署、TensorRT 部署这四套方案,各跑各的没问题,但模型更新一次就要改四份代码,维护成本太高。所以第五个项目做的事有两件:统一推理接口,以及建立精度回归测试流水线。
我定义了一个OcrEngine接口,不管底层是 C、Java 还是 TensorRT,对外暴露的方法只有三个:
public interface IOcrEngine { OcrResult detect(Mat image); float[] detectConfidence(Mat image); String version(); }C 引擎通过 JNI 封装成 Java 接口,TensorRT 引擎通过一个小的 C++ 服务暴露成 HTTP 接口,OpenCV 引擎做成一个本地方法库。这样上层业务完全感知不到底层的实现差异。
精度回归流水线的思路:准备一份 500 张标注好的测试集,包括横版印刷体、竖排文字、低分辨率截图、复杂背景四个类别。每次模型或引擎更新时,跑一遍完整测试,统计检测框的 IoU 和文本识别的编辑距离。这个流水线帮我在一次升级中抓到了非常隐蔽的 bug:C 引擎的 im2col 在不同输入宽度时没有正确迭代输出高度,导致识别结果每隔几个字符就丢掉一个,而单独看单张图的输出很难发现规律。
这里放一个常见问题速查表,是我整理这 5 个项目时踩坑经验的浓缩:
| 现象 | 原因 | 解决方案 |
|---|---|---|
| OpenCV 加载 ONNX 报 unknown layer | ONNX 包含不支持的算子 | 换新版本 OpenCV,或修改 ONNX 图 |
| 检测框整体偏移 | 后处理时坐标未反算到原图 | 记录 scale 和 padding 值并准确换算 |
| 识别全是相同字符 | CTC 解码时忽略了 blank 符号 | 在解码逻辑中加入 blank 跳转 |
| TensorRT engine 加载失败 | engine 与 GPU 型号不匹配 | 在目标机器上重新构建 engine |
| Java 推理偶尔出现 NaN | JIT 优化导致浮点精度略有差异 | 检查 softmax 中最大值减去逻辑是否正确 |
| 检测框互相重叠 | 后处理没有做 NMS | 按置信度排序并做非极大值抑制 |
4. 关键算子与原理解析
4.1 CTC 解码:从概率矩阵到文本序列
识别模型的输出是一个形状为[1, 40, 6625]的矩阵,40 是裁剪后文字行的序列长度,6625 是字符表大小加一个 blank 符号。CTC 解码要做的事情是:在每一个时间步选择一个字符,组成最终文本序列。
CTC 的规则很简单:先水平扫描每个时间步,选概率最大的索引;然后去掉连续重复的字符;最后删掉 blank 索引。
举个例子:假设字符表是 ABCD,blank 用-表示,模型输出的最优路径是A A - B B C,先去重得到A - B C,再删 blank 得到ABC。
这里最容易犯的错是漏掉“先合并相邻重复”这一步骤。如果先删 blank 再合并重复,A A - B B C会得到A B C,看起来没区别,但遇到A - A这种序列就会出问题:正确做法是合并后删 blank 得到A,错误做法得到AA。
每次时间步的概率要读过 softmax 输出。识别模型最后一个全连接层输出的是 logits,后面接了一个 softmax 归一化,所以 CTC 解码直接吃 softmax 输出即可,不要在解码时再套一层 softmax。
我在 Java 引擎里实现了一个轻量级的 beam search 解码,可以在连续时间步里同时保留概率最高的 K 条路径,最终选择全局概率最高的那一条。实测用 beam search 比简单贪心解码的错误率降低约 15%~20%,尤其对生僻词和数字混合的文本效果明显。
4.2 检测框后处理:从概率图到四边形
在 2.4 节提到了检测框后处理的基本流程。这里补充两个关键的细节。
第一个是膨胀腐蚀。DBNet 输出概率图后,我通常会先做一个形态学闭运算(先膨胀再腐蚀),把断开的文字区域连接起来。这步操作在纯 OpenCV 代码里很简单:
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) pred = cv2.morphologyEx(pred, cv2.MORPH_CLOSE, kernel)第二个是文本框的四边形校正。大多数场景下文字是水平的,但手机拍照或扫描件可能有倾斜。我在拿到最小外接矩形之后,用透视变换把倾斜的文本框校正成水平矩形,再送识别模型。透视变换在 OpenCV 里用cv2.getPerspectiveTransform和cv2.warpPerspective。这一步能把斜着拍的文字识别准确率从 60% 提升到 85% 以上。
4.3 为什么 PP-OCR 适合手写推理引擎
从工程角度我觉得 PP-OCR 是少数几个适合手写推理引擎的现代模型。原因有以下几点:
- 模型结构规整,没有像 Transformer 那样的动态序列长度和复杂的 attention mask
- 算子种类少,所有算子都可以在 C/Java 里用几百行代码实现
- 有官方导出 ONNX 的成熟工具链,不需要重新训练模型
- 模型权重规模适中,检测模型约 3MB,识别模型约 10MB,完全可以用裸二进制文件存储
我给当时做嵌入式项目的同事开玩笑说:如果哪天地球上的 AI 框架都消失了,只要给我一套卷积运算的代码,我就能把 OCR 重新发明出来。这个说法有点夸张,但也从侧面说明,理解模型的底层算子是工程能力的重要分水岭。
5. 精度与性能调优实战
5.1 精度优化:从 70 分到 95 分
在做这 5 个项目时,我总结了一套 OCR 精度优化套路,按优先级排列:
第一优先级是预处理归一化是否和训练时一致。正如 2.3 节所说,很多人因为归一化差异导致精度大幅下降。
第二优先级是后处理参数是否合理。检测模型的二值化阈值 0.3,识别模型的长度阈值,方向分类器的阈值 0.9,都会显著影响最终效果。这些参数不能靠感觉设定,要用验证集多测几组。我最常用的是网格搜索:固定检测部分,改变二值化阈值从 0.2 到 0.5,步长 0.05,看哪组识别准确率最高。
第三优先级是图像预处理增强。对于低分辨率截图的识别,可以先做一次超分辨率或者放大 2 倍再送识别模型。使用传统图像处理的锐化核也能有所改善。
5.2 性能优化:从 200ms 到 20ms
性能瓶颈通常出在检测模型。因为检测模型输入 640x640,计算量远大于识别模型的 48x320。我在 TensorRT 项目里做过的有效优化有:
- 将检测模型的输入尺寸从 640 降为 480,检测精度几乎不变,但耗时降低 40%
- 使用 FP16 推理,GPU 吞吐量提升接近一倍
- 用 CUDA Stream 将检测、方向分类、识别三个模型串成流水线,让 GPU 时刻保持忙碌
CPU 上的 OpenCV 项目性能优化比较有限,但我发现一个很有效的手段:把每张图的检测结果缓存起来,对于同一批次的扫描件,如果检测区域高度相似,直接复用检测框,只做识别。这个缓存命中率在实际业务中能到 60% 以上,整条流水线的耗时直接减半。
C 引擎和 Java 引擎的性能优化方向不一样。C 引擎注重内存复用和 NEON 指令,Java 引擎注重 JIT 预热和避免对象分配。这两类优化属于各自语言底层能力,没有太多直接可对比的经验,建议有兴趣的读者分头研究。
6. 常见问题排查实录
6.1 检测框比实际文字大很多
这个问题的根源几乎都是检测模型输入尺寸的 padding 策略和后处理坐标反查不对应。如果你在预处理时用了深色(0 值)填充,但在后处理时没有把 padding 的区域从概率图里裁掉,轮廓检测会把大片的 padding 区域也算作文字。解决办法是在findContours之前就把预测图的 padding 部分置零:
if pad_w > 0: pred[:, :, -pad_w:] = 0 if pad_h > 0: pred[:, -pad_h:, :] = 06.2 识别速度慢且 CPU 占用 100%
这个现象在纯 Java 项目里比较常见。开始时我以为是大矩阵乘法的问题,排查后发现罪魁祸首是每次推理前都要加载模型文件并解析权重。反复加载不存在的缓存文件导致频繁 GC 和 JIT 编译。
解决办法是把模型对象做成单例,进程启动时加载一次,后续直接复用。另外在 Java 里,把输入图像转成float[]的过程如果用了太多的ArrayList<Float>或Double包装类型,JIT 很难优化,应该用原始数组加手写循环来完成数据转换。
6.3 TensorRT engine 构建需要很长时间
如果 ONNX 模型比较复杂,TensorRT build engine 的时间可能长达几分钟,这在线上环境无法接受。我建议把 engine 构建放在 Docker 构建阶段完成,并把构建好的 engine 文件打进镜像。首次启动时直接从本地加载 engine,后续启动只需要几十毫秒。
另外,build engine 时的--workspace参数不要设置太大,否则 TensorRT 会在某些层上额外申请大量显存,某些显卡可能直接 OOM。我一般设置为 1GB 以内。
6.4 OpenCV 和自研引擎的识别结果不一致
这个现象通常是因为两个引擎的预处理或者后处理逻辑有细微差异。我在项目五里加入了一个调试模式,可以把同一张图分别通过两个引擎推理,并输出每一层的中间结果摘要。对比之下很容易定位是 normalize 参数不一致还是解码方式不一致。
这里有一个通用的调试技巧:先在 PC 上用 Python 脚本跑一遍 Paddle 官方库,得到“标准输出”;然后让自研引擎输出同样的日志;最后逐层对比。凡是差别超过阈值的层,都有实现 bug。
7. 跨项目经验总结与扩展方向
做完这 5 个项目,我整理了一份内部文档,这里挑几个核心经验分享出来。
模型和框架解耦是关键。做第一个 OpenCV 项目时,我把模型文件、预处理参数、后处理参数全部硬编码在代码里,后期模型一更新就要去代码里翻参数。从第三个项目开始,我改成了配置文件驱动:模型路径、输入尺寸、均值方差、二值化阈值、NMS 参数全部写在 YAML 配置里,代码只负责读配置。这样做以后,C 引擎和 Java 引擎可以共享同一套配置,模型更新时只改配置不改代码。
推理引擎的性能优化优先级要搞对。不要一上来就优化算子内部循环,先确认算子的调用次数和耗时占比。我一开始花了很多力气优化卷积的乘法顺序,后来用 profiler 一测发现 LSTM 的耗时占比高达 40%,卷积反而只占 25%。及时转方向之后,整体性能提升才真正明显。
这套技能组合本身有非常强的复利效应。通过 OpenCV 项目理解了模型部署的基本套路,通过 TensorRT 项目理解了底层 GPU 优化思路,通过 C 和 Java 项目锻炼了算子级实现能力。现在拿到任何新模型,我基本能在一两天内判断出它适合用什么方案部署、会遇到什么性能瓶颈,这是单纯调包很难获得的经验。
后续我还打算做两个扩展方向:一是把手写推理引擎扩展到 PP-YOLOE 目标检测模型,验证这套 C/Java 引擎的通用性;二是为 C 引擎增加 RISC-V 向量扩展支持,把嵌入式部署的适用范围再往外推一步。如果到时候有了新的收获,我会再回来分享。