LiteRT.js实战:浏览器端收据扫描识别全攻略
2026/9/19 20:46:29 网站建设 项目流程

先交代一下背景。我做Web端机器学习工具大概有三年多,之前接过一个内部报销系统的需求:财务每天要手工录入几百张收据小票,项目组一开始想上云端的OCR服务,结果测下来成本不低,而且财务同事对票据图片传到第三方服务器这件事非常敏感。后来我换了个思路——把收据扫描识别直接放进浏览器里跑,前端打开一个页面就能完成检测、校正、识别、导出,后端完全不用碰图像数据。最终落地这套方案的底层推理引擎,就是LiteRT.js。

这篇内容就是把我当时从选型、踩坑到真正跑通的完整过程整理出来。如果你也是前端开发者,或者正在做报销、记账、票据管理类的工具,想在浏览器里直接用深度学习模型做收据识别,这篇文章应该能给你省下不少弯路。我会说清楚为什么选LiteRT.js而不是TensorFlow.js、收据扫描的完整技术链路怎么拆、一个最小Demo怎么从零跑起来,以及我在实测中踩过的那些坑。

1. 收据扫描为什么值得跑在浏览器里

很多人在做OCR类功能时的第一反应是接云端API。这个思路没错,但放到收据扫描这个具体场景里,有几个问题特别突出:一是费用,按次计费,量大之后成本很可观;二是延迟,票据图片上传、排队、返回,一顿操作下来单张至少两三秒,体验谈不上流畅;三是隐私,收据上有商户名、金额、甚至是部分卡号信息,财务类场景对数据出域特别敏感。浏览器端推理正好能同时绕开这三个问题。

LiteRT.js的作用,就是让深度学习模型能够直接在浏览器里跑起来。它是Google在TensorFlow Lite基础上推出的轻量级运行时(Lightweight Runtime)的JavaScript版本,2024年TFLite改名成LiteRT之后,前端这边对应的SDK也跟着换成了LiteRT.js。它和TensorFlow.js最大的区别在于:TensorFlow.js要自己维护一个比较庞大的算子库和运行时,而LiteRT.js直接复用TFLite的轻量推理内核,编译成WebAssembly之后在前端跑,文件体积和内存占用都小很多,推理速度反而更快。

我当时做技术选型的时候列过一张对比表,摆到桌面上特别直观:

方案运行位置模型格式包体大小推理速度(MobileNet)隐私性
云端OCR服务器任意受网络影响
TensorFlow.js浏览器TF.js格式较大(约1MB+)中等
LiteRT.js + WASM浏览器.tflite较小(核心约100KB)较快
LiteRT.js + WebGPU浏览器.tflite较小快(接近原生)

收据扫描这个场景还有个特点:它对延迟非常敏感。用户拿手机拍一张小票,希望一秒钟内就看到金额和日期识别出来,如果转圈超过三秒,体验基本就废了。浏览器端推理没有网络往返,本地WASM跑一个轻量目标检测模型加一个CRNN识别模型,正常情况下几十毫秒到一两百毫秒就能搞定,这个延迟优势是云方案给不了的。

所以这个项目适合谁参考呢?前端工程师想做Web端AI功能但被后端资源卡住的,想做一个不需要安装App、打开网页就能用的报销工具的独立开发者,以及企业内部想做一个数据不出内网的票据识别系统的团队。适用的领域也不止收据,快递单、发票、名片之类的结构化文档识别,思路都能复用。

2. LiteRT.js:TFLite改名后的浏览器端推理引擎

先把这个名字捋清楚。很多人一听LiteRT.js会觉得陌生,其实它就是原来TensorFlow Lite的JavaScript绑定,2024年Google把TensorFlow Lite这个品牌整体升级成了LiteRT(Lightweight Runtime),定位是做一个轻量、高效、跨平台的推理运行时,不只是TensorFlow的模型能跑,ONNX等其他格式也可以转换进来。

2.1 它和TensorFlow.js到底差在哪

TensorFlow.js和LiteRT.js都能在浏览器里跑模型,但底层路线完全不同。TensorFlow.js是把TensorFlow的Python算子重写成了JavaScript版本,天然带着一个"JS实现的TF"的包袱,运行时体积大、内存占用高、部分算子的执行效率不如编译后的原生代码。LiteRT.js走的是WASM路线,核心逻辑是C++写的推理引擎,编译成WebAssembly后直接在浏览器沙箱里执行,启动更快,单次推理更接近原生性能。

还有一个很多人忽略的差异:模型兼容性。TensorFlow.js需要把模型转成TF.js格式的JSON加二进制权重,转换过程中偶尔遇到不支持的算子,尤其是新出的模型架构,容易卡壳。LiteRT.js直接吃标准的.tflite文件,TFLite的工具链已经非常成熟,各种模型的转换脚印基本都处理过了,踩坑概率低很多。我当时就是因为在测试某个最新的文本检测模型时,TensorFlow.js转换失败,换LiteRT.js一次就通了,才下决心切换的。

2.2 模型从哪来、怎么转

浏览器端跑收据扫描,一般需要两个模型:一个是定位出收据在画面中的位置的目标检测模型,一个是识别出收据区域文字内容的CRNN模型。训练好的模型导出后通常是TensorFlow的SavedModel或Keras的.h5格式,需要转换为.tflite。

转换这一步用TFLite官方提供的Python包就能做,一条命令的事:

pip install tensorflow converter = tf.lite.TFLiteConverter.from_saved_model("saved_model_dir") converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert() with open("receipt_detector.tflite", "wb") as f: f.write(tflite_model)

Optimize.DEFAULT参数是让模型做量化压缩,把原来的Float32权重压成Float16甚至Int8,模型文件能缩小到原来的四分之一左右,浏览器加载速度会有质的提升。代价是精度会有轻微下降,但收据检测这种任务容错率高,实际测试下来基本无感。

我的经验是:如果自己没有标注数据、也没有训练资源,也不用慌。目标检测可以直接用TensorFlow Hub上现成的SSD-MobileNetV2或者EfficientDet-Lite,它们是通用物体检测模型,对票据这类规整矩形物体有不错的检出率。文本识别可以用开源的中文CRNN模型,或者Tesseract.js在浏览器里做后补。先用通用模型把整个流程跑通,验证产品形态,再逐步用业务数据微调,这是成本最低的推进路径。

3. 从画面到文字:收据扫描的三段式流水线拆解

收据扫描和普通的整页OCR不一样,一张原始照片里除了收据本身,通常还有桌面、手指、其他物品这些噪音。如果直接对整张图做OCR,桌面的纹理、阴影会被误识别成文字,结果一塌糊涂。所以我当时的方案拆成了三段,每段各司其职——检测、校正、识别。

3.1 第一段:目标检测定位收据区域

目标检测模型的作用很简单:输入一张图,输出几个包含坐标的边界框,以及对应的置信度。我把模型输入尺寸定成320x320,检测头输出格式是[top, left, bottom, right]加一个置信度,置信度大于0.5的框就认为是候选收据区域。

实际使用中有个细节必须处理:模型可能同时检出多个候选框,比如画面里有两张收据,或者把名片、卡片也框出来了。这时候要做NMS(非极大值抑制),把重叠度高的框合并,然后按置信度排序,取最高的那个作为主区域。NMS逻辑在纯JavaScript里写也就三十行,但这段代码直接影响后续识别质量,值得仔细调。

3.2 第二段:透视校正,把斜着的小票"掰正"

拍摄收据很少有正向垂直俯拍的,多少都带点透视变形,尤其是用手机拍的时候。透视变形的文字直接送进OCR,识别率会掉得很厉害。校正的做法是先对检测框内的图像做边缘检测和轮廓提取,找到收据的四个角点,然后用透视变换矩阵把四边形映射成一个正矩形。

这里我当时用了OpenCV.js,它的findContours加上getPerspectiveTransform可以很干净地完成这个流程。虽然OpenCV.js的体积有将近8MB,但只在初始化时加载,而且这个阶段对收据扫描的效果提升是决定性的,值得付出这个成本。

生成结果时还有个细节:模拟膏体物质的检测框通常会比收据本身轻微外扩,导致four-corner定位时把背景也包进来。我的处理是在检测框基础上向内收缩5%,再做边缘提取,出来的角点更准确。

3.3 第三段:识别字段,抽取结构化信息

拿到校正后的收据图片之后,这一步反而没有大多数想得复杂。对于一张正视角度的收据图片,整个图片基本全是文字,可以直接用CRNN模型做整图文字识别,得到一串文本,再通过正则表达式抽取金额、日期、商户名、交易单号这些关键字段。

以金额为例,收据上的格式五花八门,有"合计:88.50"、有"实收¥ 88.50"、还有"TOTAL 88.50"。我的做法是先用候选正则列表匹配,匹配不到就退化成"取数字+小数点+两位小数的全部组合,取最大值"这种粗暴逻辑。说实话,这类业务的最终决定因素往往不是模型多精确,而是后处理的兜底策略够不够脏、够不够强。

4. 手把手跑通一个最小可用Demo

理论说了一堆,还是得上代码。下面这个Demo我尽可能压到最简,你本地跑通之后再往工程化方向补。环境要求也不高:一份现代浏览器(Chrome或Edge),一个本地静态服务器,因为WebAssembly模块通过fetch加载时对协议是有要求的,直接用file://打开大概率踩跨域问题。我自己习惯用npx serve在项目目录起一个服务。

4.1 引入LiteRT.js并加载模型

官方提供了ES Module形式的包,直接npm装:

npm install lrt

然后在页面里引入并对引擎做初始化:

import { LiteRT } from 'lrt'; const engine = await LiteRT.create({ model: './models/receipt_detector.tflite', backend: 'wasm', }); const inputTensor = engine.inputs[0]; const outputTensor = engine.outputs[0];

这里值得多说一句backend参数。LiteRT.js支持wasmwebgpu两种后端。WASM兼容性最好,什么浏览器都能跑;WebGPU得在Chrome 113以上才稳定支持,但推理速度能快一截。我的建议是优先用WASM把流程跑通,再增加一个WebGPU的检测与自动回退逻辑,这样用户体验和稳定性两不误。

4.2 图像输入与预处理

浏览器端读取图片的标准姿势是input[type=file]配合URL.createObjectURL生成缩略图,再把图片绘制到Canvas上进行像素级别的处理。

const img = new Image(); img.src = URL.createObjectURL(file); await img.decode(); const canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 320; const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, 320, 320); const imageData = ctx.getImageData(0, 0, 320, 320); const data = new Float32Array(320 * 320 * 3); for (let i = 0; i < imageData.data.length; i += 4) { const gray = 0.299 * imageData.data[i] + 0.587 * imageData.data[i + 1] + 0.114 * imageData.data[i + 2]; const idx = (i / 4) * 3; data[idx] = (gray / 255 - 0.5) / 0.5; data[idx + 1] = data[idx]; data[idx + 2] = data[idx]; }

很多第一次做浏览器端推理的人会在这一步卡住。原因往往是忘了模型期望的输入是什么:[1, 320, 320, 3]的张量,顺序是NCHW还是NHWC,归一化是[-1,1]还是[0,1]。模型不同,要求也不一样,写预处理之前一定要先确认。我这里用的是MobileNet系模型最常见的NHWC加[-1,1]归一化方案。

4.3 推理和后处理

把像素数据塞进输入张量,然后调用引擎的run方法,拿到的结果是一个包含检测框坐标和置信度的数组。

inputTensor.set(data); const outputs = await engine.run(); const boxes = []; const result = outputs[0].data; const numBoxes = result.length / 5; for (let i = 0; i < numBoxes; i++) { const [top, left, bottom, right, score] = result.slice(i * 5, i * 5 + 5); if (score > 0.5) { boxes.push({ top, left, bottom, right, score }); } }

拿到坐标后,记得要把相对于320x320输入图像的坐标等比映射回原始图片。这一步最容易忘,忘记恢复的人会发现裁剪出来的区域跟想象中差一大截。原始图片是1920x1080,模型输入是320x320,那坐标就得乘以1920/3201080/320的缩放系数。

后续的透视校正和文本识别调用另一个模型,整个链路的代码结构差不多:加载模型、构造张量、推理、解析结果。核心的坑都在预处理、坐标系映射这些"无关紧要"的地方,而不在模型本身。把这条链路写清楚之后,剩下的增强就是工程问题,不是算法问题了。

5. 实测里那些不配合的收据:反光、倾斜、超长纸条

Demo跑通只是开始,真正折磨人的是各种"不配合"的收据。我把自己踩过的坑按频率排了个序,下面这几个是最典型的,如果你也要做类似的东西,基本躲不掉。

5.1 热敏纸反光和阳光直射

很多收银小票用的是热敏纸,表面光滑,拍照时特别容易反光。反光区域在高光下直接白成一片,文字完全丢失,OCR模型再强也救不回来,因为信息本身已经在成像阶段被抹掉了。

我的处理方案是在预处理阶段做自适应阈值化。相比固定阈值,自适应阈值能根据每个像素周围的局部亮度动态决定二分类阈值,对光照不均的反光区域有明显改善。在LiteRT.js之前,我直接在Canvas上跑了一个轻量的自适应阈值实现,效果立竿见影。不过要注意,如果后续要接的是神经网络文本识别器,预处理不要做得太狠,尤其不要做二值化,因为模型本身对光照有一定鲁棒性,过度处理反而会丢掉原始纹理信息。

5.2 透视角度太大

前面说透视校正能解决拍照倾斜的问题,但校正的效果是有上限的。我实测下来,当拍摄角度超过大约45度时,收据的远端文字会被压得特别扁,四个角点定位的误差被放大,校正之后的图像文字依然是很严重的曲面变形,CRNN识别率会明显下降。

这种场景的根本解法不是改进算法,而是改进交互:在拍照界面加一个"请把手机正对收据"的提示,或者加一个网格参考线引导用户对齐。最好的技术手段常常是让问题不发生,而不是等它发生后再想办法硬扛。

5.3 超长收据

超市购物的小票有时候能拉出一米多长,一张照片根本拍不全。目标检测模型检出的区域可能只是其中一段。对于这种超长收据,我采取的是分段识别策略:检测模型先识别出收据主体,然后沿着主轴的延伸方向做滑动窗口,分几段分别做透视校正和文字识别,最后在文本层面拼接。拼接的关键是找到相邻两段的公共文本作为锚点,否则结果会出现重复或遗漏。这个拼接收尾在代码里占了不小的篇幅,但对最终体验的提升非常明显。

5.4 LiteRT.js后端的静默回退问题

还有一个纯技术层面的坑。当时我在Edge浏览器上测试,默认用的WASM后端跑得好好的,但切换到另一台只有旧版Chrome的设备时,页面直接白屏。查了半天发现是WebGPU后端初始化失败,而LiteRT.js在部分版本里失败后并不会自动回退到WASM。

解决方法是初始化的时候主动检测一下后端是否可用:

const backend = ('gpu' in navigator && await LiteRT.isBackendAvailable('webgpu')) ? 'webgpu' : 'wasm';

类似的兼容性问题在浏览器端ML里太常见了。我的习惯是在代码里写一个启动时的自检逻辑,把当前浏览器支持的后端明确记录下来,同时把后端选择暴露成UI选项,这样出了问题用户还能手动切,而不是干瞪眼。

6. 从Demo到正式工具:性能、结构化与工程化

如果只是自用或者做个技术验证,第四章的Demo已经够了。但要做成团队内部或者对外可用的工具,有几个工程化的坎必须迈过去,这部分才是把Demo落到真实产品里的关键。

6.1 把推理挪进Web Worker

在Demo阶段,我在主线程里直接跑了目标检测和CRNN识别,单张图跑完大概几百毫秒,能感知到卡顿。如果图片更大、模型更重,主线程会被阻塞好几秒,页面直接假死。正常浏览器里主线程负责交互响应,被阻塞的体验非常糟糕。解决方法是把模型加载、预处理、推理、后处理全部放到Web Worker里,主线程只负责文件选择、图像解码和结果渲染。通过回调接口在两个线程之间通信,整个页面保持流畅无明显卡顿。改造这块算是最值得投入的一笔时间,效果直接可感知。

6.2 量化与模型瘦身

前面转换模型时提到的量化,在工程化阶段必须补上。我当时的检测模型从7.2MB压到2.1MB,CRNN模型从14.3MB压到4.8MB,加载时间砍掉了三分之二。浏览器端模型的加载体积直接跟首屏体验挂钩,在这个场景下,量化基本是必选项而不是可选项。

代码层面还有几个值得做的事:用Cache API把模型文件缓存到本地,二次加载时直接走缓存不走网络;推理用的输入输出Tensor可以复用,不要每次run都重新分配内存,减少垃圾回收的压力。这些优化单个看起来不明显,叠在一起就能把单张识别时间从1.2秒压到400毫秒左右,体验完全不同。

6.3 字段结构化的兜底策略

CRNN识别出来的原始文本是长字符串,要把金额、日期、商户号这些字段提取出来,正则匹配是最直接的手段。但我必须先提醒:不同商户的收据格式差异极大,纯靠正则很难做到100%,必须设计一套兜底逻辑。

以金额字段为例,我当时的提取优先级是这样设计的:

  1. 匹配合计总计TOTAL实收后面的金额数字;
  2. 匹配最后一个出现的¥$符号后面的数字;
  3. 匹配整段文本中格式最像金额的数字(两个小数位,出现在行尾);
  4. 全部失败,返回空,交给用户手动补录。

这套策略在内部测试里能覆盖大约93%的收据,剩余的7%自动标记为"需人工检查",在UI里高亮显示。这个设计特别重要——识别工具的目标不是尽量少报错,而是尽量让用户少操心,明确告诉用户哪些可靠、哪些不可靠,比闷头给一个错结果强得多。

6.4 多页连续扫描与导出

真实财务场景里,用户不会一张一张扫码录入,而是一次性处理几十上百张。我把整个流程设计成"拍照/导入→自动识别→列表展示→批量修正→导出"的工作流。列表里每一行显示收据缩略图、识别出的金额和日期,识别置信度低的条目自动标黄,用户点击任意一行可以看原图并手动修正字段。最后支持导出JSON或CSV,直接对接公司财务系统。

这个过程中我最大的体会是:模型识别准确率做到95%,用户感知可能还是不够好;但把识别结果、置信度和人工修正入口这三个要素组织好,哪怕识别率只有90%,用户也会觉得"这工具真顺手"。产品思维和技术能力在这个项目里缺一不可。

浏览器端收据扫描这个方向,LiteRT.js目前的生态已经能支撑真实业务了。如果你也想做类似的东西,我的建议是先把目标检测加CRNN这条链路跑通,不要一开始就追求端到端的超大模型,然后集中精力优化预处理和后处理,这两块对最终效果的影响不亚于换一个更大的模型。踩过几次坑之后你会和我一样发现,浏览器端跑深度学习真正考验人的不是模型本身,而是那些看起来不起眼的边界情况。

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

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

立即咨询