MVP Factory
ai startup development

Android CameraX + TFLite GPU: real-time vision pipeline

KW
Krystian Wiewiór · · 5 min read

Meta description: Wire CameraX ImageAnalysis to TensorFlow Lite GPU Delegate with correct buffer lifecycle and YUV conversion to achieve sub-30ms inference on mid-range Android devices.

Tags: android kotlin mobile architecture cleanarchitecture


TL;DR

Sub-30ms end-to-end frame latency in a CameraX → TFLite GPU pipeline comes down to three things: correct ImageAnalysis back-pressure configuration, zero-copy YUV-to-RGB conversion, and GPU Delegate warm-up outside the hot path. Miss any one of them and you’ll drop frames on Snapdragon 6xx class devices, which is most of your production install base.


The latency budget nobody talks about

Most tutorials show you how to get inference working. They don’t show you how to ship it. In production, “working” means nothing if your pipeline stalls the camera preview or skips frames under thermal throttling.

Realistic 30ms budget breakdown for a mid-range device:

StageBudgetNotes
ImageProxy acquisition~1msBack-pressure queue must be bounded
YUV_420_888 → RGB bitmap4–8msSoftware path; GPU path can cut this to ~1ms
TFLite pre-processing2–4msNormalize + resize on CPU unless you use TensorImage
GPU Delegate inference8–15msFirst run is 3–5× slower due to shader compilation
Post-processing + dispatch2–3msKeep off main thread
Total17–31msLeaves 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.


Wiring CameraX ImageAnalysis correctly

The most common mistake: using the default STRATEGY_KEEP_ONLY_LATEST back-pressure mode while doing blocking work inside analyze(), which causes the analyzer thread to queue up.

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

Setting OUTPUT_IMAGE_FORMAT_RGBA_8888 delegates the YUV-to-RGB conversion to CameraX’s internal pipeline, which uses hardware acceleration on supported devices. This alone can recover 4–6ms on mid-range hardware.


GPU Delegate: warm-up is not optional

The GPU Delegate compiles OpenGL ES compute shaders on first inference. On a Snapdragon 665, this adds 80–200ms to your first frame. If you initialize lazily, your users feel that.

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() // run one dummy inference at init time
    }

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

Initialize this in Application.onCreate() or as a scoped singleton injected before your camera session starts. Don’t create it in onResume().


Buffer lifecycle: the hidden frame drop

ImageProxy must be closed exactly once, and only after you’re done reading its planes. Close too early and you release the buffer back to the camera HAL mid-read. Close too late and you block the ImageAnalysis queue.

private fun analyzeFrame(image: ImageProxy) {
    try {
        val bitmap = image.toBitmap() // extension on ImageProxy, uses RGBA path
        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 can execute out of order and compete for the GPU context.


YUV conversion strategy comparison

If you can’t use OUTPUT_IMAGE_FORMAT_RGBA_8888 (older CameraX versions or specific hardware), your options:

StrategyLatencyNotes
Bitmap.createBitmap from planes8–12msFully software; avoid in hot path
RenderScript YuvToRgb2–4msDeprecated in API 31+
OUTPUT_IMAGE_FORMAT_RGBA_8888~1msPreferred; CameraX >= 1.1.0
Custom GLSL shader via SurfaceTexture1–2msHighest complexity, maximum control

For new projects targeting API 26+, the CameraX native format conversion is the right default. Reach for the GLSL path only if you need to transform the frame in the same shader pass as your model’s pre-processing.


What actually matters

Use OUTPUT_IMAGE_FORMAT_RGBA_8888 in ImageAnalysis.Builder. It’s the highest-ROI change in the pipeline and takes two minutes to make.

Warm the GPU Delegate at startup. Shader compilation latency is real, consistent, and nasty to debug in production because it only hits on cold start. Absorb it during app init where users expect a loading moment.

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 compounds with every frame, and it will not show up until you’re under thermal throttling on a device you don’t own.


Share: Twitter LinkedIn