Air Painting
Wipe fog off the glass with your hands. ONNX Runtime Web tracks both palms on WebGPU to clear a frosted camera feed.
/**
* Deterministic fixtures for the air-painting example.
*
* Three separate things live here, and they make different claims:
*
* 1. `HAND_EVIDENCE` — real measurements the committed model pair produced on
* real photographs, recorded so the geometry has something to be checked
* against that is not itself synthetic. The photos are not redistributed by
* the example; only these numbers are.
* 2. `createFixtureFrame()` — a license-clean camera stand-in, rasterized in pure
* TypeScript so the Node thumbnail, the unit tests and the no-camera visual
* demo all see byte-identical pixels with no webcam, network or codec.
* 3. `syntheticHandFrames()` — a 24-sample synthetic **two-handed** trajectory
* encoded as real `[1,63]` landmark buffers plus the ROIs they were cropped
* through. It drives the *production* `hand.wgsl` / `paint.wgsl` /
* `composite.wgsl` deterministically.
*
* (3) is a **visual** fixture. It proves the shaders, the inverse crop transform
* and the lifetime plumbing, and it proves nothing whatsoever about ORT interop
* — only a real browser can, and the example says so in its own copy.
*
* The synthetic landmarks are authored **backwards**, which is what lets the
* tests assert equality rather than proximity: a canonical hand is built with
* its MCP centroid at the exact centre of the crop, and the ROI is then placed
* with its centre on the point the path wants painted. Because the centre of a
* crop maps to the centre of its ROI under any rotation, the measured centroid
* lands on the authored point exactly, whatever angle the fixture uses.
*/
import { brushToSource } from './hand-preprocess';
import { LANDMARK_SIZE, MCP_LANDMARKS, NUM_LANDMARKS } from './hand-model-contract';
import { sourceToCrop, type HandRoi, type Vec2 } from './hand-pipeline';
/** Canned frame size; 16:9 like a typical webcam crop, small enough to author in TS. */
export const FIXTURE_FRAME_WIDTH = 640;
export const FIXTURE_FRAME_HEIGHT = 360;
export const FIXTURE_FRAME_BYTES = FIXTURE_FRAME_WIDTH * FIXTURE_FRAME_HEIGHT * 4;
/**
* Measurements the committed `palm-detector.onnx` + `hand-landmark.onnx` pair
* produced through the full host pipeline on two Creative-Commons photographs,
* via the ONNX Runtime CPU execution provider.
*
* These are recorded for one reason: they are the only numbers in this file that
* a bug in the geometry could not have produced, because they came out of the
* real models on real hands. See tools/models/mediapipe-hands/image-credits.md
* for the sources and licenses.
*
* The `presence` column is the interesting one. Two other candidate photographs
* scored 0.578 and 0.583 at the **detector** and came back with presence 0.044
* and 0.017 — confident-looking palms that the landmark model correctly refused
* to vouch for. That gap is why confidence in this example is hand presence and
* never a carried-over detector score.
*/
export interface HandEvidenceSample {
readonly image: string;
readonly hands: readonly {
/** Palm detector confidence after weighted NMS. */
readonly detectorScore: number;
/** Landmark model hand presence. This is what gates the brush. */
readonly presence: number;
/** ROI rotation in degrees; the fixtures were chosen to span a wide range. */
readonly rotationDegrees: number;
/** MCP centroid in source pixels. */
readonly centroid: readonly [number, number];
}[];
}
export const HAND_EVIDENCE: readonly HandEvidenceSample[] = [
{
image: 'Open Hands Facing The Heavens (CC BY-SA 4.0), 960px rendition',
hands: [
{ detectorScore: 0.866, presence: 0.99, rotationDegrees: 72.5, centroid: [862, 954] },
{ detectorScore: 0.735, presence: 0.995, rotationDegrees: -59.8, centroid: [136, 902] },
],
},
{
image: 'Pride.be 2018 DSC08078 (CC BY-SA 2.0), 960px rendition',
hands: [
{ detectorScore: 0.693, presence: 0.954, rotationDegrees: 87.8, centroid: [889, 1079] },
],
},
];
/** Deterministic 32-bit hash, so "noise" is reproducible on every platform. */
function hash2(x: number, y: number): number {
let h = (x * 374_761_393 + y * 668_265_263) | 0;
h = (h ^ (h >>> 13)) * 1_274_126_177;
h = h ^ (h >>> 16);
return (h >>> 0) / 4_294_967_295;
}
function smoothstep(edge0: number, edge1: number, value: number): number {
const t = Math.min(1, Math.max(0, (value - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
/**
* Rasterizes the canned "camera" frame as tightly packed RGBA8.
*
* Deliberately **not a person**. The example used to ship a synthetic figure and
* it looked like exactly what it was, so the stand-in is now an abstract
* composition chosen for one job: making the frost unmistakable.
*
* That job dictates the content. It is built from two frequency bands, because
* blur is only legible if there is something at the scale the blur destroys:
*
* - Fine concentric rings, ~15 px per cycle, are the carrier. A gaussian with a
* sigma of a few quarter-resolution texels annihilates them completely, so the
* frosted state is smooth and the wiped state is visibly crisp.
* - Three soft discs are the ballast. They are far wider than the blur kernel, so
* they survive it and the frosted state still reads as *something behind glass*
* rather than as a flat grey card.
*
* The ring wavelength is the one number here that is not free: at 15 px it is
* ~15 samples per cycle, comfortably band-limited, so the sharp state shows clean
* rings instead of the moire a tighter pattern would alias into.
*
* Neutral greyscale (r = g = b) and held in the mid range. The compositor lifts
* the frost toward white, and a frame that already ran hot would clip to a flat
* white sheet the moment it fogged.
*
* Coordinates here are **source** space (un-mirrored), exactly like a real
* camera frame.
*/
export function createFixtureFrame(
width = FIXTURE_FRAME_WIDTH,
height = FIXTURE_FRAME_HEIGHT,
): Uint8Array {
const pixels = new Uint8Array(width * height * 4);
// Disc radii are height-normalized, so x is scaled to keep them circular.
const aspect = width / height;
const ringCx = width * 0.5;
const ringCy = height * 0.52;
for (let y = 0; y < height; y++) {
const v = (y + 0.5) / height;
for (let x = 0; x < width; x++) {
const u = (x + 0.5) / width;
// Low band: three soft discs, wider than any blur kernel here, so the
// frosted state keeps a sense of depth instead of going flat.
let luma = 0.1;
luma += 0.2 * disc(u, v, 0.34, 0.44, 0.26, aspect);
luma += 0.26 * disc(u, v, 0.63, 0.6, 0.2, aspect);
luma += 0.12 * disc(u, v, 0.79, 0.29, 0.13, aspect);
// High band: concentric rings measured in real pixels, so the wavelength
// is exact regardless of frame size. This is the detail the frost eats.
const d = Math.hypot(x + 0.5 - ringCx, y + 0.5 - ringCy);
luma += 0.085 * Math.sin((d / RING_WAVELENGTH_PX) * Math.PI * 2);
// Just enough grain to break banding in the smooth discs.
const value = clamp255((luma + (hash2(x, y) - 0.5) * 0.012) * 255);
const index = (y * width + x) * 4;
pixels[index] = value;
pixels[index + 1] = value;
pixels[index + 2] = value;
pixels[index + 3] = 255;
}
}
return pixels;
}
/** Ring period in pixels. Well above the Nyquist limit, so the rings never alias. */
const RING_WAVELENGTH_PX = 15;
/**
* Soft-edged disc with a height-normalized radius.
*
* `aspect` (width / height) scales x before measuring, so the disc is a real
* circle in pixels rather than a 1.78x-wide ellipse on a 16:9 frame.
*/
function disc(
x: number,
y: number,
cx: number,
cy: number,
radius: number,
aspect: number,
): number {
const d = Math.hypot((x - cx) * aspect, y - cy);
return smoothstep(radius, radius * 0.35, d);
}
function clamp255(value: number): number {
return Math.min(255, Math.max(0, Math.round(value)));
}
/** Cheap content hash so a change to the fixture frame has to be deliberate. */
export function hashBytes(bytes: Uint8Array): string {
let hash = 2_166_136_261;
for (let i = 0; i < bytes.length; i++) {
hash ^= bytes[i]!;
hash = Math.imul(hash, 16_777_619);
}
return (hash >>> 0).toString(16).padStart(8, '0');
}
/**
* Pins `createFixtureFrame()`; regenerate deliberately, never casually.
*
* Unchanged by the hand swap: the fixture image is the *camera stand-in*, and
* swapping the estimator does not change what a camera would have seen.
*/
export const FIXTURE_FRAME_HASH = '4f6e2a49';
/** Number of synthetic results the thumbnail and the visual demo replay. */
export const SYNTHETIC_FRAME_COUNT = 24;
/** Fixed timestep the synthetic sequence is authored for. */
export const SYNTHETIC_DT = 1 / 30;
/** Synthetic ROI side as a fraction of the frame's short edge. */
export const SYNTHETIC_ROI_FRACTION = 0.34;
/**
* A plausible open right hand in **crop-normalized** coordinates, in MediaPipe's
* landmark order: wrist, then thumb, index, middle, ring and pinky from base to
* tip.
*
* Hand-authored rather than captured, because a fixture that came out of the
* model could not be used to test the model's own geometry. What matters is only
* that the proportions are sane: the MCP knuckles roughly in a row across the
* middle, the wrist below them, the fingers fanning above.
*/
const RAW_HAND: readonly (readonly [number, number])[] = [
[0.5, 0.88],
[0.34, 0.78],
[0.26, 0.68],
[0.2, 0.6],
[0.15, 0.53],
[0.4, 0.5],
[0.38, 0.38],
[0.37, 0.3],
[0.36, 0.23],
[0.5, 0.48],
[0.5, 0.35],
[0.5, 0.26],
[0.5, 0.19],
[0.6, 0.5],
[0.61, 0.37],
[0.62, 0.29],
[0.63, 0.22],
[0.7, 0.54],
[0.72, 0.44],
[0.73, 0.37],
[0.74, 0.31],
];
/**
* {@link RAW_HAND} recentred so the mean of the four MCP knuckles is exactly the
* centre of the crop.
*
* This is the trick the whole fixture rests on. The centre of a crop maps to the
* centre of its ROI under any rotation and any scale, so once the canonical hand
* is centred this way, placing the ROI at a point guarantees the measured MCP
* centroid comes back at that same point — exactly, not approximately. The tests
* can then assert the inverse transform to floating-point tolerance instead of
* to some hand-waved pixel budget.
*/
export function canonicalHandLandmarks(): readonly Vec2[] {
let cx = 0;
let cy = 0;
for (const index of MCP_LANDMARKS) {
cx += RAW_HAND[index]![0];
cy += RAW_HAND[index]![1];
}
cx /= MCP_LANDMARKS.length;
cy /= MCP_LANDMARKS.length;
return RAW_HAND.map(([x, y]) => ({ x: x - cx + 0.5, y: y - cy + 0.5 }));
}
/**
* The synthetic hand paths in `brush` space (mirrored, normalized frame), one
* per track slot, both sweeping at once.
*
* Two ribbons rather than one: the author paints with both hands, so the canned
* demo and the thumbnail have to show both, or the feature is invisible to
* anyone who cannot grant a camera. They travel in opposite directions and stay
* in separate horizontal bands so they read as two independent strokes and not
* as one thick line.
*/
export function syntheticHandPath(slot: number, count = SYNTHETIC_FRAME_COUNT): readonly Vec2[] {
const path: Vec2[] = [];
for (let i = 0; i < count; i++) {
const t = count === 1 ? 0 : i / (count - 1);
const wave = Math.sin(t * Math.PI * 1.6);
// The bands are pushed apart and the wave flattened so the closest approach
// still clears three brush radii. A palm-sized brush is 60 texels across, and
// at tighter spacing the two wipes merge into a single smear.
path.push(
slot === 0
? { x: 0.32 + 0.36 * t, y: 0.28 + 0.12 * wave }
: { x: 0.68 - 0.36 * t, y: 0.78 - 0.12 * wave },
);
}
return path;
}
/**
* Presence ramp for the synthetic sequence: it crosses the 0.45 enter threshold
* on the first sample and then stays above the 0.30 stay threshold, which
* exercises acquisition without exercising a dropout.
*/
export function syntheticPresence(index: number, count = SYNTHETIC_FRAME_COUNT): number {
if (index === 0) return 0.52;
const t = count === 1 ? 1 : index / (count - 1);
return 0.55 + 0.2 * Math.sin(t * Math.PI);
}
/** Unit travel direction along a path, from neighbouring samples. */
function tangent(path: readonly Vec2[], index: number): Vec2 {
const a = path[Math.max(0, index - 1)]!;
const b = path[Math.min(path.length - 1, index + 1)]!;
const dx = b.x - a.x;
const dy = b.y - a.y;
const length = Math.hypot(dx, dy);
if (!(length > 1e-6)) return { x: 1, y: 0 };
return { x: dx / length, y: dy / length };
}
/** One slot's synthetic result: what the landmark model would have returned. */
export interface SyntheticHandResult {
readonly slot: number;
/** The ROI this crop was taken through, in source pixels. */
readonly roi: HandRoi;
/** `[1,63]` xyz in **crop pixels**, exactly as the graph emits them. */
readonly landmarks: Float32Array;
readonly presence: number;
/** Where this result should land in `brush` space; the assertion target. */
readonly expected: Vec2;
}
export interface SyntheticHandFrame {
readonly results: readonly SyntheticHandResult[];
}
/**
* Encodes both synthetic hand paths as real `[1,63]` landmark buffers plus the
* ROIs they came from.
*
* The rotation is taken from the direction of travel, so the sequence sweeps
* through a wide range of angles and a broken rotation term in the inverse
* transform cannot pass unnoticed — an unrotated fixture would let a dropped
* `sin` through, which is exactly the kind of bug that looks fine until a real
* hand tilts.
*/
export function syntheticHandFrames(
sourceWidth = FIXTURE_FRAME_WIDTH,
sourceHeight = FIXTURE_FRAME_HEIGHT,
count = SYNTHETIC_FRAME_COUNT,
): readonly SyntheticHandFrame[] {
const canonical = canonicalHandLandmarks();
const size = Math.min(sourceWidth, sourceHeight) * SYNTHETIC_ROI_FRACTION;
const paths = [syntheticHandPath(0, count), syntheticHandPath(1, count)];
const frames: SyntheticHandFrame[] = [];
for (let index = 0; index < count; index++) {
const results: SyntheticHandResult[] = [];
for (let slot = 0; slot < paths.length; slot++) {
const path = paths[slot]!;
const expected = path[index]!;
const centre = brushToSource(expected, sourceWidth, sourceHeight);
const direction = tangent(path, index);
// Mirrored back into source space, so the angle tracks the drawn stroke.
const rotation = Math.atan2(direction.y, -direction.x);
const roi: HandRoi = { cx: centre.x, cy: centre.y, size, rotation };
const landmarks = new Float32Array(NUM_LANDMARKS * 3);
for (let k = 0; k < NUM_LANDMARKS; k++) {
const point = canonical[k]!;
landmarks[k * 3] = point.x * LANDMARK_SIZE;
landmarks[k * 3 + 1] = point.y * LANDMARK_SIZE;
// z is emitted by the graph and ignored by this example; a plausible
// non-zero value keeps the buffer honest about its real layout.
landmarks[k * 3 + 2] = (point.y - 0.5) * 0.1 * LANDMARK_SIZE;
}
results.push({
slot,
roi,
landmarks,
presence: syntheticPresence(index, count),
expected,
});
}
frames.push({ results });
}
return frames;
}
/**
* Packs an ROI into the four floats `hand-crop.wgsl` and `hand.wgsl` read.
*
* Layout is `vec2f center, f32 size, f32 rotation`, which is 16 bytes and needs
* no padding.
*/
export function packRoi(roi: HandRoi, out = new Float32Array(4), offset = 0): Float32Array {
out[offset] = roi.cx;
out[offset + 1] = roi.cy;
out[offset + 2] = roi.size;
out[offset + 3] = roi.rotation;
return out;
}
/**
* Round-trips a synthetic result back to the point it was authored from.
*
* Used by the tests as an independent check that the fixture really is
* self-consistent, rather than trusting the construction above.
*/
export function cropSpaceMcpCentroid(result: SyntheticHandResult): Vec2 {
let x = 0;
let y = 0;
for (const index of MCP_LANDMARKS) {
x += result.landmarks[index * 3]! / LANDMARK_SIZE;
y += result.landmarks[index * 3 + 1]! / LANDMARK_SIZE;
}
return { x: x / MCP_LANDMARKS.length, y: y / MCP_LANDMARKS.length };
}
/** Inverse helper for tests: where a source point sits inside a result's crop. */
export function sourcePointInCrop(result: SyntheticHandResult, point: Vec2): Vec2 {
return sourceToCrop(point, result.roi);
}