纯C Runtime + WASM:将PP-OCR部署为单文件HTML离线OCR
2026/9/19 19:49:38 网站建设 项目流程

1. 从"给非技术用户交付 OCR"说起:路线对比与纯 C Runtime 的自找麻烦

1.1 需求画像

有一类需求我遇到不止一次了:对方不是程序员,电脑上没装 Python,更不想把图片传到云端去识别。最开始我的第一反应是给他打一个 exe 包,但 exe 在路上就会遇到各种问题——缺 VC 运行库、被杀毒软件误报、换台 Windows 版本可能直接跑不起来。后来我换了个思路:既然是浏览器,那就做一个双击就能打开的单文件 HTML OCR。

这个项目最终落地时,核心工作是把 PP-OCR 塞进一个纯 C Runtime,先用 Tiny 模型把整条链路跑通,再换 Medium 做精度和性能对比,最后把 runtime 和模型一起编译、压缩、内嵌进单个 HTML 页面。这篇文章把完整过程写出来,包括技术选型、模型转换、C 层管线的设计、WASM 改造,以及我踩过的几个大坑,给做端侧 OCR 部署的人一个真实参考。

1.2 三条路线横评

在动手之前,我认真比过三条主流交付路线。它们各有优点,但放到"给非技术用户离线使用"这个场景里,都有各自的致命伤。

方案优点致命问题
Python + PyInstaller 打包 exe生态完整,PP-OCR 官方支持最好包体动辄几百 MB,杀毒软件误报率极高,换平台要重新打包
本地起 HTTP 服务 + 浏览器前端前后端分离,开发调试方便用户要装运行环境、自己启动进程,心智负担太重
浏览器 WASM 单文件免安装、跨平台、离线可用、天然隔离模型不能太大,浏览器内存和地址空间有硬约束

我最终选了第三条。浏览器本身就是最普及的跨平台 runtime,只要把 OCR 引擎编译成 WASM,再把模型内嵌进 HTML,用户拿到的就是一个文件,双击就用。隐私问题也顺带解决了——所有数据都在本地,不需要上传服务器。

1.3 为什么坚持用 C API 做边界

虽然最终页面里跑的是 JS,但我在设计整个引擎时,坚持把所有核心逻辑放在一个纯 C 中间层里。这不是为了显得硬核,而是有几层实际考虑。

第一,模型推理引擎普遍提供 C API。ONNX Runtime 有onnxruntime_c_api.h,Paddle Lite 也有 C API,这些 C API 是稳定的 ABI 边界,不会像 C++ API 那样因为编译器版本、异常机制不同而出现问题。第二,C 层的内存所有权非常清晰,谁分配谁释放一眼就能看明白,这对我后面做 WASM 改造很有帮助——把 C 代码编译到浏览器环境,中间不会有 C++ 运行时和异常处理的额外开销。第三,C 层便于交叉编译,Emscripten 对纯 C 代码的编译支持比 C++ 更干净,产物体积也更小。

这里说明一下我的最终架构:模型推理部分使用 ONNX Runtime 的 C API 和 WASM 运行时,图像预处理、三段调度、后处理等胶水逻辑全部用纯 C 写。这样标题里说的"纯 C Runtime"并不是我重新写了一个推理引擎,而是指整个 OCR 管线的调度和计算主体是一个纯 C 实现的 runtime,模型算子交给经过验证的 ONNX Runtime,这是工程上最稳妥的组合。

2. PP-OCR 模型从 Paddle 到 ONNX:Tiny 与 Medium 的真实差距

2.1 三段式结构拆解

PP-OCR 不是单一模型,而是三个模型串成的流水线:先检测出文本区域,再判断文本方向,最后识别字符。我做 C 层调度之前,必须先把三个模型的输入输出形状搞清楚,否则后面每一步都会踩坑。

模型算法输入 Tensor输出 Tensor作用
detDB[1,3,H,W][1,1,H,W]概率图框出文本区域
cls分类[1,3,48,W][1,2]概率判断是否旋转 180 度
recCRNN/SVTR[1,3,48,W][1,W,class_num]概率序列逐字符输出文本

det 的输入尺寸通常是动态的,官方默认用 736x1280 左右;rec 的高度固定为 48,宽度可以动态变化。这个"动态宽高"在后面对我造成了不小的麻烦,等到了 WASM 端才彻底暴露。

2.2 用 paddle2onnx 导出推理模型

PP-OCR 官方发布的是 Paddle Inference 格式的模型,包含inference.pdmodelinference.pdiparams两个文件。我在本地验证时可以直接用 Paddle Inference,但既然目标是浏览器,就必须先转成 ONNX 格式。

我用的工具是官方paddle2onnx,命令如下:

paddle2onnx \ --model_dir ch_PP-OCRv4_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file det.onnx \ --opset_version 12 \ --enable_onnx_checker True

rec 和 cls 的模型用同样方式导出。这里有几个细节需要注意:opset version 我选了 12,太低的 opset 会导致某些算子导出时报错,太高则 ONNX Runtime WASM 后端不一定支持;导出后一定要用onnxruntime的 Python 版先跑一遍,确认输出和 Paddle 原版一致,再进 C 层,否则排查问题时会分不清是转换问题还是代码问题。

2.3 Tiny 和 Medium 的体积、精度、耗时实测

PP-OCR 系列有 mobile(Tiny)和 server(Medium)两个系列,名字跟量化无关,指的是网络主干的大小。我把两套模型都导出为 ONNX 后,在本地用同一张包含中文、英文、数字的测试图跑了一遍,结果如下:

指标Tiny(mobile)Medium(server)
det 模型体积约 4.7 MB约 82 MB
rec 模型体积约 12 MB约 79 MB
cls 模型体积约 1.5 MB约 2.5 MB
单张图推理耗时(CPU 8 线程)约 200 ms约 1600 ms
中文长文本识别准确率中上明显更高
内存占用约 500 MB超过 1.5 GB

Tiny 的优势是体积小、速度快,十兆左右的体积对浏览器完全友好;Medium 的精度确实更好,尤其是倾斜文本、复杂背景下的长文本,但它在浏览器里的体积和内存占用是致命的。这个对比让我明确了一个方向:单文件 HTML OCR 里跑 Tiny 模型,Medium 留在本地 C runtime 里做参照实验,这也是从 Tiny 到 Medium、再回到 Tiny 做优化的整个思路来源。

3. C 层三段管线的实现:检测、矫正、识别在内存里怎么流转

3.1 对外暴露的 C API 长什么样

我在 C 层设计了三个核心函数:createrundestroy。没有比这更简单的接口了。设计这组 API 时我反复强调一个原则:所有模型数据通过内存指针传入,绝不通过文件路径。因为到了浏览器里,根本没有文件系统,如果接口依赖fopen,后面 WASM 改造就是一场灾难。

/* ocr_engine.h */ #ifndef OCR_ENGINE_H #define OCR_ENGINE_H #include <stdint.h> #include <stddef.h> #ifdef __cplusplus extern "C" { #endif typedef struct OcrEngine OcrEngine; typedef struct { float confidence; int text_len; char text[256]; } OcrText; typedef struct { int num_boxes; int* box; /* 每个框 4 个点,共 num_boxes*8 个 int */ OcrText* text; /* 每个框对应的文本结果 */ } OcrResult; OcrEngine* ocr_engine_create( const uint8_t* det_model, size_t det_size, const uint8_t* rec_model, size_t rec_size, const uint8_t* cls_model, size_t cls_size); int ocr_engine_run(OcrEngine* engine, const uint8_t* rgb, int width, int height, OcrResult* result); void ocr_engine_free_result(OcrResult* result); void ocr_engine_destroy(OcrEngine* engine); #ifdef __cplusplus } #endif

这个接口把三个模型的数据一次性传入,内部负责初始化和分配。ocr_engine_run接受 RGB 连续内存,输出结构体里包含每个文本区域的坐标和识别文本。接口层完全不暴露 ONNX Runtime 的类型,这样即使以后换推理引擎,外部调用方也不用改代码。

3.2 det -> cls -> rec 数据流与 buffer 复用

三段管线在 C 层的主流程是这样的:

  1. 输入 RGB 图像,先做缩放和归一化,得到[1,3,H,W]张量,喂给 det 模型。
  2. det 输出概率图,后处理得到文本框坐标,一组浮点坐标。
  3. 对每个文本框做仿射变换,从原图中裁剪出校正后的文本区域,转成[1,3,48,W]张量。
  4. 先喂给 cls 模型,如果判断为旋转 180 度,就把裁剪图旋转 180 度。
  5. 再喂给 rec 模型,输出字符概率序列,做 CTC 贪心解码得到最终文本。

这个流程里最值得说的一点是内存缓冲区的设计。三个模型的输入张量、输出张量,在ocr_engine_create时一次性分配好,运行时只做复用,不在推理过程中做频繁的mallocfree。理由很简单:C 层后面会编译成 WASM,浏览器端的反复内存分配容易造成内存碎片,而且 Emscripten 的malloc开销不比原生环境小。实测下来,复用 buffer 后单帧推理的内存申请次数从几百次降到了十几次。

3.3 预处理与后处理:仿射变换和 CTC 贪心解码

预处理这一步看似简单,实际上坑最多。det 模型的输入需要按比例缩放到固定尺寸,同时保持宽高比,多余部分用 0 填充;rec 模型的高度固定为 48,宽度按文本区域的长宽比动态计算,但要注意宽度必须是 32 的倍数,否则 ONNX Runtime 某些算子会报错。归一化时 PP-OCR 用的是 ImageNet 的 mean 和 std,这些参数在 C 层写死,不能随意改。

后处理里最核心的是文本框到标准矩形框的仿射变换。det 输出的是任意四边形坐标,我需要计算一个变换矩阵,把原图中的四边形区域映射成 48 像素高的水平矩形。这一步用到了 OpenCV 的getAffineTransform逻辑,但我在 C 层里是手写的矩阵运算。仿射变换的正确性直接决定后续识别结果,这里我建议先用 Python 脚本把中间结果可视化,确认每个框裁剪正确了,再往 C 层移植。

rec 模型的解码用的是 CTC 贪心算法:把每一时间步概率最大的字符索引取出来,去掉空白符号和重复字符,再映射到中文字典。这里的细节是,PP-OCR 的字典里包含了 blank 字符对应的索引,解码时一定不能漏掉。

4. 编译到 WASM:C Runtime 在浏览器里的降级与改造

4.1 用 Emscripten 拿下一份 WASM Runtime

把 C 层代码编译到浏览器环境,我使用的是 Emscripten 工具链。如果你打算用 ONNX Runtime 官方提供的预编译 WASM 包,那么在浏览器端并不需要自己编译 ONNX Runtime,只需要把 C 中间层代码编译成一个单独的 wasm 模块,再通过 JS 胶水调用 ONNX Runtime Web 的 API 来完成推理。

我的编译命令大致是这样:

emcc src/ocr_engine.c src/preprocess.c src/postprocess.c \ -O3 \ -s WASM=1 \ -s ALLOW_MEMORY_GROWTH=1 \ -s MAXIMUM_MEMORY=1GB \ -s EXPORTED_FUNCTIONS="['_ocr_engine_create', '_ocr_engine_run', '_ocr_engine_destroy', '_malloc', '_free']" \ -o ocr_engine.js

这里必须说明几个参数的含义。ALLOW_MEMORY_GROWTH=1允许 WASM 内存按需增长,但代价是可能产生内存碎片;MAXIMUM_MEMORY用来限制最大内存,我一开始没设这个值,结果模型加载时内存一路涨到 2GB 才崩溃,设了上限后反而能及时暴露问题。导出函数里除了 C API,还必须导出_malloc_free,否则 JS 侧无法分配内存来传图像数据。

4.2 没有文件系统的世界:从内存加载模型的正确姿势

这是在浏览器里做模型推理和本地开发差异最大的一点。本地 C 程序可以很自然地用freaddet.onnx读进内存,但浏览器里的 WASM 模块没有文件系统。你不可能写/models/det.onnx这种路径,因为根本没有这个路径。

我的做法是:模型文件在编译期不打包进 WASM,而是在运行时由 JS 侧先把 base64 解码成Uint8Array,再传入 WASM。具体在 ONNX Runtime Web 里,加载模型可以这样:

const detBuffer = base64ToUint8Array(DET_MODEL_BASE64); const detSession = await ort.InferenceSession.create(detBuffer, { executionProviders: ['wasm'], graphOptimizationLevel: 'all', });

注意第 4 章 3.1 节我设计的 C 层接口要求传入模型内存指针,这正是为了在浏览器端保持一致:JS 从 base64 解码出模型字节数组,复制进 WASM 内存,把指针传给 C 层接口。由于模型在浏览器里是以ArrayBuffer形式存在的,所以不需要走任何虚拟文件系统,直接加载。

4.3 多线程看着美,file:// 协议下全是坑

ONNX Runtime Web 的多线程模式可以在 WASM 里启用多线程,但要依赖浏览器提供的SharedArrayBuffer。而SharedArrayBuffer有一个苛刻前置条件:页面必须通过 COOP 和 COEP 两个响应头开启跨源隔离。

如果这个单文件 HTML 部署在 HTTP 服务器上,你可以在服务器配置里加上这两个头。但我的目标场景是用户直接双击 HTML 文件,通过file://协议打开,这种情况下浏览器不会附加 COOP/COEP 头,SharedArrayBuffer不可用,多线程方案直接废掉。

所以最终我在单文件版本里老老实实用单线程 WASM,只保留 SIMD 优化。SIMD 的加速效果非常明显,在图像预处理、仿射变换这种逐像素运算上能有 2 到 3 倍的提升。为了避免推理过程卡住页面 UI,我用了一个 Web Worker 来跑整个 OCR 流程,Worker 里同样是单线程,但至少界面不会冻结。

5. 单文件 HTML OCR:把模型、字典、Runtime 全部塞进一个页面

5.1 打包策略:base64 内嵌而不是异步 fetch

单文件的意思是这个 HTML 不依赖任何外部资源,双击就能独立运行。所以运行时需要的所有东西都得内嵌:三个 ONNX 模型、中文字典、ONNX Runtime Web 的 JS 和 WASM、我的 C 层编译产物,以及页面自身的 HTML/CSS/JS。

最直接的方案是 base64。我在构建脚本里把每个二进制资源转成 base64 字符串,写进 HTML 的<script>标签中,运行时再解码回Uint8Array。这样做唯一的问题是体积膨胀。base64 会让二进制体积增加约三分之一。三个模型加 abd wasm 原始体积约 25MB,转 base64 后接近 33MB。纯文本大小的 HTML 在浏览器里打开是没问题的,但加载和解析要花费时间,所以序列化前一定要做压缩。

最终我采用的打包流程是:

  1. 先用 ONNX Runtime 的 Python 量化工具把 rec 模型转成 INT8,体积从 12MB 降到约 3.5MB。
  2. det 模型用 ONNX Runtime 动态量化,4.7MB 降到约 1.8MB。
  3. 所有二进制资源先转 base64,再内嵌进 HTML。
  4. 页面里用一个隐藏的<div>或者 JSON 字段来存这些 base64 字符串,加载时统一解码。

5.2 HTML 里跑 OCR 的完整链路

页面里跑 OCR 的链路如下:

  1. 用户选择一个图片文件。
  2. 把图片画到隐藏的canvas上,调用getImageData拿到 RGBA 像素数据。
  3. 把 RGBA 转成 RGB 连续 buffer,传给 WASM 里的 C 层预处理函数。
  4. 通过 ONNX Runtime Web 依次执行 det、cls、rec 三个 session。
  5. 拿到文本框和文本结果后,在canvas上绘制识别框和文字。

核心 JS 代码如下:

const input = new ort.Tensor('float32', rgbData, [1, 3, height, width]); const detOutput = await detSession.run({ x: input }); // detOutput 里拿到文本框坐标 const boxes = postprocessDet(detOutput); for (const box of boxes) { const crop = affineCrop(imageData, box, targetHeight); // crop 转成 rec 模型输入张量 const recOutput = await recSession.run({ x: recInput }); const text = ctcDecode(recOutput, dict); drawBox(box, text); }

这里我为了叙述方便做了一定的简化,实际上run里还可以传入fetches参数只获取指定输出,减少拷贝开销。另外ort.Tensor的数据布局必须严格是[1,3,H,W]的 NCHW 格式,RGB 顺序不能搞反,我就在这个地方栽过一次跟头——识别结果全部错乱,排查半天才发现是通道顺序问题。

5.3 体积与速度的实测调优记录

做完量化、压缩、内嵌之后,我对最终的单文件 HTML 做了一轮完整实测,结果如下:

配置HTML 体积首屏加载单张推理耗时(1080p)
全部 FP32 模型约 35MB约 8 秒约 1.2 秒
det+rec 动态量化约 22MB约 5 秒约 0.8 秒
量化 + 固定输入尺寸 480x864约 22MB约 5 秒约 0.45 秒
上面方案 + SIMD Worker约 22MB约 4.5 秒约 0.3 秒

记录里最明显的收益来自固定输入尺寸。det 模型原本动态接收任意宽高输入,我在 C 层把输入固定为 480x864,不仅让 ONNX Runtime 能更好地做图优化,还显著减少了预处理的计算量。代价是图像分辨率很低时,小字体会漏检,但对于大多数文档图片来说完全够用。

6. 我踩过的四个大坑及完整排查链路

6.1 动态 shape 导致 WASM 推理直接崩溃

这是我在集成阶段遇到的第一个严重问题。现象是:本地 C 程序跑得好好的,同样的模型和同样的预处理代码,在 WASM 里一执行就崩溃,控制台报一个莫名其妙的段错误。

排查过程是这样的:我先用 ONNX Runtime 的 Python 版跑同一条链路,完全正常,所以问题大概率出在 WASM 后端和输入 shape 上。我逐步缩小范围,最后发现 rec 模型允许动态宽度,但在 WASM 后端对动态 shape 的支持非常有限,某些卷积算子在运行时输入宽度不是 32 的倍数时会直接访问越界。解决办法是在 C 层预处理时把宽度强制对齐到 32 的倍数,比如计算出的文本区域宽度是 100,就 padding 到 128,多余部分用 0 填充。修完之后这个问题再没出现过。

6.2 模型加载内存暴涨,页面白屏

第一次在浏览器里同时加载三个模型时,页面直接白屏,整个 Tab 崩掉。我通过 Chrome 的任务管理器看到内存占用飙到 2GB 后瞬间归零,典型的 WASM 内存耗尽。

排查后发现两个问题叠加了。一是我编译 C 层时没有设置MAXIMUM_MEMORY,WASM 内存无限制增长;二是 ONNX Runtime Web 在创建 session 时默认会把模型权重预加载到 WASM 堆内存里,三个 FP32 模型同时驻留,内存自然爆炸。

解决办法是双管齐下:先给 WASM 编译参数加上MAXIMUM_MEMORY=1GB限制;再把模型全部量化成 INT8,减小驻留内存。另外我在 C 层运行时特别留意即时释放中间输出,比如 det 的输出概率图用完后马上释放,避免累积。

6.3 中文识别全是乱码:字典和解码的问题

识别结果里英文和数字完全正常,中文全部变成空白或者错乱字符。这是我调试过程中最让人头疼的问题之一。

我的排查思路是从解码流程倒推。先确认 rec 模型的输出维度,ONNX Runtime 输出的class_num和字典文件的行数是否匹配。官方 PP-OCR 字典ppocr_keys_v1.txt有 6623 个字符,但模型的class_num实际上是 6625——多出来的两个索引分别是 blank 和 space。我一开始写解码器时想当然地认为字典数量和 class_num 一致,直接把概率序列的最后一个索引映射成字典最后一个字符,结果整个映射表错位。

修正方式是在解码时明确跳过 blank 和 space 对应的索引,再按字符表映射。同时我还发现字典文件的编码必须是 UTF-8 且不带 BOM,否则第一个字符会解析错误,导致所有匹配偏移一位。

6.4 首屏加载 8 秒的优化过程

单文件 HTML 的功能全部正常之后,首屏加载要 8 秒,这个体验实在说不过去。我用 Performance 面板做了定位,耗时主要集中三块:base64 解码、WASM 实例化和模型加载编译。

优化思路是按耗时段逐个击破。base64 解码我用atob直接处理,而不是用逐字节转换的函数,速度提升非常明显。WASM 实例化时间无法完全消除,但可以减少 WASM 模块体积,把不需要的导出函数全部裁剪。模型加载编译这块,ONNX Runtime Web 支持graphOptimizationLevel调整,我把级别设为all,并且让三个 session 的创建并行执行,而不是串行。这几项叠加下来,首屏时间从 8 秒降到了 4.5 秒左右,虽然不完美,但在"双击文件就能用"的场景里完全可以接受。

这个项目做完之后,我最大的体会是:端侧部署不等于简单把模型文件拷过去,它是在内存、体积、速度、部署形态之间反复做权衡的过程。如果让我重新做一遍,我仍然会坚持"先 Tiny 全链路、再 Medium 对比、最后回到 Tiny 做优化"这个顺序——先让流程跑通,再谈优化,这是最不会翻车的路线。

最后分享一个小技巧:做单文件版本时,先把所有外部依赖(JS、WASM、模型、字典)列成清单,然后一个接一个内嵌,每内嵌一个就验证一次功能。这样调试时每一步都能定位到具体文件,比一口气全塞进去再排错省心得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询