DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Wiring Android's CameraX with TensorFlow Lite and GPU Delegate: Building a Real-Time Vision Pipeline Under 30ms Frame Latency

---
title: "Wiring CameraX to TFLite GPU Delegate: A Real-Time Vision Pipeline Under 30ms"
published: true
description: "Wire CameraX ImageAnalysis to TensorFlow Lite GPU Delegate with correct buffer lifecycle and YUV conversion to hit sub-30ms inference on mid-range Android devices."
tags: android, kotlin, mobile, architecture
canonical_url: https://mvpfactory.co/blog/camerax-tflite-gpu-delegate-real-time-vision-pipeline
---

## What We Are Building

By the end of this tutorial, you will have a production-ready CameraX → TFLite GPU Delegate inference pipeline that achieves sub-30ms end-to-end frame latency on Snapdragon 6xx class devices — which is most of your production install base. We are not just getting inference *working*. We are shipping it.

Most tutorials stop at "it runs." We are going further: correct back-pressure, zero-copy YUV conversion, and GPU warm-up that does not ambush your users on cold start.

## Prerequisites

- Android project targeting API 26+
- CameraX `1.1.0` or higher
- TensorFlow Lite with GPU Delegate dependency
- A TFLite model (image classifier used in examples below)
- Basic familiarity with Kotlin coroutines and Android lifecycle

## The Latency Budget Nobody Talks About

Let me show you a pattern I use in every project — a concrete budget for your 33ms frame window at 30fps.

| Stage | Budget | Notes |
|---|---|---|
| `ImageProxy` acquisition | ~1ms | Back-pressure queue must be bounded |
| YUV_420_888 → RGB bitmap | 4–8ms | Software path; GPU path cuts this to ~1ms |
| TFLite pre-processing | 2–4ms | Normalize + resize on CPU unless you use `TensorImage` |
| GPU Delegate inference | 8–15ms | First run is 3–5× slower due to shader compilation |
| Post-processing + dispatch | 2–3ms | Keep off main thread |
| **Total** | **17–31ms** | Leaves headroom for 30fps at 33ms/frame |

YUV conversion and GPU warm-up are where most teams blow their budget. Everything else is close to fixed.

## Step 1: Wire ImageAnalysis with the Right Back-Pressure Strategy

The default `STRATEGY_KEEP_ONLY_LATEST` mode is correct, but only if you also set `OUTPUT_IMAGE_FORMAT_RGBA_8888`. This single flag delegates YUV-to-RGB conversion to CameraX's hardware-accelerated internal pipeline and recovers 4–6ms on mid-range hardware.

Enter fullscreen mode Exit fullscreen mode


kotlin
val imageAnalysis = ImageAnalysis.Builder()
.setTargetResolution(Size(640, 480))
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
.build()
.also { it.setAnalyzer(inferenceExecutor, ::analyzeFrame) }


This is the highest-ROI change in the pipeline. It takes two minutes to make.

## Step 2: Warm Up the GPU Delegate at Startup

Here is the gotcha that will save you hours. The GPU Delegate compiles OpenGL ES compute shaders on first inference. On a Snapdragon 665, that adds 80–200ms to your first frame. Initialize lazily and your users feel every millisecond of it.

Enter fullscreen mode Exit fullscreen mode


kotlin
class VisionInferenceEngine(context: Context) {

private val interpreter: Interpreter

init {
    val gpuDelegate = GpuDelegate(
        GpuDelegate.Options().apply {
            inferencePreference = INFERENCE_PREFERENCE_SUSTAINED_SPEED
            isPrecisionLossAllowed = true // FP16 — validate on your model first
        }
    )
    val options = Interpreter.Options()
        .addDelegate(gpuDelegate)
        .setNumThreads(2)

    interpreter = Interpreter(loadModelBuffer(context), options)
    warmUp()
}

private fun warmUp() {
    val dummyInput = Array(1) { Array(224) { Array(224) { FloatArray(3) } } }
    val dummyOutput = Array(1) { FloatArray(NUM_CLASSES) }
    interpreter.run(dummyInput, dummyOutput)
}
Enter fullscreen mode Exit fullscreen mode

}


Initialize this in `Application.onCreate()` or as a scoped singleton injected before your camera session starts. Not in `onResume()`.

## Step 3: Manage Buffer Lifecycle Correctly

`ImageProxy` must be closed exactly once, and only after you finish reading its planes. The docs do not make this obvious, but close too early and you release the buffer to the camera HAL mid-read. Close too late and you block the `ImageAnalysis` queue.

Enter fullscreen mode Exit fullscreen mode


kotlin
private fun analyzeFrame(image: ImageProxy) {
try {
val bitmap = image.toBitmap()
val tensorImage = TensorImage.fromBitmap(bitmap)
val results = classifier.classify(tensorImage)
resultChannel.trySend(results)
} finally {
image.close() // always in finally — not after, not conditional
}
}


Run `analyzeFrame` on a dedicated `Executor` backed by a single thread. Using `Dispatchers.Default` or a shared pool means frames execute out of order and compete for the GPU context.

## Gotchas

**Skipping the warm-up call.** Shader compilation latency is real, consistent, and nasty to debug in production because it only hits on cold start. It will not show up in your dev environment.

**Creating the inference engine in `onResume()`.** Every app resume triggers shader compilation again. Move initialization upstream.

**Using `Bitmap.createBitmap` from YUV planes in the hot path.** This software conversion costs 8–12ms per frame. Use `OUTPUT_IMAGE_FORMAT_RGBA_8888` if you are on CameraX 1.1.0+. If you are on an older version, RenderScript YuvToRgb brings this to 2–4ms, though it is deprecated in API 31+.

**Using a shared thread pool for frame analysis.** Out-of-order execution under load compounds with every queued frame and will not surface until thermal throttling hits a device you do not own.

## Conclusion

Here is the minimal setup to get this working in production: set `OUTPUT_IMAGE_FORMAT_RGBA_8888`, warm the GPU Delegate at startup with a dummy inference, and close `ImageProxy` in a `finally` block on a single-threaded executor. Then profile your back-pressure queue depth before shipping — a queue that grows under load is the silent killer at scale.

**Relevant docs:**
- [CameraX ImageAnalysis reference](https://developer.android.com/reference/androidx/camera/core/ImageAnalysis)
- [TFLite GPU Delegate guide](https://www.tensorflow.org/lite/performance/gpu)
- [TensorImage API](https://www.tensorflow.org/lite/api_docs/java/org/tensorflow/lite/support/image/TensorImage)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)