编程 把 OCR 塞进浏览器:onnxruntime-web + PP-OCRv6 tiny 实战笔记

2026-09-01 18:27:29

把 OCR 塞进浏览器:onnxruntime-web + PP-OCRv6 tiny 实战笔记

切入点:为什么不启 Python 服务

内部系统要加 OCR,最省事的路子是后端起一个 Python 服务,装 PaddleOCR,包一层 Flask。但这样要处理并发、内存、进程管理,为一个内部工具引入一组常驻服务,成本有点高。

更好玩的问题是:能不能让浏览器自己把这事干了?

答案是能。让 ONNX 模型直接在浏览器里跑推理,图片不出本机,也不占后端资源。这套路适合内部小工具、离线环境、以及不想维护服务端的场景。

需要先说清楚:这是 demo 级方案,不是生产级高并发方案。纯前端推理受限于设备性能,但它确实能跑通,而且跑得动。


1. onnxruntime-web 接入

onnxruntime-web 是微软 ONNX Runtime 的浏览器版本,底层走 WebGL 或 WebGPU,模型推理在显卡上执行。

安装很直接:

npm install onnxruntime-web
# 或
bun add onnxruntime-web

创建入口文件:

import * as ort from 'onnxruntime-web';
ort.env.wasm.wasmPaths = '/ort/';
(window as any).ort = ort;

用构建工具打成浏览器可用的 bundle:

bun build ./src/index.ts --outfile=./static/ort.js --target=browser

HTML 里引:


之后就是四步:加载模型 → 准备输入 → 推理 → 拿结果。没有服务端。

注意:上面 wasmPaths = '/ort/' 意味着你需要把 onnxruntime-web 的 wasm 文件也放到静态目录里,保证浏览器能加载到。


2. 模型选择与下载

OCR 不是单一模型,是「文本检测 + 文本识别」两个模型串联。这里用的是百度 PP-OCRv6 tiny,从 ModelScope(魔搭)下载 ONNX 格式。

搜索关键词:PP-OCRv6_tiny,下载两个文件:

文件作用参数/大小
PP-OCRv6_tiny_det_onnx文本检测,定位所有文字区域(边界框)43 万参数,约 1.9 MB
PP-OCRv6_tiny_rec_onnx文本识别,把区域图像转成文字110 万参数,约 4.4 MB

本地目录建议:

static/models/
PP-OCRv6_det_tiny.onnx
PP-OCRv6_rec_tiny.onnx

原文中描述「tiny 版本检测模型 1.74 MB,识别模型 4.28 MB」,与前面 1.9 MB / 4.4 MB 有出入,应以实际下载文件大小为准。


3. 整体流水线

上传图片 → 缩放至长边 ≤ 960(32 的倍数)→ 检测模型推理 → 概率图
→ DBNet 后处理(二值化 → 连通域 → 文本框 → unclip 外扩)
→ 裁剪每个文本框 → 缩放至 48 固定高度 → 识别模型推理 → CTC 解码 → 文字

两个模型加起来 6 MB 出头,浏览器加载成本很低。


4. 预处理:为什么要 32 的倍数

检测模型的输入要求:

  • 长边不超过 960
  • 宽高必须是 32 的整数倍

原因:模型内部有步长为 32 的下采样层。如果输入尺寸不是 32 的倍数,到最后一层特征图尺寸会被截断,导致输出尺寸对不上原图坐标。

预处理代码如下:

let r = Math.min(1, 960 / Math.max(origW, origH));
const detW = Math.max(32, Math.round(origW * r / 32) * 32);
const detH = Math.max(32, Math.round(origH * r / 32) * 32);

注意这里用的是「向上/向下取整到 32 的倍数」而不是简单地乘比例。短边会被拉伸到 32 对齐,所以缩放后图像比例会有轻微变形,但在 DBNet 后处理时会通过 scaleX / scaleY 映射回原图坐标,所以不影响最终结果。

转 CHW + 归一化的参数来自官方模型配置:

const DET_MEAN = [0.485, 0.456, 0.406];
const DET_STD  = [0.229, 0.224, 0.225];

这是 ImageNet 风格的标准化,不是随便定的。


5. 文本检测:DBNet 后处理

检测模型输出的是一张概率图,每个像素表示「这里是文字」的概率。

后处理步骤:

  1. 二值化:概率大于 0.2 记为文字,否则背景
  2. 连通域标记:BFS 扫相邻白点,每个连通域就是一个文字区域
  3. unclip 外扩:模型输出的框通常比真实文字略小,按 面积 × 1.4 / 周长 向外扩
  4. 过滤:短边小于 3 像素的丢弃;区域内平均概率低于 0.4 的丢弃
  5. 排序:按 y 坐标排序,保证阅读顺序
const thresh = 0.2, boxThresh = 0.4, unclip = 1.4;

这段后处理是纯前端 JS 实现的,不需要任何 Python 依赖。


6. 文本识别:CRNN + CTC 解码

每个检测框裁剪出来后,缩放到固定高度 48 像素,宽度按比例保持:

const recW = Math.max(8, Math.round(48 * cw / ch));

识别模型输出形状为 [1, T, C]

  • T:时间步数
  • C:字符类别数,tiny 是 6906
  • 索引 0 是 CTC blank

CTC 贪心解码逻辑:

每个时间步取概率最大的索引
去掉 blank(索引 0)
合并连续重复的索引
映射到字符集 → 文字

例子:[15, 15, 15, 0, 0, 23, 23, 0, 5] → 合并去重 → [15, 23, 5] → 映射字符集 → 文字。


7. 踩坑记录

坑一:识别结果全乱码

现象:输出是一堆「沼桷轻哔茸藩舅爪锵喇」这样的乱码。

原因:ONNX 模型输出 6906 维,但下载的字典只有 6622 字符。argmax 算出来的索引超过字典范围时,映射到 undefined\uFFFD

解决:不从外部字典文件读字符集,直接从 ONNX 模型元数据里提取。作者用 Python 脚本解析 protobuf 元数据,用 varint 解码读取字段长度,提取出 6904 个字符;加上索引 0(blank)和最后一个空格字符,正好对应模型的 6906 维输出。

需验证:原文没有贴出这个 Python 提取脚本。「直接从 ONNX 模型元数据提取字符集」的说法需要实际验证。建议先检查你的 ONNX 文件里有没有 keys 相关的 metadata,如果没有,还是得从 PaddleOCR 官方字典文件里补全。

前端加载后的字符集:

charList = ['', ...dict, ' '];
// 0(blank) + 6904 + space = 6906

坑二:softmax 算出 NaN

现象:置信度显示 conf NaN%,识别文字变成 undefined字

原因:模型原始输出里有 NaN 值,Math.exp(NaN) 在整行 softmax 里扩散。

修复:

  • argmax 时跳过 !isFinite(v) 的值
  • softmax 时跳过 `diff 原文没给出 0.3 阈值下 UI 是否还会显示低置信度结果。从代码看识别结果按置信度分色显示(高置信绿色、中等黄色、低的不显示),但没有完整展示过滤逻辑,需自行确认。

坑四:tiny 和 small 架构完全不同,代码不能复用

想换成更大的 small 模型时发现,代码基本要重写:

对比项tinysmall
Det 输入/输出[1,3,960,960] 输入,DBNet 概率图[1,3,640,640] 输入,[1,1,640,640] 二值图
检测后处理DBNet 阈值 + unclip轮廓检测(不是 DBNet 那套)
Rec 词表690618710
模型输入尺寸960640

需验证:原文表格中「tiny Det 输出为 [1,3,960,960] DBNet 三通道」表述容易误导。从后处理代码看,实际拿到的 probData 是一维概率数组,后处理是按单通道二值图操作的。这里的 [1,3,960,960] 更可能是输入张量形状,不是输出形状。建议下载模型后用 onnxruntimeNetron 确认输入输出维度。

small 模型换了检测头架构,不再是 DBNet 的阈值 + unclip 流程,而是输出二值图后用轮廓法找文字区域。另外字符集也完全不同(18710 vs 6906)。tiny 的代码不能直接跑 small,要重写后处理和解码。


8. 完整项目结构与启动

demo/2/
├── package.json
├── server.ts              # Bun 静态服务器
└── static/
├── index.html         # OCR 应用
├── ppocr_keys_v6_tiny.json  # 6904 字符集
├── ppocr_keys_v6_tiny.txt   # 字符集(文本格式)
└── models/
├── PP-OCRv6_det_tiny.onnx   # 检测模型 1.74MB
└── PP-OCRv6_rec_tiny.onnx   # 识别模型 4.28MB

启动:

cd demo/2
bun install
bun run dev

打开 http://localhost:3001 使用。

页面功能:

  • 上传图片,Canvas 预览
  • 自动加载模型,显示加载状态
  • 识别结果按行展示:序号 + 置信度 + 文字
  • 画布上用矩形框标注文本区域,框上方显示识别文字
  • 置信度颜色分级:高置信绿色、中等黄色、低置信不显示

9. 边界与注意

  • 纯本地离线:模型文件全部在本地静态目录,图片不上传,推理在浏览器里完成。
  • 注意 CDN 依赖:原文的 index.html 里引入了 https://cdn.jsdelivr.net/npm/onnxruntime-web@1.27.0/dist/ort.min.js。如果你要完全离线,需要把这个 js 文件也下载到本地。这就是「纯本地」和「完全离线」之间的一步差距。
  • 不支持 small:tiny 的检测后处理、字符集、输入尺寸都跟 small 不通用。small 需要单独适配。
  • 原文未提供:从 ONNX 元数据提取字符集的 Python 脚本源码未贴出;ModelScope 上的具体模型文件路径未给出,只提供了搜索关键词;small 模型的架构细节也没有展开。

10. 可执行建议

  1. 先复现 demo:按上面的项目结构把文件放好,跑起来。确认模型能加载、识别能出字。
  2. 验证字符集:加载后打印 charList.length,必须是 6906。如果不是,说明字符集来源不对,用 PaddleOCR 官方 dict 对一遍。
  3. 验证预处理尺寸:打印实际送入模型的 detW / detH,确认是 32 的倍数。如果输出概率图的尺寸和预期不一致,检查缩放逻辑。
  4. 阈值从 0 开始:先不要设过滤阈值,收集一批真实图片的 softmax 概率分布,再定阈值。
  5. 离线化收尾:把 ort.min.js 以及 wasm 文件全部下载到本地,删掉 CDN 引用,断网测试。
  6. 别急着上 small:tiny 已经覆盖中英文常用场景,且加载快。要换 small 前先确认你愿意重写检测后处理和字符集映射。
  7. 线上使用前测 WebGPU:原文用的是 WebGL,性能取决于浏览器和设备。可以对比 WebGPU 和 WebGL 的推理耗时再做选择。
复制全文 生成海报 OCR 前端 AI推理 WebGPU PP-OCRv6

推荐文章

程序员茄子在线接单