ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

纯C Runtime + WASM:将PP-OCR部署为单文件HTML离线OCR

2026/9/19 19:50:00 拓冰建站 浏览量
纯C Runtime + WASM:将PP-OCR部署为单文件HTML离线OCR 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.hPaddle 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 到 ONNXTiny 与 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.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 系列有 mobileTiny和 serverMedium两个系列名字跟量化无关指的是网络主干的大小。我把两套模型都导出为 ONNX 后在本地用同一张包含中文、英文、数字的测试图跑了一遍结果如下指标TinymobileMediumserverdet 模型体积约 4.7 MB约 82 MBrec 模型体积约 12 MB约 79 MBcls 模型体积约 1.5 MB约 2.5 MB单张图推理耗时CPU 8 线程约 200 ms约 1600 ms中文长文本识别准确率中上明显更高内存占用约 500 MB超过 1.5 GBTiny 的优势是体积小、速度快十兆左右的体积对浏览器完全友好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. 编译到 WASMC 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 WASM1 \ -s ALLOW_MEMORY_GROWTH1 \ -s MAXIMUM_MEMORY1GB \ -s EXPORTED_FUNCTIONS[_ocr_engine_create, _ocr_engine_run, _ocr_engine_destroy, _malloc, _free] \ -o ocr_engine.js这里必须说明几个参数的含义。ALLOW_MEMORY_GROWTH1允许 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 秒detrec 动态量化约 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_MEMORYWASM 内存无限制增长二是 ONNX Runtime Web 在创建 session 时默认会把模型权重预加载到 WASM 堆内存里三个 FP32 模型同时驻留内存自然爆炸。解决办法是双管齐下先给 WASM 编译参数加上MAXIMUM_MEMORY1GB限制再把模型全部量化成 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、模型、字典列成清单然后一个接一个内嵌每内嵌一个就验证一次功能。这样调试时每一步都能定位到具体文件比一口气全塞进去再排错省心得多。