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
/**
 * Detector-on-loss cadence and persistent slot identity.
 *
 * MediaPipe's hand solution is fast because it almost never runs the palm
 * detector. It detects once, then rides the landmark model's own output forward:
 * each frame's 21 points define the next frame's crop, so the expensive
 * full-frame search only happens when that loop breaks. This module is that
 * policy, kept pure so the cadence can be tested without a GPU.
 *
 * ## What this module does and does not own
 *
 * It owns the **cadence** (run the detector or not) and **slot assignment**
 * (which detection becomes which persistent track). It does *not* own the brush
 * state machine or the ROI loopback: those live on the GPU in `hand.wgsl`,
 * because moving them here would mean reading 21 landmarks back to the CPU every
 * frame, which is precisely the thing this example exists to avoid.
 *
 * The one signal that does cross back is **hand presence**, a single float per
 * hand. It is requested as a CPU output from ONNX Runtime, so it costs nothing
 * extra — the run promise already has to resolve — and it is not a landmark.
 * Everything else stays on the device.
 *
 * ## Slot identity, and the honest limit of it
 *
 * Slots are track identities, not anatomical labels and not the detector's
 * output order. While both hands are tracked the detector never runs, so
 * identity is perfectly stable: each slot follows its own hand through the
 * landmark loopback.
 *
 * Assignment therefore only happens on the rare reacquisition frame, and it is
 * done by minimising total distance to the last centroid each slot was seen at.
 * That is a heuristic, and it has one known failure: if the hands cross *while*
 * one of them is lost, the two tracks can come back swapped. This is deliberately
 * not defended against with more machinery, because the existing GPU jump cap
 * already handles it correctly — a swapped slot is an implausible teleport, so
 * `has_prev` is dropped and the stroke simply breaks instead of drawing a line
 * across the canvas between two different hands. Breaking a line is the right
 * failure; a cross-hand connector is not.
 */
import {
  MAX_HANDS,
  PRESENCE_ENTER,
  PRESENCE_STAY,
  ROI_MAX_FRACTION,
  ROI_MIN_FRACTION,
  TRACK_LOST_RESULTS,
} from './hand-model-contract';
import { isRoiSane, type HandRoi, type Vec2 } from './hand-pipeline';
 
/**
 * Results between full-frame searches while at least one hand is still tracked.
 *
 * With one hand up and two slots, one slot is permanently empty, and a literal
 * reading of "detect whenever a slot is free" would run the detector on every
 * single frame for as long as the user paints one-handed — turning the common
 * case into the worst case. Upstream accepts that cost; this example does not,
 * because a second hand appearing half a second late is imperceptible while the
 * frame rate drop would not be.
 *
 * When *no* hand is tracked there is nothing to protect, so the detector runs on
 * every frame until it finds something.
 */
export const DETECTOR_RETRY_RESULTS = 6;
 
export interface HandCandidate {
  readonly roi: HandRoi;
  /** Palm centre in source pixels, used only for assignment. */
  readonly centroid: Vec2;
  readonly score: number;
}
 
export interface TrackSlotState {
  /** True while this slot is following a hand. */
  readonly active: boolean;
  /** Most recent hand presence reported by the landmark model. */
  readonly presence: number;
  /** Consecutive results that failed the presence or ROI test. */
  readonly lost: number;
  /** Last centroid recorded for this slot, in source pixels. */
  readonly centroid: Vec2 | undefined;
  /**
   * ROI to crop on the next frame, when the host is the one supplying it.
   * `undefined` means the GPU loopback owns this slot's ROI and the host must
   * not overwrite it.
   */
  readonly pendingRoi: HandRoi | undefined;
}
 
export interface HandTrackerOptions {
  readonly sourceWidth: number;
  readonly sourceHeight: number;
  readonly enterPresence?: number;
  readonly stayPresence?: number;
  readonly lostResults?: number;
  readonly retryResults?: number;
  readonly maxHands?: number;
}
 
export interface HandTracker {
  readonly slots: readonly TrackSlotState[];
  /** Slots with a hand to crop and run the landmark model on this frame. */
  activeSlots(): readonly number[];
  /** True when this frame must start with a full-frame palm search. */
  needsDetector(): boolean;
  /** Assigns fresh detections to slots. Returns the slots whose ROI the host must upload. */
  acquire(candidates: readonly HandCandidate[]): readonly number[];
  /** Records one landmark result. `presence` is the model's own scalar. */
  noteResult(slot: number, presence: number): void;
  /** Records that a slot produced nothing this frame (no run, or a bad ROI). */
  noteMissing(slot: number): void;
  /**
   * Closes one inference frame.
   *
   * The retry throttle counts *frames*, not slot updates, and a frame reports
   * between zero and two of those, so the count cannot be inferred from the
   * other calls without guessing. The caller says when a frame is over.
   */
  endFrame(): void;
  /** Clears the host-side pending ROI once it has been uploaded. */
  clearPending(slot: number): void;
  /** Drops every track, so the next frame reacquires from scratch. */
  reset(): void;
  resize(sourceWidth: number, sourceHeight: number): void;
}
 
interface MutableSlot {
  active: boolean;
  presence: number;
  lost: number;
  centroid: Vec2 | undefined;
  pendingRoi: HandRoi | undefined;
}
 
function createSlot(): MutableSlot {
  return { active: false, presence: 0, lost: 0, centroid: undefined, pendingRoi: undefined };
}
 
export function createHandTracker(options: HandTrackerOptions): HandTracker {
  const enterPresence = options.enterPresence ?? PRESENCE_ENTER;
  const stayPresence = options.stayPresence ?? PRESENCE_STAY;
  const lostResults = options.lostResults ?? TRACK_LOST_RESULTS;
  const retryResults = options.retryResults ?? DETECTOR_RETRY_RESULTS;
  const maxHands = options.maxHands ?? MAX_HANDS;
 
  let sourceWidth = options.sourceWidth;
  let sourceHeight = options.sourceHeight;
  const slots: MutableSlot[] = Array.from({ length: maxHands }, createSlot);
  let sinceDetector = Number.POSITIVE_INFINITY;
  /**
   * Set when a slot that *was* tracking a hand goes bad.
   *
   * This is the difference between "a hand I was following just disappeared" and
   * "there has only ever been one hand up". The first deserves an immediate
   * search, because the user probably moved fast or briefly occluded a hand and
   * is waiting to carry on painting. The second is the steady state of
   * one-handed use and must not be allowed to run the detector forever.
   */
  let lostSinceDetector = false;
 
  const anyActive = () => slots.some((slot) => slot.active);
 
  /** Drops a slot's track, remembering that a real hand was lost. */
  const deactivate = (slot: MutableSlot) => {
    if (slot.active) lostSinceDetector = true;
    slot.active = false;
    slot.pendingRoi = undefined;
    // The last centroid is kept on purpose: it is the best guess available when
    // this slot is reacquired, and it is what stops the two tracks swapping
    // over a brief dropout.
  };
 
  return {
    get slots() {
      return slots.map((slot) => ({ ...slot }));
    },
 
    activeSlots() {
      const out: number[] = [];
      for (let i = 0; i < slots.length; i++) if (slots[i]!.active) out.push(i);
      return out;
    },
 
    needsDetector() {
      const free = slots.filter((slot) => !slot.active).length;
      if (free === 0) return false;
      // Nothing to lose: search every frame until a hand turns up.
      if (!anyActive()) return true;
      // A hand that was being followed just went away. Go and find it now
      // rather than making the user wait out the throttle.
      if (lostSinceDetector) return true;
      return sinceDetector >= retryResults;
    },
 
    acquire(candidates) {
      sinceDetector = 0;
      lostSinceDetector = false;
      const free: number[] = [];
      for (let i = 0; i < slots.length; i++) if (!slots[i]!.active) free.push(i);
      if (free.length === 0 || candidates.length === 0) return [];
 
      // Detections a still-healthy slot is already following must not be handed
      // to a free slot as well, or both brushes would paint the same hand.
      const claimed = new Set<number>();
      for (let i = 0; i < slots.length; i++) {
        const slot = slots[i]!;
        if (!slot.active || !slot.centroid) continue;
        const nearest = nearestCandidate(slot.centroid, candidates, claimed);
        if (nearest !== undefined) claimed.add(nearest);
      }
 
      const available = candidates
        .map((candidate, index) => ({ candidate, index }))
        .filter((entry) => !claimed.has(entry.index));
      if (available.length === 0) return [];
 
      const assignment = assignByDistance(
        free.map((slot) => slots[slot]!.centroid),
        available.map((entry) => entry.candidate.centroid),
      );
 
      const uploaded: number[] = [];
      for (let i = 0; i < assignment.length; i++) {
        const candidateIndex = assignment[i];
        if (candidateIndex === undefined) continue;
        const slotIndex = free[i]!;
        const entry = available[candidateIndex]!;
        if (!isRoiSane(entry.candidate.roi, sourceWidth, sourceHeight, ROI_MIN_FRACTION, ROI_MAX_FRACTION)) {
          continue;
        }
        const slot = slots[slotIndex]!;
        slot.active = true;
        slot.lost = 0;
        // Deliberately not seeded from the detector score. A palm can score 0.58
        // and still come back with landmark presence 0.017; only the landmark
        // model gets to say a hand is really there.
        slot.presence = 0;
        slot.centroid = entry.candidate.centroid;
        slot.pendingRoi = entry.candidate.roi;
        uploaded.push(slotIndex);
      }
      return uploaded;
    },
 
    noteResult(slot, presence) {
      const state = slots[slot];
      if (!state) return;
      const threshold = state.lost === 0 && state.presence > 0 ? stayPresence : enterPresence;
      const present = Number.isFinite(presence) && presence >= threshold;
      state.presence = Number.isFinite(presence) ? Math.max(0, presence) : 0;
      if (present) {
        state.lost = 0;
        state.active = true;
        return;
      }
      state.lost += 1;
      if (state.lost >= lostResults) deactivate(state);
    },
 
    noteMissing(slot) {
      const state = slots[slot];
      if (!state) return;
      state.lost += 1;
      state.presence = 0;
      if (state.lost >= lostResults) deactivate(state);
    },
 
    endFrame() {
      sinceDetector += 1;
    },
 
    clearPending(slot) {
      const state = slots[slot];
      if (state) state.pendingRoi = undefined;
    },
 
    reset() {
      for (const slot of slots) {
        slot.active = false;
        slot.presence = 0;
        slot.lost = 0;
        slot.centroid = undefined;
        slot.pendingRoi = undefined;
      }
      sinceDetector = Number.POSITIVE_INFINITY;
      lostSinceDetector = false;
    },
 
    resize(width, height) {
      sourceWidth = width;
      sourceHeight = height;
    },
  };
}
 
function distance(a: Vec2 | undefined, b: Vec2 | undefined): number {
  if (!a || !b) return Number.POSITIVE_INFINITY;
  return Math.hypot(a.x - b.x, a.y - b.y);
}
 
function nearestCandidate(
  centroid: Vec2,
  candidates: readonly HandCandidate[],
  claimed: ReadonlySet<number>,
): number | undefined {
  let best: number | undefined;
  let bestDistance = Number.POSITIVE_INFINITY;
  for (let i = 0; i < candidates.length; i++) {
    if (claimed.has(i)) continue;
    const d = distance(centroid, candidates[i]!.centroid);
    if (d < bestDistance) {
      bestDistance = d;
      best = i;
    }
  }
  return best;
}
 
/**
 * Minimum-total-distance assignment of candidates to slots.
 *
 * Returns, per slot, the index of the candidate it should take, or `undefined`.
 * With at most two of each this is a comparison of two permutations rather than
 * a real assignment problem; what matters is only that the answer does not
 * depend on the order the detector happened to emit its boxes, which is what the
 * reversed-order test pins.
 *
 * Slots with no history sort by x so a cold start is at least deterministic.
 */
export function assignByDistance(
  slotCentroids: readonly (Vec2 | undefined)[],
  candidateCentroids: readonly Vec2[],
): readonly (number | undefined)[] {
  const slotCount = slotCentroids.length;
  const result: (number | undefined)[] = new Array(slotCount).fill(undefined);
  if (candidateCentroids.length === 0 || slotCount === 0) return result;
 
  const known = slotCentroids.some((centroid) => centroid !== undefined);
  if (!known) {
    const order = candidateCentroids
      .map((centroid, index) => ({ centroid, index }))
      .sort((a, b) => a.centroid.x - b.centroid.x);
    for (let slot = 0; slot < slotCount && slot < order.length; slot++) {
      result[slot] = order[slot]!.index;
    }
    return result;
  }
 
  const permutations = permute(candidateCentroids.map((_, index) => index));
  let bestCost = Number.POSITIVE_INFINITY;
  let best: (number | undefined)[] = result;
 
  for (const permutation of permutations) {
    const attempt: (number | undefined)[] = new Array(slotCount).fill(undefined);
    let cost = 0;
    for (let slot = 0; slot < slotCount; slot++) {
      const candidate = permutation[slot];
      if (candidate === undefined) continue;
      attempt[slot] = candidate;
      const d = distance(slotCentroids[slot], candidateCentroids[candidate]);
      // A slot with no history contributes nothing, so it never outvotes a slot
      // that does have a position to be consistent with.
      cost += Number.isFinite(d) ? d : 0;
    }
    if (cost < bestCost) {
      bestCost = cost;
      best = attempt;
    }
  }
  return best;
}
 
function permute(values: readonly number[]): number[][] {
  if (values.length <= 1) return [[...values]];
  const out: number[][] = [];
  for (let i = 0; i < values.length; i++) {
    const rest = [...values.slice(0, i), ...values.slice(i + 1)];
    for (const tail of permute(rest)) out.push([values[i]!, ...tail]);
  }
  return out;
}