LiteRT.js实战:构建浏览器端轻量收据扫描识别方案
2026/9/23 7:14:30 网站建设 项目流程

前阵子接手一个报销小工具,对方提了个需求:用户在网页里对着购物小票拍一张,系统自动识别出总金额、日期和商户名,直接生成报销单。一开始我想得很简单,放个Tesseract.js在后端或者前端跑一跑就完事,结果一测识别率感人,中等复杂度的收据,文字稍微倾斜一点,金额带个小数点,识别结果就开始飘。后来换成了基于LiteRT.js的浏览器端识别方案,整个项目才算真正落地。这篇文章就把我在这套基于浏览器的收据扫描器里做的技术选型、工程实现和踩坑过程完整拆开来写,给那些准备做类似浏览器端OCR、边缘推理、文档扫描项目的朋友一个参考。

这套方案适合谁?如果你正在做发票报销、收据记账、拼单AA、线下商店小票电子化这类场景,而且不想为了识别功能单独架一台GPU服务器,想把模型直接打包进前端页面,那LiteRT.js这套路线值得你花几分钟看完。我会尽量写得直白,代码能跑就跑,跑不通的地方也会告诉你为什么。

1. 为什么我把收据识别从后端搬到了浏览器

1.1 最开始的Tesseract.js方案,慢在哪

我最早试过Tesseract.js,社区知名度高、文档也多,而且是纯JavaScript实现,理论上在浏览器里跑起来没有门槛。但真到了收据扫描这种场景,Tesseract.js的问题就很突出。

第一是模型体积。Tesseract的核心语言包,英文加简体中文,下载下来随便几十上百兆。收据是个强结构化文档,真正有用的文字信息可能只占画面面积的20%,但Tesseract是全页面扫描,它不会帮你区分哪些是Logo、哪些是促销文案、哪些才是要报账的金额字段。第二是推理速度。在普通桌面浏览器上跑一轮全页面OCR,动辄两三秒起步,如果用户拿手机浏览器拍拍拍,帧率就更不乐观。第三是识别精度。Tesseract对印刷体英文的表现还行,一旦碰上中英混排、数字粘连、热敏纸褪色、背景有褶皱阴影,识别出来的字符串完全不能直接当报销数据用。

1.2 收据场景的四个“反常规”难点

一般的OCR场景,比如拍书页、拍名片,文字密度高、排版规整,模型只需要做“把图上所有文字读出来”这一件事。收据不一样,它有四个很反常规的难点:

  • 热敏纸是低对比度介质。很多购物小票字迹是用热敏打印的,纸张放一段时间就会泛黄变暗,字迹反而不清晰,RGB转灰度之后对比度极低,普通的OCR预处理很容易把文字和背景糊在一起。
  • 收据上的数字字段极其重要。金额、日期、单号,这些字段只要错一个数字,报销单就是废的。普通OCR在识别数字时因为缺少上下文约束,经常把“6”看成“8”、“0”看成“O”,这种错误用正则根本兜不住。
  • 收据不存在固定的版式。超市小票、餐饮小票、出租车发票、快递面单,横版竖版、宽窄不一,字段名称也千奇百怪,指望一套模板匹配所有版式不现实。
  • 用户在拍摄时几乎不可能保持完全水平。透视变形、阴影遮挡、反光,都是家常便饭。

这四点叠加起来,靠Tesseract这种通用方案很难有稳定输出。我当时最迫切的需求是:有一个能在浏览器端跑的、轻量的、专门针对收据版面的模型推理方案。

1.3 LiteRT.js出现后,方案开始变得顺理成章

后来我注意到LiteRT(Lightweight Runtime)这个项目,它是Google在TensorFlow Lite基础上持续演进的轻量化推理运行时,专为移动端、物联网端、Web端这些资源受限环境设计。LiteRT.js就是它的Web绑定版本,底层通过WebAssembly运行模型,可以在不依赖后端服务的情况下,在浏览器里完成目标检测、图像分类、语义分割、OCR识别这类推理任务。

这个方案对我最大的吸引力是三点:模型文件小,量化后的检测模型几个MB就能搞定;推理速度快,在桌面浏览器上能跑到几十毫秒一轮;而且它不只是给一个黑盒API,你对模型内部的输入输出张量是有控制权的,意味着可以做预处理、后处理、版面结构化的深度定制。这正好命中收据扫描的所有关键需求。

2. LiteRT.js到底是什么,为什么适合收据这种小模型任务

2.1 别被名字绕晕:LiteRT、TensorFlow Lite和TFLite Runtime的关系

这里花几行字说清楚命名问题,因为我当时也绕了一阵。TensorFlow Lite是Google主推的移动端/边缘端推理框架,早期大家叫它TFLite;后来随着产品线调整,Google把这套轻量化推理体系整合升级成了LiteRT,模型格式沿用了.tflite格式,底层算子层也做了大量优化。LiteRT.js就是LiteRT在浏览器/JavaScript环境下的SDK形态,它把WASM推理核心包裹成JavaScript API,让前端开发者不需要手动处理C++和WASM的加载细节。

所以在使用LiteRT.js时,你找到的模型文件依然可能是.tflite后缀,或者经过转换后的优化格式,这一点不用困惑。从工程角度理解,你只需要关注三件事:模型是什么格式、模型输入输出张量长什么样、运行时API怎么调用。

提示:LiteRT项目本身迭代速度很快,具体包的命名、安装方式、API签名在不同版本之间可能有差异。我的示例代码基于一个相对稳定的Web端调用形态,如果你手头的版本改了API,核心流程不变,按官方文档调整方法名即可。

2.2 收据扫描的三个子任务:检测、矫正、识别

一个完整的收据扫描流程不能只靠一个OCR模型搞定所有事,我把它拆成了三个阶段。

第一是文本区域检测。这一步的目标是从画面里找到所有可能的文字块,用边界框框出来,告诉系统“这里有一行字”“那里有一个数字”。这一步可以用目标检测模型完成,优点是速度快,模型体积小。我当时选用了一个基于EfficientDet-Lite训练的收据文本检测模型,输入分辨率320x320,检测类别只有“文本区域”一类,边界框输出格式是[x_center, y_center, width, height],配合非极大值抑制去掉重叠框。

第二是透视矫正。检测到文字块之后,并不是直接送去识别就完事,因为用户拍摄时收据纸张往往存在透视变形。我的做法是用检测到的文字块顶点做四点透视变换,把收据区域拉正成矩形,再送进文字识别模型。这一步对识别率提升是决定性的,同样一张收据,拉正之后再识别,准确率可以提升10个点以上。

第三是文字内容识别。拉正后的区域,裁剪放大,送入一个轻量的CRNN类识别模型,输出对应的文本行。这一步模型不需要太大,因为单个字段的文字量通常都很短,比如“总金额:128.50”,核心是保证数字和标点的准确率。

三个阶段各司其职,比一个万能的巨型OCR模型更可靠,也更符合LiteRT.js这类浏览器端推理方案的性能边界。

2.3 和ONNX Runtime Web、MediaPipe Tasks横向怎么选

既然聊到浏览器端推理,ONNX Runtime Web和MediaPipe Tasks也都是绕不开的选项,我简单说下我为什么最后落在LiteRT.js这条路上。

ONNX Runtime Web的优势在于中间格式通用性强,很多训练框架导出的ONNX模型都能直接跑,而且它同样支持WebAssembly和WebGPU后端。但收据扫描这个场景里,我的模型大多是从TensorFlow生态训练导出的.tflite,ONNX Runtime跑.tflite并不直接,需要先做格式转换,多层转换会增加不确定的算子兼容问题。

MediaPipe Tasks则是一个偏应用层的方案,它把检测、分割、手部识别这些常用功能封装成了白盒API,用起来非常省事,但对于自定义OCR模型的灵活度不够高。收据扫描需要模型和预处理后处理深度耦合,MediaPipe的封装反而变成了阻碍。

LiteRT.js的优势在于它是.tflite模型的“嫡系”运行时,模型转换链最短,对量化模型的算子支持最完整,同时WebAssembly后端在桌面和移动端浏览器的兼容性实测下来也最省心。这个选型不是说其他方案不好,而是在收据扫描这个具体场景里,LiteRT.js的性价比最高。

3. 跑起来一套能识别收据的Demo,具体怎么搞

3.1 环境准备:摄像头权限绕不开的三个坑

浏览器端扫描第一个绕不开的就是摄像头。这里有一个很多人栽过的坑:直接打开本地HTML文件或者在http协议下调用navigator.mediaDevices.getUserMedia,浏览器会直接拒绝权限。这个小票扫描器必须跑在安全上下文里,也就是https或者localhost环境。

如果你像我一样用Vite做开发,命令行启动通常是http://localhost:5173,这个没问题,浏览器把localhost视为安全上下文。但如果你的页面部署到服务器,又只有http协议,那么摄像头调用会被Chrome和Firefox默认拦截。解决办法是给站点配HTTPS证书,开发阶段可以用mkcert生成本地信任证书,生产环境就走正规的HTTPS。

第二个坑是摄像头权限的“允许一次”和“始终允许”混淆问题。用户在页面上点了拒绝之后,浏览器会记住这个站点的权限状态,你的代码再调用getUserMedia时不会弹出授权框,而是直接抛NotAllowedError。遇到这种情况,光在代码里重新请求是没用的,得引导用户去浏览器设置里把站点权限改回来,或者干脆改一下站点地址(端口号变了也会触发新的权限询问),这是开发调试时最常用的小技巧。

第三个坑是iOS Safari的摄像头采集有特殊性。在iOS上,getUserMedia返回的视频流,其视频轨道的facingMode如果设置成environment(也就是后置摄像头),在部分旧版本Safari上会出现黑屏,需要加上额外的约束或降级逻辑。我的建议是不要对facingMode做硬性要求,先尝试environment,失败后回退到默认摄像头。

3.2 模型文件怎么准备,量化等级怎么选

Demo要用到的模型文件,最简单的办法是直接使用LiteRT社区现成的文本检测模型,或者从TensorFlow Hub下载经过COCO预训练的EfficientDet-Lite系列,再用你自己的收据数据做微调。如果你的业务版式比较固定,我的实际经验是每个类别收集300到500张真实收据就能微调出一个相对可用的检测模型,主要工作反而是标注,建议用LabelImg或者Roboflow这类工具半自动标注提速。

模型量化等级是很多人容易忽视的环节。同样是EfficientDet-Lite0,FP32全精度模型、INT8动态范围量化模型、INT8全整数量化模型,推理速度和精度表现差异非常大。我在浏览器端实测下来的建议是:优先选择INT8量化模型,体积大概能压到原来的四分之一,推理速度提升两到三倍,精度损失在收据应用场景里通常可以接受,尤其是检测任务,文本区域这种大目标对量化误差不敏感。

注意:如果你要跑文字识别模型,量化需要谨慎。识别模型对数字和字母的细节敏感,INT8量化可能导致个别字符识别错误率上升。我的做法是识别模型用FP16混合精度,虽然体积比INT8大一点,但换来的是金额字段的稳定输出,这笔账划得来。

3.3 核心调用代码一:模型加载与张量定义

下面是一段我项目里实际用的LiteRT.js调用骨架,以文本检测模型为例。完整的API可能会随版本变化,但核心逻辑不会差太多:

import { loadModel, createTensor, runInference } from 'lite-rt-js'; // 加载模型,可以传入ArrayBuffer或URL const model = await loadModel('/models/receipt_detector.tflite', { backend: 'wasm', // 显式指定Wasm后端 threads: 2, // 建议2~4,线程数越高越吃CPU }); // 把Canvas/DrawImage生成的图像数据转成模型输入张量 const inputTensor = createTensor({ type: 'uint8', shape: [1, 320, 320, 3], data: imageData.data, }); // 执行推理 const outputTensors = await model.run(inputTensor); // 输出通常包含检测框、得分、类别 const boxes = outputTensors[0].data; // [N, 4] 归一化的边界框 const scores = outputTensors[1].data; // [N] 置信度

这里有一个值得注意的细节:模型输入分辨率是320x320,但用户摄像头采集的原始画面通常是1280x720甚至更高,直接把原图喂给模型不仅慢,而且检测小字号文字时反而会出问题。我的做法是先把全帧缩放到320x320,计算出缩放比例,然后把模型输出的边界框坐标映射回原始图像坐标,再去裁剪识别区域。坐标映射做不好,后续的透视矫正和字段提取就全乱了。

3.4 核心调用代码二:识别模型的调用与输出

文本识别模型的调用逻辑类似,区别在于输入张量的宽高是动态的,因为每一行文字的长度不一样。这里有一个实用技巧:不要直接把任意宽高比的图片送给CRNN模型,它一般会在高度维度上固定,比如统一缩放成32像素高,宽度按比例调整并做padding。我在项目里先检测一个文本行的外接矩形,做透视矫正后裁剪成一个规整的小图,再缩放成32x128这样的标准输入尺寸,识别效果会稳定很多。

识别输出通常是一个字符序列和对应的置信度得分。我拿到的输出是一个形状为[1, seq_len, num_classes]的概率矩阵,用贪心解码或者带CTC解码的算法把概率矩阵转换成最终字符串。这一层不建议自己去手写解码,很容易出边界错误,直接复用LiteRT生态里现成的解码工具,省时省力。

4. 实时扫描链路的完整拆解:从取景框到结构化数据

4.1 getUserMedia实时取流,帧率别贪多

浏览器端收据扫描,体验最好的方式当然是用户举着手机对准小票,屏幕上实时出现检测框,识别到完整字段后自动拍照。这背后需要一整套实时管线支撑。

第一步是摄像头取流:

const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 }, height: { ideal: 720 }, }, }); const videoElement = document.getElementById('camera-preview'); videoElement.srcObject = stream; await videoElement.play();

这里我最想强调的一点是帧率控制。很多人一上来就requestAnimationFrame无限循环,每一帧都丢给模型做推理,结果是CPU占用拉满,手机发烫,掉帧严重。收据文字识别根本不需要这么高的实时性。我实际工程里限定了每500毫秒最多跑一次完整推理链路,中间间隔的帧只做预览显示,不做识别。这样用户体感上依然觉得“实时”,但CPU占用大幅下降。

4.2 透视矫正:不是所有收据都能正着拍

现实中没有一个用户会规规矩矩地把收据水平摆好再拍,透视变形是常态。在实时链路里,我会先跑一次文本检测,拿到画面中的文本块分布。思路是:收据文字行的分布天然形成一个四边形,用这些检测框的外接轮廓估算收据纸张的边缘,然后估计出纸张的四个角点,做透视变换。

透视矫正的具体运算我建议用Canvas 2D的transform配合WebGL做纹理映射,而不是在CPU侧逐像素做矩阵运算,否则性能扛不住。实现上可以把四个角点映射到一个固定宽高比的画布上,再用drawImage把畸变图像拉正。这一块代码量不大,但确实是整个收据扫描器的“技术含量”所在,也是最值得花时间打磨的部分。

拉正之后,下一步才是真正跑文字识别,准确的顺序是:检测文本区域 -> 用文本区域四点估计纸张轮廓 -> 透视矫正 -> 裁剪文本行 -> 识别文字 -> 结构化输出。

4.3 结构化输出:总金额、日期、商户名怎么抽

OCR识别出来的是几行文本,但报销单需要的是结构化字段:总金额、日期、商户名称。这一步要做规则引擎加正则的字段抽取,我踩过的坑是不要试图用一套正则吃遍所有收据格式。

我的处理办法是按行分割识别结果,先做字段名匹配。收据里总金额常见的表达有“合计”“总计”“实收”“共计”“Total”“Amount”,日期常见的表达有“日期”“Date”“2024/”“2023/”等数字模式,商户名则通常出现在收据顶部的前三行,且不包含数字。把这些规则组合起来,再对识别置信度低的候选字段做二次补扫,整体抽取准确率能到90%左右。

如果规则实在抽不出某个字段,不要硬扛,直接把文本行丢给用户确认,做一个人工复核界面。报销类工具最重要的不是全自动,而是自动化加人工兜底,避免错误数据直接进入财务系统。这个产品取舍在老板眼里远比技术炫技更重要。

5. 跨浏览器不是口号,是一张兼容性检查表

5.1 WebAssembly的SIMD支持决定性能上限

LiteRT.js的性能高度依赖WebAssembly的SIMD扩展和线程能力。Chrome、Edge、Firefox这些主流桌面浏览器对SIMD支持已经很好,但真正决定手机端能不能流畅跑的,是移动端的WebView。

实测下来,Android的Chrome WebView和iOS的Safari在SIMD支持上都有不错的表现,但Android上有很多国产ROM自带的老版本WebView,或者用户把浏览器内核锁在旧版本,SIMD不可用,推理速度直接跌一半以上。针对这种情况,我在模型加载前会做一个特性检测,如果发现当前环境不支持SIMD,就动态切换到一个更小的模型版本,或者提示用户升级浏览器,避免模型加载了却跑不动。

5.2 Safari和iOS WebView的三个特殊问题

iOS上做浏览器端推理,我遇到最多的是三个问题。

第一个是WASM内存限制。Safari对WASM线性内存的上限管理比Chrome更严格,加载大模型时有概率触发内存不足。解决办法是把模型切成两个分段,或者用混合精度量化减小体积,保持在100MB以下,目前看来最稳妥。

第二个是摄像头帧方向。iOS上视频流的天然方向是横屏的,但用户实际使用中往往是竖屏拍摄。需要在渲染到Canvas之前做一次旋转变换,否则识别结果全部是旋转了90度的文本,置信度惨不忍睹。

第三个是Safari的Service Worker对WASM缓存策略偏保守,模型文件二次加载时很可能仍然走网络而不是走缓存。在弱网环境下,用户第二次打开网页会明显感觉变慢。我的解法是用IndexedDB手动缓存模型文件,加载时先查本地再走网络,这个改造对移动端体验提升非常明显。

5.3 企业浏览器环境的特殊限制怎么提前规避

如果你做的收据扫描器是给公司内部财务系统用的,那大概率会遇到企业浏览器环境的限制。最常见的就是IT管理员统一管控了浏览器设置,摄像头权限默认被禁用,或者某些浏览器功能被组策略锁死。

这类问题在代码层面几乎无解,因为它是浏览器外部的策略限制。我的建议是在项目交付前,就准备好一份浏览器环境兼容性清单,明确告知用户需要开启摄像头权限、允许WASM执行、放开本地存储。经验是,不要等上线了再排查环境问题,而是把环境检查做在首页引导流程里,用户一进来就自动检测各项能力,差什么提示什么,这样能少受很多气。

6. 我在这套项目里踩过的坑和修复过程

6.1 WASM文件404,问题不在你写的代码

这个坑埋得很深,一度让我怀疑是自己模型路径写错了。后来发现是前端构建工具的public资源复制机制导致后缀为.wasm的文件没有被正确输出到dist目录。WASM文件本质上是一个二进制模块,如果你用的是Webpack或者Vite,需要显式配置把.wasm文件作为静态资源处理,而不是走默认的JS打包逻辑。

我当时在Vite项目里把模型文件放到了public/models目录下,理论上构建时会原样复制,但本地开发正常、部署后404。排查到最后发现是服务器Nginx没有给.wasm后缀配置正确的MIME类型,导致浏览器拒绝加载。建议一旦遇到WASM加载失败,第一件事就是打开浏览器开发者工具看网络面板,确认服务器返回的是application/wasm而不是application/octet-stream。

6.2 SharedArrayBuffer被禁用,线程池起不来

LiteRT.js的多线程推理依赖SharedArrayBuffer,而浏览器出于安全考虑,要求页面开启跨源隔离(COOP/COEP响应头)之后才允许使用SharedArrayBuffer。

如果你不配置这两个响应头,你的页面在Chrome里会看到线程池创建失败,推理回退到单线程模式,性能断崖式下跌。我当时遇到的尴尬是:本地开发因为Vite的dev server已经配好了相关响应头,一切正常;部署到生产Nginx后漏掉了这两个响应头,线上性能直接崩了。排了半天才发现是两个响应头的问题。

修复方法是在服务器响应里加上:

Cross-Origin-Opener-Policy: same-origin Cross-Origin-Embedder-Policy: require-corp

同时需要确认页面加载的所有静态资源都带了正确的CORS头,因为COEP会严格限制跨域资源的加载。这个配置过程有点折腾,但效果是线程数拉满,推理速度翻倍。

6.3 低对比度热敏纸怎么“人工拯救”

最后说一个模型层面的优化案例。我的第一版收据扫描器在崭新的超市小票上表现很好,但用户实际拿来的小票很多是放了一个月、字迹已经开始褪色的热敏纸,识别率断崖式下降。

人工预处理在这里发挥了奇效。我在图像进入模型之前加了一个对比度增强的预处理步骤:先把图像转灰度,然后做一个局部自适应阈值二值化,把底色压暗、文字提亮。这步处理几乎不增加推理耗时,但对识别率的提升立竿见影。

值得注意的是,二值化不能一刀切,有些收据有底纹或水印,全局阈值容易把底纹当成文字。我用的是OpenCV.js的adaptiveThreshold,窗口大小设置成31,C值取5,实测在多数褪色小票上表现都不错。如果你的场景以新票为主,这步预处理甚至可以关掉,省一点计算开销。


最后再分享一个小技巧。收据扫描和普通OCR最大的不同在于,它是强约束下的结构化信息提取。我在做后处理时加了一个字段相互校验的逻辑:识别出总金额后,再把金额所在行的识别结果和OCR模型输出的所有数字字符串做一次模糊匹配,只有两者一致时才认为识别可信。这个校验成本极低,却能把报销场景最看重的金额字段的准确率再拉高好几个百分点。在实际项目里,这种“结构化约束反哺识别结果”的思路,比单纯换更大的模型划算多了。

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

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

立即咨询