基于LiteRT.js与WebGPU的浏览器端收据扫描器实战
2026/9/19 5:56:03 网站建设 项目流程

浏览器里跑OCR这件事,几年前我还觉得是个玩具。那会儿拿Tesseract.js试过识别发票,一张A4纸要等七八秒,识别率还惨不忍睹,稍微有点倾斜或者光照不均就满屏乱码。但这两年情况完全变了,WebAssembly的成熟加上WebGPU的落地,让端侧推理的性能有了质的飞跃。LiteRT.js就是在这个背景下出现的一个方案,它把Google的LiteRT(原TensorFlow Lite)运行时搬到了浏览器里,配合WebGPU做硬件加速,让收据扫描这种过去必须依赖云端API的场景,完全可以在本地完成。

这篇文章我想聊的是怎么用LiteRT.js搭一个基于浏览器的收据扫描器。所谓收据扫描器,核心就两件事:一是把收据从照片里"抠"出来并矫正,二是把上面的文字识别出来结构化。前者涉及图像预处理和边缘检测,后者就是OCR。整个链路全部在浏览器端完成,不上传任何图片到服务器,这对涉及金额、商户信息的收据来说,隐私上的优势是实打实的。适合谁看?如果你是有一定前端基础、想了解端侧AI推理怎么落地的开发者,或者你正在做一个需要处理票据、名片、文档的Web应用,这篇内容应该能给你一套可复现的思路。

1. 为什么收据扫描值得在浏览器端做

1.1 云端OCR方案的三个隐性成本

大多数人第一反应是调云端OCR接口,毕竟准确率高、接入简单。但真到了生产环境,你会发现有几个成本是绕不开的。

第一是延迟的不可控。用户拍完照点识别,请求要经过上传图片、排队、推理、返回结果这一整条链路。图片稍微大一点,光上传就得一两秒,加上服务端排队,用户盯着loading转圈三五秒是常态。收据扫描这个场景,用户往往是在便利店门口、餐厅桌边随手一拍,他要的是"拍完立刻看到结果",而不是等一个网络往返。

第二是隐私合规的麻烦。收据上有商户名、金额、时间,有些还有卡号后四位。这些数据传到第三方服务器,在很多企业内控和隐私政策下是过不了关的。你要自己搭OCR服务,那又是一整套运维成本。

第三是离线场景直接歇菜。用户在地下车库、电梯里、飞机上,网络一断,云端方案就废了。而浏览器端推理一旦模型加载完,断网照样跑。

1.2 LiteRT.js + WebGPU 带来的性能拐点

LiteRT.js的本质是把LiteRT的C++运行时通过Emscripten编译成WebAssembly,再暴露一套JavaScript API。模型文件是.tflite格式,加载后在WASM的沙箱里执行推理。关键的变化在于执行后端:早期只能走CPU,后来支持了WebGL,现在WebGPU出来了,推理性能又上了一个台阶。

WebGPU相比WebGL的优势在于它更贴近现代图形API的设计,支持compute shader,能更高效地做矩阵运算。对于OCR里的卷积网络和Transformer结构,WebGPU的加速比纯CPU能快5到10倍。我实测过一个轻量级文本检测模型,CPU后端单帧要400多毫秒,切到WebGPU之后降到60毫秒左右,这个差距直接决定了交互体验是"卡顿"还是"跟手"。

注意:WebGPU目前在各浏览器的支持情况不一致,Chrome和Edge较新版本已经默认开启,Safari和Firefox还在逐步推进。生产环境一定要做能力检测和CPU回退。

1.3 收据这个场景的特殊性

收据和普通文档OCR不太一样,它有几个特点决定了方案设计。一是背景复杂,收据往往放在桌面上拍,背景可能是木纹、桌布、手,边缘检测要能抗干扰。二是透视畸变严重,很少有人能正对着拍,基本都是斜着拍的,所以透视矫正这一步不能省。三是字体规整但排版松散,收据上的字基本都是印刷体,识别难度不高,但字段位置不固定,需要做后处理来提取"商户名""金额""日期"这些结构化信息。

理解了这三点,后面的技术选型和流程设计就有了依据。

2. 技术栈选型:LiteRT.js、React与图像处理的分工

2.1 为什么是LiteRT.js而不是ONNX Runtime Web

端侧推理框架现在主流的有两个:ONNX Runtime Web和LiteRT.js。ONNX Runtime Web的生态更开放,模型来源广,社区活跃。LiteRT.js的优势在于它和移动端LiteRT是同源的,如果你有Android或iOS端的模型,可以直接复用,不用重新转换。另外LiteRT对量化模型的支持比较成熟,int8量化后的模型体积小、推理快,很适合浏览器这种资源受限的环境。

收据扫描这个场景,模型不需要太大,检测模型几MB、识别模型十几MB就够了。LiteRT.js加载这种量级的模型,配合WebGPU,首屏加载加推理的总时间能控制在可接受范围内。ONNX Runtime Web当然也能做,但如果你团队已经有LiteRT的模型资产,选LiteRT.js能省掉模型转换和精度验证的麻烦。

2.2 React在其中的角色定位

React在这里不是必须的,但它能帮你把状态管理理清楚。收据扫描的流程是有状态的:摄像头流、拍照、图像预处理、检测、矫正、识别、结果展示,每一步都有loading、成功、失败三种状态。用React的useState和useReducer来管理这套状态机,比裸写DOM要清晰得多。

我自己的做法是把整个扫描流程封装成一个自定义Hook,比如useReceiptScanner,内部管理模型加载、推理调用、结果解析,组件层只负责渲染。这样UI和逻辑解耦,换框架也不用重写核心逻辑。

2.3 图像预处理为什么不能省

很多人拿到图片直接丢给OCR模型,结果识别率上不去,然后怪模型不行。其实问题往往出在预处理。收据扫描的预处理至少要做三件事:

  • 缩放:手机拍的照片动辄4000x3000,直接送进模型既慢又没必要。检测模型输入一般512x512或640x640就够了,等比缩放后再送。
  • 灰度化与对比度增强:收据是白纸黑字,彩色信息对识别没帮助,转灰度能减少计算量。对比度增强能让浅色字迹更清晰。
  • 去噪:手机在暗光下拍照噪点很多,轻度高斯模糊能平滑噪点,但要注意别把字也糊掉了。

这些操作用Canvas的2D API或者OffscreenCanvas就能做,不需要额外的库。如果要做透视矫正,还需要自己实现一个简单的透视变换,这个后面细说。

3. 从拍照到文字:收据扫描的完整链路拆解

3.1 摄像头采集与图像质量把控

第一步是拿到一张质量过关的图。用getUserMedia调摄像头,这里有几个参数值得注意。facingMode设成environment用后置摄像头,widthheight设成{ ideal: 1920 }这种理想值,让浏览器自己协商。别硬设成固定值,不同设备支持的分辨率不一样,硬设可能导致采集失败。

采集到视频流之后,我建议做一个实时质量检测。简单点的方法是算当前帧的拉普拉斯方差,方差太低说明画面模糊,提示用户"请保持稳定"。这个计算量很小,在requestAnimationFrame里跑完全没问题。用户看到实时提示,拍出来的图质量会好很多,后面识别的压力就小了。

拍照的时机也有讲究。不要用户一点就立刻截帧,可以等一两帧,让自动对焦稳定下来。有些设备从点击到对焦完成有延迟,截早了就是糊的。

3.2 边缘检测与透视矫正的实现思路

收据在照片里通常是一个四边形,但因为拍摄角度,这个四边形是变形的。要矫正它,得先找到四个角点。

找角点的经典做法是Canny边缘检测加霍夫变换,但这套在浏览器里跑起来不轻。我的做法是先用一个轻量级的语义分割模型(也是LiteRT格式)把收据区域分割出来,得到一张mask,然后对mask做轮廓提取,用多边形拟合找到四个顶点。这个方案比传统CV方法鲁棒,对复杂背景的适应性好很多。

找到四个角点之后,做透视变换。原理是求解一个3x3的单应性矩阵,把变形的四边形映射成矩形。这个矩阵可以通过四组对应点解出来,OpenCV里有getPerspectiveTransform,浏览器里没有现成的,但自己实现也就几十行代码。核心是解一个8元一次方程组,用高斯消元或者直接调数学库都行。

// 透视变换的核心:根据四组对应点求单应性矩阵 function computeHomography(src, dst) { // src和dst都是4个点的数组,每个点是[x, y] // 构造8x8矩阵和8x1向量,解线性方程组 const A = []; const b = []; for (let i = 0; i < 4; i++) { const [x, y] = src[i]; const [u, v] = dst[i]; A.push([x, y, 1, 0, 0, 0, -u * x, -u * y]); A.push([0, 0, 0, x, y, 1, -v * x, -v * y]); b.push(u); b.push(v); } // 解A * h = b,h是8个未知数 const h = solveLinearSystem(A, b); return [ h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7], 1 ]; }

拿到矩阵后,对目标矩形的每个像素,反向映射回原图取像素值。这里要注意用双线性插值,不然矫正后的图会有锯齿。

3.3 文本检测与识别模型的选择

矫正完之后就是OCR。我建议拆成两步:先检测文本框,再识别文字。一步到位的端到端模型当然有,但拆开的好处是每一步都能单独调优,而且检测和识别可以用不同的模型,灵活性更高。

检测模型我选的是基于DBNet的轻量版本,输入512x512,输出是每个像素属于文本区域的概率图。后处理就是二值化加连通域分析,得到一个个文本框。识别模型用CRNN或者轻量Transformer,输入是矫正后的文本条,输出是字符序列。

模型转换这块,如果你手头是PyTorch模型,先转ONNX,再用LiteRT的转换工具转成.tflite。转换时记得做动态范围量化,模型体积能压到原来的四分之一,精度损失通常在1%以内,对收据这种印刷体识别完全够用。

3.4 结构化字段提取的后处理逻辑

识别出来的是一堆文本行,收据扫描的最终目标是提取出"商户名""总金额""日期"这些字段。这一步纯靠规则和正则。

我的做法是先按文本框的纵坐标排序,还原阅读顺序。然后对每一行文本做关键词匹配。比如包含"合计""总计""应付"的行,大概率是总金额行,从里面用正则抽数字。日期用日期正则匹配。商户名通常在收据顶部,取前几行里最长的、不含数字的那一行。

这套规则不可能覆盖所有收据格式,但能覆盖大部分常见场景。遇到识别不出来的,可以给用户一个手动修正的入口,把结果展示成可编辑的表单,用户改一下就行。这比追求100%自动提取要务实得多。

4. 性能优化:让推理在浏览器里跑得动

4.1 WebGPU后端的启用与回退策略

启用WebGPU的代码不复杂,但要做好能力检测。LiteRT.js在初始化的时候可以指定后端,如果WebGPU不可用,要能自动回退到WASM的CPU后端。

async function createInterpreter(modelBuffer) { const backends = ['webgpu', 'wasm']; for (const backend of backends) { try { const interpreter = await liteRT.load(modelBuffer, { backend: backend, numThreads: navigator.hardwareConcurrency || 4 }); console.log(`使用后端: ${backend}`); return interpreter; } catch (e) { console.warn(`${backend} 后端不可用,尝试下一个`); } } throw new Error('没有可用的推理后端'); }

CPU后端下numThreads设成CPU核心数,能利用多线程加速。但注意WASM的多线程需要跨域隔离(COOP/COEP响应头),部署的时候要配好,不然多线程起不来。

4.2 模型量化与输入尺寸的权衡

模型量化是端侧推理的必修课。float32的模型精度最高但体积大、速度慢,int8量化后体积缩小到四分之一,推理速度提升两三倍,精度损失在可接受范围内。收据识别这种任务,int8完全够用。

输入尺寸的权衡更微妙。检测模型输入从512提到640,小字体的检测率会提升,但推理时间也线性增加。我的经验是512是个甜点,再大收益递减。识别模型的输入高度一般固定32或48,宽度按文本条的实际宽高比动态调整,这样既不浪费计算,又能保证长文本条的识别质量。

4.3 推理结果的缓存与复用

用户可能会连续扫描多张收据,模型没必要每次都重新加载。把加载好的interpreter缓存在内存里,下次直接用。React里可以用useRef或者模块级的单例来存。

另外,如果用户对同一张图反复调整矫正框,检测模型的结果可以复用,只需要重新做透视变换和识别。把检测结果缓存起来,能省掉一次推理。

4.4 大图处理的内存陷阱

浏览器里处理大图,内存是个容易踩的坑。一张4000x3000的RGBA图,光像素数据就48MB,如果中间过程创建多个副本,很容易触发内存溢出,尤其在移动端浏览器上。

我的做法是尽早降采样。拿到原图后,先缩放到一个工作尺寸(比如长边1280),后续所有处理都在这个尺寸上做。需要输出高清矫正图的时候,再用原图做一次最终的透视变换。这样中间过程的内存占用能控制在几MB级别。

还有一点,用完的Canvas和ImageBitmap要及时释放,canvas.width = 0或者调close()方法,别让垃圾回收器猜你的意图。

5. 踩过的坑与实战经验

5.1 模型加载失败的排查路径

模型加载失败是新手最容易卡住的地方,而且报错信息往往很模糊。我整理了一套排查顺序。

先看网络请求。打开DevTools的Network面板,确认.tflite文件是不是200返回了。有时候是路径写错,有时候是服务器没配MIME类型,浏览器把二进制文件当文本处理了。

再看文件完整性。模型文件下载不完整也会加载失败,对比一下文件大小和源文件是否一致。如果是通过构建工具打包的,注意别让打包器对.tflite做了压缩或转码,要把它当静态资源原样输出。

最后看后端兼容性。WebGPU后端对模型算子有要求,如果模型里用了WebGPU不支持的算子,加载会失败。这时候切回WASM后端试试,如果WASM能跑,那就是算子兼容问题,需要换模型或者改算子实现。

5.2 识别率上不去的三个常见原因

第一个原因是预处理没做好。前面说过,缩放、灰度、对比度增强这三步不能省。我见过有人直接把原图送进模型,识别率比预处理后低了十几个百分点。

第二个原因是文本框切分不当。检测出来的文本框如果切得太碎,单个框里只有一两个字,识别模型缺乏上下文,容易出错。如果切得太大,一行里混了多个字段,识别出来也是乱的。文本框的合并和拆分策略要根据实际效果调。

第三个原因是模型和场景不匹配。用通用文档模型去识别收据,效果往往不如专门在收据数据上微调过的模型。如果条件允许,收集几百张真实收据做微调,识别率能有明显提升。

5.3 移动端浏览器的兼容性处理

移动端浏览器是另一个战场。iOS的Safari对WebGPU的支持还在推进中,Android各厂商浏览器内核版本参差不齐。我的策略是功能检测加优雅降级

WebGPU不可用就退到WASM,WASM多线程不可用就退到单线程。摄像头采集用getUserMedia,如果设备不支持,提供一个文件上传的入口作为兜底。这样不管用户用什么设备,至少有一条路能走通。

还有内存限制。移动端浏览器给单个标签页的内存配额比桌面端小得多,处理大图的时候要格外小心。我一般会把工作尺寸再降一档,长边控制在960,牺牲一点精度换稳定性。

5.4 用户体验层面的细节打磨

技术跑通只是第一步,体验上的细节决定了用户愿不愿意用。

进度反馈要明确。模型加载、检测、识别每个阶段都要有进度提示,别让用户对着白屏干等。模型加载可以显示百分比,推理阶段可以显示"正在识别文字"。

结果可编辑。自动提取的字段不可能100%准确,给用户一个修正的界面,改完能导出。这比追求全自动但错误百出要实用。

失败要能重试。识别失败的时候,别只弹个"识别失败",要告诉用户可能的原因,比如"图片太模糊,请重新拍摄",并给一个重试按钮。

导出格式要贴合使用场景。收据扫描的结果,用户往往要导入记账软件,所以导出CSV或者JSON比导出图片更有用。

6. 这套方案还能往哪些方向延伸

收据扫描跑通之后,这套技术栈其实可以复用到很多类似场景。名片识别、身份证识别、快递单识别,底层链路是一样的,换个模型和后处理规则就行。

再往深了做,可以加多帧融合。用户拍收据的时候往往连拍好几张,把多帧的识别结果做投票融合,能显著提升准确率。这个思路在视频OCR里很常见,搬到收据场景同样有效。

还有一个方向是端侧模型微调。浏览器里做训练现在还不现实,但可以做联邦学习式的更新,把用户修正过的样本匿名上传,定期更新模型。这样模型能持续适应真实的收据分布,越用越准。

最后提一句,LiteRT.js这个生态还在快速演进,WebGPU的支持也在不断完善。现在入坑,等生态成熟的时候,你已经积累了一堆实战经验,这个时间差就是优势。我自己是从Tesseract.js一路踩坑过来的,看着端侧OCR从"能用"变成"好用",这个过程本身就挺有意思的。

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

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

立即咨询