
早几年要是有人跟我说OCR能塞进浏览器跑离线识别我大概率是不信的。OCR引擎这东西从模型训练到推理部署一路都是Python生态里的活光依赖就是一大坨更别说模型推理本身的计算量。但这两年WebAssembly加上模型量化、端侧推理引擎的成熟事情开始起变化。我自己折腾了一个项目把百度开源的PP-OCR从Python里拽出来塞进纯C Runtime再从Tiny模型一路换到Medium最后打包成单文件HTML直接双击就能在浏览器里跑OCR。这篇文章就是完整复盘说清楚每一步怎么走以及那些文档里不会写的坑。先说结论这条路是通的。单文件HTML 纯前端离线OCR不仅能跑速度还能接受Tiny模型在普通PC上识别一张图大概几百毫秒Medium模型慢一些但准确率明显高。整个过程涉及模型导出、ONNX Runtime接入、C接口封装、Emscripten编译、Canvas绘制每一环都有讲究踩坑的地方也不少。适合想了解OCR端侧部署、WASM实战或者纯粹想做个离线工具的朋友参考。1. 项目拆解为什么非要把OCR塞进C Runtime1.1 核心需求摆脱Python让OCR随处可跑PP-OCR本身是PaddleOCR里的系列模型官方推理依赖Paddle Inference或者Paddle Lite日常用法基本离不开Python。除非你租台服务器装好环境不然想让别人用你的OCR能力要么给人家装Python环境要么给一个API接口。这两种方式都有明显痛点Python环境对普通用户太劝退API接口又依赖网络和服务器稳定。我的需求很明确把一个离线、免安装、可分享的OCR工具给到终端用户。那思路上就两条路一是用C直接调Paddle的预测库但Paddle的C库体积大、依赖多运行时还要装一堆DLL不适合“一个文件带走”的交付形态二是把模型转成ONNX用ONNX Runtime的C API做推理再通过Emscripten编译成WebAssembly最终嵌入HTML。后者才是能真正做到“单文件交付”的路。1.2 Tiny与Medium模型规模与精度的对比PP-OCRv4有一系列规格官方给了移动端和服务器端两档我习惯叫它Tiny和Medium。Tiny对应移动端模型体积小、速度快、精度稍低Medium对应服务器端模型体积大一到两倍精度更高但推理资源消耗也上去了。模型规格检测模型体积识别模型体积推理速度普通PC准确率表现Tiny移动端约1M约4M快单图几百ms常规场景够用复杂版式偶尔翻车Medium服务器端约3M约11M慢约2-3倍复杂背景、小字、倾斜文本表现更好这两个量级差异在C Runtime里会被真实放大因为它没有Python那种懒加载和JIT的“遮羞布”模型加载、内存分配、计算全部实打实发生。项目里先Tiny后Medium本质是先跑通流程、再追求效果的过程。我建议任何想复现的人也从Tiny起步因为模型小排错快跑通了再换Medium就是模型文件的事代码几乎不用动。1.3 整体技术链路从Paddle模型到单文件HTML整个链路可以画成一条线PaddleOCR官方模型 → paddle2onnx导出为ONNX → ONNX Runtime C推理代码 → Emscripten编译为WebAssembly → 模型转base64嵌入JS → 封装进HTML。这条链路里最核心的部分是C推理代码因为在WebAssembly环境里你没法用Python没有办法动态加载模型文件所有东西都得在编译期或运行时用代码显式处理。需要强调一点PP-OCR不是单一模型而是三个模型串联的流水线分别是文本检测、方向分类、文本识别。所以即使是“把PP-OCR塞进C Runtime”这件事本质是写一个C编排器依次调度三个ONNX模型。这一步是项目最容易出问题的地方后面我会详解。2. 工具链选型ONNX Runtime、Emscripten与编译环境搭建2.1 推理引擎不用Paddle Lite改用ONNX Runtime做C Runtime推理有两个选型方案Paddle Lite和ONNX Runtime。Paddle Lite是百度自家的端侧推理引擎理论上对Paddle模型支持最顺滑但它对WASM的支持不够成熟API也比较繁琐。ONNX Runtime是微软开源的跨平台推理引擎官方提供WASM构建产物API简洁生态成熟社区活跃。最终选择ONNX Runtime核心原因三个一是官方有现成的WebAssembly构建省去大量适配工作二是C API简单清晰几个核心函数就能跑通推理三是模型文件可以完全从内存加载这对嵌入单文件HTML来说是至关重要的能力。Paddle Lite在这点上没有ONNX Runtime做得干净利落。2.2 模型导出paddle2onnx的实操命令导出这一步网上教程一搜一大把但坑在于版本匹配。我这里把实测通过的组合贴出来# 安装 pip install paddlepaddle2.6.0 paddle2onnx1.0.8 paddleocr2.7.0 # 导出检测模型 paddle2onnx --model_dir ./ch_PP-OCRv4_det_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./ch_PP-OCRv4_det.onnx \ --opset_version 11 \ --enable_onnx_checker True # 导出方向分类模型 paddle2onnx --model_dir ./ch_ppocr_mobile_v2.0_cls_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./ch_ppocr_mobile_v2.0_cls.onnx \ --opset_version 11 \ --enable_onnx_checker True # 导出识别模型 paddle2onnx --model_dir ./ch_PP-OCRv4_rec_infer \ --model_filename inference.pdmodel \ --params_filename inference.pdiparams \ --save_file ./ch_PP-OCRv4_rec.onnx \ --opset_version 11 \ --enable_onnx_checker True导出的时候特别注意opset_version我建议固定11或者12太高的版本在ONNX Runtime WASM构建里容易出现算子不兼容。另外导出后一定要用onnxruntime的Python接口先跑一遍推理确定输出数值合理再继续这一步能过滤掉80%的后端坑。2.3 Emscripten编译环境Windows/Linux的取舍Emscripten是唯一的WASM编译工具链没有替代品。安装方式很简单git clone https://github.com/emscripten-core/emsdk.git cd emsdk ./emsdk install latest ./emsdk activate latest source ./emsdk_env.sh如果要用ONNX Runtime官方的预编译WASM产物理论上不需要自己编译直接下载官方npm包里的onnxruntime-web.wasm即可。但官方产物有两个局限一是默认只支持单线程二是算子集裁剪过某些PP-OCR的算子可能缺失。所以我强烈建议自己编译虽然过程耗时但能彻底掌控裁剪和优化项。自己编译ONNX Runtime WASM的完整流程# 拉取ONNX Runtime源码 git clone --recursive https://github.com/microsoft/onnxruntime.git cd onnxruntime # 配置WASM构建 ./build.sh --config Release \ --build_wasm \ --enable_wasm_simd \ --enable_wasm_threads \ --disable_wasm_exception_catching \ --disable_wasm_checks \ --skip_tests \ --parallel 8构建完成会生成onnxruntime_wasm.js、onnxruntime_wasm.wasm等文件。这里有两个参数值得注意enable_wasm_threads会引入Web Worker多线程支持但随之而来的问题是浏览器加载WASM时需要设置COOP/COEP响应头这对“单文件HTML直接打开”的交付形态是致命的。所以后续封装时我选择单线程版本换取的便利是双击HTML文件即用不需要起本地服务器。性能和体验的取舍在桌面端单线程其实够用尤其Tiny模型。3. 核心代码实现C Runtime里的OCR推理管线3.1 模型加载从文件、从内存、从base64ONNX Runtime的C API里加载模型最直接的方式是OrtCreateSessionFromFile但从文件加载在WASM环境下走不通因为浏览器没有文件系统概念。正确姿势是OrtCreateSessionFromArray直接从内存字节数组创建会话这样模型不管是嵌在C数组里还是由JS侧传过来都能统一处理。OrtStatus* status OrtCreateSessionFromArray(env, model_data, model_size, session_options, session);为了适配单文件HTML的交付我在C里直接包含两份模型数组一份检测模型一份识别模型。方向分类器在纯中文识别场景中影响不大可以先砍掉简化流程。数组来自模型的二进制文件编译时用xxd工具转成C头文件xxd -i ch_PP-OCRv4_det.onnx det_model.c xxd -i ch_PP-OCRv4_rec.onnx rec_model.c这样得到的是unsigned char数组对应文件内容。编译时直接链接进WASM二进制反而省去了运行时文件IO的时间模型加载能做到毫秒级。3.2 预处理图像缩放、归一化与动态shapeOCR的预处理细节决定了推理的稳定性。PP-OCR系列对输入图像的预处理要求检测模型输入是归一化后的RGB图像减均值0.5、除以方差0.5识别模型输入是灰度图或者三通道图同样归一化到[0,1]。在C里需要手写图像缩放和归一化逻辑。检测模型对输入尺寸有要求官方是动态shape也就是resize到短边960、长边按比例缩放后补到32的倍数。这一步很关键直接关系到检测效果// 计算缩放后尺寸 float scale 960.0f / std::min(src_w, src_h); int resize_w static_castint(src_w * scale); int resize_h static_castint(src_h * scale); // 补全到32的倍数 resize_w (resize_w / 32 1) * 32; resize_h (resize_h / 32 1) * 32;补到32的倍数这步很多人会漏漏掉的后果是检测输出的特征图尺寸对不上后处理阶段直接出错或丢框。为什么是32因为DB检测网络里有5次步长为2的下采样2的5次方是32这个信息能从网络结构里推出来不是拍脑袋定的。识别模型的预处理就简单多了输入是检测模型剪出的文本行小图统一resize到高度32宽度按比例缩放最后组成[N, 3, 32, W]的张量。之所以高度是32同样是因为识别骨干网络的缩放因子。3.3 后处理从ONNX输出到文本框坐标检测模型输出是一个概率图shape是[1, 1, H, W]每个像素点表示该位置是文本区域的概率。要还原成文本框需要做以下几件事阈值二值化、连通域分析、轮廓提取、最小外接矩形计算、坐标映射回原图。这一段是C Runtime里最繁琐的部分因为OpenCV在WASM里不可用所有图像处理都得手写。我的实现思路是// 1. 二值化概率大于阈值(0.3)的像素置255否则0 // 2. 膨胀3x3核增强连通性 // 3. 找连通域BFS从每个未访问的前景像素开始遍历 // 4. 对每个连通域计算最小外接矩形旋转矩形 // 5. 将旋转矩形四个角点按缩放比例映射回原图 // 6. 过滤面积小于一定阈值的噪声框最小外接矩形的计算我用了旋转卡壳的简化版因为文本区域大多是近似水平的也能直接用轮廓点集合的协方差矩阵算主方向然后把点投影到主方向轴上求包围盒。实测效果稳定代码量也小适合手写。识别模型的输出是一个序列shape是[1, seq_len, num_classes]seq_len是序列长度num_classes是字典大小加一个CTC空白符。后处理就是沿时间轴取argmax再去掉连续重复和空白符最后按索引查字典得到文字std::string res; int last_label -1; for (int t 0; t seq_len; t) { int label argmax(rec_output t * num_classes, num_classes); if (label ! 0 label ! last_label) { // 0是CTC blank res dict[label]; } last_label label; }这里的去重逻辑是CTC解码的精髓连续相同字符中间如果没有blank分隔会被合并成一个。理解这一点才能明白为什么“你好”和“你你好”在模型输出里可能长一个样要靠语言模型或特殊处理后处理去修正。3.4 编排器三个模型串联的完整流程文本检测模型输出文本框方向分类器判断文本是否颠倒识别模型输出文字。这三个模型在C里的编排逻辑是串行的// Step1: 检测 float* det_out run_det_model(rgb_data, resize_w, resize_h); std::vectorTextBox boxes postprocess_det(det_out, src_w, src_h); // Step2: 对每个box裁剪、缩放、方向分类、识别 for (auto box : boxes) { cv_style_crop_and_resize(src_data, box, crop); int label run_cls_model(crop); if (label 0) rotate_180(crop); std::string text run_rec_model(crop); }方向分类器在纯中文横排文本里大部分时候输出是“未旋转”但遇到手机拍照的图片偶尔会有倒置情况保留这一步骤对体验提升明显。不过为了单文件体积我最终在Tiny阶段砍掉了它因为砍掉后模型总大小能控制在5M左右HTML里base64膨胀后也才不到7M。Medium阶段我把它加了回来毕竟准确率优先。4. 编译WebAssembly与封装单文件HTML4.1 Emscripten编译C代码的完整命令C推理代码写好后用Emscripten编译出WASM这一步的参数会直接影响最终产物的大小和性能。我的编译命令是emcc ocr_engine.cpp \ -o ocr_engine.js \ -I./onnxruntime/include \ -L./onnxruntime/lib \ -lonnxruntime_providers_webgpu \ -O3 \ -msimd128 \ -s WASM1 \ -s ALLOW_MEMORY_GROWTH1 \ -s MAXIMUM_MEMORY1GB \ -s EXPORTED_FUNCTIONS[_malloc,_free,_ocr_init,_ocr_run,_ocr_free] \ -s EXPORTED_RUNTIME_METHODS[ccall,cwrap,getValue,setValue] \ --preload-file res/dict.txt几个参数要解释一下ALLOW_MEMORY_GROWTH1允许WASM内存随需扩展否则模型一加载就爆内存msimd128启用SIMD指令集加速对图像类计算有20%-50%的性能提升EXPORTED_FUNCTIONS里暴露的函数是JS侧要调用的C接口必须把初始化、推理、释放三个核心函数都暴露出来。编译产物是wasm js两个文件。js是胶水代码负责加载wasm、管理内存、提供JavaScript可调用的包装函数。我在封装时对js胶水代码做了一点精简把不必要的模块加载逻辑去掉减少最终文件体积。4.2 模型数据嵌入让wasm自己“长”出模型传统思路是wasm onnx模型 js三者分离但这样就要三个文件不符合单文件交付。我的做法是模型已经在编译阶段被xxd转成的C数组链接进wasm了这样wasm里就自带了检测和识别模型。HTML在加载wasm时直接fetch自己所在blob或者直接把wasm转成base64内联进JS。实际操作中我把编译好的wasm二进制转成base64字符串写入HTML里script const wasmBase64 AGFzbQEAAAA...此处是完整的wasm base64; const wasmBytes Uint8Array.from(atob(wasmBase64), c c.charCodeAt(0)); // 然后用WebAssembly.instantiate加载 /script但这就带来一个问题一个几MB的base64字符串写在HTML里编辑器会卡死浏览器解析也可能变慢。优化方案是把它放到HTML末尾的script标签里并确保没有语法高亮插件去实时解析这段超长字符串。这种方式牺牲了一点点加载性能但换来了真正“一个文件走天下”的便携性。4.3 HTML端Canvas绘制与识别框叠加HTML端的逻辑不多但都是直接影响用户体验的部分。核心流程是用户上传图片 → 读取到Canvas → 获取像素数据传给WASM → 回收识别结果 → 在Canvas上绘制文本框和文字。// 图片转像素数据并传给WASM const ctx canvas.getContext(2d); ctx.drawImage(img, 0, 0, naturalW, naturalH); const imageData ctx.getImageData(0, 0, naturalW, naturalH); // 分配内存并拷贝像素 const ptr wasmModule._malloc(imageData.data.length); wasmModule.HEAPU8.set(imageData.data, ptr); const resultPtr wasmModule._ocr_run(ptr, naturalW, naturalH); // 解析结果结构体数组 const view new Float32Array(wasmModule.HEAPU8.buffer, resultPtr, numBoxes * 9); for (let i 0; i numBoxes; i) { const x1 view[i * 9], y1 view[i * 9 1], x2 view[i * 9 2], y2 view[i * 9 3], x3 view[i * 9 4], y3 view[i * 9 5], x4 view[i * 9 6], y4 view[i * 9 7]; // 绘制四边形 }绘制识别框时我直接用Canvas的beginPath moveTo lineTo画四边形这样能适配旋转框而不是简单的矩形。识别文字直接绘制在框上方默认红色半透明背景加白字视觉上清晰不遮内容。4.4 内存管理C与JS之间的数据交换这是整个项目里最容易踩坑的地方。C里训练的模型加载和推理需要大量内存JS侧又需要把图片像素传给C、把结果拿回来两边内存如果管理不好轻则卡顿重则崩溃。我的约定是所有从JS传入WASM的图片数据由JS侧负责malloc和free所有WASM内部计算结果需要返回给JS的由C内部用静态缓冲区保存JS侧只读不释放下次调用自动覆盖。这样避免了JS释放C内存这种跨语言野指针问题。// C侧返回结果 extern C float* ocr_run(const uint8_t* img, int w, int h) { // ... static float result[MAX_BOXES * 9]; // 填充result return result; }这种做法在多次调用时是安全的因为static缓冲区不会悬空。代价是设计成了单实例不支持并发调用但这在单文件HTML的离线使用场景下完全够用。5. 常见问题排查与性能优化实录5.1 问题速查表我踩过的那些坑问题现象根因分析解决方案编译产物巨大超过100Monnxruntime自带的算子全量保留启用算子裁剪或用cmake配置--minimal_buildSIMD运行时崩溃浏览器不支持WebAssembly SIMD加特性检测fallback到非SIMD版本base64后的HTML双击打开白屏WASM实例化失败可能是响应头问题确认用单线程版本不依赖SharedArrayBuffer识别结果全是乱码字典文件和模型不匹配确保ppocr_keys_v1.txt与训练字符集一致检测框坐标错位预处理缩放系数未同步传回后处理将缩放比例存储为全局变量后处理对照使用内存持续上涨JS侧反复调用malloc不free统一封装free逻辑每次推理后释放图片数据模型加载速度慢base64解码大字符串改用分块解码或者压缩编码减少启动时峰值5.2 性能调优量化、算子裁剪与内存复用性能优化是项目里花时间最多的部分。Tiny模型在WASM上跑刚开始速度并不理想单张图要1到2秒优化后最快到300毫秒左右。第一招是算子裁剪。ONNX Runtime支持从源码编译时指定只保留需要的算子这样wasm体积能大幅缩小。具体做法./build.sh --config Release \ --build_wasm \ --enable_wasm_simd \ --skip_tests \ --cmake_extra_defines onnxruntime_USE_MPIOFF \ --cmake_extra_defines onnxruntime_MINIMAL_BUILDON不是所有算子都支持裁剪DB检测和CRNN识别用到的Conv、BatchNorm、Relu、Softmax这些主流算子都可以。实测裁剪后wasm从30M降到13M体积缩一半以上。第二招是内存复用。模型推理需要反复分配中间张量频繁malloc/free开销不小。ONNX Runtime内部有arena内存池编译时默认开启但WASM环境里效果不稳定。我自己在C里对检测、识别各复用一份输入输出buffer避免重复分配。第三招是SIMD优化。启用msimd128之后图像缩放和归一化部分能从纯标量循环提速3到4倍。Emscripten官方的SIMD支持已经默认开启只需要在编译时加-msimd128。前提是用户的浏览器得支持实测Chrome、Edge、Firefox近期版本都能跑。第四招是模型量化。ONNX Runtime支持动态量化训练后直接转INT8体积缩小到四分之一速度也提一波。但量化的精度损失在OCR这种精细任务上不可忽略尤其识别模型容易出现单字错误。我的取舍是Tiny模型做INT8量化Medium模型保持FP32因为Medium的精度优势不能被量化吃掉。5.3 单文件HTML的体验细节从能用做到好用单文件HTML做完自己先当用户用了一周发现几个体验细节值得优化。首先上传图片后要有进度反馈。WASM推理是同步阻塞的图片一大页面会卡死几秒用户会以为崩溃了。我的方案是用requestAnimationFrame或者setTimeout把推理放到下一帧先渲染一个“识别中”的遮罩再执行推理体验顺畅很多。其次结果要有可复制性。Canvas上画了文本用户想复制文字只能手打太反人类。我加了一个“复制全部文字”的按钮把识别文本按行拼接一键复制。实现上很简单识别结果本来就在内存里遍历拼字符串就行。再次支持拖拽上传。input[typefile]的体验太生硬加拖拽监听把文件drop事件转换成Image对象识别流程和上传一致。最后图片压缩。手机照片动辄几MB直接喂给检测模型会卡顿。我在Canvas里加了最长边限制超过2000像素就等比缩放。检测模型对长图的支持有限压缩后反而准确率更高速度也更稳。写在最后的一点体会项目做到最后最大的收获不是OCR本身跑通而是理解了端侧部署的完整链路。Python里的模型到浏览器里的可运行工具中间隔着的不仅仅是格式转换更是运行范式、内存模型、编译方式的全方位切换。C Runtime WASM的组合让“AI能力分发”这件事有了新的解法——不需要服务器不需要装环境一个文件就能把模型带出去。再分享一个小技巧如果你也想做类似的单文件AI工具建议从Tiny模型起步先把整条链路跑通再考虑换模型、加功能、做优化。链路没通之前所有优化都是空中楼阁。另外编译ONNX Runtime WASM那次等待是我整个项目里最煎熬的两个小时但那之后的路就顺了。希望这篇复盘能帮后来的人少踩几个坑省下那几个小时。