Air Painting

Wipe fog off the glass with your hands. ONNX Runtime Web tracks both palms on WebGPU to clear a frosted camera feed.

Open fullscreen
/**
 * The half of MediaPipe's hand solution that is **not** in the ONNX files.
 *
 * The two converted graphs are a palm detector and a landmark reader. Between
 * them sit a stack of MediaPipe "calculators" that ship as graph configuration
 * rather than as weights, and every one of them has to be reimplemented here:
 * SSD anchor generation, sigmoid + threshold, weighted non-maximum suppression,
 * the palm-box-to-rotated-ROI transform, the crop geometry, the inverse
 * transform back to the frame, and the landmark-to-next-ROI loopback that lets
 * the detector be skipped.
 *
 * Pure TypeScript, no GPU/DOM/ORT: the same functions run in the browser, in the
 * ORT-free Node thumbnail and in the unit tests. The Python reference this was
 * ported from lives in `tools/models/mediapipe-hands/hand_pipeline.py` and the
 * two were checked against each other on real photographs before any of it was
 * allowed near the example.
 *
 * ## Coordinate spaces
 *
 * The detector sees a **letterboxed square**: the camera frame scaled to fit
 * 192x192 with black padding, so its outputs are normalized to that padded
 * square and mean nothing until the padding is divided back out. Everything the
 * detector produces therefore stays in `square` space until
 * {@link roiToSource} converts it, once, into **source pixels**.
 *
 * ROIs are held in source pixels rather than normalized units on purpose. An ROI
 * carries a rotation, and a rotation is only meaningful in a space with square
 * units; expressing it in normalized frame coordinates would shear the crop on
 * any non-square camera.
 */
import {
  ANCHOR_NUM_LAYERS,
  ANCHOR_OFFSET,
  ANCHOR_STRIDES,
  DETECTOR_SCORE_THRESHOLD,
  DETECTOR_SIZE,
  MCP_LANDMARKS,
  NMS_IOU_THRESHOLD,
  NUM_ANCHORS,
  NUM_COORDS,
  NUM_LANDMARKS,
  ROI_ROTATION_END_KEYPOINT,
  ROI_ROTATION_START_KEYPOINT,
  ROI_SCALE,
  ROI_SHIFT_Y,
  ROI_TARGET_ANGLE,
} from './hand-model-contract';
 
export interface Vec2 {
  readonly x: number;
  readonly y: number;
}
 
/** Palm keypoints the detector emits per anchor: wrist, MCPs and palm edges. */
export const PALM_KEYPOINT_COUNT = 7;
 
/** One decoded palm, in `square` space (letterboxed, normalized to [0,1]). */
export interface PalmDetection {
  readonly score: number;
  readonly xmin: number;
  readonly ymin: number;
  readonly xmax: number;
  readonly ymax: number;
  /** {@link PALM_KEYPOINT_COUNT} palm points, also in `square` space. */
  readonly keypoints: readonly Vec2[];
}
 
/**
 * A rotated square region to crop for the landmark model.
 *
 * `cx`, `cy` and `size` are **source pixels**; `rotation` is radians, measured
 * so that applying it lifts the hand into the upright pose the landmark model
 * was trained on.
 */
export interface HandRoi {
  readonly cx: number;
  readonly cy: number;
  readonly size: number;
  readonly rotation: number;
}
 
/** Aspect-preserving fit of a frame into the detector's square. */
export interface Letterbox {
  readonly sourceWidth: number;
  readonly sourceHeight: number;
  readonly size: number;
  /** Source pixels -> square pixels. */
  readonly scale: number;
  readonly padX: number;
  readonly padY: number;
}
 
export function computeLetterbox(
  sourceWidth: number,
  sourceHeight: number,
  size = DETECTOR_SIZE,
): Letterbox {
  if (!(sourceWidth > 0) || !(sourceHeight > 0)) {
    throw new Error(`Frame size must be positive, received ${sourceWidth}x${sourceHeight}.`);
  }
  const scale = size / Math.max(sourceWidth, sourceHeight);
  return {
    sourceWidth,
    sourceHeight,
    size,
    scale,
    padX: (size - sourceWidth * scale) / 2,
    padY: (size - sourceHeight * scale) / 2,
  };
}
 
// --- anchors ----------------------------------------------------------------
 
/**
 * The fixed SSD anchor centres, as a flat `[x0, y0, x1, y1, ...]`.
 *
 * `fixed_anchor_size: true` in the upstream options means every anchor is 1x1,
 * so only the centres are ever used and the widths are dropped. Layers that
 * share a stride merge into one grid: stride 8 contributes 2 anchors per cell
 * over a 24x24 grid (1,152) and the three stride-16 layers contribute 6 per cell
 * over 12x12 (864), which is exactly {@link NUM_ANCHORS}.
 */
export function ssdAnchors(size = DETECTOR_SIZE): Float64Array {
  const centres: number[] = [];
  let layer = 0;
  while (layer < ANCHOR_NUM_LAYERS) {
    const stride = ANCHOR_STRIDES[layer]!;
    let last = layer;
    let perLocation = 0;
    while (last < ANCHOR_NUM_LAYERS && ANCHOR_STRIDES[last] === stride) {
      // One anchor for aspect ratio 1.0 plus one for the interpolated scale.
      perLocation += 2;
      last += 1;
    }
    const cells = Math.ceil(size / stride);
    for (let y = 0; y < cells; y++) {
      for (let x = 0; x < cells; x++) {
        for (let k = 0; k < perLocation; k++) {
          centres.push((x + ANCHOR_OFFSET) / cells, (y + ANCHOR_OFFSET) / cells);
        }
      }
    }
    layer = last;
  }
  if (centres.length !== NUM_ANCHORS * 2) {
    throw new Error(
      `Anchor generation produced ${centres.length / 2} anchors, expected ${NUM_ANCHORS}.`,
    );
  }
  return Float64Array.from(centres);
}
 
// --- detector decode --------------------------------------------------------
 
/** Logistic with the upstream logit clamp, so a huge logit cannot produce NaN. */
export function detectorScore(logit: number, clip: number): number {
  if (!Number.isFinite(logit)) return 0;
  const clamped = Math.min(clip, Math.max(-clip, logit));
  return 1 / (1 + Math.exp(-clamped));
}
 
/**
 * Decodes `Identity` `[1,2016,18]` and `Identity_1` `[1,2016,1]` against the
 * anchors, keeping anything above `scoreThreshold`, best first.
 *
 * The box terms are offsets **in detector pixels** relative to the anchor
 * centre, hence the division by the input size. `reverse_output_order: true`
 * upstream means the layout is `(x, y, w, h)` and not the `(y, x, h, w)` the
 * name "SSD" would suggest; getting that backwards yields boxes that look
 * plausible and track the wrong axis.
 */
export function decodeDetections(
  boxes: ArrayLike<number>,
  scores: ArrayLike<number>,
  anchors: ArrayLike<number>,
  options: { readonly scoreThreshold?: number; readonly scoreClip?: number; readonly size?: number } = {},
): PalmDetection[] {
  const scoreThreshold = options.scoreThreshold ?? DETECTOR_SCORE_THRESHOLD;
  const scoreClip = options.scoreClip ?? 100;
  const size = options.size ?? DETECTOR_SIZE;
  const out: PalmDetection[] = [];
 
  for (let i = 0; i < NUM_ANCHORS; i++) {
    const score = detectorScore(scores[i] ?? 0, scoreClip);
    if (!(score >= scoreThreshold)) continue;
 
    const ax = anchors[i * 2] ?? 0;
    const ay = anchors[i * 2 + 1] ?? 0;
    const b = i * NUM_COORDS;
    const cx = (boxes[b] ?? 0) / size + ax;
    const cy = (boxes[b + 1] ?? 0) / size + ay;
    const w = (boxes[b + 2] ?? 0) / size;
    const h = (boxes[b + 3] ?? 0) / size;
 
    const keypoints: Vec2[] = [];
    for (let k = 0; k < PALM_KEYPOINT_COUNT; k++) {
      keypoints.push({
        x: (boxes[b + 4 + 2 * k] ?? 0) / size + ax,
        y: (boxes[b + 5 + 2 * k] ?? 0) / size + ay,
      });
    }
    out.push({
      score,
      xmin: cx - w / 2,
      ymin: cy - h / 2,
      xmax: cx + w / 2,
      ymax: cy + h / 2,
      keypoints,
    });
  }
  out.sort((a, b) => b.score - a.score);
  return out;
}
 
const boxWidth = (d: PalmDetection) => d.xmax - d.xmin;
const boxHeight = (d: PalmDetection) => d.ymax - d.ymin;
 
export function iou(a: PalmDetection, b: PalmDetection): number {
  const ix = Math.max(0, Math.min(a.xmax, b.xmax) - Math.max(a.xmin, b.xmin));
  const iy = Math.max(0, Math.min(a.ymax, b.ymax) - Math.max(a.ymin, b.ymin));
  const intersection = ix * iy;
  const union = boxWidth(a) * boxHeight(a) + boxWidth(b) * boxHeight(b) - intersection;
  return union > 0 ? intersection / union : 0;
}
 
/**
 * MediaPipe's **weighted** NMS, which is not the usual "keep the best, drop the
 * rest".
 *
 * Each survivor is a score-weighted blend of every box it suppressed, so the
 * kept box is a consensus of its cluster rather than one arbitrary winner. That
 * materially steadies the ROI: plain NMS lets the crop jitter between frames as
 * the argmax anchor flips between neighbours, and the landmark model sees that
 * jitter as the hand moving.
 */
export function weightedNms(
  detections: readonly PalmDetection[],
  options: { readonly iouThreshold?: number; readonly maxDetections?: number } = {},
): PalmDetection[] {
  const iouThreshold = options.iouThreshold ?? NMS_IOU_THRESHOLD;
  const maxDetections = options.maxDetections ?? 2;
 
  let remaining = [...detections].sort((a, b) => b.score - a.score);
  const kept: PalmDetection[] = [];
 
  while (remaining.length > 0 && kept.length < maxDetections) {
    const best = remaining[0]!;
    const cluster = remaining.filter((d) => iou(best, d) > iouThreshold);
    const rest = remaining.filter((d) => iou(best, d) <= iouThreshold);
    const total = cluster.reduce((sum, d) => sum + d.score, 0);
 
    if (total > 0) {
      let xmin = 0;
      let ymin = 0;
      let xmax = 0;
      let ymax = 0;
      const keypoints = Array.from({ length: PALM_KEYPOINT_COUNT }, () => ({ x: 0, y: 0 }));
      for (const d of cluster) {
        const weight = d.score / total;
        xmin += weight * d.xmin;
        ymin += weight * d.ymin;
        xmax += weight * d.xmax;
        ymax += weight * d.ymax;
        for (let k = 0; k < PALM_KEYPOINT_COUNT; k++) {
          keypoints[k]!.x += weight * (d.keypoints[k]?.x ?? 0);
          keypoints[k]!.y += weight * (d.keypoints[k]?.y ?? 0);
        }
      }
      // The blended box keeps the cluster leader's score: it is the confidence
      // of the best evidence, not an average that a crowd of weak anchors could
      // inflate.
      kept.push({ score: best.score, xmin, ymin, xmax, ymax, keypoints });
    } else {
      kept.push(best);
    }
    remaining = rest;
  }
  return kept;
}
 
// --- ROI --------------------------------------------------------------------
 
/** Wraps an angle into [-pi, pi). */
export function normaliseAngle(angle: number): number {
  return angle - 2 * Math.PI * Math.floor((angle + Math.PI) / (2 * Math.PI));
}
 
/** ROI in `square` space, before the letterbox is divided back out. */
export interface SquareRoi {
  readonly xCenter: number;
  readonly yCenter: number;
  readonly size: number;
  readonly rotation: number;
}
 
/**
 * `DetectionsToRectsCalculator` followed by `RectTransformationCalculator`.
 *
 * The hand's own axis is the vector from palm keypoint 0 (wrist centre) to
 * keypoint 2 (middle-finger MCP); the ROI is rotated so that axis points up.
 * The box is then grown by {@link ROI_SCALE} and slid along that rotated axis by
 * {@link ROI_SHIFT_Y}, which spends the extra room on the fingers instead of
 * centring it on the forearm.
 */
export function detectionToSquareRoi(detection: PalmDetection): SquareRoi {
  const start = detection.keypoints[ROI_ROTATION_START_KEYPOINT] ?? { x: 0, y: 0 };
  const end = detection.keypoints[ROI_ROTATION_END_KEYPOINT] ?? { x: 0, y: 0 };
  // Negated dy because image y grows downward while the target angle is measured
  // in the usual counter-clockwise convention.
  const rotation = normaliseAngle(ROI_TARGET_ANGLE - Math.atan2(-(end.y - start.y), end.x - start.x));
  const w = boxWidth(detection);
  const h = boxHeight(detection);
  // shift_x is 0 upstream, so only the shift_y terms survive.
  return {
    xCenter: (detection.xmin + detection.xmax) / 2 - h * ROI_SHIFT_Y * Math.sin(rotation),
    yCenter: (detection.ymin + detection.ymax) / 2 + h * ROI_SHIFT_Y * Math.cos(rotation),
    size: Math.max(w, h) * ROI_SCALE,
    rotation,
  };
}
 
/** `square` point -> source pixels, i.e. undo the padding then the scale. */
export function squareToSource(point: Vec2, letterbox: Letterbox): Vec2 {
  return {
    x: (point.x * letterbox.size - letterbox.padX) / letterbox.scale,
    y: (point.y * letterbox.size - letterbox.padY) / letterbox.scale,
  };
}
 
/**
 * `square` ROI -> source-pixel ROI.
 *
 * The letterbox is a uniform scale plus a translation, so it preserves angles
 * and the rotation carries over untouched; only the centre and the side length
 * have to be converted.
 */
export function roiToSource(roi: SquareRoi, letterbox: Letterbox): HandRoi {
  const centre = squareToSource({ x: roi.xCenter, y: roi.yCenter }, letterbox);
  return {
    cx: centre.x,
    cy: centre.y,
    size: (roi.size * letterbox.size) / letterbox.scale,
    rotation: roi.rotation,
  };
}
 
/**
 * Crop-normalized `[0,1]^2` -> source pixels.
 *
 * This is the exact transform `hand-crop.wgsl` implements, kept here so the
 * shader can be tested against a reference rather than by eye.
 */
export function cropToSource(point: Vec2, roi: HandRoi): Vec2 {
  const c = Math.cos(roi.rotation);
  const s = Math.sin(roi.rotation);
  const dx = (point.x - 0.5) * roi.size;
  const dy = (point.y - 0.5) * roi.size;
  return { x: roi.cx + dx * c - dy * s, y: roi.cy + dx * s + dy * c };
}
 
/** Inverse of {@link cropToSource}; used to author fixtures backwards. */
export function sourceToCrop(point: Vec2, roi: HandRoi): Vec2 {
  const c = Math.cos(roi.rotation);
  const s = Math.sin(roi.rotation);
  const dx = point.x - roi.cx;
  const dy = point.y - roi.cy;
  const size = roi.size === 0 ? 1e-6 : roi.size;
  return { x: (dx * c + dy * s) / size + 0.5, y: (-dx * s + dy * c) / size + 0.5 };
}
 
// --- landmark decode --------------------------------------------------------
 
/**
 * `Identity` `[1,63]` is in **crop pixels**, not normalized units.
 *
 * That is worth stating loudly because a `[1,63]` float output that happens to
 * fall in `[0,224]` looks exactly like a normalized output multiplied by
 * something, and dividing by the wrong constant produces landmarks that are
 * merely slightly wrong rather than obviously broken.
 */
export function landmarkToSource(
  raw: ArrayLike<number>,
  roi: HandRoi,
  cropSize: number,
): Vec2[] {
  const points: Vec2[] = [];
  for (let i = 0; i < NUM_LANDMARKS; i++) {
    const x = (raw[i * 3] ?? 0) / cropSize;
    const y = (raw[i * 3 + 1] ?? 0) / cropSize;
    points.push(cropToSource({ x, y }, roi));
  }
  return points;
}
 
/** Mean of the four MCP knuckles; see the note on {@link MCP_LANDMARKS}. */
export function mcpCentroid(points: readonly Vec2[]): Vec2 {
  let x = 0;
  let y = 0;
  for (const index of MCP_LANDMARKS) {
    x += points[index]?.x ?? 0;
    y += points[index]?.y ?? 0;
  }
  return { x: x / MCP_LANDMARKS.length, y: y / MCP_LANDMARKS.length };
}
 
/**
 * The tracking loopback: next frame's ROI, straight from this frame's landmarks.
 *
 * This is what makes the detector skippable. The hand's axis is taken from
 * landmark 0 (wrist) to landmark 9 (middle-finger MCP) — the landmark model's
 * equivalent of the palm keypoints the detector used — and the ROI is the
 * axis-aligned bounding box of all 21 points, squared off and grown.
 *
 * Production runs this on the GPU inside `hand.wgsl`, because running it here
 * would mean reading the landmarks back to the CPU every single frame and the
 * whole point of the output path is that they never leave the device. This
 * implementation is the reference the shader is tested against.
 */
export function landmarksToRoi(points: readonly Vec2[], scale = 2): HandRoi {
  const start = points[0] ?? { x: 0, y: 0 };
  const end = points[9] ?? { x: 0, y: 0 };
  const rotation = normaliseAngle(ROI_TARGET_ANGLE - Math.atan2(-(end.y - start.y), end.x - start.x));
  let xmin = Infinity;
  let ymin = Infinity;
  let xmax = -Infinity;
  let ymax = -Infinity;
  for (const point of points) {
    if (point.x < xmin) xmin = point.x;
    if (point.y < ymin) ymin = point.y;
    if (point.x > xmax) xmax = point.x;
    if (point.y > ymax) ymax = point.y;
  }
  return {
    cx: (xmin + xmax) / 2,
    cy: (ymin + ymax) / 2,
    size: Math.max(xmax - xmin, ymax - ymin) * scale,
    rotation,
  };
}
 
/** True when an ROI is finite and its side is a plausible fraction of the frame. */
export function isRoiSane(
  roi: HandRoi | undefined,
  sourceWidth: number,
  sourceHeight: number,
  minFraction: number,
  maxFraction: number,
): boolean {
  if (!roi) return false;
  if (!Number.isFinite(roi.cx) || !Number.isFinite(roi.cy)) return false;
  if (!Number.isFinite(roi.size) || !Number.isFinite(roi.rotation)) return false;
  const short = Math.min(sourceWidth, sourceHeight);
  if (!(short > 0)) return false;
  const fraction = roi.size / short;
  return fraction >= minFraction && fraction <= maxFraction;
}