Air Painting
Wipe fog off the glass with your hands. ONNX Runtime Web tracks both palms on WebGPU to clear a frosted camera feed.
/**
* Browser coordinator for the air-painting example.
*
* ONNX Runtime Web owns the WebGPU device, vgpu adopts it, and two sessions —
* a palm detector and a hand landmark model — share it. Both model **inputs**
* are built by a compute shader straight into GPU buffers, and the landmark
* **outputs** are consumed by WGSL through a non-owning wrap. On a tracked frame
* nothing crosses to the CPU except a single float of hand presence per hand.
*
* Per tracked result, serialized and latest-frame-wins:
*
* camera texture -> hand-crop.wgsl (ROI from GPU) -> flush ->
* landmark.run x{0,1,2} -> retain outputs -> wrapBuffer ->
* submit hand+paint -> await queue.flush() -> wrapper.dispose()
*
* The ROI those crops use was written by `hand.wgsl` from the *previous* frame's
* landmarks. That loopback is what lets the detector be skipped, and it is why
* the host can drive the whole thing without ever learning where the hand is.
*
* On a reacquisition frame the detector runs first and its outputs **are** read
* back to the CPU, because anchor decoding and weighted NMS are host work with
* no GPU implementation here. That readback is the one honest cost in this
* design; it is rare by construction and it is measured separately in the status
* line rather than averaged into the tracked figure.
*
* Two loops, deliberately decoupled: a continuous rAF display loop that
* composites the newest frame plus the persistent mask, and this single-flight
* inference loop. A 15 Hz hand never stalls a 60 Hz video.
*/
import type { Gpu, Surface } from 'vgpu';
import { surface as createSurface } from 'vgpu';
import type { BrowserRendererOptions, ExampleRenderer, RenderSize } from '../../lib/example-renderer';
import {
assertGpuTensor,
createSharedDeviceSession,
createSiblingSession,
OrtInitCancelled,
withWrappedTensors,
type OrtTensor,
type SharedDeviceSession,
type SharedDeviceStage,
type SiblingSession,
} from '../../lib/ort-webgpu';
import type { CameraSource } from './camera-source';
import { createInferenceScheduler, type InferenceScheduler } from './inference-scheduler';
import {
DETECTOR_BOXES_DIMS,
DETECTOR_BOXES_OUTPUT,
DETECTOR_INPUT_DIMS,
DETECTOR_INPUT_NAME,
DETECTOR_SCORES_DIMS,
DETECTOR_SCORES_OUTPUT,
DETECTOR_URL,
LANDMARK_INPUT_DIMS,
LANDMARK_INPUT_NAME,
LANDMARK_POINTS_DIMS,
LANDMARK_POINTS_OUTPUT,
LANDMARK_PRESENCE_OUTPUT,
LANDMARK_SIZE,
LANDMARK_URL,
MAX_HANDS,
} from './hand-model-contract';
import {
computeLetterbox,
decodeDetections,
detectionToSquareRoi,
mcpCentroid,
roiToSource,
ssdAnchors,
weightedNms,
type HandRoi,
} from './hand-pipeline';
import { createHandTracker, type HandCandidate, type HandTracker } from './hand-tracker';
import { createVisualPipeline, type HandResultInput, type VisualPipeline } from './visual-pipeline';
export type AirPaintPhase =
| 'initializing'
| 'waiting-for-hand'
| 'painting'
| 'unsupported'
| 'error';
export interface AirPaintStatus {
readonly phase: AirPaintPhase;
readonly detail?: string;
/** Completed inferences. */
readonly runs: number;
/** Rolling inference rate in Hz; the plan's floor is 15. */
readonly inferenceHz?: number;
/** How many of those runs had to rerun the palm detector. */
readonly acquisitions: number;
}
export interface AirPaintRendererOptions extends BrowserRendererOptions {
/**
* A camera already acquired from a user gesture. The renderer takes ownership
* and disposes it, which stops the tracks and the camera indicator.
*/
readonly camera: CameraSource;
readonly onStatus?: (status: AirPaintStatus) => void;
}
export interface AirPaintRenderer extends ExampleRenderer {
/** Zeroes the paint mask and breaks stroke continuity. Nothing else changes. */
clear(): void;
}
/** Rolling inference-rate estimate over a fixed window. */
function createRateMeter(windowMs = 1000) {
let count = 0;
let windowStart: number | undefined;
let last: number | undefined;
return {
sample(nowMs: number): number | undefined {
windowStart ??= nowMs;
count++;
const elapsed = nowMs - windowStart;
if (elapsed < windowMs) return last;
last = (count * 1000) / elapsed;
count = 0;
windowStart = nowMs;
return last;
},
};
}
const STAGE_DETAIL: Record<SharedDeviceStage, string> = {
runtime: 'Loading ONNX Runtime Web…',
model: 'Fetching the 10 MB hand landmark model…',
session: 'Creating the WebGPU session…',
device: 'Adopting the runtime device…',
ready: 'Waiting for a hand…',
};
export function createRenderer(options: AirPaintRendererOptions): AirPaintRenderer {
let disposed = false;
let reportedError = false;
let shared: SharedDeviceSession | undefined;
let detector: SiblingSession | undefined;
let gpu: Gpu | undefined;
let surface: Surface | undefined;
let pipeline: VisualPipeline | undefined;
let tracker: HandTracker | undefined;
let scheduler: InferenceScheduler<number> | undefined;
let shutdown: Promise<void> | undefined;
let observer: ResizeObserver | undefined;
let displayHandle = 0;
let pendingSize: RenderSize | undefined;
let resizeFrame = 0;
let lastDpr = typeof window === 'undefined' ? 1 : window.devicePixelRatio;
let loggedEvidence = false;
/** Reusable input tensors over the pipeline's persistent crop buffers. */
let detectorInputTensor: OrtTensor | undefined;
let landmarkInputTensors: OrtTensor[] = [];
let runs = 0;
let acquisitions = 0;
let inferenceHz: number | undefined;
let lastResultMs: number | undefined;
let pendingReset = false;
let copiedToken = -1;
let hasFrame = false;
let painting = false;
const rate = createRateMeter();
// 2,016 anchor centres, computed once: they depend only on the input size.
const anchors = ssdAnchors();
const status = (phase: AirPaintPhase, detail?: string) => {
try {
options.onStatus?.({ phase, detail, runs, inferenceHz, acquisitions });
} catch {
// Status reporting must never break rendering.
}
};
const handleFailure = (error: unknown) => {
if (error instanceof OrtInitCancelled || disposed) return;
if (reportedError) return;
reportedError = true;
status('error', error instanceof Error ? error.message : String(error));
try {
options.onError?.(error);
} catch {
// The host's reporter must not mask the original failure.
}
};
const applyResize = () => {
resizeFrame = 0;
const size = pendingSize;
pendingSize = undefined;
if (disposed || !size || !surface) return;
try {
surface.resize([
Math.max(1, Math.round(size.width * size.dpr)),
Math.max(1, Math.round(size.height * size.dpr)),
]);
// The mask lives in normalized brush space, so nothing painted is lost.
} catch (error) {
handleFailure(error);
}
};
const resize = (size: RenderSize) => {
if (disposed || size.width <= 0 || size.height <= 0) return;
pendingSize = size;
if (!resizeFrame) resizeFrame = requestAnimationFrame(applyResize);
};
const measure = () => {
const rect = options.canvas.getBoundingClientRect();
resize({
width: rect.width,
height: rect.height,
dpr: Math.min(2, Math.max(1, window.devicePixelRatio || 1)),
});
};
const onWindowResize = () => {
if (window.devicePixelRatio === lastDpr) return;
lastDpr = window.devicePixelRatio;
measure();
};
/**
* Copies the newest decoded camera frame into the device texture, at most once
* per frame token.
*
* Both loops need it — the display loop to composite and the inference loop to
* crop — so the copy is centralized here rather than duplicated. Without this
* the two loops would race and the models could sample a texture from before
* the frame the scheduler was woken for.
*/
const ensureFrameCopied = () => {
if (!pipeline) return;
if (options.camera.token === copiedToken) return;
copiedToken = options.camera.token;
pipeline.copyExternalFrame(options.camera.frame);
hasFrame = true;
};
/**
* Display loop. Never awaits inference: it copies the newest decoded frame and
* composites the persistent mask, so video stays smooth between hand results.
*/
const drawDisplayFrame = () => {
displayHandle = 0;
if (disposed || !pipeline || !surface) return;
try {
ensureFrameCopied();
pipeline.renderVisualFrame(surface, {
dpr: Math.min(2, Math.max(1, window.devicePixelRatio || 1)),
hasFrame,
showCursor: painting,
});
} catch (error) {
handleFailure(error);
return;
}
displayHandle = requestAnimationFrame(drawDisplayFrame);
};
/**
* Full-frame palm search. Only runs when a track has been lost.
*
* This is the one place landmark-adjacent numbers reach the CPU: the detector's
* 2,016 anchors have to be decoded and suppressed on the host, so its outputs
* are requested as CPU tensors. It is deliberately not on the tracked path.
*/
async function runDetector(): Promise<void> {
if (!pipeline || !detector || !tracker || !detectorInputTensor || !gpu) return;
acquisitions++;
pipeline.cropDetectorInput();
// Submit the crop before ORT enqueues its own work on the same queue.
await gpu.device.queue.flush();
if (disposed) return;
const outputs = await detector.session.run(
{ [detector.inputNames[0] ?? DETECTOR_INPUT_NAME]: detectorInputTensor },
[DETECTOR_BOXES_OUTPUT, DETECTOR_SCORES_OUTPUT],
);
if (disposed) return;
const boxesTensor = outputs[DETECTOR_BOXES_OUTPUT];
const scoresTensor = outputs[DETECTOR_SCORES_OUTPUT];
try {
const boxes = boxesTensor?.data as Float32Array | undefined;
const scores = scoresTensor?.data as Float32Array | undefined;
if (!boxes || !scores) {
throw new Error('The palm detector returned no CPU-readable outputs.');
}
const letterbox = computeLetterbox(pipeline.sourceWidth, pipeline.sourceHeight);
const detections = weightedNms(decodeDetections(boxes, scores, anchors), {
maxDetections: MAX_HANDS,
});
const candidates: HandCandidate[] = detections.map((detection) => {
const roi = roiToSource(detectionToSquareRoi(detection), letterbox);
return { roi, centroid: { x: roi.cx, y: roi.cy }, score: detection.score };
});
for (const slot of tracker.acquire(candidates)) {
const roi = tracker.slots[slot]?.pendingRoi;
if (roi) {
pipeline.writeRoi(slot, roi);
tracker.clearPending(slot);
}
}
} finally {
boxesTensor?.dispose();
scoresTensor?.dispose();
}
}
/** One inference, from a fresh camera token to painted mask. */
async function runOnce(_token: number): Promise<void> {
if (disposed || !shared || !gpu || !pipeline || !tracker) return;
const { session } = shared;
ensureFrameCopied();
if (tracker.needsDetector()) {
await runDetector();
if (disposed) return;
}
const slots = tracker.activeSlots();
const landmarkOutputs: (OrtTensor | undefined)[] = [];
const results: HandResultInput[] = Array.from({ length: MAX_HANDS }, () => ({
presence: 0,
}));
try {
// Sequential on purpose: the two runs share one device and one queue, and
// overlapping them would only contend. The scheduler already guarantees
// that no *other* frame is in flight.
for (const slot of slots) {
const input = landmarkInputTensors[slot];
if (!input) continue;
pipeline.cropLandmarkInput(slot);
await gpu.device.queue.flush();
if (disposed) return;
const outputs = await session.run(
{ [session.inputNames[0] ?? LANDMARK_INPUT_NAME]: input },
[LANDMARK_POINTS_OUTPUT, LANDMARK_PRESENCE_OUTPUT],
);
if (disposed) return;
const points = outputs[LANDMARK_POINTS_OUTPUT];
const presenceTensor = outputs[LANDMARK_PRESENCE_OUTPUT];
landmarkOutputs[slot] = points;
// Presence is a single float and is requested on the CPU. It is a
// control signal, not a landmark: it decides whether the brush paints
// and whether the detector has to run again, and both of those are host
// decisions. The 21 landmarks it accompanies never leave the device.
const presence = Number((presenceTensor?.data as Float32Array | undefined)?.[0] ?? 0);
presenceTensor?.dispose();
results[slot] = { presence };
tracker.noteResult(slot, presence);
}
for (let slot = 0; slot < MAX_HANDS; slot++) {
if (!slots.includes(slot)) tracker.noteMissing(slot);
}
// Closes the frame for the detector's retry throttle, which counts frames
// rather than slot updates.
tracker.endFrame();
const nowMs = performance.now();
const dt = lastResultMs === undefined ? 1 / 30 : (nowMs - lastResultMs) / 1000;
lastResultMs = nowMs;
const reset = pendingReset;
pendingReset = false;
// Every landmark buffer that actually ran, in slot order, so the wrapped
// array indices line up with the shader's two bindings.
const raws: GPUBuffer[] = [];
const wrappedSlots: number[] = [];
for (let slot = 0; slot < MAX_HANDS; slot++) {
const tensor = landmarkOutputs[slot];
if (!tensor) continue;
raws.push(
assertGpuTensor(tensor, {
dataType: 'float32',
dims: [...LANDMARK_POINTS_DIMS],
label: `air-painting landmarks slot ${slot}`,
}),
);
wrappedSlots.push(slot);
}
await withWrappedTensors(gpu, raws, (wrapped) => {
if (!loggedEvidence && wrapped.length > 0) {
loggedEvidence = true;
// One-time diagnostic that makes the interop contract observable
// without reading a single landmark back to the CPU.
console.info('[air-painting] interop', {
deviceIdentity: gpu!.gpu === shared!.device,
siblingSessions: 2,
inputLocation: 'gpu-buffer',
inputDims: [...LANDMARK_INPUT_DIMS],
outputLocation: 'gpu-buffer',
outputDims: [...LANDMARK_POINTS_DIMS],
outputBytes: raws[0]?.size,
wrapperRawIdentity: wrapped[0]?.gpu === raws[0],
cpuReadback: 'hand presence only (1 float per hand)',
preprocessing: 'gpu',
});
}
const consumed = results.map((result, slot) => {
const index = wrappedSlots.indexOf(slot);
return index >= 0 ? { ...result, landmarks: wrapped[index] } : result;
});
pipeline!.consumeHandLandmarks(consumed, dt, { reset });
});
runs++;
inferenceHz = rate.sample(nowMs);
painting = tracker.activeSlots().length > 0;
status(painting ? 'painting' : 'waiting-for-hand');
} finally {
// Nested and unconditional: the borrow is only safe because these run on
// success, on error, and on cancellation alike. The *input* tensors are
// long-lived views of pipeline-owned buffers and are disposed at teardown.
for (const tensor of landmarkOutputs) tensor?.dispose();
}
}
const ready = (async () => {
status('initializing', STAGE_DETAIL.runtime);
shared = await createSharedDeviceSession({
modelUrl: LANDMARK_URL,
label: 'air-painting hand landmarks',
isCancelled: () => disposed,
onStage: (stage) => status('initializing', STAGE_DETAIL[stage]),
sessionOptions: {
// The 21 landmarks stay on the device; the presence scalar comes back on
// the CPU because it is a control signal the host has to act on.
preferredOutputLocation: {
[LANDMARK_POINTS_OUTPUT]: 'gpu-buffer',
[LANDMARK_PRESENCE_OUTPUT]: 'cpu',
},
},
});
if (disposed) throw new OrtInitCancelled();
status('initializing', 'Fetching the 4 MB palm detector…');
detector = await createSiblingSession(shared, {
modelUrl: DETECTOR_URL,
label: 'air-painting palm detector',
isCancelled: () => disposed,
// Anchor decoding and weighted NMS are host work, so this stage's outputs
// are read back by design. It runs only on reacquisition.
preferredOutputLocation: 'cpu',
});
if (disposed) throw new OrtInitCancelled();
gpu = shared.gpu;
surface = createSurface(gpu, options.canvas, { autoResize: false });
pipeline = createVisualPipeline(gpu, {
sourceWidth: options.camera.width,
sourceHeight: options.camera.height,
label: 'air-painting',
});
tracker = createHandTracker({
sourceWidth: options.camera.width,
sourceHeight: options.camera.height,
});
// Built once over the pipeline's persistent crop buffers, which never change
// identity, so no tensor is allocated or freed on the hot path.
detectorInputTensor = shared.ort.Tensor.fromGpuBuffer(pipeline.detectorInput.gpu, {
dataType: 'float32',
dims: [...DETECTOR_INPUT_DIMS],
});
landmarkInputTensors = Array.from({ length: MAX_HANDS }, (_unused, slot) =>
shared!.ort.Tensor.fromGpuBuffer(pipeline!.landmarkInput(slot).gpu, {
dataType: 'float32',
dims: [...LANDMARK_INPUT_DIMS],
}),
);
scheduler = createInferenceScheduler<number>({
run: runOnce,
onError: handleFailure,
});
measure();
if (typeof ResizeObserver === 'function') {
observer = new ResizeObserver(measure);
observer.observe(options.canvas);
}
window.addEventListener('resize', onWindowResize);
options.camera.start((token) => {
// Fresh decoded frame: request one inference. Older pending tokens are
// dropped by the scheduler on purpose.
scheduler?.request(token);
});
displayHandle = requestAnimationFrame(drawDisplayFrame);
status('waiting-for-hand', STAGE_DETAIL.ready);
})().catch((error: unknown) => {
handleFailure(error);
});
return {
ready,
invalidate() {
// The display loop is continuous; there is nothing to coalesce.
},
resize,
clear() {
if (disposed) return;
try {
pipeline?.clearMask();
// Continuity is broken on the next consumed result, so no connector is
// drawn from where the hand was before the clear.
pendingReset = true;
} catch (error) {
handleFailure(error);
}
},
dispose() {
if (disposed) return;
disposed = true;
if (displayHandle) cancelAnimationFrame(displayHandle);
displayHandle = 0;
if (resizeFrame) cancelAnimationFrame(resizeFrame);
resizeFrame = 0;
observer?.disconnect();
observer = undefined;
if (typeof window !== 'undefined') window.removeEventListener('resize', onWindowResize);
// Stop both producers before draining, so nothing new is submitted.
options.camera.dispose();
const draining = scheduler?.stop();
shutdown ??= (async () => {
try {
await ready.catch(() => undefined);
await draining?.catch(() => undefined);
} finally {
// Input tensors are views of pipeline buffers, so they have to go
// before the pipeline that owns those buffers.
for (const tensor of landmarkInputTensors) {
try {
tensor.dispose();
} catch {
// Teardown must continue even if a resource is already gone.
}
}
landmarkInputTensors = [];
try {
detectorInputTensor?.dispose();
} catch {
// Same.
}
detectorInputTensor = undefined;
try {
pipeline?.dispose();
} catch {
// Same.
}
try {
surface?.dispose();
} catch {
// Same.
}
// The sibling holds no device of its own, so it is released first and
// the facade-owning session last. vgpu never destroys ORT's device.
await detector?.release().catch(() => undefined);
await shared?.release().catch(() => undefined);
}
})();
void shutdown;
},
};
}
/** Re-exported so tests can build the same measurement the shader produces. */
export function referenceMeasurement(
landmarks: ArrayLike<number>,
roi: HandRoi,
sourceWidth: number,
sourceHeight: number,
): { readonly x: number; readonly y: number } {
const points: { x: number; y: number }[] = [];
for (let i = 0; i < 21; i++) {
const x = (landmarks[i * 3] ?? 0) / LANDMARK_SIZE;
const y = (landmarks[i * 3 + 1] ?? 0) / LANDMARK_SIZE;
const c = Math.cos(roi.rotation);
const s = Math.sin(roi.rotation);
const dx = (x - 0.5) * roi.size;
const dy = (y - 0.5) * roi.size;
points.push({ x: roi.cx + dx * c - dy * s, y: roi.cy + dx * s + dy * c });
}
const centroid = mcpCentroid(points);
return { x: 1 - centroid.x / sourceWidth, y: centroid.y / sourceHeight };
}