
前阵子接手一个报销小工具对方提了个需求用户在网页里对着购物小票拍一张系统自动识别出总金额、日期和商户名直接生成报销单。一开始我想得很简单放个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出现后方案开始变得顺理成章后来我注意到LiteRTLightweight 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生态训练导出的.tfliteONNX 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-Lite0FP32全精度模型、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窗口大小设置成31C值取5实测在多数褪色小票上表现都不错。如果你的场景以新票为主这步预处理甚至可以关掉省一点计算开销。最后再分享一个小技巧。收据扫描和普通OCR最大的不同在于它是强约束下的结构化信息提取。我在做后处理时加了一个字段相互校验的逻辑识别出总金额后再把金额所在行的识别结果和OCR模型输出的所有数字字符串做一次模糊匹配只有两者一致时才认为识别可信。这个校验成本极低却能把报销场景最看重的金额字段的准确率再拉高好几个百分点。在实际项目里这种“结构化约束反哺识别结果”的思路比单纯换更大的模型划算多了。