MNIST Classifier

Draw a digit and classify it with ONNX Runtime Web on WebGPU. Render the GPU-resident logits through a non-owning vgpu buffer wrap.

Open fullscreen
/**
 * Pure CPU preprocessing from a drawing surface to the model's input tensor.
 *
 * mnist-12 expects `float32[1, 1, 28, 28]` with values in [0, 1], white strokes
 * on a black field, digit-centred the way the MNIST dataset is normalized:
 * crop to the ink, scale the long side into a 20x20 box, then place it in a
 * 28x28 field by centre of mass.
 *
 * 784 values per interaction are tiny, so this stays on the CPU where it is easy
 * to test. The zero-copy claim in this example applies to the model *output*.
 */
 
/** Side length of the model input. */
export const INPUT_SIZE = 28;
/** MNIST normalizes the glyph into a 20x20 box inside the 28x28 field. */
export const DIGIT_BOX = 20;
/** Values at or below this are treated as background when cropping. */
export const INK_THRESHOLD = 0.08;
 
export interface InkBounds {
  readonly minX: number;
  readonly minY: number;
  readonly maxX: number;
  readonly maxY: number;
}
 
/**
 * Converts RGBA canvas bytes to a single-channel foreground field in [0, 1].
 * The drawing surface paints opaque white ink on black, so luminance and alpha
 * agree; multiplying them tolerates both a cleared and a filled backdrop.
 */
export function foregroundFromRgba(
  data: Uint8ClampedArray | Uint8Array,
  width: number,
  height: number,
): Float32Array {
  const expected = width * height * 4;
  if (data.length !== expected) {
    throw new Error(`Expected ${expected} RGBA bytes for ${width}x${height}, received ${data.length}.`);
  }
  const field = new Float32Array(width * height);
  for (let i = 0, p = 0; i < data.length; i += 4, p++) {
    const luma = (0.299 * data[i]! + 0.587 * data[i + 1]! + 0.114 * data[i + 2]!) / 255;
    field[p] = luma * (data[i + 3]! / 255);
  }
  return field;
}
 
/** Bounding box of ink above the threshold, or `undefined` for an empty field. */
export function inkBounds(
  field: Float32Array,
  width: number,
  height: number,
  threshold = INK_THRESHOLD,
): InkBounds | undefined {
  let minX = width;
  let minY = height;
  let maxX = -1;
  let maxY = -1;
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      if (field[y * width + x]! <= threshold) continue;
      if (x < minX) minX = x;
      if (x > maxX) maxX = x;
      if (y < minY) minY = y;
      if (y > maxY) maxY = y;
    }
  }
  if (maxX < 0) return undefined;
  return { minX, minY, maxX, maxY };
}
 
/** Box-filter average of `field` over the source rectangle. */
function sampleBox(
  field: Float32Array,
  width: number,
  height: number,
  x0: number,
  y0: number,
  x1: number,
  y1: number,
): number {
  const startX = Math.max(0, Math.floor(x0));
  const startY = Math.max(0, Math.floor(y0));
  const endX = Math.min(width, Math.max(startX + 1, Math.ceil(x1)));
  const endY = Math.min(height, Math.max(startY + 1, Math.ceil(y1)));
  let sum = 0;
  let count = 0;
  for (let y = startY; y < endY; y++) {
    for (let x = startX; x < endX; x++) {
      sum += field[y * width + x]!;
      count++;
    }
  }
  return count === 0 ? 0 : sum / count;
}
 
/**
 * Normalizes a drawing into the model's 28x28 input.
 *
 * Returns `undefined` when the surface holds no ink, so callers can skip
 * inference instead of classifying an empty canvas.
 */
export function preprocessDigit(
  field: Float32Array,
  width: number,
  height: number,
): Float32Array | undefined {
  const bounds = inkBounds(field, width, height);
  if (!bounds) return undefined;
 
  const inkWidth = bounds.maxX - bounds.minX + 1;
  const inkHeight = bounds.maxY - bounds.minY + 1;
  // Preserve aspect ratio: the long side fills the 20 px box.
  const scale = DIGIT_BOX / Math.max(inkWidth, inkHeight);
  const boxWidth = Math.max(1, Math.round(inkWidth * scale));
  const boxHeight = Math.max(1, Math.round(inkHeight * scale));
 
  // Resample the cropped ink into boxWidth x boxHeight with a box filter.
  const box = new Float32Array(boxWidth * boxHeight);
  for (let y = 0; y < boxHeight; y++) {
    for (let x = 0; x < boxWidth; x++) {
      const x0 = bounds.minX + (x * inkWidth) / boxWidth;
      const x1 = bounds.minX + ((x + 1) * inkWidth) / boxWidth;
      const y0 = bounds.minY + (y * inkHeight) / boxHeight;
      const y1 = bounds.minY + ((y + 1) * inkHeight) / boxHeight;
      box[y * boxWidth + x] = sampleBox(field, width, height, x0, y0, x1, y1);
    }
  }
 
  // Centre of mass of the scaled glyph, then translate it to the field centre.
  let mass = 0;
  let momentX = 0;
  let momentY = 0;
  for (let y = 0; y < boxHeight; y++) {
    for (let x = 0; x < boxWidth; x++) {
      const value = box[y * boxWidth + x]!;
      mass += value;
      momentX += value * (x + 0.5);
      momentY += value * (y + 0.5);
    }
  }
  const centreX = mass > 0 ? momentX / mass : boxWidth / 2;
  const centreY = mass > 0 ? momentY / mass : boxHeight / 2;
  const offsetX = Math.round(INPUT_SIZE / 2 - centreX);
  const offsetY = Math.round(INPUT_SIZE / 2 - centreY);
 
  const pixels = new Float32Array(INPUT_SIZE * INPUT_SIZE);
  for (let y = 0; y < boxHeight; y++) {
    const targetY = y + offsetY;
    if (targetY < 0 || targetY >= INPUT_SIZE) continue;
    for (let x = 0; x < boxWidth; x++) {
      const targetX = x + offsetX;
      if (targetX < 0 || targetX >= INPUT_SIZE) continue;
      pixels[targetY * INPUT_SIZE + targetX] = Math.min(1, Math.max(0, box[y * boxWidth + x]!));
    }
  }
  return pixels;
}
 
/** FNV-1a over quantized values; pins fixtures without storing 784 floats twice. */
export function hashPixels(pixels: Float32Array): string {
  let hash = 0x811c9dc5;
  for (const value of pixels) {
    hash ^= Math.round(value * 255) & 0xff;
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash.toString(16).padStart(8, '0');
}
 
/** Numerically stable softmax, mirroring the shader's algorithm exactly. */
export function softmax(logits: ArrayLike<number>): Float32Array {
  let max = -Infinity;
  for (let i = 0; i < logits.length; i++) max = Math.max(max, logits[i]!);
  const out = new Float32Array(logits.length);
  let sum = 0;
  for (let i = 0; i < logits.length; i++) {
    const value = Math.exp(logits[i]! - max);
    out[i] = value;
    sum += value;
  }
  for (let i = 0; i < out.length; i++) out[i] = out[i]! / sum;
  return out;
}
 
/** Index of the largest logit. */
export function argmax(logits: ArrayLike<number>): number {
  let best = 0;
  for (let i = 1; i < logits.length; i++) if (logits[i]! > logits[best]!) best = i;
  return best;
}