资讯动态

基于LiteRT.js的浏览器端收据扫描器:WebAssembly与WebGPU加速实战

发布时间:2026/9/24 23:56:40 来源:尧图企业网站定制
浏览器里跑OCR这件事我从Tesseract.js刚出来那会儿就在折腾当时的体验说实话挺劝退的——加载慢、识别率一般、大图直接卡死主线程。后来PaddleOCR的Web版本出来精度上去了但包体积又成了新问题。直到LiteRT.js进入视野配合WebAssembly和WebGPU这两把利器才算真正让在浏览器里扫收据这件事变得可用了。这篇文章不讲虚的就围绕基于LiteRT.js构建浏览器端收据扫描器这个项目把技术选型的逻辑、核心实现路径、性能调优的细节、以及我在实际开发中踩过的坑全部摊开来讲。适合正在做前端OCR、对WebAssembly/WebGPU加速感兴趣、或者想了解React端侧推理怎么配合的开发者。读完你至少能搞清楚为什么选LiteRT.js而不是Tesseract.js或PaddleOCR WebWebGPU到底在OCR流程里加速了哪一步以及收据这种特殊场景下有哪些反直觉的预处理技巧。1. 为什么收据扫描这件事值得单独做一个浏览器端方案1.1 收据OCR和通用OCR根本不是一个问题很多人觉得OCR就是OCR把图片丢进去出文字就完了。但收据这个场景有它非常特殊的性质直接套通用OCR方案效果会打不少折扣。收据的典型特征是什么窄长条、背景通常是热敏纸的灰白色或泛黄、字体是等宽或半等宽的打印体、文字排列密集且行间距小、经常有折痕和卷曲、拍照时光照不均匀导致局部过曝或阴影。这些特征叠加在一起对OCR引擎的预处理和版面分析能力要求其实比扫描文档要高。我实测过用通用OCR模型直接识别收据照片金额那一栏的识别错误率明显高于正文原因很简单金额通常字号更大、加粗、有时候还带货币符号训练数据里这种分布占比不高。所以收据扫描器不能只是调个OCR API必须在预处理和版面分析上做针对性工作。1.2 浏览器端做OCR的真实驱动力把OCR放在浏览器里跑而不是传到服务器核心驱动力有三个而且这三个在收据场景下都特别成立。第一是隐私。收据上有金额、商户名称、有时候还有卡号后四位这些信息用户天然不愿意上传到别人的服务器。端侧推理意味着数据不出设备这对个人记账类应用来说是刚需。第二是离线可用。记账这件事很多时候发生在没有网络的场景——地下停车场出来随手拍一张、出差在飞机上整理票据。如果OCR依赖网络请求这些场景直接废掉。第三是成本。收据扫描如果走云端API每张图一次调用量大之后成本很可观。端侧推理一次加载模型后续推理零边际成本。LiteRT.js前身是TensorFlow Lite的Web端方案在这三个维度上都给出了不错的答案模型可以打包进应用离线加载推理完全在本地WebAssembly保证兼容性WebGPU在有条件的设备上进一步加速。1.3 LiteRT.js在浏览器推理生态里的位置浏览器端推理方案现在大致分三派纯JS实现如Tesseract.js的早期版本、WebAssembly编译的C推理引擎如ONNX Runtime Web、LiteRT.js、以及WebGPU原生实现如transformers.js的部分后端。LiteRT.js的定位很清晰它把移动端成熟的TFLite推理能力搬到Web通过WebAssembly提供CPU推理通过WebGPU提供GPU加速。它的优势在于模型格式统一.tflite、算子覆盖全、社区工具链成熟。对于收据OCR这种需要跑检测识别两阶段模型的场景LiteRT.js的算子支持度比纯JS方案好太多。注意LiteRT.js的WebGPU后端目前对算子的支持还在完善中不是所有TFLite算子都能跑在GPU上。实际项目中需要做好fallback到WASM的准备。2. 收据扫描器的技术栈拆解与选型逻辑2.1 整体架构两阶段还是端到端收据OCR的架构选择上我强烈建议走两阶段先做文本检测找出文字区域再做文本识别把区域里的文字读出来。端到端方案一张图直接出文字序列在收据这种长文本、密集排版的场景下注意力机制容易丢失位置信息而且调试起来非常痛苦——你没法知道是检测错了还是识别错了。两阶段的好处是每一阶段可以独立优化、独立替换。检测阶段用轻量级的DBNet或CRAFT的TFLite版本识别阶段用CRNN或SVTR的TFLite版本。LiteRT.js对这两类模型的算子支持都比较成熟。具体到模型选择检测我用的是基于MobileNetV3 backbone的DBNet精简版输入尺寸320x320模型大小约2.3MB。识别用的是CRNNCTC输入高度32、宽度动态模型大小约4.7MB。两个模型加起来7MB左右gzip压缩后传输约3MB对于Web应用来说完全可以接受。2.2 WebAssembly与WebGPU的分工这里要说清楚一个容易混淆的点WebAssembly和WebGPU不是二选一的关系而是协作关系。WebAssembly负责的是推理引擎的控制流、算子调度、内存管理这些逻辑。LiteRT.js编译成WASM之后整个推理框架跑在WASM虚拟机里。而WebGPU负责的是把计算密集的矩阵运算、卷积运算卸载到GPU上执行。在收据OCR的流程里检测阶段的计算量远大于识别阶段因为检测要处理整张图的所有像素识别只处理裁剪出来的小文本条。所以WebGPU加速的收益主要体现在检测阶段。我实测下来在支持WebGPU的设备上检测阶段耗时从WASM的约180ms降到约45ms识别阶段从约120ms降到约80ms识别阶段本身计算量小加速比没那么夸张。// 初始化LiteRT.js推理会话的典型流程 import { loadTFLiteModel, Tensor } from litertjs/core; async function initOCRModels() { // 优先尝试WebGPU后端 let backend wasm; if (navigator.gpu) { try { const adapter await navigator.gpu.requestAdapter(); if (adapter) backend webgpu; } catch (e) { console.warn(WebGPU不可用回退到WASM, e); } } const detector await loadTFLiteModel(/models/dbnet_mobile.tflite, { backend, numThreads: navigator.hardwareConcurrency || 4 }); const recognizer await loadTFLiteModel(/models/crnn_ctc.tflite, { backend, numThreads: navigator.hardwareConcurrency || 4 }); return { detector, recognizer, backend }; }2.3 React在其中的角色定位React在这个项目里不是可有可无的壳。收据扫描器的交互状态相当复杂相机预览、拍照、裁剪、预处理预览、识别进度、结果编辑、多张收据管理。这些状态用React的useReducerContext管理比裸写DOM清晰得多。但要注意一个坑OCR推理是CPU/GPU密集型任务绝对不能放在React的渲染线程里同步执行。我的做法是把推理逻辑全部放在Web Worker里React只负责发送图片数据、接收进度和结果。Worker和主线程之间用Transferable Objects传递ImageData避免结构化克隆的开销。// Worker中执行推理的核心逻辑 self.onmessage async (e) { const { imageData, type } e.data; if (type recognize) { const startTime performance.now(); // 检测阶段 const detInput preprocessForDetection(imageData); const detOutput await detector.run(detInput); const boxes postprocessDetection(detOutput); self.postMessage({ type: progress, stage: detection, time: performance.now() - startTime }); // 识别阶段 const results []; for (const box of boxes) { const crop cropAndRectify(imageData, box); const recInput preprocessForRecognition(crop); const recOutput await recognizer.run(recInput); results.push({ box, text: decodeCTC(recOutput) }); } self.postMessage({ type: result, results, totalTime: performance.now() - startTime }); } };3. 收据图像预处理那些文档里不会写的细节3.1 为什么预处理决定了识别率的上限我做过一组对比实验同一批50张收据照片不做任何预处理直接送进OCR字符准确率约72%加上针对性的预处理之后准确率提升到91%。这个差距说明预处理不是锦上添花而是决定性的环节。收据照片最影响识别的问题按严重程度排序光照不均阴影、过曝 透视畸变拍摄角度倾斜 背景噪声桌面纹理、手写笔迹 分辨率不足文字模糊。预处理要按这个优先级逐个解决。3.2 自适应二值化的参数调优二值化是收据预处理的核心步骤。全局阈值比如Otsu在光照均匀时效果好但收据照片几乎不可能光照均匀。所以必须用自适应阈值也就是对图像每个局部区域单独计算阈值。自适应阈值的两个关键参数是blockSize局部区域大小和C阈值偏移量。blockSize太小会导致文字笔画内部出现空洞太大则失去自适应的意义。我的经验值是对于宽度在1000-2000px的收据照片blockSize取31-51之间C取10-15。这个范围是我在几十张不同光照条件的收据上试出来的比OpenCV默认的blockSize11、C2效果好很多。// 自适应二值化的实现基于积分图加速 function adaptiveThreshold(gray, width, height, blockSize, C) { const integral buildIntegralImage(gray, width, height); const output new Uint8Array(width * height); const r Math.floor(blockSize / 2); for (let y 0; y height; y) { for (let x 0; x width; x) { const x1 Math.max(0, x - r); const y1 Math.max(0, y - r); const x2 Math.min(width - 1, x r); const y2 Math.min(height - 1, y r); const count (x2 - x1 1) * (y2 - y1 1); const sum integral[(y2 1) * (width 1) (x2 1)] - integral[y1 * (width 1) (x2 1)] - integral[(y2 1) * (width 1) x1] integral[y1 * (width 1) x1]; const threshold sum / count - C; output[y * width x] gray[y * width x] threshold ? 255 : 0; } } return output; }用积分图加速之后这个O(n)的算法在2000x3000的图上跑一次大约30ms完全可以接受。3.3 透视矫正从四点定位到仿射变换收据拍摄时几乎不可能完全正对透视畸变会让文字行变成斜的严重影响识别。矫正的思路是找到收据的四个角点然后做透视变换把它拉正。找角点的方法我用的是轮廓检测多边形逼近先二值化然后找最大轮廓用approxPolyDP逼近成四边形。如果逼近结果不是四边形比如收据被遮挡就退而求其次用最小外接矩形。这里有个实操细节收据的四个角经常因为背景颜色接近而检测不准。我的做法是在轮廓检测之前先做一次形态学闭运算kernel 5x5把边缘的断裂补上角点检测的稳定性会好很多。// 透视变换的核心计算变换矩阵并重映射 function perspectiveTransform(src, srcCorners, dstWidth, dstHeight) { // srcCorners: [tl, tr, br, bl] 每个是 {x, y} const dstCorners [ { x: 0, y: 0 }, { x: dstWidth - 1, y: 0 }, { x: dstWidth - 1, y: dstHeight - 1 }, { x: 0, y: dstHeight - 1 } ]; // 解8元一次方程组求变换矩阵标准DLT算法 const H computeHomography(srcCorners, dstCorners); const output new Uint8ClampedArray(dstWidth * dstHeight * 4); for (let y 0; y dstHeight; y) { for (let x 0; x dstWidth; x) { const w H[6] * x H[7] * y 1; const sx Math.round((H[0] * x H[1] * y H[2]) / w); const sy Math.round((H[3] * x H[4] * y H[5]) / w); if (sx 0 sx src.width sy 0 sy src.height) { const si (sy * src.width sx) * 4; const di (y * dstWidth x) * 4; output[di] src.data[si]; output[di 1] src.data[si 1]; output[di 2] src.data[si 2]; output[di 3] 255; } } } return new ImageData(output, dstWidth, dstHeight); }3.4 分辨率与模型输入尺寸的匹配LiteRT.js的模型有固定输入尺寸检测模型是320x320。但收据是窄长的直接resize到正方形会严重变形。我的做法是保持宽高比resize短边缩到320长边按比例缩放后如果超过某个上限比如1280就分段处理。分段处理的意思是把长收据切成有重叠的几段每段单独检测最后合并结果。重叠区域取20%左右避免切断文字行。合并时用NMS非极大值抑制去掉重复的检测框。这个策略听起来简单但实际做的时候有个坑切分位置如果正好落在文字行中间那一行会被切成两半两段都识别不完整。我的解决办法是切分时先做水平投影找到文字行之间的空白间隙在间隙处切分。这样虽然增加了计算量但避免了断行问题。4. 检测与识别模型的LiteRT.js落地细节4.1 DBNet检测模型的输出解码DBNet的输出是一张概率图probability map和一张阈值图threshold map最终的二值图是概率图经过阈值图调制后得到的。LiteRT.js拿到的输出是原始张量需要自己做后处理。后处理的核心步骤是概率图二值化→找连通域→对每个连通域求最小外接矩形→按面积和长宽比过滤。这里有个容易忽略的点DBNet输出的概率图分辨率通常是输入尺寸的1/4所以算出来的框坐标要乘以4再映射回原图。function postprocessDetection(output, inputWidth, inputHeight, originalWidth, originalHeight) { const probMap output.data; // Float32Array, 尺寸 80x80 const mapSize 80; const scaleX originalWidth / inputWidth; const scaleY originalHeight / inputHeight; // 二值化 const binary new Uint8Array(mapSize * mapSize); for (let i 0; i probMap.length; i) { binary[i] probMap[i] 0.3 ? 1 : 0; } // 连通域标记BFS实现 const labels connectedComponents(binary, mapSize, mapSize); const boxes []; for (const comp of labels) { if (comp.pixels.length 10) continue; // 过滤太小的区域 const rect minAreaRect(comp.pixels); const aspectRatio rect.width / rect.height; // 收据文字行的典型长宽比在3:1到30:1之间 if (aspectRatio 1.5 || aspectRatio 40) continue; boxes.push({ x: rect.x * 4 * scaleX, y: rect.y * 4 * scaleY, width: rect.width * 4 * scaleX, height: rect.height * 4 * scaleY }); } return nms(boxes, 0.5); }4.2 CRNN识别模型的CTC解码CRNN的输出是时间步×字符集的概率矩阵用CTC解码得到最终文本。CTC解码有两种贪心解码和束搜索解码。贪心解码快但容易出错束搜索准但慢。在浏览器端我建议用贪心解码因为收据文字大多是常见字符贪心解码的准确率已经够用。CTC解码的关键是处理blank标签和重复字符。标准的CTC规则是合并连续相同字符去掉blank。但这里有个坑如果收据上真的有连续相同字符比如1000里的两个0CTC的合并规则会把它错误地合并成一个。解决办法是在训练时确保模型学会在重复字符之间插入blank推理时严格按CTC规则解码即可。function decodeCTC(output, charSet) { const seqLen output.dims[1]; const numClasses output.dims[2]; const data output.data; let prevIdx -1; const chars []; for (let t 0; t seqLen; t) { let maxIdx 0; let maxProb -Infinity; for (let c 0; c numClasses; c) { const prob data[t * numClasses c]; if (prob maxProb) { maxProb prob; maxIdx c; } } // blank标签通常是索引0 if (maxIdx ! 0 maxIdx ! prevIdx) { chars.push(charSet[maxIdx]); } prevIdx maxIdx; } return chars.join(); }4.3 模型量化与体积优化原始训练出来的模型是FP32的检测模型约9MB识别模型约18MB。这个体积对于Web应用来说太大了。必须做量化。我用的方案是训练后动态范围量化dynamic range quantization把权重从FP32转成INT8激活值在推理时动态量化。这个方案不需要校准数据集转换简单体积能压到原来的1/4左右。检测模型降到2.3MB识别模型降到4.7MB。量化会带来一定的精度损失我实测下来字符准确率下降约1.5个百分点从92.5%降到91%。这个代价换4倍的体积缩减我认为是值得的。如果对精度要求更高可以用全整型量化需要校准数据集精度损失能控制在0.5个百分点以内但转换流程复杂不少。提示LiteRT.js的WebGPU后端对INT8量化的支持不如WASM后端成熟。如果发现WebGPU下量化模型推理结果异常先切回WASM验证是否是量化算子的问题。5. 性能优化从3秒到800毫秒的实战路径5.1 性能瓶颈的定位方法优化之前先要找到瓶颈。我在Worker里埋了细粒度的计时点图像解码、预处理、检测推理、检测后处理、识别推理逐框累计、识别后处理、结果合并。跑一批测试图之后各阶段耗时占比一目了然。我最初版本的耗时分布是这样的图像解码120ms、预处理380ms、检测推理180ms、检测后处理90ms、识别推理1200ms、识别后处理60ms、结果合并30ms。总计约2060ms。瓶颈非常明显识别推理占了58%预处理占了18%。5.2 识别阶段的批处理优化识别推理慢的原因是逐个文本框串行推理每个框都要走一次完整的模型前向传播。而CRNN的输入高度固定32宽度是动态的不同文本框的宽度差异很大导致每次推理的计算量不均衡。优化的思路是批处理把多个文本框padding到相同宽度组成一个batch一起推理。LiteRT.js支持batch推理一次传入[N, 32, W]的张量输出[N, T, C]。这样GPU的利用率大幅提升因为单次推理的并行度更高了。但批处理有个约束所有输入必须padding到同一宽度。如果收据上既有很短的文本如又有很长的文本如商户全称padding到最长宽度会浪费大量计算。我的做法是按宽度分桶宽度相近的框分到同一批桶内padding。这样既享受了批处理的并行优势又避免了过度padding。function batchRecognize(recognizer, crops, charSet) { // 按宽度分桶 const buckets new Map(); for (const crop of crops) { const bucketKey Math.ceil(crop.width / 32) * 32; if (!buckets.has(bucketKey)) buckets.set(bucketKey, []); buckets.get(bucketKey).push(crop); } const results []; for (const [targetWidth, bucketCrops] of buckets) { // 构建batch张量 [N, 32, targetWidth] const batchSize bucketCrops.length; const inputData new Float32Array(batchSize * 32 * targetWidth); for (let n 0; n batchSize; n) { const crop bucketCrops[n]; const offset n * 32 * targetWidth; for (let y 0; y 32; y) { for (let x 0; x targetWidth; x) { const sx Math.min(crop.width - 1, Math.floor(x * crop.width / targetWidth)); inputData[offset y * targetWidth x] crop.data[y * crop.width sx] / 255; } } } const output recognizer.run( new Tensor(inputData, [batchSize, 32, targetWidth]) ); for (let n 0; n batchSize; n) { results.push(decodeCTC(sliceBatch(output, n), charSet)); } } return results; }批处理之后识别阶段从1200ms降到了约350ms提升非常明显。5.3 预处理阶段的SIMD加速预处理阶段的380ms主要花在灰度化、自适应二值化、透视变换这些逐像素操作上。这些操作天然适合SIMD并行。WebAssembly的SIMD提案已经在主流浏览器里可用了。LiteRT.js本身编译时就开启了SIMD支持但我们自己写的预处理代码如果还是纯JS逐像素循环就享受不到这个加速。我的做法是把预处理的核心循环用C写好编译成WASM模块在JS里调用。灰度化自适应二值化用SIMD重写之后从380ms降到了约90ms。透视变换因为涉及坐标计算和随机访存SIMD加速效果没那么好从120ms降到80ms左右。5.4 内存管理与GC压力控制浏览器端推理还有一个容易被忽略的性能杀手垃圾回收。每次推理都创建新的Tensor对象、新的ImageData、新的Float32Array这些对象在推理完成后变成垃圾频繁触发GC会导致推理过程中出现不可预测的卡顿。我的做法是预分配缓冲区复用内存。检测模型的输入张量、输出张量、识别模型的输入输出张量都在初始化时分配好推理时直接写入。ImageData也用OffscreenCanvas的getImageData复用同一个缓冲区。// 预分配推理缓冲区 class InferenceBufferPool { constructor() { this.detInput new Float32Array(1 * 320 * 320 * 3); this.detOutput new Float32Array(1 * 80 * 80); this.recInput null; // 动态分配按最大宽度预分配 this.recInputMax new Float32Array(1 * 32 * 512); this.recOutput new Float32Array(1 * 128 * 97); // 假设字符集97个 } getDetInput() { return this.detInput; } getRecInput(width) { if (width 512) return this.recInputMax.subarray(0, 32 * width); return new Float32Array(32 * width); // 超宽时临时分配 } }这套内存复用方案把推理过程中的GC触发次数从每张图约15次降到了接近0次卡顿感基本消失。6. 实际开发中踩过的坑与排查过程6.1 WebGPU初始化失败的静默降级项目上线后收到用户反馈在某些设备上识别特别慢。排查发现这些设备虽然navigator.gpu存在但requestAdapter()返回null可能是驱动问题或浏览器策略限制。我最初的代码没有处理这种情况导致推理会话初始化失败后没有正确降级到WASM而是直接报错。修复方案是在初始化流程里加完整的try-catch和降级逻辑并且把实际使用的后端上报到监控。这样既能保证功能可用又能收集WebGPU的实际覆盖率数据。async function initBackend() { const backends [webgpu, wasm]; for (const backend of backends) { try { if (backend webgpu !navigator.gpu) continue; const session await createSession(backend); // 跑一次warmup推理验证后端真的可用 await session.warmup(); return { session, backend }; } catch (e) { console.warn(Backend ${backend} failed:, e); } } throw new Error(No available backend); }关键点是warmup推理有些情况下WebGPU的adapter能拿到但实际执行kernel时会失败。只有跑一次真实推理才能确认后端真正可用。6.2 长收据分段处的文字截断前面提到过长收据要分段处理。我最初的分段策略是简单按固定高度切分结果发现切分线正好落在文字行上时那一行的识别结果会变成乱码或者空。排查过程是这样的我先用一张已知内容的收据做测试在切分线附近打印出检测框的坐标发现切分线穿过了一个检测框。然后我改成在切分前先做水平投影找到投影值最低的位置即文字行之间的间隙作为切分点。但这样又出现了新问题如果收据某一段文字特别密集找不到明显的间隙怎么办最终的方案是优先在间隙处切分如果找不到间隙就在文字行中间切分但切分后对跨越切分线的检测框做特殊处理——把两个半截的框合并成一个完整的框重新裁剪识别。这个逻辑写起来有点绕但效果很好长收据的识别完整率从85%提升到了98%。6.3 不同浏览器的Canvas像素格式差异预处理阶段需要从Canvas获取ImageData。在Chrome上一切正常但在Safari上发现颜色通道顺序不对导致灰度化之后的图像偏色进而影响二值化效果。原因是Safari在某些情况下返回的ImageData是BGRA格式而不是RGBA。解决办法是在灰度化之前先检测像素格式取一个已知颜色的像素点检查通道值是否符合预期。如果不符就交换通道。function detectPixelFormat(imageData) { // 找一个非透明像素 for (let i 0; i imageData.data.length; i 4) { if (imageData.data[i 3] 0) { // 假设收据背景是浅色R和B通道值应该接近 const r imageData.data[i]; const b imageData.data[i 2]; // 如果差异过大可能是BGRA return Math.abs(r - b) 50 ? bgra : rgba; } } return rgba; }这个检测不是100%可靠但配合后续的二值化自适应阈值即使偶尔判断错误也不会造成灾难性后果。6.4 模型加载的缓存策略7MB的模型文件如果每次打开页面都重新下载用户体验很差。我用了两级缓存Service Worker缓存模型文件IndexedDB缓存已经编译好的WASM模块和WebGPU pipeline。Service Worker缓存比较简单关键是Cache-Control头要设置好模型文件用immutable长max-age。IndexedDB缓存WASM编译结果稍微复杂一些因为WASM编译后的模块不能直接序列化。我的做法是缓存原始的WASM字节码加载时用WebAssembly.compile()重新编译。虽然编译有开销约200ms但比重新下载7MB还是快得多。注意WebGPU的shader编译结果目前无法持久化缓存每次页面加载都需要重新编译。这是WebGPU规范的限制只能等后续版本支持。7. 识别结果的后处理与结构化提取7.1 从文本行到结构化字段OCR出来的是一行行文本但用户要的是结构化的收据信息商户名、日期、总金额、明细项。这个从非结构化到结构化的过程规则引擎比机器学习模型更可控。我的做法是先用正则匹配关键字段。金额的匹配模式是[¥$]?\s*\d[.,]\d{2}日期的匹配模式覆盖常见格式YYYY-MM-DD、YYYY/MM/DD、MM-DD-YYYY等。商户名通常在收据顶部取前3行中置信度最高且不匹配金额/日期的文本行。明细项的提取更复杂一些需要识别商品名 数量 单价 金额这种模式。我用的是基于位置关系的启发式规则同一行内如果有多个数字最右边的通常是金额左边的是数量或单价。这个规则在大多数收据上有效但遇到特殊排版比如数量在商品名上方就会失效。7.2 置信度过滤与人工复核不是所有识别结果都可信。我给每个文本行计算一个置信度分数CTC解码时所有非blank时间步的概率均值低于阈值的行标记为待复核在UI上用黄色高亮。这个设计很重要因为收据OCR不可能做到100%准确与其让用户发现错误后自己找不如主动标出可能有问题的地方。实测下来置信度阈值设在0.85左右比较合适低于这个值的行确实有较大概率存在识别错误。7.3 多张收据的批量处理与去重记账场景下用户经常一次拍多张收据。批量处理时要注意内存管理不能把所有图片的ImageData都留在内存里要处理完一张释放一张。我的做法是用一个队列每次只加载一张图的ImageData处理完立即置null并触发GC。去重是另一个需求用户可能不小心拍了同一张收据两次。我用的是感知哈希pHash比对对每张收据的缩略图计算64位哈希汉明距离小于5的认为是重复。这个阈值是在测试了上百张收据后确定的既能检测出重复又不会把相似但不同的收据误判。8. 一些关于端侧OCR的思考做这个项目的过程中我越来越觉得端侧OCR的价值不在于替代云端OCR而在于覆盖那些云端OCR覆盖不了的场景。隐私敏感、网络不稳定、成本敏感——这三个场景加起来其实覆盖了相当大一部分日常需求。LiteRT.js在这个方向上是一个很好的基础设施但它不是银弹。模型的选择、预处理的质量、后处理的规则这些才是决定最终效果的关键。我见过太多项目把精力花在换推理引擎上却忽略了预处理和后处理这两个真正影响识别率的环节。WebGPU的普及会进一步降低端侧推理的门槛但目前它的碎片化问题还比较严重。不同设备、不同浏览器版本的表现差异很大做好降级和监控是必须的。我在项目里加了一个简单的性能上报记录每台设备实际使用的后端和推理耗时这些数据对后续优化很有帮助。最后分享一个我在调试过程中总结的小技巧准备一组标准测试收据覆盖各种光照条件、拍摄角度、收据类型超市小票、餐饮发票、打车发票等每次修改预处理或后处理逻辑后都跑一遍这组测试对比识别率的变化。这个习惯帮我避免了好几次改了一个地方另一个地方变差的回归问题。测试集不需要很大20-30张有代表性的收据就足够发现大部分问题。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价