
做嵌入式 OCR 的人多半都经历过类似的折磨模型精度明明够代码却死活搬不到目标设备上。我在连续踩了三次 Paddle Inference 的移植坑之后决定动手写一个纯 C 的 OCR Runtime也就是这个 lw.PPOCR.C。项目从最早的可行性验证一路走到 preview.5核心链路已经从能跑进化到能稳定落地。这一版我的工作重心放在内存生命周期管理、模型加载校验、API 收敛以及并发 session 的实验性支持上也是目前所有 preview 版本里最接近正式版的一版。如果你正在评估嵌入式 OCR 方案或者要给现有 C/C 工程塞一个能直接编进去的识别模块又或者单纯好奇纯 C 怎么撑起一条 OCR 推理链路这篇应该能帮你省不少时间。我会把为什么选纯 C、preview.5 改了什么、推理链路在 C 里怎么实现、实测数据什么样以及接入时会踩的坑完整讲一遍。1. 从官方推理库到纯 C 运行时这步棋的动机1.1 官方推理方案在真实工程里的三个硬伤先说结论PaddleOCR 的模型精度没有任何问题问题出在官方推理库的工程化属性上。第一个硬伤是产物体积。这里我说的不是模型文件而是运行时本身。Paddle Inference 的动态库加上各种第三方依赖装完通常几十 MB 起步完整安装包过百 MB 也很常见。这在开发机上不是事但到了存储紧张的工业设备、老款 ARM 板卡、或者一些还在用 128MB Flash 的国产化硬件上光把运行时塞进去就能让系统分区告急。我接触过几个安防领域的实际项目设备系统分区长期只剩二三十 MB 空间这种环境根本没有官方推理库的位置。第二个硬伤是 C 运行时的版本连锁反应。官方 SDK 对 glibc、libstdc 版本有要求可很多嵌入式交叉编译工具链还停在 GCC 4.8 甚至 5.x 上。为了匹配旧编译环境你得找到对应版本的 SDK然后开始跟这个函数在哪个版本引入那个头文件能不能兼容这类问题搏斗。运气不好的话光解决编译问题就能消耗两三天。第三个硬伤是工程侵入性。大量老项目的构建系统就是一组 MakefileC 文件放一个目录头文件放一个目录逻辑清晰。你往里面塞一个 Paddle Inference意味着必须引入新的构建系统、第三方依赖管理、动态库拷贝流程。需求明明是加一个 OCR 识别功能评审的时候却变成重构整个构建系统这在高风险项目里基本是直接被打回的命。1.2 纯 C 运行时的定位与边界所以 lw.PPOCR.C 从第一天起就定了四条硬规矩不依赖 C 标准库不依赖第三方动态链接库对外接口全部是 C ABI模型文件从 PaddleOCR 官方导出。推理引擎只实现 OCR 推理链路够用的算子不碰通用深度学习框架的能力边界。这个定位至关重要。它不是又一个通用的 DNN 推理框架而是刚好够跑 PP-OCR 检测、方向分类、识别三件套的专用运行时。正因为砍掉了通用性代码量能控制在很小的体量纯 C 的工程复杂度才真正可控。通用框架要处理的是无数种网络结构而这里只需要把 PP-OCR 这条固定链路做深做透两者难度完全不同。1.3 为什么 preview 版本迭代这么慢不少朋友问过preview 都到第五个了怎么还没转正。说句实在话OCR Runtime 这类底层组件最难的不是把 Demo 跑通而是把边界情况磨平。preview.1 我在验证纯 C 能不能完整跑完 PP-OCR v3 链路preview.2 补上了检测后处理的完整实现preview.3 开始适配量化模型preview.4 主攻多线程调度到 preview.5 终于有余力处理内存生命周期和 API 收敛。每一次预览版解决的都是那种单次识别看不出、长时间运行或并发调用必暴露的问题这类问题恰恰是正式版绕不过去的门槛。2. preview.5 这版重点改了什么2.1 内存生命周期的一次系统性重做preview.5 最核心的改动是把运行时内部所有资源句柄统一梳理了一遍。之前版本的推理会话在反复创建和销毁时偶尔会出现内存没有完全归还系统的情况。用 valgrind 和 AddressSanitizer 做长时压力测试能定位到部分缓存块在特定时序下没有回到空闲链表。严格说这不算泄漏因为进程退出时内存还是会被回收但对公共服务进程来说是定时炸弹。这一版把模型参数区、中间激活缓存、线程工作区三类内存彻底分开管理并给每个 session 增加了显式重置能力。推荐的用法是一个lw_ppocr_session可以反复识别内部工作缓存会按需渐进扩大但不会无限膨胀当进程进入空闲期调用lw_ppocr_session_reset可以把空闲缓存归还系统。这个设计对需要 7x24 小时运行的设备特别重要。2.2 模型加载从一次性读入改为按需解析第二个变化在模型加载链路。之前的实现把整个模型文件一次性读进内存再开始解析这对几十 MB 的模型来说问题不大但设备内存紧张时峰值占用会非常难看。preview.5 改成部分映射加惰性解析先读文件头确认模型类型、算子列表和权重形状信息再按需读取各个权重块。模型区只保留真正会参与计算的参数元数据用完即丢。同时增加了模型指纹校验。PP-OCR 模型可能来自官方导出的 ONNX也可能是用户基于 Paddle 训练后自己导出的两种来源的文件格式细节存在差异。这版在校验阶段会输出语义明确的错误码告诉你具体是模型版本不支持、算子缺失还是权重形状不匹配而不是丢给你一行含糊的日志。2.3 API 收敛与错误码规范化预览版阶段的接口命名一直有些试验性质preview.4 之前的名字前后缀不统一调用方记忆成本高。preview.5 把对外 API 统一成lw_ppocr_session_create、lw_ppocr_session_destroy、lw_ppocr_recognize这一组旧名称保留了一版兼容宏但注释里明确标注了废弃计划会在后续正式版移除。错误码也重新定义了。以前遇到问题基本就是 -1调用方根本分不清是模型路径错误、图像解码失败还是推理过程中的计算溢出。现在错误码按场景分类错误码前缀含义典型场景LW_PPOCR_ERR_MODEL_*模型相关错误模型文件不存在、算子不支持、指纹校验失败LW_PPOCR_ERR_IMAGE_*图像相关错误解码失败、尺寸超限、通道数不支持LW_PPOCR_ERR_RES_*运行时资源错误内存分配失败、并发数超限LW_PPOCR_ERR_CALC_*计算过程错误中间张量形状异常、推理输出越界2.4 并发 session 的实验性支持preview.5 还加了一个实验性特性多 session 并发推理。服务端场景往往是多个请求同时进来如果每个请求都重新加载一遍模型内存直接翻好几倍不现实。我的方案是模型参数区在多个 session 之间共享只读中间激活区按线程隔离。这样同时开 8 个并发 session增加的内存只是激活区的那部分而不是模型区的 8 倍。实测下来并发吞吐的收益在 CPU 资源充足时非常明显但在双核板卡上收益有限这也是为什么它被标记为实验性支持而不是默认特性。3. 纯 C OCR 推理链路的技术要点3.1 前处理图像解码、缩放与归一化的细节OCR 推理的第一步是图像前处理。PP-OCR 系列模型对输入尺寸有固定要求检测模型通常要求最长边限制在 960 或 1024 以内识别模型需要把文本框高度归一化到 32 或 48 像素宽度则尽量保持原始比例。前处理链路包括图像解码JPEG/PNG/BMP、颜色通道转换、缩放和归一化四步。纯 C 环境下没有 OpenCV 可用我直接内嵌了一个极简版的图像解码器只支持常见格式。缩放采用双线性插值归一化就是常见的value / 255操作。这里有个非常容易被忽略的细节识别前处理如果直接把文本框宽度暴力拉伸到固定尺寸字符会横向变形识别率断崖式下降。正确做法是按比例缩放后补齐空白区域这个处理逻辑写起来不难但很多人一上来就栽在这里。OCR 精度掉得莫名其妙先查前处理大概率不是模型的问题。3.2 推理算子复刻 PP-OCR 需要的最小算子集PP-OCR 链路实际用到的算子集合比想象中少卷积、批归一化、ReLU/LeakyReLU、最大池化、平均池化、双线性上采样、全连接以及识别模型里用到的 LSTM/GRU。真正头疼的是把这些算子写快。朴素实现的卷积层在小尺寸特征图上跑起来还能看但识别模型要处理几十万像素的输入每层特征图都不小卷积如果不做任何优化耗时非常难堪。preview.5 目前的方案是1x1 卷积做部分展开3x3 卷积用 im2col 加简化版 GEMM 的思路实现ARM 平台额外开启 NEON intrinsics 加速。这套路线不是我的原创而是沿用了 Caffe 和 ncnn 早期的成熟路径先用最直白的实现保证正确性再对热点算子逐个优化永远不要一上来就写汇编那会让后期维护成本失控。3.3 后处理DB 文本检测与 CTC 解码后处理是看起来简单、实际坑最多的环节。检测模型输出的是概率图和阈值图要先做二值化然后找连通域计算每个连通域的外接多边形再还原回原图坐标。识别模型输出的则是每个时间步在字符表上的概率分布需要通过 CTC 解码拿到最终文本合并重复字符处理空格等 blank 位。这里有一个容易踩的深坑如果使用自定义中文字符表要注意 PaddleOCR 默认用空格作为 blank 的编码约定。合并规则写不对中文里连续出现的相同字会被错误合并比如很很很可能被吞成很。preview.5 在所有后处理组件里加入了边界断言Debug 模式下自动检查索引越界Release 模式下关闭这个设计帮我抓到了不止一个隐蔽的内存越界 bug。3.4 一个简单的推理调用时序用文字描述整个链路可能不够直观这里给一个简化版的调用时序// 伪代码展示内部主流程 int lw_ppocr_recognize(lw_ppocr_session_t* s, lw_ppocr_image_in_t* in, lw_ppocr_result_t** out, int* out_count) { // 1. 图像解码 缩放 归一化得到检测模型输入张量 tensor_t det_input preprocess_det_image(in); // 2. 检测模型推理得到概率图和阈值图 tensor_t det_out run_det_model(s, det_input); // 3. DB 后处理二值化 - 连通域 - 文本行框 box_t* boxes db_postprocess(det_out, box_count); // 4. 对每个文本行做方向分类可选和识别 for (int i 0; i box_count; i) { tensor_t rec_input crop_and_preprocess(boxes[i]); tensor_t rec_out run_rec_model(s, rec_input); char* text ctc_decode(rec_out); // 填充结果结构体 } // 5. 释放中间资源返回结果 return LW_PPOCR_OK; }实际代码里每一步都有大量边界检查但这个主流程能帮助你理解它和数据流的关系。4. 实测跨平台部署的真实数据4.1 桌面 Linux 平台的基线数据在桌面平台主要验证正确性和运行时的稳定性。我用 PP-OCRv4 中文模型在标准测试图片上做了基准测试这里给出一个量级参考不同型号 CPU、不同图像内容下数据会有明显浮动。测试项数据测试平台Linux x86_644 核 CPU8GB 内存模型配置PP-OCRv4 中文检测 识别FP32 精度测试输入640x480 自然场景图片单次完整识别耗时约 300~600 ms取决于文本行数内存峰值约 200~300 MB模型常驻内存识别准确率与 PaddleOCR 官方结果基本持平这个结论其实很关键纯 C 实现和官方推理库用的模型完全一致前处理和算子实现只要遵循同样的算法逻辑精度就是完全可验证的。我这边和官方推理结果做了逐字段对比识别文本内容一致率达到 99% 以上个别差异来自多线程下浮点累加顺序导致的极小置信度波动。4.2 ARM 嵌入式板卡的表现真正能体现纯 C 运行时价值的场景是嵌入式 ARM 平台。我在 RK3568 和树莓派 4B 上都做了测试。RK3568 这类板子FP32 模型单次完整识别大约在 1.5 到 3 秒之间换用 INT8 量化模型可以砍掉一半左右的时间。NEON 优化在这个场景下收益极其明显尤其是卷积层开 NEON 和纯 C 循环的差距可以达到 2 到 4 倍。这再次验证了那条经验先保证能跑再针对热点算子做指令级优化。嵌入式平台的内存占用也需要单独说。模型区本身是固定的内存浮动主要来自中间激活区。检测模型一张 960 分辨率输入图跑下来激活区大概要几十 MB 量级识别模型按单个文本行的尺度计算激活区小很多。如果板卡内存特别紧张可以考虑分批处理图像把检测和识别两个模型分开加载用空间换时间。4.3 性能瓶颈定位和优化排序用 perf 采集运行数据可以发现时间几乎全耗在卷积算子上。这是 CPU OCR 推理引擎的宿命因为 PP-OCR 的检测模型本质就是一个全卷积网络。我做过几次优化迭代收益从高到低的排序是算子融合把 conv、batch norm、ReLU 合并成一次循环减少中间张量的内存读写。多线程按通道拆分每个线程负责一部分输出通道最后拼接。量化从 FP32 换到 INT8既能减体积也能提速但需要重新校验精度损失。手工指令优化排在最末因为它最容易引入难以调试的 bug收益却被前三项吃掉了大半。还有一个反直觉的发现有时单线程连续推理反而比多线程更稳。因为线程切换本身有开销如果图像只有一两行文本多线程收益可以忽略不计。于是我把并发开关设计成了可配置项默认单线程由调用方根据实际负载决定是否开启。5. 接入最小实践5.1 编译环境与产物lw.PPOCR.C 的源码直接用 Makefile 管理没有引入 CMake为的就是贴近老项目的使用习惯。编译产物是一个静态库和一个头文件接入方只需要把头文件路径加进 include 搜索目录把静态库链接进工程即可。# 从项目发布页获取源码后在项目根目录编译 make BUILD_TYPErelease # 生成动态库的话 make BUILD_TYPErelease SHARED1编译产物会输出到 build 目录下。目前支持的编译环境包括 GCC、Clang 和常见 ARM 交叉编译链arm-linux-gnueabihf-gcc 等。由于没有 C 运行时依赖只要目标平台有可用的 C 编译器基本都能编过。这也正是当初选纯 C 的最大红利。5.2 C API 最小调用示例接入代码非常简单完整流程可以压缩在一个函数里#include stdio.h #include lw_ppocr.h int main(void) { lw_ppocr_config_t cfg; lw_ppocr_config_init(cfg); cfg.model_det_dir ./models/ch_PP-OCRv4_det; cfg.model_rec_dir ./models/ch_PP-OCRv4_rec; cfg.use_quantized 0; cfg.threads 2; lw_ppocr_session_t* session NULL; int ret lw_ppocr_session_create(cfg, session); if (ret ! LW_PPOCR_OK) { fprintf(stderr, create session failed: %d\n, ret); return 1; } lw_ppocr_image_in_t input; lw_ppocr_image_in_init(input); input.path ./test.jpg; lw_ppocr_result_t* results NULL; int count 0; ret lw_ppocr_recognize(session, input, results, count); if (ret ! LW_PPOCR_OK) { fprintf(stderr, recognize failed: %d\n, ret); lw_ppocr_session_destroy(session); return 1; } for (int i 0; i count; i) { printf(box[%.1f, %.1f, %.1f, %.1f] text%s score%.3f\n, results[i].box[0], results[i].box[1], results[i].box[2], results[i].box[3], results[i].text, results[i].score); } lw_ppocr_result_release(results, count); lw_ppocr_session_reset(session); lw_ppocr_session_destroy(session); return 0; }这个示例展示了完整生命周期配置初始化、session 创建、单次识别、结果释放、缓存重置和 session 销毁。注意lw_ppocr_result_release和lw_ppocr_session_reset是两个不同动作前者释放结果对象占用的内存后者只归还运行时内部空闲缓存不会销毁 session。在长驻进程里这两个接口配合使用内存水位可以保持得很平。5.3 接入时最容易踩的三个坑第一个坑是模型文件结构不一致。PP-OCRv3 和 PP-OCRv4 的模型导出的文件名、权重布局有差异preview.5 虽然做了指纹校验但如果你混用不同版本模型报错信息会明确告诉你模型版本不支持。接入前最好确认模型来源和运行时的兼容版本表。第二个坑是识别结果的编码。返回值中的文本统一是 UTF-8 编码这在 Linux 下打印没有问题但 Windows 老式控制台默认可能用 GBK 编码直接输出会出现乱码。不是运行时的问题是终端编码问题接入时统一转码即可。第三个坑是多线程调用时不能共用同一个 session 做并发推理。一个 session 同一时间只能处理一个请求多线程并发需要创建多个 session模型参数区会自动共享。这是 preview.5 实验性并发功能的前提忘掉这一点会出现结果错乱甚至崩溃。6. 开发过程中踩过的坑和后续方向6.1 交叉编译链路里的三个拦路虎交叉编译是嵌入式开发绕不开的环节也是最容易浪费时间的环节。我遇到的第一类是平台差异导致的代码假设失效典型的是int 固定 4 字节这种错误假设。换上 64 位 ARM 平台后long在 Windows 和 Linux 上宽度不同文件偏移也需要重新处理。解决办法很土但很有效所有涉及字节宽度的地方统一使用int32_t、uint16_t这类显式类型编译器会帮你找出大多数隐患。第二类是字节序问题。模型文件的权重数据直接按小端序写入在大端序平台上加载会得到完全错误的结果。虽然现在主流平台几乎都是小端序但代码里预留了字节序转换接口遇到特殊硬件时可以启用。第三类是浮点环境差异。某些精简版 ARM 系统默认关闭硬浮点或 NEON运行时行为会退化到软浮点速度慢得离谱。这个不是代码 bug但非常影响用户体验。我在 CMake 之外单独提供了一份交叉编译参数样例把-mfpuneon -mfloat-abihard这类参数写在了文档里。6.2 内存对齐和缓存友好的中间布局纯 C 写算子还有一个避不开的问题内存布局与缓存友好性。最初版本的所有中间张量都按行优先顺序连续排列逻辑简单但卷积算子访问特征图时缓存命中率很低。后来把通道数扩展到按 4 对齐ARM 上对应 NEON 的 128 位向量宽度又把卷积输入按[channel][height][width]重排为[height][width][channel]配合 CHW 到 NHWC 的转换在 ARM 平台上实测性能提升了接近一倍。这类优化没有高深技巧纯粹是看性能分析工具报告再去调整数据布局的基本功。还有一个值得分享的经验中间张量不要在每次推理时反复 malloc/free。preview.5 引入了轻量级的张量池按形状缓存已分配的内存块下次直接用。这避免了内存碎片也让内存分配的耗时消失了。代价是增加了少量代码复杂度但从结果看完全值得。6.3 后续计划稳定版和更多后端preview.5 之后我的计划是先做一段时间的社区反馈收集集中修复 API 语义不够清晰的地方然后把 1.0 稳定版发出来。功能层面几个方向在并行考虑针对 RISC-V 平台的向量指令优化、更激进的算子融合以降低内存带宽消耗、以及支持 PaddleOCR 最新的检测模型结构。另外还有一个小想法把识别模型的 LSTM 部分替换成卷积形式进一步减少序列推理的依赖现在还在验证精度的影响。考虑到 OCR Runtime 对稳定性的要求这类架构调整会放在稳定版之后再进行。最后分享一个个人体会做底层运行时最忌讳的就是在看起来能跑的状态下过早宣布完成。preview.5 能走到现在靠的是不停地在极端场景里折腾自己——长期运行测试、并发压力、不同编译器告警全开、内存越界检测逐项过。这些功夫做足了用户拿到手里才会少踩坑。