--- license: apache-2.0 library_name: litert pipeline_tag: depth-estimation tags: - litert - tflite - depth-estimation - monocular-depth - on-device - gpu - depth-anything base_model: depth-anything/DA3-SMALL base_model_relation: quantized --- # Depth Anything 3 (Small) — LiteRT GPU, monocular depth On-device **LiteRT / TFLite** conversion of [**Depth Anything 3 — Small**](https://huggingface.co/depth-anything/DA3-SMALL) (ByteDance-Seed, Apache-2.0) for **monocular depth**, running fully on the mobile **GPU** via the LiteRT `CompiledModel` API (ML Drift delegate). No CPU fallback ops — the whole graph is GPU-compatible. | | | |---|---| | Task | Monocular depth (single RGB → depth) | | Backbone | DINOv2 ViT-S + RoPE, DPT/DualDPT depth head | | Input | `[1, 3, 896, 504]` NCHW float32, ImageNet-normalized, **native portrait aspect** | | Output | `[1, 1, 896, 504]` depth | | Precision / size | FP16, **55 MB** | | Device | Pixel 8a, LiteRT GPU (`Accelerator.GPU`), **~0.9 s / image** (FP16, CompiledModel.Run) | | Fidelity | **corr 0.99948** vs official PyTorch; on-device **GPU-vs-CPU cos 0.99993** (re-verified, see below) | ![Depth Anything 3 Small — input photo and on-device depth map (Pixel 8a, LiteRT GPU)](sample-depth.png) ## Why a fixed 896×504 (native aspect, not square) DA3 processes images at their **native aspect ratio** (`upper_bound_resize`, longer side → 896, multiple of 14). Forcing a square `896×896` and letterbox-padding drops the match to corr **0.977** (the black padding leaks into the content through global attention). Converting at the native rectangle restores **corr 0.9994** and is also faster (fewer tokens). This checkpoint is built for **portrait ~9:16**. For another aspect, re-convert at that shape (or your camera's fixed aspect) with the script below. ## Preprocessing (must match) ``` resize to 504×896 (W×H) → x/255 → (x - mean) / std mean = [0.485, 0.456, 0.406], std = [0.229, 0.224, 0.225] # ImageNet, RGB, NCHW ``` ## GPU-clean conversion (what was patched) Converted with `litert-torch`. DA3 is not GPU-clean out of the box; the following exact, GPU-clean rewrites were applied (all numerically faithful unless noted): 1. checkpoint `model.` key-prefix strip (load fix) 2. RoPE `max_position = int(positions.max())+1` → constant (torch.export data-dependent) 3. fused-QKV attention → 3 separate Linears + 4D attention (avoids 5D RESHAPE; exact, 1e-6) 4. **LayerScale** `gamma` folded into `attn.proj` / `mlp.fc2` (the LayerScale MUL otherwise mis-lays-out the token dim on the GPU delegate: `fully_connected {1,1,N,C} vs {N,1,1,C}`) 5. `pos_embed` bicubic interpolation **baked** to a constant (the interpolate of a constant emits `GATHER_ND` on desktop and `RESIZE_BILINEAR` with 0 runtime inputs on device) 6. **ConvTranspose2d(k=s,stride=s)** → zero-stuff (nearest-upsample × top-left mask) + `Conv2d` (flipped weight) — exact equivalent (~1e-7), because the Pixel-8a GPU rejects `TRANSPOSE_CONV` and the conv+ depth-to-space alternative needs >4D 7. DPT-head `custom_interpolate` `align_corners=True → False` (GPU bans `align_corners=True` resize) — **the only non-exact rewrite**; source of the residual ~0.05 % vs the official model 8. head UV pos-embed-again disabled (its `make_sincos` broadcast emits `BROADCAST_TO`; ratio-0.1 refinement) 9. camera-token insertion `x[:, :, 0] = cam_token` → `torch.cat` (in-place index-assign → `SELECT_V2`) Net result: `GATHER_ND = 0`, no `>4D` tensors, no `TRANSPOSE_CONV` / `BROADCAST_TO` / banned ops. ## Fidelity note (honest) corr **0.99948** vs the official FP32 PyTorch pipeline. FP16 is **not** a factor (FP32≡FP16, corr 1.0). The residual ~0.05 % is the `align_corners=True→False` change in (7), which the mobile GPU forces — an irreducible hardware constraint, not a conversion error. Structure and edge sharpness are visually identical. ## On-device GPU verification (re-confirmed) Re-verified on a Pixel 8a with the official LiteRT C++ runtime + ML Drift accelerator: the model compiles to **`Replacing 1460 out of 1460 node(s) with delegate (LITERT_CL)`** (full residency, single partition, no XNNPACK CPU fallback), and the **on-device GPU output matches the CPU/XNNPACK reference at cos 0.99993 / Pearson 0.99975** for the same input — i.e. the GPU result is numerically faithful, not merely "resident" (GPU full residency does not by itself guarantee a correct result). ## Minimal usage **Android (Kotlin, CompiledModel GPU)** ```kotlin val model = CompiledModel.create(context.assets, "da3_small_gpu_fp16.tflite", CompiledModel.Options(Accelerator.GPU), null) val inputs = model.createInputBuffers() val outputs = model.createOutputBuffers() inputs[0].writeFloat(chw) // [1,3,896,504] ImageNet-normalized, NCHW (portrait) model.run(inputs, outputs) val depth = outputs[0].readFloat() // [1,1,896,504] ``` **Python (desktop verification)** ```python MEAN = np.array([0.485, 0.456, 0.406], np.float32) STD = np.array([0.229, 0.224, 0.225], np.float32) import numpy as np from PIL import Image from ai_edge_litert.interpreter import Interpreter img = Image.open("portrait.jpg").convert("RGB").resize((504, 896)) # W=504, H=896 x = ((np.asarray(img, np.float32) / 255 - MEAN) / STD).transpose(2, 0, 1)[None] it = Interpreter(model_path="da3_small_gpu_fp16.tflite"); it.allocate_tensors() it.set_tensor(it.get_input_details()[0]["index"], x); it.invoke() d = it.get_tensor(it.get_output_details()[0]["index"])[0, 0] # [896,504] d = (d - d.min()) / (d.max() - d.min()) Image.fromarray((d * 255).astype(np.uint8)).save("depth.png") ``` ## Training data & PII Depth Anything 3 was trained by ByteDance-Seed on a large-scale collection of monocular-depth data — a mix of **synthetic depth datasets and real images with pseudo-labelled depth** (the Depth Anything line scales to tens of millions of images). No new training was performed for this conversion — it is a weights-faithful (corr ≈ 1.0) format change of the public `depth-anything/DA3-SMALL` checkpoint. Because the source data includes real-world indoor/outdoor scenes, it may incidentally contain people, faces, vehicles, signage and other PII; no PII was deliberately collected and this conversion adds none. Apply your own content/PII filtering as appropriate. See the original [Depth Anything 3](https://github.com/ByteDance-Seed/depth-anything-3) release and [paper](https://arxiv.org/abs/2511.10647) for full dataset details. ## Performance Measured on a **Pixel 8a** (Tensor G3, Android 16) with the standard TFLite [`benchmark_model`](https://ai.google.dev/edge/litert/models/measurement) 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) | 144 / 1460 | 6882.8 ms | | TFLite `benchmark_model` | CPU (XNNPACK, 4 threads) | — | 3947.2 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 (3947.2 ms on CPU against 6882.8 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 (144 / 1460); the remainder runs on the CPU and the split costs a per-partition round trip. ## Snapdragon NPU (Hexagon) The NPU is **1.76x faster** than the GPU (137.7 ms against 242.7 ms) and loads 6.12x faster (283 ms against 1733 ms). | backend | inference (median / min) | load | |---|---:|---:| | NPU (Hexagon v81) | 137.7 ms / 135.5 ms | 283 ms | | GPU (Adreno) | 242.7 ms / 230.8 ms | 1733 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. Every run held thermal status `NONE` throughout. Headroom 0.64-0.75, where 1.0 is the throttling threshold. The NPU rows here ran artifacts compiled ahead of time for SM8850 with QAIRT 2.47.0; the GPU rows ran the published files as they are. 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](https://github.com/john-rocky/hf-to-litertlm/blob/main/docs/android-npu.md), and we did not measure it here. GPU wiring is in the [GPU recipe](https://github.com/john-rocky/hf-to-litertlm/blob/main/docs/android-gpu.md). ## 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`](https://ai.google.dev/edge/litert/models/measurement) 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 | |---|---:|---:|---:|---:| | `da3_small_gpu_fp16.tflite` | 4,201.3 ms | 4,055.0–4,251.9 ms | 109 | 445 MB | ## License Apache-2.0, inherited from the upstream [Depth Anything 3](https://github.com/ByteDance-Seed/depth-anything-3). This is a format conversion; all credit to the original authors (ByteDance-Seed).