Android CameraX + TFLite GPU: real-time vision pipeline
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:
| Stage | Budget | Notes |
|---|---|---|
ImageProxy acquisition | ~1ms | Back-pressure queue must be bounded |
| YUV_420_888 → RGB bitmap | 4–8ms | Software path; GPU path can cut 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.
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:
| Strategy | Latency | Notes |
|---|---|---|
Bitmap.createBitmap from planes | 8–12ms | Fully software; avoid in hot path |
| RenderScript YuvToRgb | 2–4ms | Deprecated in API 31+ |
OUTPUT_IMAGE_FORMAT_RGBA_8888 | ~1ms | Preferred; CameraX >= 1.1.0 |
| Custom GLSL shader via SurfaceTexture | 1–2ms | Highest 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.