James Carl Sitsit

One unreadable receipt should not kill the batch

FloatWatch 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

The useful part of FloatWatch'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 FloatWatch 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, but it leaves a blind spot. A bad image, a file access problem, and a programming error can all become the same empty string at this boundary. The next version should preserve an error reason 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 FloatWatch, a Flutter app that reconciles a GCash outlet's cash and e-float on-device. The full build is in the FloatWatch deep dive.