MoViNet-A0 Stream — LiteRT (on-device video action recognition, GPU)
On-device streaming video action recognition: recognises human actions across a
stream of camera frames — one frame at a time, constant memory, real-time — running
fully on the LiteRT CompiledModel GPU delegate (no CPU fallback).
Architecture:MoViNet-A0 streaming variant
(Google Research) — a causal 2+1D CNN.
MoViNet's temporal convolutions and global-average-pools each keep a small buffer of
the recent past, so the network can be fed one frame at a time and its prediction
sharpens as more frames of the same action arrive. The stock streaming graph carries
that history in 5D state tensors [1, T, H, W, C], which a GPU delegate cannot
compile (all tensors must be ≤ 4D). This model is re-authored as a single-frame,
4D-only functional forward (47 inputs / 28 outputs) with the recurrent state
threaded explicitly through the graph I/O:
I/O slot
count
shape
meaning
input[0]
1
[1,3,172,172]
current RGB frame (NCHW, 0..1)
input[1..28]
28
[1,C,H,W]
temporal-conv stream buffers (11 convs)
input[29..44]
16
[1,C,1,1]
streaming avg-pool running sums (15 SE + head)
input[45]
1
[1,1,1,1]
inv_count = 1 / current frame number
input[46]
1
[1,1,1,1]
constant 1.0 (Mali output decoupler)
output[0]
1
[1,600]
Kinetics-600 logits
output[1..11]
11
[1,C,H,W]
current per-temporal-conv frame
output[12..27]
16
[1,C,1,1]
fresh per-frame spatial means
The stream-buffer shift register and pool running-sum accumulation are done
host-side: each frame you run once, shift each stream buffer (drop oldest, append
the emitted current frame), accumulate running_sum += emitted_mean, and feed both
back as inputs. The converted graph is all float32, 0 tensors of rank > 4, 0
GPU-incompatible ops and matches the original PyTorch model bit-for-bit
(correlation 0.99999999999, top-5 identical; device GPU on a Pixel 8a locks onto
"jumping jacks" within a few frames). Keeping the state in-graph tripped three silent
Mali CompiledModel bugs, which is why the state plumbing is host-side.
Minimal usage
Python (LiteRT / ai-edge-litert, frame-by-frame)
python
1from ai_edge_litert.interpreter import Interpreter
2import numpy as np
34it = Interpreter(model_path="movinet_a0_stream.tflite"); it.allocate_tensors()5inp, out = it.get_input_details(), it.get_output_details()67DIMS =[2,2,2,4,2,2,4,2,2,2,4]# temporal-conv buffer depths8offs, o =[],09for d in DIMS: offs.append(o); o += d
10hist =[[np.zeros(inp[1+ offs[c]+ i]["shape"], np.float32)for i inrange(DIMS[c])]11for c inrange(11)]# host-side shift registers12psum =[np.zeros(inp[29+ i]["shape"], np.float32)for i inrange(16)]# running sums1314for n, frame inenumerate(video_frames, start=1):# frame: [1,3,172,172], RGB, 0..115 it.set_tensor(inp[0]["index"], frame.astype(np.float32))16for c inrange(11):17for i inrange(DIMS[c]): it.set_tensor(inp[1+ offs[c]+ i]["index"], hist[c][i])18for i inrange(16): it.set_tensor(inp[29+ i]["index"], psum[i])19 it.set_tensor(inp[45]["index"], np.full((1,1,1,1),1.0/ n, np.float32))# inv_count20 it.set_tensor(inp[46]["index"], np.ones((1,1,1,1), np.float32))# decoupler21 it.invoke()22 logits = it.get_tensor(out[0]["index"])[0]# [600]23for c inrange(11):# shift: drop oldest, append current24 hist[c]= hist[c][1:]+[it.get_tensor(out[1+ c]["index"]).copy()]25for i inrange(16):# accumulate running sum26 psum[i]= psum[i]+ it.get_tensor(out[12+ i]["index"])2728print("top-1:",int(logits.argmax()))
Kotlin (Android, LiteRT CompiledModel GPU)
kotlin
1val options = CompiledModel.Options(Accelerator.GPU)2val model = CompiledModel.create(context.assets,"movinet_a0_stream.tflite", options,null)3val inBufs = model.createInputBuffers()// [0]=frame, [1..28]=stream, [29..44]=pool sums, [45]=inv_count, [46]=1.04val outBufs = model.createOutputBuffers()// [0]=logits, [1..11]=current frames, [12..27]=fresh means56inBufs[46].writeFloat(floatArrayOf(1f))// constant decoupler7// reset recurrent state (zeros) at the start of a clip; keep host-side stream buffers + pool sums89for((n, frameNCHW)in videoFrames.withIndex()){// frame: [1,3,172,172], RGB, 0..110 inBufs[0].writeFloat(frameNCHW)11 inBufs[45].writeFloat(floatArrayOf(1f/(n +1)))// inv_count12// (stream inputs 1..28 and pool inputs 29..44 already staged from the previous frame)13 model.run(inBufs, outBufs)14val logits = outBufs[0].readFloat()// [600] Kinetics-60015// host-side: shift each stream buffer with the emitted current frame (outBufs[1..11]),16// and accumulate poolSum[i] += outBufs[12+i], then write both back to inBufs for the next frame.17}
A full implementation (camera → per-frame → top-5, with the host-side shift register and pool
accumulation) is in the sample app's ActionRecognizer.kt.
Conversion
Re-authored and converted with litert-torch. See the sample app and build script:
build_movinet.py + stream_model.py.
Performance
Measured on a Pixel 8a (Tensor G3, Android 16) with the standard TFLite benchmark_model tool — 10 warm-up runs then 50 timed runs, reported as the tool's mean.
Runtime
Backend
Graph on GPU
Latency
TFLite benchmark_model (TfLiteGpuDelegateV2)
GPU (OpenCL)
71 / 455
53.0 ms
TFLite benchmark_model
CPU (XNNPACK, 4 threads)
—
9.8 ms
Any on-device figure recorded when this model shipped came from a different runtime. It was taken through LiteRT's own CompiledModel accelerator (logcat reports it as LITERT_CL), which is the path the Kotlin sample app and the LiteRT API use, and it appears elsewhere on this card. The rows above are the classic TFLite OpenCL delegate, measured with a tool anyone can download and re-run. The two are not comparable, so read the rows above as a reproducible floor rather than as this model's speed on LiteRT.
On this delegate the CPU is the faster choice for (9.8 ms on CPU against 53.0 ms on GPU) — worth knowing before you reach for the GPU on a mid-range phone.
Note that the GPU does not take the whole graph here (71 / 455); the remainder runs on the CPU and the split costs a per-partition round trip.
Snapdragon NPU (Hexagon)
The GPU delegate declines this graph on the S26: LiteRtException: Failed to compile model. The NPU runs it at 2.15 ms.
backend
inference (median / min)
load
NPU (Hexagon v81)
2.15 ms / 2.09 ms
112 ms
Measured on a Samsung Galaxy S26 (Snapdragon 8 Elite Gen 5 / SM8850, Hexagon v81, Android 16), LiteRT CompiledModel 2.2.0, one accelerator per process, 5 warm-up runs then N=50 timed runs, median reported. The run held thermal status NONE throughout. Headroom 0.68, where 1.0 is the throttling threshold.
The NPU rows here ran artifacts compiled ahead of time for SM8850 with QAIRT 2.47.0. LiteRT can also compile for the NPU on the device at first load, which is what lets you ship the published file unchanged — that path and the ten runtime libraries it needs are in the NPU recipe, and we did not measure it here. GPU wiring is in the GPU recipe.
Raspberry Pi 5 (CPU)
Measured on a Raspberry Pi 5 Model B Rev 1.1 (8 GB, Raspberry Pi OS 64-bit) with the LiteRT benchmark_model tool from litert-cli-nightly 0.2.0.dev20260805: CPU inference (XNNPACK, 4 threads), 3 invocations per file of 10 warm-up plus 50 timed runs (the tool caps a phase at 150 s, so very slow graphs run fewer — the Runs column is the actual timed total). The latency is the median across invocations; the spread is the min–max over all timed runs. No thermal throttling occurred during these runs (vcgencmd get_throttled stayed 0x0).
File
Inference (median)
Spread (min–max)
Runs
Peak memory
movinet_a0_stream.tflite
4.63 ms
4.54–5.59 ms
649
138 MB
License
Apache-2.0 (MoViNet / Atze00/MoViNet-pytorch). Kinetics-600 label taxonomy from the
DeepMind Kinetics dataset.