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
/**
 * Browser lifecycle for the depth example: ONNX Runtime Web owns the GPUDevice,
 * vgpu adopts that exact device, and the depth tensor is read by the shader
 * straight out of the buffer the model wrote it to.
 *
 * Three models are selectable and each is fetched only when it is picked —
 * eagerly loading all three would be 163 MiB of downloads for a page most
 * visitors will look at once. Switching tears the previous session down in the
 * order `lib/ort-webgpu.ts` documents before building the next one.
 */
import type { Buffer, Gpu, Surface } from 'vgpu';
import { surface as createSurface } from 'vgpu';
import {
  assertGpuTensor,
  createSharedDeviceSession,
  OrtInitCancelled,
  withWrappedTensor,
  type OrtTensor,
  type SharedDeviceSession,
} from '../../lib/ort-webgpu';
import { createInferencePump, type InferencePump } from './inference-pump';
import { createSwitchQueue } from './model-switch';
import { DEFAULT_MODEL_ID, getDepthModel, type DepthModel, type DepthModelId } from './model-contract';
import {
  createPreprocessScratch,
  preprocessDepthSource,
  type PreprocessScratch,
} from './preprocess';
import {
  createColourBuffer,
  createDepthBuffer,
  createSideBySidePipeline,
  writeColour,
  type SideBySidePipeline,
} from './renderer';
 
/**
 * Committed, CC0-licensed indoor photograph used as the default source.
 *
 * Declared here rather than in fixtures.ts so the browser bundle never reaches
 * the 230 KB thumbnail fixture that sits next to it.
 */
export const SOURCE_IMAGE_URL = '/examples/depth-estimation/source.jpg';
 
export type DepthSource = 'image' | 'camera';
 
export type DepthPhase =
  | 'initializing'
  | 'loading-model'
  | 'estimating'
  | 'ready'
  | 'camera-unavailable'
  | 'failed';
 
export interface DepthStatus {
  readonly phase: DepthPhase;
  readonly modelId: DepthModelId;
  readonly source: DepthSource;
  /** Milliseconds of the last completed inference, once there is one. */
  readonly lastInferenceMs?: number;
  readonly downloadLoadedBytes?: number;
  readonly downloadTotalBytes?: number;
}
 
export interface DepthRendererOptions {
  readonly canvas: HTMLCanvasElement;
  readonly imageUrl: string;
  readonly onStatus?: (status: DepthStatus) => void;
  readonly onError?: (error: unknown) => void;
}
 
export interface DepthRenderer {
  setModel(id: DepthModelId): void;
  setSource(source: DepthSource): void;
  dispose(): void;
}
 
export function createDepthRenderer(options: DepthRendererOptions): DepthRenderer {
  let disposed = false;
  let reportedError = false;
 
  let modelId: DepthModelId = DEFAULT_MODEL_ID;
  let model: DepthModel = getDepthModel(modelId);
  let source: DepthSource = 'image';
  let phase: DepthPhase = 'initializing';
  let lastInferenceMs: number | undefined;
  let downloadLoadedBytes: number | undefined;
  let downloadTotalBytes: number | undefined;
 
  let shared: SharedDeviceSession | undefined;
  let gpu: Gpu | undefined;
  let surface: Surface | undefined;
  let view: SideBySidePipeline | undefined;
  let idleDepth: Buffer | undefined;
  let colour: Buffer | undefined;
  let scratch: PreprocessScratch | undefined;
 
  let image: HTMLImageElement | undefined;
  let video: HTMLVideoElement | undefined;
  let stream: MediaStream | undefined;
  let observer: ResizeObserver | undefined;
 
  /**
   * Bumped by every model or source change. A run that started before the
   * change may still be in flight; it must clean up but must not paint.
   */
  let generation = 0;
  let firstFrameGeneration = -1;
  let diagnosticTimer: ReturnType<typeof setTimeout> | undefined;
  let diagnosticStage: string | undefined;
 
  const logStage = (stage: string, targetModel = modelId) => {
    if (diagnosticTimer !== undefined) clearTimeout(diagnosticTimer);
    diagnosticStage = stage;
    console.info(`[depth switch] ${new Date().toISOString()} ${stage}`, { modelId: targetModel });
    if (stage.endsWith('-start')) {
      diagnosticTimer = setTimeout(() => {
        console.warn(`[depth switch] STUCK STAGE after 20s: ${diagnosticStage}`, {
          modelId: targetModel,
        });
      }, 20_000);
    } else {
      diagnosticTimer = undefined;
    }
  };
 
  const status = () => {
    options.onStatus?.({
      phase,
      modelId,
      source,
      lastInferenceMs,
      downloadLoadedBytes,
      downloadTotalBytes,
    });
  };
  const setPhase = (next: DepthPhase) => {
    phase = next;
    status();
  };
 
  const pump: InferencePump = createInferencePump({
    run: () => runOnce(generation),
    onError: (error) => fail(error),
    minIntervalMs: 500,
  });
 
  // Model switches are serialized: see model-switch.ts for why overlapping
  // teardowns and loading every model clicked past are both unacceptable.
  const switches = createSwitchQueue<DepthModelId>((error) => fail(error));
 
  function fail(error: unknown): void {
    if (disposed || error instanceof OrtInitCancelled) return;
    if (!reportedError) {
      reportedError = true;
      setPhase('failed');
      options.onError?.(error);
    }
  }
 
  function measure(): void {
    if (!surface || !gpu) return;
    const rect = options.canvas.getBoundingClientRect();
    if (rect.width > 0 && rect.height > 0) surface.resize([rect.width, rect.height]);
    drawIdle();
  }
 
  /** Paints the panel colour so the canvas is never an empty white box. */
  function drawIdle(): void {
    if (!gpu || !surface || !view || !idleDepth || !colour) return;
    view.draw(gpu, surface, idleDepth, colour, model, { hasResult: false });
  }
 
  function currentSource(): CanvasImageSource | undefined {
    if (source === 'camera') return video && video.videoWidth > 0 ? video : undefined;
    return image && image.naturalWidth > 0 ? image : undefined;
  }
 
  async function runOnce(startedAt: number): Promise<void> {
    if (disposed || !shared || !gpu || !surface || !view || !scratch) return;
    const frameSource = currentSource();
    if (!frameSource) return;
 
    setPhase('estimating');
    const { ort, session } = shared;
    const prepared = preprocessDepthSource(frameSource, model, scratch);
    // The colour half shows the exact crop the model was given, not the source
    // image, so the two panels line up pixel for pixel.
    writeColour(colour!, prepared.rgba);
    const input = new ort.Tensor('float32', prepared.nchw, model.inputDims as unknown as number[]);
    const began = performance.now();
 
    let output: OrtTensor | undefined;
    try {
      const outputs = await session.run({ [model.inputName]: input });
      output = outputs[model.outputName];
      const raw = assertGpuTensor(output, {
        dataType: 'float32',
        dims: model.outputDims,
        label: `depth-estimation ${model.id}`,
      });
      // The model or source changed while this ran: release below, but do not
      // paint a result the user is no longer asking for.
      if (disposed || startedAt !== generation) return;
 
      await withWrappedTensor(gpu, raw, (wrapped) => {
        view!.draw(gpu!, surface!, wrapped, colour!, model, { hasResult: true });
      });
      lastInferenceMs = performance.now() - began;
      if (firstFrameGeneration !== startedAt) {
        firstFrameGeneration = startedAt;
        logStage('first-frame');
      }
      setPhase('ready');
    } finally {
      // Ordering matters: the wrapper is already gone by here, so releasing the
      // tensor is what actually frees the buffer.
      output?.dispose();
      input.dispose();
    }
  }
 
  async function teardownSession(): Promise<void> {
    logStage('teardown-start');
    pump.pause();
    await pump.active?.catch(() => {});
    view?.dispose();
    view = undefined;
    idleDepth?.dispose();
    idleDepth = undefined;
    colour?.dispose();
    colour = undefined;
    surface?.dispose();
    surface = undefined;
    gpu = undefined;
    // Disposes the vgpu facade first and only then the ORT session; the adopted
    // device is never destroyed here.
    await shared?.release();
    shared = undefined;
    logStage('teardown-done');
  }
 
  async function initialize(signal?: AbortSignal): Promise<void> {
    setPhase('loading-model');
    const target = generation;
    const initializingModel = model;
    let reportedProgressBucket = -1;
    const session = await createSharedDeviceSession({
      modelUrl: initializingModel.url,
      label: `depth-estimation ${initializingModel.id}`,
      signal,
      isCancelled: () => disposed || generation !== target,
      onStage(stage) {
        if (stage === 'model') logStage('fetch-start', initializingModel.id);
        if (stage === 'session') {
          logStage('fetch-done', initializingModel.id);
          logStage('create-start', initializingModel.id);
        }
        if (stage === 'ready') logStage('create-done', initializingModel.id);
      },
      onModelProgress(loaded, total) {
        downloadLoadedBytes = loaded;
        downloadTotalBytes = total;
        const bucket = total ? Math.floor((loaded / total) * 10) : Math.floor(loaded / (5 * 1024 * 1024));
        if (bucket !== reportedProgressBucket) {
          reportedProgressBucket = bucket;
          console.info(`[depth switch] ${new Date().toISOString()} fetch-progress`, {
            modelId: initializingModel.id,
            loadedBytes: loaded,
            totalBytes: total,
          });
        }
        status();
      },
      // Error and above. The transformer graphs pin their shape arithmetic to
      // CPU and ONNX Runtime says so at warning level on every session create;
      // that is expected and harmless, but it reaches the browser console as
      // console.error, where Next's dev overlay presents it as a crash. Raising
      // the threshold silences it at the source instead of filtering the
      // console, so genuine errors are still logged and still thrown.
      sessionOptions: { logSeverityLevel: 3 },
    });
    if (disposed || generation !== target) {
      await session.release();
      return;
    }
 
    shared = session;
    gpu = session.gpu;
    surface = createSurface(gpu, options.canvas, { dpr: [1, 2] });
    view = createSideBySidePipeline(gpu);
    idleDepth = createDepthBuffer(gpu, model);
    colour = createColourBuffer(gpu, model);
    scratch ??= createPreprocessScratch(model.width, model.height);
 
    measure();
    downloadLoadedBytes = undefined;
    downloadTotalBytes = undefined;
    setPhase('ready');
    logStage('first-frame-start');
    pump.resume();
    if (source === 'camera') pump.startContinuous();
    else pump.request();
  }
 
  async function startCamera(): Promise<void> {
    try {
      stream = await navigator.mediaDevices.getUserMedia({
        video: { facingMode: 'user' },
        audio: false,
      });
      if (disposed) {
        for (const track of stream.getTracks()) track.stop();
        return;
      }
      const element = document.createElement('video');
      element.playsInline = true;
      element.muted = true;
      element.srcObject = stream;
      await element.play();
      video = element;
      pump.startContinuous();
    } catch (error) {
      // Denial or no camera is not fatal: fall back to the image.
      stopCamera();
      source = 'image';
      setPhase('camera-unavailable');
      pump.request();
    }
  }
 
  function stopCamera(): void {
    pump.stopContinuous();
    for (const track of stream?.getTracks() ?? []) track.stop();
    stream = undefined;
    if (video) {
      video.srcObject = null;
      video = undefined;
    }
  }
 
  const boot = async () => {
    const element = new Image();
    element.decoding = 'async';
    element.src = options.imageUrl;
    await element.decode().catch(() => undefined);
    if (disposed) return;
    image = element;
 
    observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(measure);
    observer?.observe(options.canvas);
    // Put initial creation behind the same queue as later selections. A user
    // can change the picker while the first model is still downloading.
    if (generation === 0) {
      switches.push(modelId, async (_selected, signal) => initialize(signal));
    } else {
      // A switch may already have initialized before the image decoded.
      pump.request();
    }
  };
 
  void boot().catch(fail);
 
  return {
    setModel(id) {
      if (disposed || id === modelId) return;
      modelId = id;
      model = getDepthModel(id);
      generation += 1;
      lastInferenceMs = undefined;
      downloadLoadedBytes = undefined;
      downloadTotalBytes = undefined;
      scratch = undefined;
      // Report the choice before awaiting anything. The picker is a controlled
      // input bound to this status, so if the new id only appears once the
      // session is ready -- seconds away, and a 94 MiB download for the largest
      // model -- React restores the previous value and the click looks
      // rejected, which is exactly what a failed switch looks like.
      setPhase('loading-model');
      switches.push(id, async (_selected, signal) => {
        await teardownSession();
        // Superseded while the old session drained.
        if (disposed || signal.aborted || id !== modelId) return;
        await initialize(signal);
      });
    },
    setSource(next) {
      if (disposed || next === source) return;
      source = next;
      generation += 1;
      if (next === 'camera') {
        setPhase('estimating');
        void startCamera().catch(fail);
      } else {
        stopCamera();
        setPhase('ready');
        pump.request();
      }
    },
    dispose() {
      if (disposed) return;
      disposed = true;
      if (diagnosticTimer !== undefined) clearTimeout(diagnosticTimer);
      observer?.disconnect();
      stopCamera();
      pump.stop();
      switches.cancel();
      // Drain an in-flight switch first, so teardown cannot race a session
      // that is still being created.
      void Promise.resolve(switches.active)
        .catch(() => {})
        .then(() => teardownSession())
        .catch(() => {});
    },
  };
}