MVP Factory
ai startup development

CameraX + MoveNet Thunder INT8: sub-25ms Android pose estimation

KW
Krystian Wiewiór · · 5 min read

Meta description: Wire CameraX ImageAnalysis to MoveNet Thunder INT8 with GPU delegate for sub-25ms Android pose estimation, NNAPI fallback, and keypoint thresholding.


TL;DR

Running MoveNet Thunder (INT8 quantized) on Android via CameraX ImageAnalysis is achievable under 25ms on mid-range hardware — but only if you eliminate the frame preprocessing bottleneck. The YUV→RGB bitmap conversion path is where most teams hemorrhage 15–30ms per frame before inference even starts. This post walks through the full pipeline: delegate selection, input tensor layout, and keypoint confidence thresholding for production biomechanics apps.


Why pose estimation is a frame-budget problem

Most teams optimize the model and ignore the camera pipeline. On a Snapdragon 695 or equivalent mid-range SoC, MoveNet Thunder INT8 can comfortably run in 18–22ms with GPU delegate — but a naive ImageProxy → Bitmap conversion via toBitmap() adds 25–40ms on top. You’ve blown your frame budget before a single keypoint fires.

The breakdown:

Pipeline stageNaive pathOptimized path
YUV→RGB conversion25–40ms4–8ms
Input tensor copy8–12ms1–2ms
MoveNet Thunder INT8 inference18–22ms18–22ms
Keypoint post-processing2–3ms2–3ms
Total53–77ms25–35ms

The delta is entirely in preprocessing, not the model.


The CameraX ImageAnalysis setup

Bind ImageAnalysis with STRATEGY_KEEP_ONLY_LATEST — drop frames, never queue them. For a biomechanics use case you want the freshest keypoints, not a backlog.

val imageAnalysis = ImageAnalysis.Builder()
    .setTargetResolution(Size(256, 256)) // advisory hint; CameraX selects nearest supported size
    .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
    .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
    .build()

imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy ->
    runInference(imageProxy)
    imageProxy.close()
}

setTargetResolution is a hint, not a guarantee — CameraX picks the nearest resolution the camera hardware supports. Always explicitly scale the resulting bitmap to exactly 256×256 before passing it to the model.

OUTPUT_IMAGE_FORMAT_RGBA_8888 is the flag that actually matters. CameraX handles the YUV→RGBA conversion in native code on a hardware-accelerated path. Don’t let the ImageProxy arrive as YUV and convert it in Kotlin — that path is 5–8x slower, and it’s where most of the naive pipeline’s latency lives.


Delegate selection: GPU → NNAPI fallback

val gpuDelegate = try {
    GpuDelegate(GpuDelegate.Options().apply {
        inferencePreference = INFERENCE_PREFERENCE_SUSTAINED_SPEED
        precisionLossAllowed = true // enables FP16 on GPU
    }).also { delegates.add(it) }
} catch (e: Exception) {
    null
}

val options = Interpreter.Options().apply {
    if (gpuDelegate != null) {
        addDelegate(gpuDelegate)
    } else {
        addDelegate(NnApiDelegate())
    }
    numThreads = 2
}

precisionLossAllowed = true matters on GPU delegate with INT8 models — it permits the runtime to use FP16 intermediate computations, which is the native precision of most mobile GPUs. NNAPI fallback catches devices where GPU delegate initialization fails (common on older Mali GPUs with driver issues).


Input tensor layout and the 256×256 contract

MoveNet Thunder expects [1, 256, 256, 3] INT8 input with values in [0, 255]. The mistake I see consistently in production codebases is normalizing to [-1, 1] — that’s for float MoveNet models. Both Thunder and Lightning have float variants that expect normalized input, but the INT8 quantized models don’t.

Allocate your ByteBuffer once at class level and pass it into the preprocessing function on every frame. GC pressure from per-frame allocations will introduce jank that no delegate optimization recovers.

// Allocate once at class level — never inside the analysis loop
private val inputBuffer = ByteBuffer.allocateDirect(1 * 256 * 256 * 3).apply {
    order(ByteOrder.nativeOrder())
}

fun preprocessFrame(bitmap: Bitmap, buffer: ByteBuffer): ByteBuffer {
    buffer.rewind()
    // bitmap must already be explicitly scaled to 256×256
    val pixels = IntArray(256 * 256)
    bitmap.getPixels(pixels, 0, 256, 0, 0, 256, 256)

    for (pixel in pixels) {
        buffer.put(((pixel shr 16) and 0xFF).toByte()) // R
        buffer.put(((pixel shr 8) and 0xFF).toByte())  // G
        buffer.put((pixel and 0xFF).toByte())           // B
    }
    return buffer.rewind() as ByteBuffer
}

Pass inputBuffer as the buffer argument on each call — zero allocation on the hot path.


Keypoint confidence thresholding

MoveNet outputs 17 keypoints as [y, x, confidence] triples. For biomechanics — where you’re computing joint angles, not just overlaying a skeleton — a confidence threshold of 0.3 is the baseline. Drop below that and you’re feeding noise into your angle calculations.

data class Keypoint(val y: Float, val x: Float, val confidence: Float)

fun parseKeypoints(output: Array<Array<Array<FloatArray>>>): List<Keypoint?> {
    val raw = output[0][0]
    return (0 until 17).map { i ->
        val confidence = raw[i][2]
        if (confidence >= 0.3f) Keypoint(raw[i][0], raw[i][1], confidence) else null
    }
}

Returning null for low-confidence keypoints forces the downstream biomechanics layer to handle missing data explicitly — which is the correct behavior for calculating metrics like knee valgus angle or hip drop in gait analysis.


Conclusion

In my experience building production systems that run on-device ML, the inference model is rarely the bottleneck — the data pipeline around it is. Three things will get you under 25ms:

  1. Set OUTPUT_IMAGE_FORMAT_RGBA_8888 in ImageAnalysis — this one flag offloads YUV conversion to the hardware path and cuts your preprocessing time by 5–8x.
  2. Allocate your input ByteBuffer once at class level, not per frame — GC pauses from hot-path allocations dwarf inference latency on mid-range devices.
  3. Threshold keypoints at 0.3 confidence and propagate null downstream — biomechanics calculations on low-confidence keypoints produce physically invalid joint angles that corrupt your metrics silently.

android mobile kotlin architecture


Share: Twitter LinkedIn