浏览器里跑OCR这件事,我从Tesseract.js刚出来那会儿就在折腾,当时的体验说实话挺劝退的——加载慢、识别率一般、大图直接卡死主线程。后来PaddleOCR的Web版本出来,精度上去了但包体积又成了新问题。直到LiteRT.js进入视野,配合WebAssembly和WebGPU这两把利器,才算真正让"在浏览器里扫收据"这件事变得可用了。这篇文章不讲虚的,就围绕基于LiteRT.js构建浏览器端收据扫描器这个项目,把技术选型的逻辑、核心实现路径、性能调优的细节、以及我在实际开发中踩过的坑,全部摊开来讲。适合正在做前端OCR、对WebAssembly/WebGPU加速感兴趣、或者想了解React+端侧推理怎么配合的开发者。读完你至少能搞清楚:为什么选LiteRT.js而不是Tesseract.js或PaddleOCR Web,WebGPU到底在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。识别用的是CRNN+CTC,输入高度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的useReducer+Context管理比裸写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默认的blockSize=11、C=2效果好很多。
// 自适应二值化的实现(基于积分图加速) 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"里的两个0),CTC的合并规则会把它错误地合并成一个。解决办法是在训练时确保模型学会在重复字符之间插入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张有代表性的收据就足够发现大部分问题。