ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从OpenCV到自研引擎:PP-OCR五个部署项目全解析

从OpenCV到自研引擎:PP-OCR五个部署项目全解析 去年年底整理了下自己做的几个 PP-OCR 相关的开源项目总共 5 个从最早用 OpenCV DNN 跑通流程到后来用 TensorRT 把延迟压下去再到为嵌入式设备和纯内网环境手写了纯 C、纯 Java 的推理引擎。整个过程踩的坑比写代码时间多得多今天把思路、选型、细节和坑都拿出来聊聊。如果你打算在项目里集成 OCR或者想了解一套模型能不能从大框架迁移到自研引擎这篇应该值得你花几分钟。这个系列项目正好覆盖了三种典型部署场景能跑 Python 和 GPU 的开发环境、需要极致性能的 TensorRT 生产环境、以及没有现成框架的封闭环境。纯 C 引擎最后跑在了一个只有 20KB 空闲 RAM 的单片机上纯 Java 引擎跑在 Android 和桌面 JVM 上所以不是玩票是真的可以用。我把五个项目的核心代码都拆开了今天重点说每一步为什么这么做以及哪些地方特别容易翻车。1. 为什么是 PP-OCR项目规划与选型逻辑1.1 PP-OCR 真正打动我的三个优势我选 PP-OCR 做底子不是因为它最“新”而是因为它的工程化程度在开源 OCR 模型里确实最稳。第一是模型结构清晰检测、方向分类、识别三个模块完全解耦可以独立替换这对后期做自定义推理引擎特别重要。第二是训练参数和预训练权重齐全PaddleOCR 仓库里给了一整套从数据合成到量化部署的流程省掉了大量自研算法的时间。第三是模型体积和精度的平衡做得很好轻量级的中文识别模型只有 4M 左右这在资源受限的设备上是决定性问题。不过也要提醒一句PP-OCR 是 PaddlePaddle 训练出来的模型导出 ONNX 之后才能喂给 OpenCV 或者 TensorRT这个过程里会有很多细节后面我会逐个说。它的模型结构也不像一个简单的 CNN尤其是识别模型里的循环结构或者注意力模块在和推理引擎对接时对算子的完整性要求很高。1.2 五个项目的整体路线图我的五个项目不是一个并行列表而是一条演进线先用 OpenCV DNN 把 PP-OCR 的端到端流程跑通验证图像处理部分没有问题然后上 TensorRT解决性能瓶颈接着因为一个客户要求在纯 C 环境下运行我把模型权重离线导出成自定义格式手写了纯 C 推理引擎再之后为了覆盖 Java 服务端和 Android又在纯 Java 上重写了一遍推理核心最后把这几个后端统一成一套接口支持运行时切换。这个路线的好处是每走一步上一步踩的坑都成了下一步的向导。比如 OpenCV 阶段发现某些算子不支持到了 TensorRT 阶段就知道哪些结构需要先调整到了自研引擎阶段又会发现原来框架自动处理的内存布局和算子融合现在全得自己来。如果你也想走类似路线建议按这个顺序来不要一上来就手写引擎——先有一个能跑通的基准实现后面所有性能优化都有对照。2. 第一个项目OpenCV DNN 模块推理 PP-OCR2.1 用 OpenCV 加载 PP-OCR 模型要解决哪些问题OpenCV DNN 最大的价值是不需要安装 PaddlePaddle只用一份 ONNX 模型文件就能跑推理。我当时用的 OpenCV 4.5.4 版本加载 PP-OCRv2 的三个 ONNX 文件都没有问题。但要注意ONNX 只是标准格式OpenCV DNN 对算子的支持并不完整。PP-OCR 检测模型里有可变形卷积DCNOpenCV 4.x 有两版实现了支持但需要在导出 ONNX 时把 DCN 的算子节点配置正确否则会报“Unknown layer”异常。我的做法是先单独测试每个模型是否能加载再测试是否能跑通。加载用cv::dnn::readNetFromONNX即可但真正的坑在输入 blob。OpenCV 的blobFromImage默认按 BGR 读取、不执行归一化而 PP-OCR 期望输入是 RGB 且已经做了相应归一化。所以不能直接用默认参数我写成这样cv::Mat blob cv::dnn::blobFromImage(img, 1.0, cv::Size(640, 640), cv::Scalar(), true, false);这里第三个参数是输入尺寸第四个是 mean我传空因为把归一化放到后面单独做。第四个参数true表示把 BGR 换成 RGBfalse表示不裁剪。然后逐像素除以 255。检测模型在训练时是除以 255 作为归一化没有减均值除方差那些东西。如果你不这样做检测结果会偏得非常离谱——我一开始在上面栽过调了半天方向分类器结果问题是输入RGB错了。2.2 输入图像预处理与结果后处理的不变式PP-OCR 的三个模型预处理和后处理是完全不同的这点很多人忽略。检测模型输入尺寸固定为 640x640输出有四个通道需要做 sigmoid得到概率图再用局部阈值做二值化最后用连通域或者轮廓查找得到检测框。方向分类器输入是 48x192归一化是减 0.5 除 0.5输出是二分类概率是否旋转 180 度。识别模型输入高度固定 32宽度不定通常是按文本行实际宽度缩放至 32 的高度宽度尽量保持比例且是 32 的倍数它的输入归一化同样是减 0.5 除 0.5输出是 6625 个字符类的概率分布我用的中文模型字符表是这个数换代码库可能不同。这些细节如果只照搬 PaddleOCR 的 Python 代码容易忽略它们对推理引擎的依赖。比如识别模型的宽度是动态的OpenCV DNN 里的input是 4D blob第三维和第四维可以在运行时设置但模型在训练时用的 shape 是 [1,3,32,320] 之类的固定宽度导出 ONNX 时如果固定了宽度动态宽度推理就只能补 padding识别结果里会有大量空字符所以导出的识别模型一定要做成动态 shape。在 OpenCV 中readNetFromONNX会自动识别动态尺寸然后setInput时传的实际 blob 尺寸会作为输入尺寸这基本没问题。2.3 OpenCV DNN 推理的实测性能与瓶颈OpenCV DNN 在 GPU 上可以使用 CUDA 后端但我当时在普通 PC 上只测了 CPU。一张 640x640 的检测图在 i5-8500 上大约要 300ms 到 500ms识别 32 宽的文本行大概 20ms 到 50ms整体端到端在一张有 5 行字的图上要 500ms 甚至更多。这个速度做离线识别可以但要实时视频流就歇菜了。性能瓶颈主要来自两方面一是 OpenCV DNN 的自研算子没有做特别深度的优化很多底层计算不如专门推理库高效二是 PP-OCR 检测模型的特征提取部分计算量确实大尤其是 DCN 和 FPNeck 那一套。这个阶段更多是“先把流程跑对”为后面的 TensorRT 和自研引擎提供精度基准。我在这个阶段把三个模型的输入输出全部封装成接口并对同一批测试图片做好标注和结果保存后面每个项目上线前都用这批图回归避免优化后结果变坏。3. 第二个项目TensorRT 加速 PP-OCR3.1 从 ONNX 到 TensorRT 的转换流程TensorRT 给 OCR 带来的提升是质变。我的 GPU 是 RTX 3060用 TensorRT 8.5 把检测模型从 OpenCV 的 500ms 干到了 15ms 左右识别模型从 40ms 干到了不到 5ms。转换流程本身很简单官方工具trtexec一行命令trtexec --onnxdet.onnx --saveEnginedet.engine --fp16 --minShapesinput:1x3x640x640 --optShapesinput:1x3x640x640 --maxShapesinput:8x3x640x640但识别模型是动态宽度就要显式指定动态 shape 的范围。比如--minShapesinput:1x3x32x32 --optShapesinput:8x3x32x320 --maxShapesinput:8x3x32x640。如果不给动态 shapetrtexec 会按模型导出时的固定 shape 生成 engine后面输入其他宽度就会报“Profile not found”。这是新手最容易搞错的地方。转换时还可能遇到算子不支持的情况尤其是 PaddleOCR 导出 ONNX 后某些算子会以子图或者自定义节点形式出现。比如 GRU 和 LSTM老版本的 TensorRT 支持不佳需要升级到 8.2 以上或者用 plugin。我的经验是先按原模型转如果报错去查它是哪个算子如果在 TensorRT 算子文档里不存在那就得考虑在导出 ONNX 时把模型结构改掉比如把双向 LSTM 转换为矩阵运算展开。这个过程比较痛苦但为了性能值得做。3.2 模型拼接与端到端优化TensorRT 阶段我还做了一件事把检测、方向分类、识别三个模型在业务层面拼成一个 pipeline而不是分别调用三次推理后再走 CPU 上的后处理。虽然模型在硬件上没融合但通过 CUDA 流和显存复用可以减少 H2D/D2H 拷贝。同时我把检测输出框的裁剪、缩放也做到了 CUDA 上避免中间结果反复拷贝到 CPU。这里要注意的是 TensorRT 的 Engine 文件和显卡型号、TensorRT 版本强绑定。换 GPU 或者升级 TensorRT 得重新转 engine所以工程上不能只在代码里绑死一个 engine 文件。我是在应用启动时检查设备的 compute capability然后选择对应版本的 engine 文件。还有一个容易忽略的坑TensorRT 默认是多线程安全的吗实际上同一个IExecutionContext不能并发执行多线程推理要为每个线程创建独立的 context但 engine 可以共享。我在这里踩了线程安全问题后来改成thread_local的 context 才解决。3.3 TensorRT 精度和性能实测对比我用了 20 张不同场景的图对比 OpenCV 和 TensorRT 的识别结果。TensorRT 用 FP16 后检测框和识别文本基本一致只有个别小字漏检或者置信度稍微下降整体准确率损失小于 0.5%。如果对精度敏感可以保留 FP32速度仍然比 OpenCV 快数倍。另一个可选项是 INT8 量化需要校准数据集我试过一次速度再提升 30%但精度掉了 2% 到 3%感觉在 OCR 场景不值当。这个项目最终给我的结果端到端识别一张高清文档图从 OpenCV 的 1.2 秒降到了 30ms 以内真正把 OCR 从“能看”变成了“能用”。更重要的是它验证了 PP-OCR 的模型结构可以被外部引擎完整支持这给了后面两个自研引擎很大的信心。TensorRT 阶段积累的模型预处理和后处理的稳定实现也直接复用到了纯 C 和纯 Java 版本里。4. 第三个项目纯 C 自研推理引擎4.1 为什么要自己用 C 手写一个推理引擎需求来自一台只有 16MB 内存、没有 Linux、没法装任何库的 ARM 工业设备。OpenCV 编译不开 CUDATensorRT 根本不存在唯一能依赖的只有 libc。当时摆在面前两条路要么在板子上跑 Paddle Lite但 SDK 版本太老模型格式不兼容要么把 PP-OCR 跑通需要的算子自己实现一遍。我选了后者。很多人问为什么不用已有框架框架本身依赖太多哪怕静态编译也会引入文件系统、动态库、线程等复杂逻辑在资源极度受限的环境里不可控。自研引擎最大的优势是可以把执行路径压到最短加载权重、分配内存、按拓扑继续计算。模型文件不再用 ONNX而是写一个 Python 脚本把 Paddle 模型里面的权重逐一导出来存成自定义的二进制格式里面有算子类型、输入输出 shape、权重数据C 引擎只需要解析简化的协议。4.2 模型定义、算子实现与内存分配策略我先做了一个离线转换脚本把 ONNX 模型遍历一遍把每个节点的类型、输入名、输出名、权重全部序列化到自定义格式里。因为 PP-OCR 模型结构相对固定不追求做一个通用 ONNX 解析器只针对 PaddleOCR 派生的 ONNX 子集实现解析比如 Conv、BatchNorm、Relu、MaxPool、AveragePool、Gemm、LSTM、Concat、Split、Resize、Sigmoid、Softmax 这些算子。算子实现方面卷积采用了 im2col GEMM 的方法。先把输入图像 Block 展开成二维矩阵然后用矩阵乘法完成卷积。虽然内存占用比直接卷积多一些但在可控范围内而且易写易优化。BN 层在推理时其实可以折叠进 Conv 的权重和偏置里这是性能优化的重要一步。把 BN 的 scale、shift 融合进 Conv 内核可以省掉一整层遍历和计算速度提升 20% 以上。我在第一批测试时没做融合后面加上后效果显著。内存分配策略则直接决定能不能跑起来。我先解析一遍整个模型拓扑计算每一层的输入输出大小然后用一个内存池按生命周期进行复用。比如检测模型特征图很大中间特征图不断创建销毁的话malloc 很容易造成碎片所以我为每个可能的 shape 预先分配好 buffer并通过引用计数管理生命周期。LSTM 内部有三个门计算有多个中间张量我都复用固定的 buffer。最终整个模型推理过程中 malloc 的次数只有个位数基本不产生堆碎片。4.3 纯 C 推理在嵌入式设备上的实际结果我最初在 PC 上调试后来交叉编译到 ARM Cortex-A7 上主频 1GHz内存 16MB。检测模型输入 320x320识别模型输入 32x160。端到端识别一张小图的延迟在 1.5 秒到 2 秒之间比起 PC 慢了不少但在这块芯片上已经算能接受。模型权重整体只有 6MB 左右内存池峰值也就 10MB 以内刚好塞进限制里。这个阶段的体验是自研引擎最耗时间的不是算符本身而是调试。输出结果差一点不知道是权重解析错了还是卷积算错了。我后来写了一个通用的模型比对工具同样输入把自研引擎中间层的结果和 Paddle 的 Python 推理结果逐层比较定位到具体层再翻代码。建议任何自研推理引擎都要做这个工具否则排查精度问题等于大海捞针。5. 第四个项目纯 Java 自研推理引擎5.1 Java 部署 OCR 的典型痛点用 Java 做 OCR 服务有天然痛点PaddlePaddle 的 Java 接口要么依赖 JNI要么依赖外部进程部署时非常麻烦。PyTorch 的 Java 端到端也有类似问题。很多 Java 团队最终都会选择把 OCR 放到 Python 微服务里然后通过 HTTP 调但这会增加一跳网络也增加部署复杂度。我接到的需求是一个纯内网离线环境只能跑一个 Java 进程不允许启动其他语言写的服务。所以纯 Java 版引擎顺理成章被提上日程。当时也有个折中方案用 Java 调 OpenCV 的 Java API。但 OpenCV Java 包底层还是 native 库依然涉及 so/dll 依赖。如果不能拿来跑在这套环境里那么纯 Java 实现模型推理就成了唯一选择。除了 Java 标准库之外不依赖任何第三方这是我给这个项目定的硬指标。5.2 在 Java 里复刻 C 引擎的算子实现Java 引擎没有直接照搬 C 代码而是重新设计了一套基于float[]和int[]的张量结构。我特意不用 N 维Tensor对象因为 Java 对象头和装箱开销非常夸张。表示一个二维矩阵我用一个float[] data加int[] shape再加一个int offset就够了所有运算都基于这个结构操作。算子实现上卷积还是用 im2col gemm。不过 Java 没有本地矩阵库我手写了一个简单的gemm循环经过三重展开和循环重排并且保证内层循环中数组访问尽量连续。在多数 JVM 上如果代码写得规整JIT 会做自动向量化实际速度不会比 C 慢太多但内存占用会明显高一些因为 Java 数组本身有对象头多维数组更是可怕所以我尽量避免创建多维数组。LSTM 实现是另一个麻烦点。PP-OCR 识别模型若包含双向 LSTM则序列长度等于图像宽度推理时如果宽度达到 320序列就 320LSTM 循环一共 640 次双向。在 Java 里640 次小矩阵乘法如果每次都新建矩阵对象GC 会非常痛苦。我用预分配的临时矩阵和分段计算来解决把 LSTM 的输入、遗忘、输出、候选四个门的计算一起做而不是拆成四个独立的小矩阵乘法减少循环遍历。5.3 纯 Java 推理引擎的性能优化与 GC 调优实测下来纯 Java 引擎在桌面 JVM 上做检测 320x320大概需要 200ms识别 32x160 大约 30ms。比 C 引擎在 PC 上慢 1.5 到 2 倍但作为服务端接口单线程也足够支撑每秒 3 到 5 次 OCR。真正的问题在 GC我最初的版本在每次推理时产生大量float[]临时数组老年代疯狂增长频繁 Full GC 导致延迟毛刺很大。后来我引入了简单的对象池把每个线程的临时 buffer 做成ThreadLocal每次推理从池里取用完归还。这个改动让 Full GC 从每秒两三次降到几分钟一次。另一个关键优化是固定线程数。在一个独立的 Java 进程里跑推理不要像处理普通 web 请求那样开几十个线程并发推理大部分时间会花在线程切换和锁竞争上。我按 CPU 核心数减一设置固定线程池并为每个线程预分配独立的OcrEngine实例彻底避免共享缓冲区带来的锁。最终在 8 核服务器上稳定 QPS 比初始版本提升了 4 倍。6. 第五个项目统一多后端推理接口与工具链6.1 不同后端差异的抽象设计五个项目做到最后我发现每次都要写一套前端调用代码OCR 的检测、方向分类、识别流程是固定的但执行引擎不同。于是我定义了一个统一接口public interface OcrEngine { OcrResult detectAndRecognize(Mat image); }在 C 这边也有对应的函数指针版本。接口背后由工厂方法根据配置创建不同后端。比如 Java 版本可以用EngineType.TENSORRT、EngineType.OPENCV、EngineType.PURE_JAVA。这样业务代码只需要面向接口切换后端时改一行配置。接口抽象时一定要把“输入图像格式”统一不能一个后端接Mat另一个接byte[]。我给 C 引擎和 Java 引擎都定义了一个不依赖具体框架的简单图像结构包含像素指针/引用、宽、高、通道数。然后各后端自己适配。这套设计避免了业务层和后端耦合也让 5 个开源项目可以在同一个 demo 工程里跑起来。6.2 模型一致性校验与自动化回归统一接口的另一个好处是可以做自动化对比。我写了一个回归脚本用同一批 100 张测试图分别跑 OpenCV 后端、TensorRT 后端和自研引擎然后计算检测框 IoU 和识别文本的编辑距离。每次修改算子后都要跑一遍确保改动不引入精度回退。这个脚本救了我好几次比如在优化卷积时有一处内存索引写错检测精度从 90% 掉到 80%个别图完全跑偏要不是有回归测试很难发现。为了方便排查脚本还会输出每个引擎每一层的统计信息包括均值、方差、最大值。不同引擎在同一层的这些数值应当非常接近如果偏差超过阈值脚本会自动标记。这个“逐层指纹比对”是从自研引擎阶段沿用下来的后来成了整个工具链的核心。6.3 这套工具链最终带来的效率提升把这五个项目放在一起看最初的 OpenCV 版本是基准TensorRT 版本是性能标杆C 和 Java 版本是覆盖极端场景。统一工具链后我在新设备上部署 OCR 只需要三步导出 ONNX - 转成目标引擎格式 - 跑回归测试。以前每次换平台都要重写前端和后处理现在只需要写一个适配层。我后来还加了模型热更新功能识别模型文件放在外部指定目录接口监听文件变化校验 md5 后自动加载新引擎。这个功能在 Java 端做很简单C 端靠信号量实现。不过需要注意更新模型之前必须先把所有正在推理的任务处理完否则新老模型并发使用同一块内存会出问题。7. 常见问题与避坑实录7.1 检测框坐标缩放错误从检测模型拿到的是相对坐标还是绝对坐标这个问题困扰了很多人。PP-OCR 检测输出框的坐标是在输入图像的分辨率下计算的也就是说如果检测模型输入是 640x640而原图是 1920x1080直接拿输出坐标去原图裁剪就会偏很多。正确做法是记录缩放比例检测输出后按比例映射回原图。我最初偷懒直接把输出框当原图坐标用结果边界框全偏到左上角。后处理函数里一定要把“模型输入尺寸”和“原图尺寸”传进来算清楚 scale_x 和 scale_y。7.2 方向分类器结果不稳定的原因方向分类器虽然精度很高但它的输入是文本区域裁剪后的图片如果裁剪时没有做角度矫正分类效果就下降。另外方向分类器的训练数据里很多图本身就是正着的反转样本相对少遇到极端长图超过模型输入的宽高比时分类概率会偏向 0 度。我在 pipeline 里加了一个规则当方向分类器输出概率差小于 0.6 时不旋转文本行保持原样。这个小策略把最终识别准确率提高了 1.2%。别小看这 1.2%在身份证、票据识别这类场景里连续 100 张图可能因为多错几个字就废掉。7.3 自研引擎的精度逐层比对方法自研引擎精度不对我强烈建议做逐层比对。Python 侧用 Paddle 的模块拿到每一层输出C 引擎里也输出每一层的结果然后把两者导成二进制文件写个小脚本算最大绝对误差。通常误差来源有这几个卷积没有正确翻转 kernel、BN 折叠时 epsilon 用错、LSTM 的初始化隐藏状态没有清零、Softmax 计算时减没减最大值。我印象最深的是 LSTM 权重排列方式和 PyTorch/Paddle 不一样差一个分组维度结果只有个别字错不逐层比对根本发现不了。7.4 内存碎片和驱动兼容问题汇总纯 C 引擎在嵌入式板子上跑稳定之后有一次连续运行 48 小时后内存越用越多排查下来是图像输入缓冲区每次重新 malloc而板子上的堆非常小碎片化导致无法释放整块连续内存。后来我改成固定从对象池里分配图像 buffer才彻底解决。TensorRT 方面除了版本绑定显卡驱动版本也会影响 engine 运行。如果驱动太旧即使 TensorRT 版本满足也会运行时报错。建议部署前先检查nvidia-smi驱动版本再对照 TensorRT release notes 里的兼容矩阵。老显卡比如 GTX 1070如果 TensorRT 版本太高可能会因为 CUDA 架构不在支持列表里而直接加载失败。这时候要么降级 TensorRT要么用低架构版本编译或者干脆用 OpenCV 后端顶一下。最后再分享一个小技巧如果你也要做类似的跨平台 OCR别一开始就把所有代码写死到一个框架上。先跑通一个最朴素的实现比如 OpenCV 版本把它当标准答案。后续不管是 TensorRT 还是自研引擎都用同一套回归图去验证千万别嫌麻烦。我在做自研引擎时就是这么干的结果很多看起来特别诡异的问题一回归就现原形省下来的排查时间远远超过写工具的时间。另外权重格式一定要自己设计得足够简单不要直接用 ONNX 解析除非你真需要支持任意模型。针对性设计反而能让你在每个平台上都轻装上阵。|eot_id|
返回列表