MVP Factory
ai startup development

On-device VLM receipt parsing with Android NNAPI delegate

KW
Krystian Wiewiór · · 5 min read

Meta description: Wire a quantized PaliGemma 3B INT4 model to Android NNAPI for real-time receipt parsing. Covers token budgets, frame throttling, and ML Kit fallback on 4GB devices.


TL;DR

Running a quantized Vision-Language Model on mid-range Android hardware is viable — but only if you treat memory, tokens, and thermals as first-class constraints, not afterthoughts. What follows covers the full pipeline: NNAPI delegate configuration, OCR-to-token budget enforcement, frame throttling, and when to punt low-confidence crops to ML Kit.


The problem with naive VLM deployment on Android

Most teams benchmark on a Pixel 8 Pro with active cooling and declare success. Mid-range chipsets — your Snapdragon 6-series, Dimensity 700-series devices — tell a very different story. You’re working with a hard ceiling around 3.5–4GB of usable RAM after the OS and app overhead, a weaker NPU, and thermal envelopes that throttle within 90 seconds of sustained inference.

A naively loaded PaliGemma 3B in FP16 requires ~6GB VRAM equivalent. Non-starter. INT4 quantization brings the weight footprint down to roughly 1.6GB, leaving headroom for the NNAPI runtime, frame buffer, and token working memory.


NNAPI delegate configuration

The NNAPI delegate is your bridge to the on-device NPU/DSP. Declare explicit precision and acceleration policy upfront:

val nnApiDelegate = NnApiDelegate(
    NnApiDelegate.Options().apply {
        acceleratorName = "qti-dsp"         // target DSP, not CPU fallback
        useNnapiCpu = false
        allowFp16PrecisionForFp32 = true
        executionPreference = NnApiDelegate.Options.EXECUTION_PREFERENCE_SUSTAINED_SPEED
    }
)

val interpreterOptions = Interpreter.Options().apply {
    addDelegate(nnApiDelegate)
    setNumThreads(2)                        // leave threads for UI and camera pipeline
}

Avoid EXECUTION_PREFERENCE_FAST_SINGLE_ANSWER for streaming receipt frames — it spikes the DSP clock and triggers throttling faster than sustained mode.


The OCR-to-token pipeline

Receipt images are noisy: crumpled paper, glare, small fonts. Feeding a raw 1280×960 frame directly into a VLM is a token budget disaster. The pipeline splits responsibilities across six ordered stages:

StageComponentOutput
1. CaptureCameraX ImageProxyRaw frame (1280×960)
2. Pre-cropOpenCVCandidate receipt regions
3. Confidence gateML Kit Text DetectionHigh-confidence vs. low-confidence crops
4a. High-confidence pathTile + Resize (224×224) → PaliGemma INT4 via NNAPIStructured JSON extraction
4b. Low-confidence fallbackML Kit OCRRaw text strings
5. Post-processRegex / mergeFinal structured receipt

ML Kit handles the dirty work on degraded regions, keeping the VLM focused on structured extraction where it actually adds value.


Enforcing a strict token budget

The VLM is the memory ceiling’s primary tenant. The figures below were measured via Android Studio Memory Profiler on a Snapdragon 695 device running a clean foreground process; your numbers will vary by chipset and OS version.

Token BudgetWorking Mem (est.)Viable on 4GB Device?Extraction Quality
256 tokens~210 MBYesPartial (totals, date)
512 tokens~380 MBYesGood (line items + totals)
768 tokens~520 MBMarginalExcellent
1024 tokens~680 MBNo — OOM risk—

My production default is 512 tokens. The prompt template is engineered to be dense:

val prompt = buildString {
    append("<image>")
    append("Extract receipt JSON: {merchant, date, line_items:[{desc,qty,price}], total, currency}. ")
    append("Be terse. Max 400 output tokens.")
}

The explicit output cap in the prompt reduces the probability of the model running long on verbose receipts.


Frame throttling and thermal strategy

Continuous camera inference is a thermal trap. The strategy that holds up in production:

private val inferenceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
private var lastInferenceMs = 0L
private val MIN_INTERVAL_MS = 800L  // ~1.25 fps inference rate

fun onFrameAvailable(frame: ImageProxy) {
    val now = SystemClock.elapsedRealtime()
    if (now - lastInferenceMs < MIN_INTERVAL_MS) {
        frame.close(); return
    }
    if (thermalStatusProvider.isThrottling()) {
        frame.close(); return  // hard stop, not degraded inference
    }
    lastInferenceMs = now
    inferenceScope.launch { runInference(frame) }
}

Use PowerManager.getThermalHeadroom() (API 29+) as your throttle signal. When headroom drops below 0.7, halt inference entirely rather than reducing quality — partial results on a receipt are worse than no results.


When ML Kit wins

The VLM is not always the right tool. ML Kit’s text recognition v2 processes a 224×224 crop in under 30ms on the same mid-range device. For line items where OCR confidence is above 0.92, a regex post-processor over ML Kit output produces equivalent structured results at a fraction of the cost.

In my experience building production systems with hybrid pipelines: reserve the VLM for ambiguous layouts, handwritten annotations, and multi-currency edge cases. Route everything else through ML Kit. In my testing on a corpus of ~1,200 grocery and retail receipts, this routing strategy cut VLM inference frequency by roughly 40–60% — though your split will depend heavily on receipt quality and merchant format diversity.


Takeaways

Lock your token budget at 512 before writing model code. Memory pressure on mid-range devices is a design constraint, not a tuning knob. Size your pipeline around the ceiling, not the ideal case.

Use ML Kit as a pre-filter, not a fallback. Route high-confidence OCR regions through regex extraction first. Call the VLM only for crops that ML Kit can’t confidently parse. The inference frequency reduction on typical receipt corpora is significant — measure it for your specific workload.

Monitor thermal headroom, not just frame rate. A dropped frame is recoverable. A thermal-throttled NPU that degrades mid-transaction destroys the user experience. Build the thermal gate before you build anything else.


Tags: kotlin android mobile architecture mlkit


Share: Twitter LinkedIn