WebcamPerceptionDemo.kt
src/jvmMain/kotlin/afield/examples/onnx/WebcamPerceptionDemo.kt · 367 lines
package afield.examples.onnx
import afield.ai.inference.onnx.ClipImage
import afield.ai.inference.onnx.ClipText
import afield.ai.inference.onnx.ClipVector
import afield.ai.inference.onnx.DepthAnythingWorker
import afield.ai.inference.onnx.OnnxRuntime
import afield.media.image.util.PngEncoder
import org.bytedeco.javacv.OpenCVFrameConverter
import org.bytedeco.javacv.OpenCVFrameGrabber
import org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2RGBA
import org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGRA2RGBA
import org.bytedeco.opencv.global.opencv_imgproc.COLOR_GRAY2RGBA
import org.bytedeco.opencv.global.opencv_imgcodecs.imread
import org.bytedeco.opencv.global.opencv_imgproc.cvtColor
import org.bytedeco.opencv.opencv_core.Mat
import org.lwjgl.system.MemoryUtil
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.Paths
import kotlin.math.min
import kotlin.math.roundToInt
object WebcamPerceptionDemo
Depth and CLIP, on real webcam frames.
Not a FIELD_TEST: it needs a camera and a human to point it at something, so it cannot run in the
smoke catalog. It exists to answer the one question the smokes cannot — does this work on an actual
image — and to make the answer visible, by writing a side-by-side PNG of the frame and its depth map.
run-jvm-main afield.examples.onnx.WebcamPerceptionDemo
Arguments, all optional and positional: output PNG path, camera index, frame count.
There is no Vulkan here on purpose. WebcamTexture needs a Renderer, and the point of this demo is
the perception, not the presentation — the CPU frame buffer a camera produces is exactly what the
models want, which is the same observation that made
afield.graphics.texture.video.WebcamTexture.withLatestFrame worth adding.
object WebcamPerceptionDemo {
Prompts to classify each frame against. Deliberately a scene-level set, not object labels.
private val PROMPTS = listOf(
"a person sitting at a desk",
"an empty room",
"a close-up of a face",
"a computer screen",
"a dark or blurry image"
)
fun main(args: Array<String>)
body — 132 lines, from line 55
@JvmStatic
fun main(args: Array<String>) {
val outPath = Paths.get(args.getOrNull(0) ?: "/tmp/field-webcam-perception.png")
val source = args.getOrNull(1) ?: "0"
val frameCount = args.getOrNull(2)?.toIntOrNull() ?: 24
// Encode the prompts before touching the camera, so a slow first model load is not blamed on
// the grabber and the camera light is not on any longer than necessary.
print("encoding ${PROMPTS.size} prompts… ")
val startedText = System.nanoTime()
val prompts: Map<String, ClipVector> = ClipText.encodeAll(PROMPTS)
println("${(System.nanoTime() - startedText) / 1_000_000}ms")
val cameraIndex = source.toIntOrNull()
if (cameraIndex == null) {
// A still image, for when there is no camera to be had — including the case that matters on
// macOS, where the camera exists but this process has not been granted access to it.
runOnStill(Paths.get(source), outPath, prompts)
return
}
println("webcam perception: camera=$cameraIndex frames=$frameCount out=$outPath")
val grabber = OpenCVFrameGrabber(cameraIndex)
val converter = OpenCVFrameConverter.ToMat()
try {
grabber.start()
} catch (t: Throwable) {
System.err.println("could not open camera $cameraIndex: ${t.message ?: t.javaClass.name}")
System.err.println(
"On macOS the camera grant belongs to the *responsible app* — the outermost application " +
"in this process's ancestry, not the JVM and not necessarily a terminal. Grant Camera " +
"to the app named below in System Settings > Privacy & Security > Camera, then restart " +
"that app (a cached denial survives until it does)."
)
responsibleApp()?.let { System.err.println(" responsible app: $it") }
?: System.err.println(" (could not determine the responsible app from the process tree)")
OnnxRuntime.closeAll()
kotlin.system.exitProcess(2)
}
try {
val camW = grabber.imageWidth
val camH = grabber.imageHeight
println("camera opened: ${camW}x$camH")
val clipImage = ClipImage()
val depth = DepthAnythingWorker(width = 266, height = 266)
var latestRgba: ByteBuffer? = null
var actualW = 0
var actualH = 0
var nonBlackFrames = 0
try {
repeat(frameCount) { frameIndex ->
val frame = grabber.grab()
if (frame == null) {
println(" frame $frameIndex: null")
return@repeat
}
val mat = converter.convert(frame) ?: return@repeat
val w = mat.cols()
val h = mat.rows()
if (w <= 0 || h <= 0) return@repeat
val rgba = toRgba(mat, w, h)
latestRgba = rgba
actualW = w
actualH = h
// A camera that is open but blocked hands back solid black; say so rather than
// reporting confident nonsense about an all-zero image.
val brightness = meanLuma(rgba, w, h)
if (brightness > 2.0) nonBlackFrames++
depth.submit(rgba, w, h)
val embedding = clipImage.encode(rgba, w, h)
val ranked = embedding.classify(prompts)
val depthReady = depth.consume()
val stats = depth.lastStats
// Only every few frames, so the log stays readable.
if (frameIndex % 4 == 0 || frameIndex == frameCount - 1) {
val best = ranked.first()
println(
" frame %2d luma=%5.1f clip=%-28s %.3f clip=%3dms depth=%s"
.format(
frameIndex, brightness, "\"${best.first}\"", best.second,
clipImage.lastInferenceMs,
if (stats == null) "pending" else
"%3dms raw=[%.2f, %.2f]%s".format(
depth.lastInferenceMs, stats.rawMin, stats.rawMax,
if (depthReady) " new" else ""
)
)
)
}
}
// Let the last submitted frame finish so the picture matches the final log line.
val deadline = System.nanoTime() + 20_000_000_000L
while (!depth.consume() && System.nanoTime() < deadline) Thread.sleep(20)
println(
"frames with signal: $nonBlackFrames/$frameCount " +
"depth inferred=${depth.framesInferred} dropped=${depth.framesDropped}"
)
if (nonBlackFrames == 0) {
System.err.println(
"every frame was black — the camera opened but delivered no image. " +
"That is what a macOS camera-permission denial looks like from Java."
)
}
val rgba = latestRgba
if (rgba != null) {
val embedding = clipImage.encode(rgba, actualW, actualH)
report(embedding, prompts)
val png = montage(rgba, actualW, actualH, depth.depthFp16, depth.width, depth.height)
Files.createDirectories(outPath.toAbsolutePath().parent)
Files.write(outPath, png)
println()
println("wrote ${png.size / 1024}KB to $outPath")
}
} finally {
depth.close()
}
} finally {
runCatching { grabber.stop() }
runCatching { grabber.release() }
OnnxRuntime.closeAll()
}
}
The outermost .app bundle in this process's ancestry — who macOS holds responsible for a TCC
request, and therefore who has to be granted camera access.
Worth computing rather than guessing: a JVM launched from an agent session is a descendant of the agent's host application, so telling someone to grant permission to "your terminal" sends them to the wrong row in System Settings.
private fun responsibleApp(): String? {
var handle: ProcessHandle? = ProcessHandle.current()
var outermost: String? = null
var hops = 0
while (handle != null && hops < 32) {
val command = handle.info().command().orElse(null)
if (command != null && ".app/" in command) {
outermost = command.substringBefore(".app/") + ".app"
}
handle = handle.parent().orElse(null)
hops++
}
return outermost
}
The same analysis as the camera path, on one image decoded from disk.
source — 43 lines, from line 212
private fun runOnStill(imagePath: Path, outPath: Path, prompts: Map<String, ClipVector>) {
println("still-image perception: in=$imagePath out=$outPath")
val mat = imread(imagePath.toAbsolutePath().toString())
require(!mat.empty()) { "could not decode an image from $imagePath" }
val w = mat.cols()
val h = mat.rows()
println("image: ${w}x$h, ${mat.channels()} channels")
val rgba = toRgba(mat, w, h)
mat.release()
mat.close()
// An all-zero buffer here means the decode or the RGBA conversion failed, not that the picture
// is dark. Every number below would still be produced, and would still look plausible, so fail
// instead of reporting confident results about nothing.
val luma = meanLuma(rgba, w, h)
check(luma > 0.0) { "decoded image is entirely black — RGBA conversion produced no pixels" }
val clipImage = ClipImage()
val depth = DepthAnythingWorker(width = 266, height = 266)
try {
depth.submit(rgba, w, h)
val deadline = System.nanoTime() + 60_000_000_000L
while (!depth.consume() && System.nanoTime() < deadline) Thread.sleep(20)
depth.lastError?.let { error("depth failed: ${it.message}") }
val embedding = clipImage.encode(rgba, w, h)
println("luma=%.1f clip=%dms depth=%dms raw=[%.2f, %.2f]".format(
luma, clipImage.lastInferenceMs, depth.lastInferenceMs,
depth.lastStats?.rawMin ?: Float.NaN, depth.lastStats?.rawMax ?: Float.NaN
))
report(embedding, prompts)
val png = montage(rgba, w, h, depth.depthFp16, depth.width, depth.height)
Files.createDirectories(outPath.toAbsolutePath().parent)
Files.write(outPath, png)
println()
println("wrote ${png.size / 1024}KB to $outPath")
} finally {
depth.close()
OnnxRuntime.closeAll()
}
}
Zero-shot ranking plus two semantic axes, the part that is genuinely CLIP-specific.
source — 65 lines, from line 257
private fun report(embedding: ClipVector, prompts: Map<String, ClipVector>) {
println()
println("zero-shot over ${prompts.size} prompts:")
embedding.classify(prompts).forEach { (label, p) ->
println(" %-32s %6.2f%%".format(label, p * 100))
}
println()
println("semantic axes on the same frame (projection onto a direction in meaning):")
axis(prompts, "an empty room", "a person sitting at a desk").let {
println(" empty-room → person-at-desk %+.4f".format(embedding.project(it)))
}
axis(prompts, "a dark or blurry image", "a computer screen").let {
println(" dark → screen %+.4f".format(embedding.project(it)))
}
}
private fun axis(prompts: Map<String, ClipVector>, from: String, to: String): ClipVector =
prompts.getValue(from) axisTo prompts.getValue(to)
private fun toRgba(mat: Mat, w: Int, h: Int): ByteBuffer {
val rgbaMat = Mat()
when (mat.channels()) {
4 -> cvtColor(mat, rgbaMat, COLOR_BGRA2RGBA)
3 -> cvtColor(mat, rgbaMat, COLOR_BGR2RGBA)
1 -> cvtColor(mat, rgbaMat, COLOR_GRAY2RGBA)
else -> error("unsupported channel count ${mat.channels()}")
}
val bytes = w * h * 4
check(rgbaMat.isContinuous) { "expected a continuous Mat, got step ${rgbaMat.step()}" }
val pointer = rgbaMat.data() ?: error("converted Mat had no backing pixel buffer")
// JavaCPP leaves the capacity of Mat.data() unset, so `asByteBuffer()` returns an EMPTY buffer
// and any bounds-checked copy out of it silently moves zero bytes -- which reads downstream as
// a black camera rather than as a bug. Wrap the raw address with the size computed here, the
// way WebcamTexture.copyMatToBuffer already does.
val src = MemoryUtil.memByteBuffer(pointer.address(), bytes)
val out = ByteBuffer.allocateDirect(bytes).order(ByteOrder.nativeOrder())
out.put(src.duplicate())
out.rewind()
rgbaMat.release()
rgbaMat.close()
return out
}
private fun meanLuma(rgba: ByteBuffer, w: Int, h: Int): Double {
var total = 0L
var count = 0
// Sample a grid rather than every pixel; this is only a "is there an image at all" check.
var y = 0
while (y < h) {
var x = 0
while (x < w) {
val o = (y * w + x) shl 2
val r = rgba.get(o).toInt() and 0xff
val g = rgba.get(o + 1).toInt() and 0xff
val b = rgba.get(o + 2).toInt() and 0xff
total += (r * 299 + g * 587 + b * 114) / 1000
count++
x += 8
}
y += 8
}
return if (count == 0) 0.0 else total.toDouble() / count
}
Camera frame on the left, colour-mapped depth on the right, scaled to the same height.
private fun montage(
rgba: ByteBuffer,
camW: Int,
camH: Int,
depthFp16: ByteBuffer,
depthW: Int,
depthH: Int
): ByteArray {
val outH = camH
val depthOutW = (depthW.toDouble() / depthH * outH).roundToInt().coerceAtLeast(1)
val outW = camW + depthOutW
val pixels = IntArray(outW * outH)
for (y in 0 until outH) {
for (x in 0 until camW) {
val o = (y * camW + x) shl 2
val r = rgba.get(o).toInt() and 0xff
val g = rgba.get(o + 1).toInt() and 0xff
val b = rgba.get(o + 2).toInt() and 0xff
pixels[y * outW + x] = (0xff shl 24) or (r shl 16) or (g shl 8) or b
}
}
val depthShorts = depthFp16.asShortBuffer()
for (y in 0 until outH) {
val sy = (y.toDouble() / outH * depthH).toInt().coerceIn(0, depthH - 1)
for (x in 0 until depthOutW) {
val sx = (x.toDouble() / depthOutW * depthW).toInt().coerceIn(0, depthW - 1)
val t = ai.onnxruntime.platform.Fp16Conversions.fp16ToFloat(depthShorts.get(sy * depthW + sx))
pixels[y * outW + camW + x] = ramp(t.coerceIn(0f, 1f))
}
}
return PngEncoder.encode(pixels, outW, outH)
}
A compact dark-to-hot ramp; near is bright because the model's output is inverse depth.
private fun ramp(t: Float): Int {
val r = (255 * (t * 1.6f).coerceIn(0f, 1f)).toInt()
val g = (255 * ((t - 0.25f) * 1.6f).coerceIn(0f, 1f)).toInt()
val b = (255 * ((t - 0.6f) * 2.2f).coerceIn(0f, 1f)).toInt()
return (0xff shl 24) or (r shl 16) or (g shl 8) or b
}
}