Skip to content

DepthAnythingWorker.kt

src/jvmMain/kotlin/afield/ai/inference/onnx/DepthAnythingWorker.kt · 247 lines

@file:afield.runtime.completion.CompletionCategory("Machine learning")

package afield.ai.inference.onnx

import afield.app.diagnostics.logging.lprintln
import java.nio.ByteBuffer
import java.nio.ByteOrder

DepthAnything on its own thread, so a 100–400ms inference cannot stall the renderer.

Depth Anything Small costs ≈101ms at 266x266 and ≈388ms at 518x518 on a CPU execution provider; calling DepthAnything.infer from a render loop turns 60fps into 2.6fps. So the renderer gets staleness instead of latency: the most recent depth map that happens to be finished, which at 266x266 is six frames old at 60fps and looks fine for displacement, parallax and depth-keyed compositing, and looks like lag for anything a hand is steering directly.

// once
val depth = DepthAnythingWorker(width = 266, height = 266)
val tex = StreamingTexture(depth.depthFp16, depth.width, depth.height,
                           imageFormat = VK10.VK_FORMAT_R16_SFLOAT)
// per frame
depth.submit(webcam.frameBuffer, 640, 480)   // returns immediately, drops old frames
if (depth.consume()) tex.recordUpload(commandBuffer)

Frames are dropped, not queued

DepthAnythingWorker.submit overwrites any frame that has not been picked up yet. A queue would be wrong here: it would grow without bound whenever inference is slower than capture, which is always, and every frame it held would be more stale than the one behind it. Dropping means the model is always working on the newest frame available when it became free.

Why consume copies

DepthAnythingWorker.depthFp16 has a fixed identity because StreamingTexture captures its source buffer once, at construction. The worker therefore cannot write into it directly — the renderer could be reading it mid-write and would show a torn map with a visible seam. Instead the worker publishes into an internal buffer, and DepthAnythingWorker.consume copies that into DepthAnythingWorker.depthFp16 under the lock, on the calling thread. The copy is ~537KB at 518x518, tens of microseconds, and it is the only work the render thread does.

class DepthAnythingWorker

Runs DepthAnything on a background thread and hands the renderer the newest finished depth map.

@param width model input width, a multiple of DepthAnything.PATCH @param height model input height, likewise @param model which weights to use @param provider execution provider to request @param invert see DepthAnything.invert @param adaptation see DepthAnything.adaptation

class DepthAnythingWorker(
    val width: Int = 266,
    val height: Int = 266,
    model: OnnxModel = DepthAnything.FP32,
    provider: OnnxRuntime.OnnxProvider = OnnxRuntime.OnnxProvider.AUTO,
    invert: Boolean = false,
    adaptation: Float = 0.1f,
) : AutoCloseable {

    private val estimator = DepthAnything(width, height, model, provider, adaptation, invert)

val activeProvider: String

The provider actually in force.

    val activeProvider: String
        get() = estimator.activeProvider

val depthFp16: ByteBuffer

The most recently consumed depth map, fp16 in [0,1], width*height*2 bytes.

Only mutated by consume, so it is stable for the whole of a frame's rendering. Zero-filled until the first consume returns true.

    val depthFp16: ByteBuffer = ByteBuffer.allocateDirect(width * height * 2).order(ByteOrder.LITTLE_ENDIAN)

Deliberately java.lang.Object rather than the Any() the compiler suggests: this lock is waited on and notified, and wait/notifyAll are Java monitor methods that kotlin.Any does not expose.

    @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") private val lock = Object()

Input staging. Sized on first submit; the webcam does not change size mid-stream.

    private var pendingInput: ByteBuffer? = null
    private var workInput: ByteBuffer? = null
    private var pendingW = 0
    private var pendingH = 0
    private var inputReady = false

    private val publishedOutput: ByteBuffer =
        ByteBuffer.allocateDirect(width * height * 2).order(ByteOrder.LITTLE_ENDIAN)
    private var outputReady = false

    @Volatile private var running = true

var framesSubmitted: Long

Frames handed to submit, including ones later dropped.

    @Volatile
    var framesSubmitted: Long = 0L
        private set

var framesInferred: Long

Frames actually inferred. The difference from framesSubmitted is the drop count.

    @Volatile
    var framesInferred: Long = 0L
        private set

var framesDropped: Long

Frames overwritten in staging because inference was still busy when a newer one arrived.

    @Volatile
    var framesDropped: Long = 0L
        private set

var lastInferenceMs: Long

Wall time of the most recent inference, in milliseconds.

    @Volatile
    var lastInferenceMs: Long = 0L
        private set

var lastStats: DepthStats?

Normalisation window of the most recent inference, or null before the first one completes.

    @Volatile
    var lastStats: DepthStats? = null
        private set

var lastError: Throwable?

Whatever the worker thread last failed with, if anything.

    @Volatile
    var lastError: Throwable? = null
        private set

    private val thread = Thread({ loop() }, "DepthAnythingWorker-${width}x$height").apply {
        isDaemon = true
        // Below the render and audio threads on purpose: a late depth map is a cosmetic problem, a
        // late frame is not.
        priority = Thread.NORM_PRIORITY - 1
        start()
    }

init

    init {
        // A worker made by live box code belongs to that run, including setup failures and Stop.
        afield.runtime.lifecycle.RunScope.current()?.own(this)
    }

fun submit(rgba: ByteBuffer, srcW: Int, srcH: Int)

Offer a frame. Returns immediately.

@param rgba tightly packed RGBA8, srcW*srcH*4 bytes readable from index 0. Copied before this returns, so the caller may overwrite it — which is what a webcam capture thread will do.

    fun submit(rgba: ByteBuffer, srcW: Int, srcH: Int) {
        require(srcW > 0 && srcH > 0) { "source must be non-empty, got ${srcW}x$srcH" }
        val needed = srcW * srcH * 4
        require(rgba.capacity() >= needed) {
            "RGBA buffer too small: need $needed bytes for ${srcW}x$srcH, have ${rgba.capacity()}"
        }

        synchronized(lock) {
            if (!running) return
            var target = pendingInput
            if (target == null || target.capacity() < needed) {
                target = ByteBuffer.allocateDirect(needed).order(ByteOrder.nativeOrder())
                pendingInput = target
            }
            // Absolute-indexed copy: leaves the caller's position/limit untouched, which matters
            // because the webcam's buffer is shared and its position is not ours to move.
            for (i in 0 until needed) target.put(i, rgba.get(i))
            pendingW = srcW
            pendingH = srcH
            if (inputReady) framesDropped++
            inputReady = true
            framesSubmitted++
            lock.notifyAll()
        }
    }

fun consume(): Boolean

Publish a finished depth map into depthFp16, if one is waiting.

Call once per rendered frame, and upload only when it returns true — re-uploading an unchanged texture is pure cost.

@return true if depthFp16 changed

    fun consume(): Boolean = synchronized(lock) {
        if (!outputReady) return false
        depthFp16.clear()
        publishedOutput.clear()
        depthFp16.put(publishedOutput)
        depthFp16.flip()
        publishedOutput.clear()
        outputReady = false
        true
    }

override fun close()

Stop the worker thread and release the model's buffers. The cached session is left alone.

body — 58 lines, from line 189
    override fun close() {
        synchronized(lock) {
            running = false
            lock.notifyAll()
        }
        runCatching { thread.join(2_000) }
    }

    private fun loop() {
        while (true) {
            var input: ByteBuffer? = null
            var w = 0
            var h = 0
            synchronized(lock) {
                while (running && !inputReady) lock.wait()
                if (!running) return
                val pending = pendingInput
                if (pending != null) {
                    val needed = pendingW * pendingH * 4
                    var work = workInput
                    if (work == null || work.capacity() < needed) {
                        work = ByteBuffer.allocateDirect(needed).order(ByteOrder.nativeOrder())
                        workInput = work
                    }
                    // Copy out of the staging buffer so submit() can keep accepting frames while
                    // this one is being inferred.
                    for (i in 0 until needed) work.put(i, pending.get(i))
                    w = pendingW
                    h = pendingH
                    input = work
                }
                inputReady = false
            }
            val frame = input ?: continue

            try {
                val stats = estimator.infer(frame, w, h)
                synchronized(lock) {
                    publishedOutput.clear()
                    estimator.depthFp16.clear()
                    publishedOutput.put(estimator.depthFp16)
                    publishedOutput.flip()
                    estimator.depthFp16.clear()
                    outputReady = true
                }
                lastStats = stats
                lastInferenceMs = estimator.lastInferenceMs
                framesInferred++
                lastError = null
            } catch (t: Throwable) {
                lastError = t
                lprintln("warning,onnx", "depth inference failed: ${t.message ?: t.javaClass.name}")
                // Do not spin on a persistent failure -- a broken model would otherwise burn a core.
                Thread.sleep(250)
            }
        }
    }
}