资讯详情

纯C语言实现PP-OCR推理运行时:轻量级嵌入式部署新方案

📅 2026/9/9 14:08:29 | 华诺云谱 👁 阅读
纯C语言实现PP-OCR推理运行时:轻量级嵌入式部署新方案
折腾了几个月我手里这个纯 C 的 OCR Runtime——lw.PPOCR.C今天发到 preview.5 了。先交代一下它是干嘛的这是一个不依赖 Python、不依赖 PaddlePaddle 框架、甚至完全不用 C 就能跑 PP-OCR 系列模型的推理运行时。换句话说你想在嵌入式板卡、老旧 Windows 工控机、资源受限的 Linux 环境里本地塞一个 OCR 能力除了 Tesseract 和一堆云端 API 之外现在多了一个能本地跑、体积可控的开源选项。写这份发布记录一方面是记录 preview.5 这版的改动另一方面也想把几轮迭代背后的设计取舍、踩坑过程和真实数据摊开讲。文章会偏源码视角适合正在做模型部署、或者正要踩进“模型推理移植”这个坑的读者。已经用 Python 版 PaddleOCR 跑通业务、想进一步降本提效的团队也能从中找到一些可复用的方案。1. 为什么会有 lw.PPOCR.C被现场需求逼出来的纯 C 运行时1.1 事情是从一块 ARM 板卡开始的早先做一个工业读码项目需要在 ARM 板卡上本地识读铭牌信息。原型阶段用的自然是 Python 版 PaddleOCR模型效果确实没话说但到了交付阶段几条限制开始变得致命内存占用Python PaddlePaddle OpenCV 一起跑起来光基础内存就 300MB 往上现场板卡总共才 512MB启动时间冷启动经常 3 到 5 秒部分设备要求开机 2 秒内进入工作状态交叉编译目标板卡 CPU 是 ARMv7PaddlePaddle 官方轮子根本没有这个平台想自己交叉编译依赖链长得让人头疼运维成本Python 环境在客户现场出了任何问题现场人员处理起来很费劲还不如交付一个静态库省心。这些限制不是某一个项目独有的做边缘视觉的同行大概率都撞过类似的墙。当时第一反应是找现成的轻量推理方案所以我把 ONNX Runtime、NCNN、MNN、TFLite 都过了一遍。但每个方案都有自己的别扭之处ONNX Runtime 功能全但体量大交叉编译步骤多NCNN 和 MNN 虽然移动端友好底层却是 CAPI 也基本按 C 设计想嵌到一些不能开 C 异常、禁用 RTTI 的裸机或 RTOS 环境里还是很别扭。于是动了“自己写一个纯 C runtime”的念头。1.2 对比了一圈剩下“纯 C”这个答案为什么非要用纯 C一句话纯 C 是所有语言里集成成本最低的通用语言。C 编译产物遵循稳定的 ABI任何语言都能通过 C 接口调用它。C 写得克制一点就能在裸机 RTOS、嵌入式 Linux、Windows、macOS 上无缝编译没有 C 标准库、异常、RTTI 这些重量级约束。对做集成的人来说一个纯 C 的静态库扔进任何构建系统里都能直接链接比让客户装 Python 环境、配 CUDA、拉一堆依赖要舒服太多。这个判断在 preview.1 阶段就被验证了一位做上位机的同事用 C# 通过 P/Invoke 调我的 C API半天就把 OCR 接进了他的 WinForms 程序另一个做网关的团队直接把这个 runtime 编译成 .so用 Go 的 cgo 调用。那一刻我意识到“纯 C”不是复古而是最稳妥的互操作策略。1.3 PPOCR 的名字说明了两件事项目叫 lw.PPOCR.C其中 PPOCR 已经表明它和 PaddleOCR 生态的关系模型的权重、预处理规范、后处理逻辑全部对齐 PaddleOCR 官方仓库检测用的 DBNet、方向分类、识别用的 CRNN 结构也全部来自开源模型。lw.PPOCR.C 不重新训练模型也不改动模型结构只做一件事——用纯 C 实现一套能加载并执行这些模型的推理运行时。这么定位有几个好处模型效果有官方基础保证不需要自己造数据和训练同时 runtime 是独立的一层可以被随意嵌入各种工程。代价是官方模型结构一旦变化runtime 里的算子就要跟着补。这也是项目一直保持在 preview 状态的原因——兼容新模型永远在处理列表中。2. preview.5 的更新清单模型、平台与 API 三个方向preview.5 说起来是第五个预览版但不是简单打几个补丁而是把整体架构又往前推了一步。改动集中在模型兼容、平台支持、API 稳定性三个方向。2.1 算子补齐识别网络从“能跑”到“跑得完整”之前的版本里文本检测网络 DBNet 已经能完整跑通但识别网络 CRNN 里的双向 LSTM 一直做得比较凑合。preview.5 把这一块彻底重写了双向 LSTM 的前向计算现在按时间步展开支持可变序列长度不再要求输入必须 resize 到固定宽度增加 CTC 贪心解码和前缀 Beam Search 两种解码方式默认用 CTC 贪心追求精度时切 Beam Search代价是耗时上涨补齐识别网络中用到的 LeakyReLU、BatchNorm 推理期折叠、全连接层等算子。识别网络完整跑通之后整个 OCR 流水线才算真正闭环。之前很多场景测试只能到检测框现在能从图上直接输出字符串可交付性一下强了很多。2.2 平台支持Windows、ARM Linux 与 muslpreview.1 到 preview.4 主要在 x86_64 Linux 上验证这个版本补了两个重要平台Windows x64 / x86MSVC 和 MinGW 都能编译静态库输出 .lib/.aARM Linuxaarch64 / armv7支持 ARMv7 硬浮点并在树莓派 4B 上完整验证过musl针对 Alpine 这类精简发行版提供了独立编译链配置。新增平台不是换编译器就行不少问题出在字节对齐和大小端假设上。比如模型转换工具生成的权重文件早期用了一个自定义结构头在 x86 上因为结构体默认对齐不同编译器处理结果会有细微差异。后来改成显式逐字段打包彻底摆脱对齐策略影响。2.3 后处理管线重写与内存池收紧之前后处理里用了一个外部图像库来做二值化和轮廓查找preview.5 里全部换成自研实现DBNet 输出的概率图先做阈值二值化再做连通域标记最后用旋转矩形拟合文本行文本框四点坐标一次到位不再依赖外部库内存池从“按网络层级分配”改成“按模型实例分配”整条流水线对同一实例复用缓冲区高并发场景下内存峰值下降明显。2.4 部分实测数据一览以下数据是这几天在几台设备上跑同一张 640×640 测试图得到的模型为 PP-OCRv4 mobile 系列det cls rec单线程仅供参考平台编译器检测识别总耗时峰值内存x86_64 Linuxi5-8500TGCC 12 -O2约 95ms约 42MBx86_64 Windowsi7-10700MSVC /O2约 110ms约 45MBARM Linux树莓派 4BGCC 10 -O2约 620ms约 48MBARM LinuxRK3568交叉编译 -O2约 780ms约 50MB同样的模型在 Python 版 PaddleOCR 下i5-8500T 大约 180ms 左右包含前后处理。纯 C runtime 虽然算子优化还比较粗糙但因为少了 Python 解释开销和框架调度开销反而快了一些。内存方面Python 版在同样图片上的峰值常超过 300MBC 版的优势比较明显。2.5 这版修的典型问题简单列几个有代表性的修复修复 BatchNorm 折叠后某些输入通道顺序与原模型不一致导致的精度退化修复 CTC 解码时 blank 索引在不同字典版本下不一致的问题修复多实例同时创建时全局日志缓冲区竞争问题修复 armv7 上未开启 NEON 导致部分算子走慢路径的问题。这些 bug 的具体细节后面“踩坑”章节展开说。它们不是一个局部代码错误每一类都对应一个通用的工程问题值得单独琢磨。3. 技术骨架拆解一个纯 C Runtime 是怎么构成的很多人一听“纯 C 推理引擎”第一反应是难、代码量大。实际拆开看如果目标只是跑特定几个模型而不追求通用训练框架那种什么模型都能跑的能力工程量完全可控。lw.PPOCR.C 的构成主要分四块。3.1 模型不直接用 .pdmodel而是先转换Paddle 官方导出的 .pdmodel 是 protobuf 格式里面携带完整计算图描述、算子属性和变量定义解析代码量大对内存受限设备也不友好。我的做法是写一个 PC 端转换工具lw-ppocr-convert把 .pdmodel/.pdiparams 转成自定义.lwmodel格式。转换后的文件格式非常直接文件头魔数 版本 模型类型标记 输入输出张量元信息 算子序列 权重数据段。每个算子记录类型、输入输出索引、必要属性参数权重数据统一放文件末尾并在记录里给出 offset。这样 runtime 端解析器不到 500 行 C 代码就能实现直接绕开 protobuf 全套解析逻辑。代价是发布时要带一个转换步骤模型不能直接用 Paddle 原始文件。但对部署场景来说这不是坏事转换过程本身就是一次模型版本固定和结构校验反而减少运行时出错的可能。3.2 张量、内存池与算子表runtime 内部用结构体表示张量记录维度、数据类型、数据指针和数据归属。当前支持 FP32 和 FP16 两种输入格式FP16 权重会在加载时自动转 FP32后续再决定是否直接做半精度计算。关键设计是每个模型实例维护一个内存池池子大小在创建实例时按模型最大中间张量估算分配。推理过程中所有算子的输出内存都从池子里取不调 malloc/free。好处有两个一是避免内存碎片二是提升速度。实测下来推理过程中的内存分配开销基本可以忽略。算子表就是函数指针数组按枚举值索引。新增算子就是在表上挂一个函数满足输入张量数组、输出张量数组、属性参数三套约定即可。接口统一之后卷积、池化、激活、LSTM 各归各的调试起来非常清晰。3.3 三阶段 Pipeline检测 / 方向分类 / 识别整体流水线完全对齐 PaddleOCR 的思路检测阶段输入图短边对齐到 640 做等比例缩放归一化后进入 DBNet输出概率图随后在概率图上做自适应阈值、膨胀、连通域标记、最小外接矩形得到文本候选框方向分类阶段把每个候选框裁剪成小图resize 到 48×192 左右送入方向分类器判断是 0° 还是 180°。如果判断为 180°就把图像旋转后再送识别识别阶段文本行图像统一高 32、宽动态调整送入 CRNN。识别网络输出每一帧在字典上的概率分布经 CTC 解码得到索引序列再通过字典映射成字符串。三个阶段层层递进任一步出错都会表现为整张图的识别结果变差。调试时一定要分阶段测输入单张检测图看框得准不准输入单个文本行看识别对不对。链路清晰之后排查效率会高很多。3.4 DB 文本检测后处理为什么要自己写最早做后处理时图省事直接把 OpenCV 拉进来用findContours和minAreaRect。但 OpenCV 的 C API 和“纯 C runtime”的定位是冲突的即使它核心是 C链接它同样意味着把一大堆依赖带进目标环境而且体积对部分嵌入式平台并不友好。preview.5 之后后处理全部用纯 C 重写。核心是连通域标记算法用经典两次扫描配合并查集做等价类合并代码 200 行出头。接下来求每个连通域的最小外接矩形用旋转卡壳思想先求点集凸包再按角度扫描最小面积矩形。实现时容易绕进去建议把流程拆成“凸包计算 → 旋转扫描 → 四点排序”三段每段单独做单元测试。这样自研代码量虽然增加了但 runtime 对外只有一个静态库没有任何强依赖客户现场的部署焦虑会降低非常多。4. 从 preview.1 到 preview.4踩过的坑比代码多这部分不写进 release notes但我觉得比 release notes 更有参考价值。这些坑是一点点试出来的回头看每一个都对应一类常见的“自己写推理引擎”的工程问题。4.1 训练框架版本不同导出的模型结构天差地别最早我只适配了 PaddleOCR 官方导出的模型跑得还算顺。后来想支持“自定义模型”——用户用 PaddleOCR 训练脚本训出来的模型在结构上往往和官方预训练模型有细微差别有些版本导出时多了一个额外算子有些把插值算子的布局参数改了有些直接多了一个预处理子图。根子在于模型结构的碎片化。我的应对方案有两层第一转换工具里对常见差异做兼容能融合的算子尽量融合第二转换不通过时给出清晰的报错信息把“哪个算子不认识”直接打在日志里而不是让用户在设备端对着黑盒猜。这个思路延续到了所有后续版本。4.2 浮点“差一点”结果就差很多有一次在 x86 上自测效果很好换到 ARM 板子立刻变差。排查到最后发现有几个激活层实现里用了不同的近似计算更隐蔽的是某个卷积算子在 x86 上走了 FMA 指令而 ARM 板子没开 NEON浮点运算顺序不同导致误差累积最终在靠近决策边界的分割图上产生了肉眼可见的差异。这件事之后我给算子实现定了一条规矩所有数值路径必须是确定性实现不依赖特定指令集的融合计算优化指令集时用独立编译单元写 SIMD 版本默认路径和优化路径分开。编译器层面-ffast-math这类激进优化一概不开哪怕能带来明显加速。对推理引擎来说让用户跑出来的结果可复现比跑得更快更重要。4.3 动态 shape 是嵌入场景的头号敌人Paddle 模型导出时有些节点的张量维度是 -1表示动态维度。PC 上无所谓但在固定内存池设计下动态 shape 会让内存规划失效。preview.3 以前遇到动态 shape 我就直接动态分配结果就是内存碎片和不可控的峰值。现在的处理方式是转换工具把动态维度标记出来runtime 在创建实例时通过 host API 显式指定实际输入尺寸再按上限分配内存池。比如检测网络输入固定为 1×3×640×640识别网络高度固定 32、宽度上限 640。如果未来要支持任意分辨率至少把内存池拆成“固定缓冲 溢出缓冲”两级。4.4 多实例并发的线程安全问题出在内存池preview.2 的时候有人反馈两个线程各创建一个实例、并行跑识别会偶发崩溃。排查下来不是模型数据被改而是全局内存池分配器里用了共享空闲链表两个实例同时取内存时产生竞争。修复方案说起来简单每个实例独立内存池全局只保留只读算子表和常量数据。之后自然线程安全。教训是推理引擎的并发安全不能靠加锁解决要靠消除共享可变状态。锁只能保证不崩但会带来不可控的等待把共享状态拆干净才是一劳永逸的办法。5. 用 20 行 C 代码跑通一张图发布会落到代码上。这个 runtime 的 API 设计目标是普通用户只看一个头文件就能把 OCR 能力接进现有工程。5.1 API 只留了三个函数头文件lw_ppocr.h里核心 API 就三个lw_ocr_t *lw_ocr_create(const char *det_model, const char *cls_model, const char *rec_model); int lw_ocr_recognize(lw_ocr_t *ocr, const unsigned char *img, int w, int h, int channels, lw_ocr_result_t *result); void lw_ocr_destroy(lw_ocr_t *ocr);第三和第四个参数传入整张图内部完成缩放、检测、方向分类、识别整个链路。结果结构体里是文本框四点、置信度和文本字符串数组。下面是最小使用示例#include lw_ppocr.h #include stdio.h #include stdlib.h int main(int argc, char **argv) { if (argc 2) return -1; int w, h, n; unsigned char *pixels lw_img_load(argv[1], w, h, n); // 读图函数可参考 stb_image 接入 lw_ocr_t *ocr lw_ocr_create(det.lwmodel, cls.lwmodel, rec.lwmodel); lw_ocr_result_t result; if (lw_ocr_recognize(ocr, pixels, w, h, n, result) 0) { for (int i 0; i result.num; i) { printf(box(%d,%d)(%d,%d)(%d,%d)(%d,%d) score%.3f text%s\n, result.items[i].box[0].x, result.items[i].box[0].y, result.items[i].box[1].x, result.items[i].box[1].y, result.items[i].box[2].x, result.items[i].box[2].y, result.items[i].box[3].x, result.items[i].box[3].y, result.items[i].score, result.items[i].text); } } lw_ocr_result_free(result); lw_ocr_destroy(ocr); free(pixels); return 0; }模型数据不硬编码进 runtime所以调用前要用转换工具把三个 Paddle 模型生成对应的.lwmodel文件。5.2 CMake 编译与交叉编译工程用 CMake 组织本地编译命令很简单mkdir build cd build cmake .. -DCMAKE_BUILD_TYPERelease make -j4交叉编译树莓派版时提供 toolchain 文件即可cmake .. \ -DCMAKE_TOOLCHAIN_FILEcmake/aarch64-linux-gnu.toolchain.cmake \ -DCMAKE_BUILD_TYPERelease工具链文件里有几个需要注意的地方指定CMAKE_SYSTEM_NAMELinux和CMAKE_SYSTEM_PROCESSORaarch64把 C 标准设为c99避免编译环境悄悄引入 C11 之后的新特性开启-fno-exceptions -fno-rtti防止任何盲目的 C 运行时假设链接阶段加-static-libgcc减少目标系统不装编译环境的可能性。5.3 实际效果在两种平台上的数据为了给读者一个直观体感我拿一张拍摄的纸质菜单图在两种平台上做了完整识别。图片 1200×900包含 16 行文字x86_64 Linuxi5-8500T三段式总耗时约 96ms16 行全部识别正确耗时最高的是识别阶段约 60msARM Linux树莓派 4B总耗时约 640ms内存峰值约 50MB识别结果一致。检测框定位比较准确倾斜文字也能覆盖识别阶段对模糊小字偶有错字这属于模型本身能力边界runtime 层面没有额外损失精度。5.4 一个建议的工作目录结构如果要把这个 runtime 集成进自己的工程建议按下面的目录组织vendor/lw_ppocr/ include/lw_ppocr.h lib/linux64/liblw_ppocr.a lib/win64/lw_ppocr.lib lib/aarch64/liblw_ppocr.a models/det.lwmodel models/cls.lwmodel models/rec.lwmodel头文件和静态库打进自己的版本库模型文件放到可执行文件同级目录。交付到现场时不需要安装任何额外系统组件。6. 下一步计划与我想说的大实话发布 preview.5 不是终点。按目前规划后面还有几个方向要推进。6.1 路线图短期最优先的是 PP-OCRv5 模型适配。官方模型结构持续演进runtime 兼容速度要跟得上其次是性能优化当前卷积算子还是最朴素的实现方式im2col GEMM 以及 ARM NEON 显式向量化已经在排期里再往后是量化FP32 算子全链路换成 int8 推理是嵌入式场景最有效的提速手段但要先处理量化模型的权重格式和量化参数解析。接口层面等 API 稳定后我打算再精简一层 C 接口方便后续做 Java、Go、C# 的官方绑定省得每个集成方都自己写 JNI 或 P/Invoke。6.2 纯 C 到底适合谁如果你只是在自己电脑上写脚本批量识别图片Python 版 PaddleOCR 依然是效率最高的选择没有必要折腾 C 版本。但如果你遇到下面几种情况纯 C runtime 才真正有意义目标设备内存 256MB 以下Python 运行时无法落地需要一键部署不能接受客户现场安装一大堆运行库需要在没有标准 C 库的环境下集成部分 RTOS、裸机场景需要长期稳定 ABI方便多语言混合调用。6.3 给想自己写推理引擎的人的话自己写推理引擎最大的收益不是省掉一个依赖而是逼着你把模型从训练到部署的每一步都看清楚。当你能说出某个算子为什么这样实现、某个中间张量占多大内存、某次精度损失发生在哪一层时你对整个项目的掌控力完全不一样。从预览版一路做下来我最深的体会是推理 runtime 的工作量从来不在“实现算子”而在“兼容各种模型结构”和“稳定输出可复现的结果”。这两件事没有秘诀只能靠真实模型反复测试、收集失败样本、不断补兼容层。如果你想走这条路我的建议是从最小闭环开始先跑通一个检测网络、输出一堆坐标再慢慢加识别、方向分类。每个阶段都锁死一个可用版本不要指望一次性写出完美的 Runtime。等模型、平台、API 三个方向都稳定了再把版本号里的 preview 去掉也不迟。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。