Depth Estimation

Estimate depth from a photo or webcam with ONNX Runtime Web on WebGPU. Shade its GPU-resident output beside the input through a zero-copy vgpu buffer wrap.

Open fullscreen
/**
 * Turns an image or video frame into the NCHW float input a depth model wants.
 *
 * Every model gets the identical framing — centre-crop to the model's aspect
 * ratio, then scale — so switching models changes the estimate, never the shot.
 * The pixel maths is split out from the Canvas2D work so it can be unit tested
 * without a DOM.
 */
import {
  IMAGENET_MEAN,
  IMAGENET_STD,
  type DepthModel,
  type Normalization,
} from './model-contract';
 
export interface CropRect {
  readonly sx: number;
  readonly sy: number;
  readonly sw: number;
  readonly sh: number;
}
 
/**
 * Largest centred rectangle of `source` that matches the target aspect ratio.
 *
 * Cover, not contain: letterboxing would feed the network bars of dead pixels
 * and it would happily predict depth for them.
 */
export function coverCrop(
  sourceWidth: number,
  sourceHeight: number,
  targetWidth: number,
  targetHeight: number,
): CropRect {
  if (sourceWidth <= 0 || sourceHeight <= 0) {
    throw new Error(`Invalid source size ${sourceWidth}x${sourceHeight}.`);
  }
  if (targetWidth <= 0 || targetHeight <= 0) {
    throw new Error(`Invalid target size ${targetWidth}x${targetHeight}.`);
  }
  const target = targetWidth / targetHeight;
  let sw = sourceWidth;
  let sh = sourceHeight;
  if (sourceWidth / sourceHeight > target) sw = Math.round(sh * target);
  else sh = Math.round(sw / target);
  return { sx: Math.round((sourceWidth - sw) / 2), sy: Math.round((sourceHeight - sh) / 2), sw, sh };
}
 
/**
 * Packs RGBA bytes into planar NCHW float32.
 *
 * Index layout is `c * H * W + y * W + x`, which is what every one of these
 * graphs expects. Alpha is dropped.
 */
export function rgbaToNchw(
  rgba: Uint8ClampedArray | Uint8Array,
  width: number,
  height: number,
  normalization: Normalization,
): Float32Array {
  const pixels = width * height;
  if (rgba.length < pixels * 4) {
    throw new Error(`Expected ${pixels * 4} RGBA bytes for ${width}x${height}, got ${rgba.length}.`);
  }
  const out = new Float32Array(3 * pixels);
  const imagenet = normalization === 'imagenet';
  for (let p = 0; p < pixels; p += 1) {
    const base = p * 4;
    for (let c = 0; c < 3; c += 1) {
      const value = rgba[base + c]! / 255;
      out[c * pixels + p] = imagenet ? (value - IMAGENET_MEAN[c]!) / IMAGENET_STD[c]! : value;
    }
  }
  return out;
}
 
/** Canvas2D scratch space reused across frames so webcam mode stays allocation-light. */
export interface PreprocessScratch {
  readonly canvas: HTMLCanvasElement | OffscreenCanvas;
  readonly context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
}
 
export function createPreprocessScratch(width: number, height: number): PreprocessScratch {
  const canvas =
    typeof OffscreenCanvas === 'undefined'
      ? Object.assign(document.createElement('canvas'), { width, height })
      : new OffscreenCanvas(width, height);
  const context = canvas.getContext('2d', { willReadFrequently: true }) as
    | CanvasRenderingContext2D
    | OffscreenCanvasRenderingContext2D
    | null;
  if (!context) throw new Error('depth-estimation: 2D canvas context is unavailable.');
  return { canvas, context };
}
 
/** Intrinsic size of an image or video source. */
export function sourceSize(source: CanvasImageSource): { width: number; height: number } {
  if (typeof HTMLVideoElement !== 'undefined' && source instanceof HTMLVideoElement) {
    return { width: source.videoWidth, height: source.videoHeight };
  }
  if (typeof HTMLImageElement !== 'undefined' && source instanceof HTMLImageElement) {
    return { width: source.naturalWidth, height: source.naturalHeight };
  }
  const candidate = source as { width?: number; height?: number };
  return { width: Number(candidate.width ?? 0), height: Number(candidate.height ?? 0) };
}
 
export interface PreprocessedFrame {
  /** Planar NCHW input for the model. */
  readonly nchw: Float32Array;
  /** The same crop as RGBA8, uploaded as-is for the colour half of the view. */
  readonly rgba: Uint8ClampedArray;
}
 
/**
 * Full browser path: crop, scale, and normalize one frame for `model`.
 *
 * The CPU upload here is deliberate and unavoidable — the zero-copy claim in
 * this example is about the model's OUTPUT, which is never read back.
 */
export function preprocessDepthSource(
  source: CanvasImageSource,
  model: DepthModel,
  scratch: PreprocessScratch,
): PreprocessedFrame {
  const { width: sourceWidth, height: sourceHeight } = sourceSize(source);
  if (!sourceWidth || !sourceHeight) {
    throw new Error('depth-estimation: source has no intrinsic size yet.');
  }
  const { sx, sy, sw, sh } = coverCrop(sourceWidth, sourceHeight, model.width, model.height);
  const { canvas, context } = scratch;
  if (canvas.width !== model.width || canvas.height !== model.height) {
    canvas.width = model.width;
    canvas.height = model.height;
  }
  context.imageSmoothingEnabled = true;
  context.imageSmoothingQuality = 'high';
  context.drawImage(source, sx, sy, sw, sh, 0, 0, model.width, model.height);
  const { data } = context.getImageData(0, 0, model.width, model.height);
  return { nchw: rgbaToNchw(data, model.width, model.height, model.normalization), rgba: data };
}