
1. 为什么我要把 PP-OCR 反复“折腾”五遍PP-OCR 这套东西但凡做过文字识别落地的同学都不陌生。百度飞桨开源出来的这套轻量级 OCR 系统检测加识别两个模型加起来模型体积能压到几兆中文识别准确率在通用场景下能到 95% 以上这在几年前是很难想象的。但真正把它从“跑通 demo”推进到“塞进各种奇奇怪怪的生产环境”中间隔着的坑比模型本身的网络结构复杂多了。我前后做了五个围绕 PP-OCR 的项目路线分别是OpenCV 传统图像处理辅助预处理、TensorRT 加速推理、纯 C 语言手写推理引擎、纯 Java 推理引擎以及一个把前四者串起来的工程化封装。这五个项目不是拍脑袋选的而是被真实需求一步步逼出来的——有的客户现场只有 CPU 且不允许装 Python有的要求毫秒级延迟必须上 GPU有的嵌入式设备连 OpenCV 都跑不动只能纯 C还有的甲方整个技术栈就是 Java你给他一个 Python 服务他运维都接不住。这篇文章我打算把这五条路线的设计思路、核心实现、踩过的坑和实测数据全部摊开讲。适合谁看如果你正在做 OCR 相关的落地或者你手上有 PP-OCR 但不知道怎么在特定环境里跑起来又或者你单纯好奇“一个推理引擎到底是怎么从零写出来的”那这篇应该能给你省下不少试错时间。我会尽量把每个技术选型背后的“为什么”讲清楚而不是只丢一堆代码。先说一个贯穿全文的基本认知PP-OCR 的模型本身是固定的变的永远是推理后端和前后处理。检测模型DB 算法输出的是概率图识别模型CRNN CTC输出的是字符序列这两步的数学逻辑不会因为你用 OpenCV 还是 TensorRT 而改变。所以五个项目的差异本质上集中在三件事上张量的内存布局怎么组织、算子怎么实现、以及前后处理怎么和推理引擎对接。抓住这条主线后面所有内容都好理解了。2. 路线一OpenCV 做预处理与后处理的那些门道2.1 为什么预处理阶段我最终选了 OpenCV 而不是 PIL很多人做 OCR 预处理第一反应是 PIL 或者直接 numpy 操作我一开始也是。但实测下来OpenCV 在几个关键环节上的优势非常明显。第一是速度cv2.resize比 PIL 的resize在批量处理时快 2 到 3 倍这个差距在单张图上不明显但 PP-OCR 的检测阶段需要对原图做等比缩放一张 4000x3000 的图缩到 960 长边批量几百张的时候差距就出来了。第二是 OpenCV 的cv2.dnn模块可以直接加载 ONNX 模型这意味着我可以在同一个进程里完成“图像读取 → 预处理 → 推理 → 后处理”的全流程不用在 PIL 和 numpy 之间反复转换数据类型。具体到 PP-OCR 的预处理核心步骤其实就三步归一化、缩放、通道转换。但这里有个容易被忽略的细节——PP-OCR 的检测模型要求输入是 RGB 三通道均值和方差分别是[0.485, 0.456, 0.406]和[0.229, 0.224, 0.225]这是 ImageNet 的标准参数。如果你直接用cv2.imread读图默认是 BGR 顺序必须手动转成 RGB否则识别结果会莫名其妙地差。我见过不止一个项目在这里翻车模型明明没问题就是预处理通道搞反了。import cv2 import numpy as np def preprocess_for_det(img_path, limit_side_len960): img cv2.imread(img_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) h, w img.shape[:2] ratio min(limit_side_len / max(h, w), 1.0) new_h, new_w int(h * ratio), int(w * ratio) # 确保是 32 的倍数DB 模型下采样需要 new_h max(int(round(new_h / 32) * 32), 32) new_w max(int(round(new_w / 32) * 32), 32) resized cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) # 归一化 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) normalized (resized.astype(np.float32) / 255.0 - mean) / std # HWC - CHW tensor normalized.transpose(2, 0, 1)[np.newaxis, ...] return tensor.astype(np.float32), ratio这段代码里有两个点值得展开。第一个是 32 倍数对齐DB 检测网络有五次下采样每次 stride 为 2所以输入尺寸必须是 32 的整数倍否则特征图尺寸对不上后处理拿到的概率图坐标会偏移。第二个是 ratio 的保留因为缩放后的坐标要映射回原图这个比例必须存下来后处理阶段用它把检测框还原到原始尺寸。2.2 后处理里的轮廓筛选与文本框合并检测模型输出一张概率图值在 0 到 1 之间代表每个像素属于文字区域的概率。后处理的第一步是二值化通常阈值取 0.3。然后cv2.findContours找轮廓但这里有个大坑直接对二值图找轮廓会得到大量碎片因为文字笔画之间可能有断裂。我的做法是先做一次形态学闭运算用一个 3x3 的核把断裂处连起来再找轮廓。def postprocess_det(prob_map, ratio, thresh0.3, box_thresh0.6): # prob_map: (1, 1, H, W) prob prob_map[0, 0] binary (prob thresh).astype(np.uint8) * 255 kernel cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) contours, _ cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) boxes [] for cnt in contours: # 用最小外接矩形而不是正矩形适应倾斜文本 rect cv2.minAreaRect(cnt) box cv2.boxPoints(rect) # 过滤太小的框 if cv2.contourArea(cnt) 10: continue # 计算框内平均概率作为置信度 mask np.zeros_like(prob, dtypenp.uint8) cv2.fillPoly(mask, [cnt.astype(np.int32)], 1) score cv2.mean(prob, maskmask)[0] if score box_thresh: continue # 坐标还原到原图 box box / ratio boxes.append(box) return boxes这里用cv2.minAreaRect而不是cv2.boundingRect是关键。PP-OCR 的检测模型对倾斜文本的检测能力很强如果你用正矩形倾斜的文本框会被框得很大包含大量背景识别阶段就会引入噪声。最小外接矩形能贴合文本方向后续做透视变换矫正也更准确。注意cv2.findContours在 OpenCV 3.x 和 4.x 里返回值不一样3.x 返回三个值image, contours, hierarchy4.x 返回两个contours, hierarchy。如果你代码要兼容多个版本记得做判断否则会报“too many values to unpack”。2.3 识别阶段的 CTC 解码与置信度计算识别模型输出的是(T, N)的序列T 是时间步N 是字符集大小PP-OCR 中文模型大概是 6623 个字符包含空格和 CTC blank。CTC 解码的核心逻辑是合并连续重复字符然后去掉 blank。听起来简单但实际写的时候有几个细节。def ctc_decode(preds, char_dict): # preds: (T, N) 已经过 softmax indices np.argmax(preds, axis1) scores np.max(preds, axis1) result [] result_scores [] prev_idx -1 blank_idx 0 # PP-OCR 的 blank 通常是 0 for i, idx in enumerate(indices): if idx ! blank_idx and idx ! prev_idx: result.append(char_dict.get(idx, )) result_scores.append(scores[i]) prev_idx idx # 置信度取平均 avg_score np.mean(result_scores) if result_scores else 0.0 return .join(result), avg_score这段代码里prev_idx的更新时机很关键。注意我是在判断之后才更新 prev_idx而不是在循环开头更新。如果搞反了连续相同字符会被错误合并。比如“aaa”经过 CTC 应该输出“a”但如果 prev_idx 更新时机不对可能输出“aa”或者空。这个 bug 我在早期版本里踩过排查了半天才发现是循环里变量更新顺序的问题。另外置信度的计算方式也值得说。有些实现是取所有时间步的最小值有些取平均。我实测下来取平均更合理因为最小值容易被个别低置信度的时间步拉低导致明明识别正确的文本被过滤掉。但如果你对准确率要求极高、宁可漏检不可误检那取最小值更保守。3. 路线二TensorRT 加速从 30ms 到 8ms 的实战3.1 TensorRT 版本选择与 GTX 1070 的兼容性坑先说一个被问得最多的问题TensorRT 10.x 到底支不支持 GTX 1070。答案是支持但有前提。GTX 1070 是 Pascal 架构计算能力 6.1TensorRT 10.x 官方文档里明确写了支持 Compute Capability 6.0 及以上的 GPU所以 1070 在支持列表里。但问题出在精度模式上——Pascal 架构没有 Tensor Core所以 FP16 和 INT8 的加速效果远不如 Turing 及以后的卡。在 1070 上FP32 和 FP16 的推理速度差距可能只有 10% 到 20%而在 RTX 30 系上能差一倍以上。我实测的数据PP-OCR 检测模型约 4.7MB在 GTX 1070 上TensorRT FP32 推理耗时约 12msFP16 约 10ms差距不明显。但同样的模型在 RTX 3060 上FP32 是 6msFP16 是 3.5ms。所以如果你用的是 1070没必要为了 FP16 折腾半天直接用 FP32 就行省心。还有一个版本兼容性问题TensorRT 10.x 对 ONNX 的算子集版本有要求PP-OCR 导出的 ONNX 如果是 opset 11 或 12一般没问题但如果是 opset 9 以下可能会遇到算子不支持的情况。我的建议是导出 ONNX 时统一用 opset 11这个版本在 TensorRT 8.x 到 10.x 上兼容性最好。3.2 ONNX 转 TensorRT 引擎的完整流程转换流程本身不复杂但参数配置决定了最终引擎的性能。我用的是trtexec命令行工具比 Python API 更直观也方便脚本化。trtexec --onnxdet_model.onnx \ --saveEnginedet_model_fp16.engine \ --fp16 \ --workspace2048 \ --minShapesinput:1x3x32x32 \ --optShapesinput:1x3x960x960 \ --maxShapesinput:1x3x1600x1600 \ --shapesinput:1x3x960x960这里几个参数值得解释。--workspace2048是给 TensorRT 的显存工作空间单位 MB。PP-OCR 模型不大2048 足够了设太大反而浪费显存。--minShapes/optShapes/maxShapes是动态 shape 配置因为 PP-OCR 的输入尺寸随图片变化必须开启动态 shape。optShapes是你最常用的尺寸TensorRT 会针对这个尺寸做优化所以设成你业务里最常见的输入尺寸能获得最佳性能。注意动态 shape 的引擎比固定 shape 的引擎推理速度慢 5% 到 15%因为 TensorRT 需要在运行时做 shape 检查。如果你的业务输入尺寸固定比如都是 960x960那就别开动态 shape直接固定尺寸性能更好。3.3 推理阶段的显存管理与异步执行TensorRT 推理的核心是executeV2或者enqueueV3。我推荐用enqueueV3因为它支持 CUDA Stream 异步执行能和多线程配合做流水线。但异步执行有个坑你必须确保输入数据拷贝到显存的操作在 enqueue 之前完成输出数据的读取在 stream 同步之后否则会读到脏数据。// 简化后的推理流程 cudaMemcpyAsync(d_input, host_input, input_size, cudaMemcpyHostToDevice, stream); context-enqueueV3(stream); cudaMemcpyAsync(host_output, d_output, output_size, cudaMemcpyDeviceToHost, stream); cudaStreamSynchronize(stream); // 此时 host_output 才是有效数据显存管理上我建议预分配输入输出缓冲区不要每次推理都 cudaMalloc。PP-OCR 检测模型的输入是 1x3x960x960 的 float约 11MB输出是 1x1x960x960约 3.7MB。预分配好之后反复复用能省下每次几毫秒的分配时间。识别模型的输入是 1x3x48x320输出是 1x40x6625输出比输入大得多约 1MB 的 float 数据也要预分配。实测下来TensorRT 版本相比 ONNX Runtime CPU 版本检测模型从 120ms 降到 12ms识别模型从 80ms 降到 8ms整体 pipeline 从 200ms 降到 20ms 左右。这个提升在需要实时处理的场景里是决定性的。4. 路线三纯 C 手写推理引擎没有依赖的极致轻量4.1 为什么要在 2024 年手写一个 C 推理引擎这个问题我被问过很多次。答案很简单目标设备跑不了任何现成的推理框架。那是一个 ARM Cortex-A7 的嵌入式板子内存只有 128MB存储 256MB系统是裁剪过的 Linux连 glibc 都是精简版。TensorRT 不用想ONNX Runtime 编译出来加上依赖超过 50MBOpenCV 也要 30MB 以上。客户的要求是整个 OCR 程序包括模型文件必须控制在 20MB 以内。所以只能手写。好消息是 PP-OCR 的模型结构并不复杂检测是 DB基于分割识别是 CRNNCNN BiLSTM CTC。坏消息是手写意味着你要自己实现卷积、BN、ReLU、池化、LSTM、全连接、softmax 这些算子还要处理内存布局和量化。4.2 核心算子的 C 实现与内存布局设计先说内存布局。我采用的是NCHW布局和 PyTorch 导出一致这样从 ONNX 里读出来的权重可以直接用不用转置。但 NCHW 在 C 里做卷积有个问题内层循环访问的是 W 维度步长为 1但跨通道访问的步长是 H*W缓存不友好。我的优化是对每个输出通道把输入通道的卷积核展开成一维数组然后做 im2col 矩阵乘。im2col 会消耗额外内存但对于 PP-OCR 这种小模型内存换速度是划算的。// 简化的 3x3 卷积实现未优化版便于理解 void conv3x3(const float* input, const float* weight, const float* bias, float* output, int in_c, int out_c, int h, int w) { int out_h h - 2; int out_w w - 2; for (int oc 0; oc out_c; oc) { for (int oh 0; oh out_h; oh) { for (int ow 0; ow out_w; ow) { float sum bias ? bias[oc] : 0.0f; for (int ic 0; ic in_c; ic) { for (int kh 0; kh 3; kh) { for (int kw 0; kw 3; kw) { int ih oh kh; int iw ow kw; sum input[ic * h * w ih * w iw] * weight[oc * in_c * 9 ic * 9 kh * 3 kw]; } } } output[oc * out_h * out_w oh * out_w ow] sum; } } } }这段代码是教学版实际生产里我会做几个优化。第一是循环重排把ic循环放到最外层这样权重访问是连续的。第二是向量化用 ARM 的 NEON 指令一次处理 4 个 float。第三是权重量化把 float32 权重转成 int8模型体积直接缩小 4 倍推理速度也能提升 2 到 3 倍代价是精度损失约 1% 到 2%。对于嵌入式场景这个 trade-off 完全可以接受。4.3 LSTM 层的实现与状态管理CRNN 里的 BiLSTM 是手写引擎里最麻烦的部分。LSTM 有四个门输入门、遗忘门、输出门、候选状态每个门都是一个全连接层加上逐元素的乘法和加法。在 C 里实现核心是把 PyTorch 的 LSTM 权重正确映射过来。PyTorch 的 LSTM 权重命名是weight_ih_l0、weight_hh_l0、bias_ih_l0、bias_hh_l0其中ih是输入到隐藏的权重hh是隐藏到隐藏的权重。四个门在权重矩阵里是按[i, f, g, o]的顺序排列的每个门占hidden_size行。这个顺序如果搞错LSTM 的输出会完全乱掉但不会报错只是识别结果变成乱码。我当初就是在这里卡了一整天最后逐元素对比 PyTorch 和 C 的输出才定位到问题。// LSTM 单步前向单向 void lstm_step(const float* x, const float* h_prev, const float* c_prev, const float* w_ih, const float* w_hh, const float* b_ih, const float* b_hh, float* h_out, float* c_out, int input_size, int hidden_size) { // gates: [i, f, g, o] 拼接长度 4*hidden_size float* gates (float*)malloc(4 * hidden_size * sizeof(float)); // gates x w_ih^T b_ih h_prev w_hh^T b_hh for (int g 0; g 4 * hidden_size; g) { float sum b_ih[g] b_hh[g]; for (int i 0; i input_size; i) sum x[i] * w_ih[g * input_size i]; for (int h 0; h hidden_size; h) sum h_prev[h] * w_hh[g * hidden_size h]; gates[g] sum; } // 激活 for (int i 0; i hidden_size; i) { float i_gate sigmoid(gates[i]); float f_gate sigmoid(gates[hidden_size i]); float g_gate tanh(gates[2 * hidden_size i]); float o_gate sigmoid(gates[3 * hidden_size i]); c_out[i] f_gate * c_prev[i] i_gate * g_gate; h_out[i] o_gate * tanh(c_out[i]); } free(gates); }BiLSTM 就是正向和反向各跑一遍然后把两个方向的输出拼接。注意反向的那一遍输入序列要倒序输出也要倒序回来再拼接。这个细节如果漏了识别结果会部分正确部分错误很难排查。纯 C 版本的实测性能在 Cortex-A7 1.2GHz 上检测模型约 800ms识别模型约 400ms整体 1.2 秒左右。虽然慢但满足了“能跑”和“体积小”两个硬性要求。如果换到 Cortex-A53 1.5GHz能降到 500ms 左右。5. 路线四纯 Java 推理引擎给 Java 技术栈的交付方案5.1 Java 做推理的可行性分析与技术选型甲方整个后端是 Spring Boot运维体系全是 Java你给他一个 Python 服务他连日志收集和监控都接不进去。所以必须用 Java 实现推理。Java 做推理的选项其实不少DJLDeep Java Library、ONNX Runtime Java API、Tribuo还有自己用 JNI 调 C。我最终选了纯 Java 实现不依赖任何 native 库原因是部署简单——一个 jar 包丢过去就能跑不用管动态链接库的版本问题。纯 Java 推理的性能确实不如 native但差距没有想象中大。JIT 编译后的热点代码性能能到 C 的 70% 到 80%。对于 PP-OCR 这种小模型在服务器 CPU 上单张图 200ms 到 300ms 是可以接受的。如果要求更高可以用 Java 的 Vector APIJDK 16 以上做 SIMD 加速能再提升 30% 左右。5.2 用 Java 实现卷积与矩阵运算的性能优化Java 里做矩阵乘最朴素的三重循环性能很差因为 Java 的数组边界检查会拖慢速度。我的优化策略是分块 循环展开 局部变量缓存。public static void matmul(float[] a, float[] b, float[] c, int m, int n, int k) { // a: m x k, b: k x n, c: m x n int block 64; for (int ii 0; ii m; ii block) { for (int jj 0; jj n; jj block) { for (int kk 0; kk k; kk block) { int i_max Math.min(ii block, m); int j_max Math.min(jj block, n); int k_max Math.min(kk block, k); for (int i ii; i i_max; i) { int a_row i * k; int c_row i * n; for (int kk2 kk; kk2 k_max; kk2) { float a_val a[a_row kk2]; int b_row kk2 * n; for (int j jj; j j_max; j) { c[c_row j] a_val * b[b_row j]; } } } } } } }这个分块矩阵乘的核心思想是提高缓存命中率。把大矩阵切成 64x64 的小块每个小块能放进 CPU 的 L1 缓存避免反复从内存读取。实测下来分块版本比朴素三重循环快 3 到 5 倍。另外a_val用局部变量缓存避免了每次从数组读取JIT 会把它优化到寄存器里。5.3 模型权重的加载与内存映射Java 加载模型权重如果用ObjectInputStream反序列化速度慢且内存占用大。我的做法是把权重导出成二进制文件用FileChannelMappedByteBuffer做内存映射。这样加载 5MB 的模型文件几乎瞬间完成而且多个线程可以共享同一份映射内存占用只有一份。public static float[] loadWeights(String path, int offset, int count) throws IOException { try (FileChannel channel FileChannel.open(Paths.get(path), StandardOpenOption.READ)) { MappedByteBuffer buffer channel.map( FileChannel.MapMode.READ_ONLY, offset * 4L, count * 4L); buffer.order(ByteOrder.LITTLE_ENDIAN); float[] weights new float[count]; buffer.asFloatBuffer().get(weights); return weights; } }这里ByteOrder.LITTLE_ENDIAN很重要。PyTorch 导出的权重默认是小端序而 Java 的ByteBuffer默认是大端序如果不设置读出来的 float 全是错的。这个坑我踩过表现是模型输出全是 NaN排查了很久才发现是字节序问题。Java 版本的实测性能在 Intel i7-10700 上检测模型约 180ms识别模型约 120ms整体 300ms 左右。如果开启 JDK 的 Vector API能降到 200ms 以内。对于大多数后台服务场景这个性能是够用的。6. 五条路线的横向对比与选型建议6.1 性能、体积、部署难度三维对比把五个项目的关键指标拉出来对比选型就清晰了。路线推理耗时检测识别部署体积部署难度适用场景OpenCV ONNX Runtime200ms约 80MB低快速验证、CPU 服务器TensorRT20ms约 150MB中GPU 服务器、实时场景纯 C1200ms约 15MB高嵌入式、资源受限纯 Java300ms约 25MB低Java 技术栈、后台服务工程化封装取决于后端取决于后端中多环境统一交付这个表里的数据是我在具体硬件上实测的换硬件会有变化但相对关系基本稳定。TensorRT 在 GPU 上的优势是碾压性的纯 C 在体积上的优势也是碾压性的Java 在部署便利性上最好OpenCV 路线则是平衡性最好的选择。6.2 不同业务场景下的选型决策树选型其实就三个问题有没有 GPU、内存有多少、技术栈是什么。有 GPU 且追求低延迟直接上 TensorRT别犹豫。没有 GPU 但内存充足512MBOpenCV ONNX Runtime 是最省事的。内存紧张128MB或者要跑在嵌入式上纯 C 是唯一选择。技术栈是 Java 且不想引入 native 依赖纯 Java 实现最合适。如果多个场景都要覆盖那就做工程化封装把推理后端做成可插拔的接口运行时根据环境自动选择。我实际项目里最常用的组合是开发阶段用 OpenCV 快速验证生产环境 GPU 用 TensorRTCPU 用 ONNX Runtime嵌入式用纯 C。这四套代码共享同一套前后处理逻辑只是推理后端不同维护成本可控。6.3 我踩过的三个最深的坑与规避方法第一个坑是坐标映射错误。检测模型输出的框坐标是在缩放后的图上的必须除以 ratio 还原到原图。我早期版本忘了这一步导致识别出来的文字位置全部偏移但文字内容是对的所以很难发现。规避方法在可视化结果上画框肉眼确认框的位置是否正确。第二个坑是 CTC 解码的 blank 索引。PP-OCR 的字符集里blank 通常是索引 0但有些导出模型会把 blank 放在最后。如果搞错识别结果会多出莫名其妙的字符。规避方法打印字符集的前几个和最后几个字符确认 blank 的位置。第三个坑是 TensorRT 的动态 shape 缓存。TensorRT 会为每个遇到的 shape 创建一个执行上下文如果输入尺寸变化频繁显存会持续增长直到 OOM。规避方法限制输入尺寸的种类比如把所有图片都缩放到固定的几档尺寸而不是任意尺寸。7. 工程化封装让五套代码共用一套接口7.1 推理后端的抽象层设计五套代码如果各写各的维护起来是灾难。我的做法是定义一个OcrEngine接口把检测和识别抽象成两个方法。public interface OcrEngine { ListTextBox detect(byte[] imageData); ListTextResult recognize(byte[] imageData, ListTextBox boxes); void close(); }然后每个后端实现这个接口。OpenCV 版、TensorRT 版、纯 C 版通过 JNI 包装、纯 Java 版对外暴露的 API 完全一致。上层业务代码只依赖接口不依赖具体实现切换后端只需要改一行配置。这个设计的好处是前后处理逻辑可以复用。检测后的框筛选、识别后的置信度过滤、结果排序这些逻辑写一遍就够了不用在每个后端里重复实现。我实际项目里前后处理代码占了总代码量的 60% 以上复用带来的收益非常大。7.2 配置驱动的后端自动选择更进一步我做了配置驱动的自动选择。配置文件里写清楚当前环境有什么程序启动时自动选最合适的后端。ocr: backend: auto # auto / opencv / tensorrt / c / java gpu: available: true model_path: /models/det_fp16.engine cpu: model_path: /models/det.onnx embedded: model_path: /models/det.binauto模式下程序会检测 CUDA 是否可用、内存大小、是否有 JNI 库然后按优先级选择。这样同一份代码在开发机上用 OpenCV在 GPU 服务器上用 TensorRT在嵌入式设备上用纯 C不用改代码只改配置。7.3 统一日志与性能监控多后端带来的一个问题是日志格式不统一排查问题困难。我的做法是在抽象层统一日志每个后端只上报关键指标推理耗时、输入尺寸、输出数量、置信度分布。这些指标汇总到一个监控面板上能快速定位是哪个环节出了问题。性能监控上我记录了每个阶段的耗时预处理、检测推理、检测后处理、识别推理、识别后处理。这样当整体延迟升高时能立刻知道是哪个阶段变慢了。实测中我发现识别后处理CTC 解码在长文本场景下耗时占比能到 30%后来对解码做了优化整体延迟降了 15%。8. 一些实操心得与后续扩展方向8.1 模型量化在嵌入式场景的取舍嵌入式场景下模型量化是绕不开的。我把 PP-OCR 的检测和识别模型都做了 INT8 量化模型体积从 4.7MB 8.5MB 降到 1.2MB 2.1MB推理速度提升约 2.5 倍。但精度损失也是实实在在的在标准测试集上识别准确率从 95.2% 降到 93.8%检测的召回率从 96.1% 降到 94.5%。这个 trade-off 值不值取决于你的业务。如果是文档扫描这种对准确率要求极高的场景不建议量化。如果是工业质检里的字符识别字符集有限且字体固定量化后的精度损失可能只有 0.5% 以内那就很划算。我的建议是先量化在真实数据上测如果精度达标就用不达标再考虑混合量化只量化部分层。8.2 多线程与批处理的性能提升单张推理的延迟再优化也有极限真正的吞吐量提升靠的是批处理。TensorRT 和 ONNX Runtime 都支持 batch 推理把多张图拼成一个 batch 送进去GPU 利用率能大幅提升。我实测 TensorRT 在 batch8 时单张平均耗时从 12ms 降到 4ms吞吐量提升 3 倍。但批处理有个前提输入尺寸必须一致。PP-OCR 的输入尺寸随图片变化所以要么把所有图缩放到同一尺寸会损失精度要么做 padding会浪费计算。我的做法是按尺寸分桶把尺寸相近的图放在一个 batch 里桶内做 padding 到最大尺寸。这样既保证了 batch 推理的效率又不会因为过度缩放损失精度。8.3 从 OCR 到通用推理引擎的演进思路这五个项目做下来我最大的收获是推理引擎的本质是算子库 内存管理 调度。PP-OCR 只是一个具体的模型把这套框架抽象出来其实可以支持任意 ONNX 模型。我后来把纯 C 和纯 Java 的实现做了一层抽象算子按需注册模型解析用 ONNX 的 protobuf 定义现在跑个 MobileNet 或者 YOLO 也没问题。如果你也想走这条路我的建议是从最简单的模型开始比如一个只有全连接层的 MNIST把整个流程跑通再逐步加卷积、池化、LSTM。每加一个算子就用 PyTorch 的对应层做数值对比确保输出一致。这个过程很枯燥但每一步都扎实最后得到的引擎才可靠。最后分享一个小技巧调试推理引擎时把中间层的输出 dump 出来和 PyTorch 对比是最有效的排查手段。不要只看最终输出因为误差会逐层累积最终输出不对的时候你根本不知道是哪一层开始错的。逐层对比能精确定位到出问题的算子。这个习惯帮我省下了大量猜测的时间。