One unreadable receipt should not kill the batch
Cashi runs receipt OCR on-device with ML Kit. Its batch wrapper preserves every input path and isolates a failed image instead of dropping the run.
By James Carl Sitsit · From the Cashi case study
The useful part of Cashi's OCR wrapper is not the call to ML Kit. It is the decision to keep going when one image fails.
Receipt scanning happens as a batch. If the fifth image cannot be read, the first four results should not disappear with it. OcrService.extractBatch handles each image on its own and records an empty text result when extraction throws:
try {
final text = await extractText(imagePaths[i]);
results.add((path: imagePaths[i], text: text));
} catch (_) {
results.add((path: imagePaths[i], text: ''));
}
The small detail I like here is that both branches append the original path. A successful image returns its path and text. A failed image returns its path and an empty string. The batch keeps its shape, so downstream code still knows which input needs attention.
Failure becomes data
An empty OCR result goes through the same parser and confidence checks as every other result. With no amount, type, or date, it cannot pass the save gate. It is sent to review instead of terminating the batch.
That gives the rest of the pipeline one contract to handle: every selected path produces a result, but the text may be empty. The parser does not need a second batch-level recovery path just because ML Kit rejected one image.
This is the part that makes on-device OCR usable in the daily flow. ML Kit can read text without a cloud call, and Cashi does not turn one extraction failure into a lost batch.
The catch is too quiet
I would not present the snippet as finished error handling. catch (_) discards the reason for the failure. This gives me failure isolation, but the snippet does not preserve enough information for good diagnostics.
That trade was enough to keep the batch moving. If I were building this boundary again, I would return an optional error reason beside the empty string so a bad image, a file access problem, and a recognizer error stay distinguishable in logs while keeping the per-image behavior.
The design I would keep is simple: do not make one receipt responsible for the fate of every receipt after it. Keep the input path, mark the failed extraction, and let the review flow decide what happens next.
This is from Cashi, a Flutter app that reconciles a GCash outlet's cash and e-float on-device. The case study covers the full build.
- flutter
- ocr
- mobile