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 | 作用 |
|---|---|---|---|---|
| det | DB | [1,3,H,W] | [1,1,H,W]概率图 | 框出文本区域 |
| cls | 分类 | [1,3,48,W] | [1,2]概率 | 判断是否旋转 180 度 |
| rec | CRNN/SVTR | [1,3,48,W] | [1,W,class_num]概率序列 | 逐字符输出文本 |
det 的输入尺寸通常是动态的,官方默认用 736x1280 左右;rec 的高度固定为 48,宽度可以动态变化。这个"动态宽高"在后面对我造成了不小的麻烦,等到了 WASM 端才彻底暴露。
2.2 用 paddle2onnx 导出推理模型
PP-OCR 官方发布的是 Paddle Inference 格式的模型,包含inference.pdmodel和inference.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 Truerec 和 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 层设计了三个核心函数:create、run、destroy。没有比这更简单的接口了。设计这组 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 层的主流程是这样的:
- 输入 RGB 图像,先做缩放和归一化,得到
[1,3,H,W]张量,喂给 det 模型。 - det 输出概率图,后处理得到文本框坐标,一组浮点坐标。
- 对每个文本框做仿射变换,从原图中裁剪出校正后的文本区域,转成
[1,3,48,W]张量。 - 先喂给 cls 模型,如果判断为旋转 180 度,就把裁剪图旋转 180 度。
- 再喂给 rec 模型,输出字符概率序列,做 CTC 贪心解码得到最终文本。
这个流程里最值得说的一点是内存缓冲区的设计。三个模型的输入张量、输出张量,在ocr_engine_create时一次性分配好,运行时只做复用,不在推理过程中做频繁的malloc和free。理由很简单: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 程序可以很自然地用fread把det.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 在浏览器里打开是没问题的,但加载和解析要花费时间,所以序列化前一定要做压缩。
最终我采用的打包流程是:
- 先用 ONNX Runtime 的 Python 量化工具把 rec 模型转成 INT8,体积从 12MB 降到约 3.5MB。
- det 模型用 ONNX Runtime 动态量化,4.7MB 降到约 1.8MB。
- 所有二进制资源先转 base64,再内嵌进 HTML。
- 页面里用一个隐藏的
<div>或者 JSON 字段来存这些 base64 字符串,加载时统一解码。
5.2 HTML 里跑 OCR 的完整链路
页面里跑 OCR 的链路如下:
- 用户选择一个图片文件。
- 把图片画到隐藏的
canvas上,调用getImageData拿到 RGBA 像素数据。 - 把 RGBA 转成 RGB 连续 buffer,传给 WASM 里的 C 层预处理函数。
- 通过 ONNX Runtime Web 依次执行 det、cls、rec 三个 session。
- 拿到文本框和文本结果后,在
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、模型、字典)列成清单,然后一个接一个内嵌,每内嵌一个就验证一次功能。这样调试时每一步都能定位到具体文件,比一口气全塞进去再排错省心得多。