3

Wavelet Decomposition: Multiresolution

drag X to scrub detail scales ยท click to swap signal

A 1D signal โ€” three jumps and two smooth bumps โ€” is decomposed onto an orthonormal Haar wavelet basis , giving detail coefficients at scales together with a single coarse approximation . Reconstruction is the wavelet synthesis . The animation starts from just the coarsest approximation โ€” a constant โ€” and adds detail bands one scale at a time, from coarsest to finest. Top: the running reconstruction (blue) converges onto the original (faint gray) as bands accumulate; sharp features and discontinuities appear precisely when their characteristic scale is included. Bottom: the stacked detail coefficients at each scale, fading in as their band is activated; jumps in produce bright localized coefficients at every scale, while smooth regions yield near-zero details โ€” the multiresolution sparsity that makes wavelets useful for compression and denoising. Inspired by Gabriel Peyrรฉ's signal-decomposition visualizations.

idle
400 lines ยท vanilla
view source
// 1D Haar wavelet decomposition + progressive reconstruction.
// One concept, limited palette, short loop.

const N = 256;                // signal length (power of 2)
const LEVELS = 8;             // log2(N)
const BG = '#0c1014';
const ORIG_COLOR = 'rgba(180, 188, 196, 0.35)';
const RECON_COLOR = '#5fa8d3';     // muted blue
const RECON_FADE = 'rgba(95, 168, 211, 0.18)';
const BAR_COLOR = '#3fa39a';       // muted teal
const BAR_FADE = 'rgba(63, 163, 154, 0.18)';
const TEXT_COLOR = 'rgba(220, 226, 232, 0.85)';
const HUD_DIM = 'rgba(150, 158, 168, 0.55)';
const GRID_COLOR = 'rgba(60, 70, 80, 0.4)';

const T_ADD = 8.0;            // seconds to add all bands
const T_HOLD = 2.0;           // hold full reconstruction
const T_SNAP = 0.35;          // brief snap-back blink
const T_TOTAL = T_ADD + T_HOLD + T_SNAP;

const IDLE_THRESHOLD = 3.0;   // seconds of mouse inactivity before auto-anim resumes

let W = 0;
let H = 0;
let signal;                   // Float32Array(N) โ€” original
let approx;                   // scaling coefficient (single scalar c0)
let details;                  // array of Float32Array, details[j] length 2^j, j=0..LEVELS-1
let recon;                    // Float32Array(N) โ€” current reconstruction
let bandAdded;                // Float32Array(LEVELS+1) โ€” fade-in [0..1] per band
                              // index 0 = approx, 1..LEVELS = details[0..LEVELS-1]
let elapsed = 0;
let signalMin = 0;
let signalMax = 1;

// Interaction state
let signalKind = 0;           // 0 = piecewise, 1 = fbm-with-edge, 2 = sinusoid sum
let lastMouseX = -1;
let lastMouseY = -1;
let idleTime = IDLE_THRESHOLD; // start in idle/auto mode
let userActive = false;       // true while scrubbing

function buildSignal() {
  const s = new Float32Array(N);
  for (let i = 0; i < N; i++) {
    const x = i / N;
    let y = 0;
    // two smooth bumps
    y += 0.55 * Math.exp(-((x - 0.18) * (x - 0.18)) / (2 * 0.04 * 0.04));
    y += 0.7  * Math.exp(-((x - 0.78) * (x - 0.78)) / (2 * 0.05 * 0.05));
    // gentle ramp underlay
    y += 0.15 * Math.sin(2 * Math.PI * x * 1.0);
    // three jumps (piecewise offsets)
    if (x > 0.32) y += 0.45;
    if (x > 0.48) y -= 0.6;
    if (x > 0.62) y += 0.35;
    s[i] = y;
  }
  return s;
}

// Small deterministic LCG so each rebuild of the fbm signal stays stable
// for the duration it's on screen (we re-seed once per swap).
function makeRng(seed) {
  let state = (seed >>> 0) || 1;
  return function () {
    state = (state * 1664525 + 1013904223) >>> 0;
    return state / 0x100000000;
  };
}

// Fractional Brownian motion via midpoint-displacement-ish summed-octave
// noise, plus one sharp edge so wavelet detail bursts at every scale.
function buildFbmWithEdge(seed) {
  const rng = makeRng(seed);
  const s = new Float32Array(N);
  // Sample value-noise at multiple octaves: pick a small number of random
  // anchor points per octave, lerp between them.
  const octaves = 6;
  for (let o = 0; o < octaves; o++) {
    const samples = 1 << (o + 1);   // 2,4,8,16,32,64 anchors
    const amp = Math.pow(0.55, o);
    const anchors = new Float32Array(samples + 1);
    for (let k = 0; k <= samples; k++) anchors[k] = rng() * 2 - 1;
    for (let i = 0; i < N; i++) {
      const t = (i / N) * samples;
      const k = Math.floor(t);
      const f = t - k;
      const a = anchors[k];
      const b = anchors[k + 1];
      // smoothstep
      const w = f * f * (3 - 2 * f);
      s[i] += amp * (a * (1 - w) + b * w);
    }
  }
  // one big edge somewhere in the middle third
  const edgePos = 0.35 + rng() * 0.3;
  const edgeAmp = (rng() > 0.5 ? 1 : -1) * (0.7 + rng() * 0.3);
  const edgeIdx = Math.floor(edgePos * N);
  for (let i = edgeIdx; i < N; i++) s[i] += edgeAmp;
  return s;
}

// Clean sinusoid sum: smooth, no jumps. Wavelets concentrate the energy
// at coarse scales; finer detail bands are nearly empty.
function buildSinusoidSum() {
  const s = new Float32Array(N);
  for (let i = 0; i < N; i++) {
    const x = i / N;
    let y = 0;
    y += 0.55 * Math.sin(2 * Math.PI * x * 1.0 + 0.4);
    y += 0.35 * Math.sin(2 * Math.PI * x * 2.0 + 1.2);
    y += 0.22 * Math.sin(2 * Math.PI * x * 4.0 - 0.6);
    s[i] = y;
  }
  return s;
}

function buildSignalByKind(kind) {
  if (kind === 0) return buildSignal();
  if (kind === 1) return buildFbmWithEdge(0x9e3779b9 ^ Math.floor(Date.now() & 0xffffff));
  return buildSinusoidSum();
}

function signalLabel(kind) {
  if (kind === 0) return 'jumps + bumps';
  if (kind === 1) return 'fbm + edge';
  return 'sinusoid sum';
}

// Recompute coefficients, y-range, and reset reconstruction for the
// currently selected signalKind.
function rebuildForSignal() {
  signal = buildSignalByKind(signalKind);

  let mn = Infinity, mx = -Infinity;
  for (let i = 0; i < N; i++) {
    if (signal[i] < mn) mn = signal[i];
    if (signal[i] > mx) mx = signal[i];
  }
  const pad = (mx - mn) * 0.15 + 0.05;
  signalMin = mn - pad;
  signalMax = mx + pad;

  const dec = haarDecompose(signal);
  approx = dec.approxScalar;
  details = dec.details;

  recon = reconstructUpTo(approx, details, 0);
  bandAdded = new Float32Array(LEVELS + 1);
  bandAdded[0] = 1;

  elapsed = 0;
}

// In-place Haar DWT on a copy. Returns { approx (scalar), details: Float32Array[] }.
// Convention: at each level k (length L -> L/2), pairs (a,b) become avg=(a+b)/sqrt2,
// diff=(a-b)/sqrt2. We keep the "diff" (detail) arrays per level; the final
// surviving scalar is the coarsest approximation.
function haarDecompose(sig) {
  const buf = new Float32Array(sig);
  const detailArrays = [];          // detailArrays[k] has length (N >> (k+1))
  const SQRT2 = Math.SQRT2;
  let len = N;
  while (len > 1) {
    const half = len >> 1;
    const d = new Float32Array(half);
    for (let i = 0; i < half; i++) {
      const a = buf[2 * i];
      const b = buf[2 * i + 1];
      buf[i] = (a + b) / SQRT2;
      d[i] = (a - b) / SQRT2;
    }
    detailArrays.push(d);
    len = half;
  }
  // detailArrays[0] is finest (length N/2), detailArrays[LEVELS-1] coarsest (length 1)
  // For "progressive add coarsest-first" we'll iterate in reverse.
  return { approxScalar: buf[0], details: detailArrays };
}

// Reconstruct using approx + a subset of detail levels.
// `useUpTo` = how many detail levels (counted from coarsest) to include.
// useUpTo = 0 -> approx only. useUpTo = LEVELS -> full.
function reconstructUpTo(approxScalar, detailArrays, useUpTo) {
  const SQRT2 = Math.SQRT2;
  // Start from coarsest length-1 array.
  let cur = new Float32Array(1);
  cur[0] = approxScalar;
  // The coarsest detail is detailArrays[LEVELS-1] (length 1).
  // To rebuild upward, at each step we need either the real detail (if added)
  // or zeros.
  for (let step = 0; step < LEVELS; step++) {
    // Index into detailArrays from coarsest: detailArrays[LEVELS-1-step]
    const dIdx = LEVELS - 1 - step;
    const includeThis = step < useUpTo;
    const d = detailArrays[dIdx];
    const halfLen = cur.length;     // before doubling
    const next = new Float32Array(halfLen * 2);
    for (let i = 0; i < halfLen; i++) {
      const a = cur[i];
      const b = includeThis ? d[i] : 0;
      next[2 * i]     = (a + b) / SQRT2;
      next[2 * i + 1] = (a - b) / SQRT2;
    }
    cur = next;
  }
  return cur;
}

// Same but with FRACTIONAL inclusion for the band currently fading in.
// `fullBands` are fully added, `partialBand` is the next one with weight w in [0..1].
function reconstructPartial(approxScalar, detailArrays, fullBands, partialBand, w) {
  const SQRT2 = Math.SQRT2;
  let cur = new Float32Array(1);
  cur[0] = approxScalar;
  for (let step = 0; step < LEVELS; step++) {
    const dIdx = LEVELS - 1 - step;
    let weight = 0;
    if (step < fullBands) weight = 1;
    else if (step === fullBands && partialBand) weight = w;
    const d = detailArrays[dIdx];
    const halfLen = cur.length;
    const next = new Float32Array(halfLen * 2);
    for (let i = 0; i < halfLen; i++) {
      const a = cur[i];
      const b = weight === 0 ? 0 : weight * d[i];
      next[2 * i]     = (a + b) / SQRT2;
      next[2 * i + 1] = (a - b) / SQRT2;
    }
    cur = next;
  }
  return cur;
}

function init({ ctx, width, height }) {
  W = width;
  H = height;
  signalKind = 0;
  rebuildForSignal();

  lastMouseX = -1;
  lastMouseY = -1;
  idleTime = IDLE_THRESHOLD;
  userActive = false;

  ctx.fillStyle = BG;
  ctx.fillRect(0, 0, W, H);
}

function easeInOut(t) {
  return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) * 0.5;
}

function mapY(yVal, top, bot) {
  const t = (yVal - signalMin) / (signalMax - signalMin);
  return bot - t * (bot - top);
}

function drawTopPanel(ctx, top, bot) {
  // background
  ctx.fillStyle = BG;
  ctx.fillRect(0, top, W, bot - top);

  // zero-line / mid grid
  ctx.strokeStyle = GRID_COLOR;
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, (top + bot) * 0.5);
  ctx.lineTo(W, (top + bot) * 0.5);
  ctx.stroke();

  // Original signal โ€” faint gray
  ctx.strokeStyle = ORIG_COLOR;
  ctx.lineWidth = 1.5;
  ctx.beginPath();
  for (let i = 0; i < N; i++) {
    const x = (i / (N - 1)) * W;
    const y = mapY(signal[i], top + 6, bot - 6);
    if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
  }
  ctx.stroke();

  // Reconstruction โ€” solid blue
  ctx.strokeStyle = RECON_COLOR;
  ctx.lineWidth = 2;
  ctx.lineJoin = 'round';
  ctx.beginPath();
  for (let i = 0; i < N; i++) {
    const x = (i / (N - 1)) * W;
    const y = mapY(recon[i], top + 6, bot - 6);
    if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y);
  }
  ctx.stroke();
}

function drawBottomPanel(ctx, top, bot) {
  ctx.fillStyle = BG;
  ctx.fillRect(0, top, W, bot - top);

  const innerTop = top + 14;
  const innerBot = bot - 8;
  const totalH = innerBot - innerTop;
  // LEVELS rows of detail bars; coarsest at top, finest at bottom
  const rowH = totalH / LEVELS;

  for (let row = 0; row < LEVELS; row++) {
    // row=0 -> coarsest band visually on top
    const dIdx = LEVELS - 1 - row;             // coarsest first
    const d = details[dIdx];
    const fadeIdx = row + 1;                    // bandAdded index
    const fade = bandAdded[fadeIdx];
    if (fade <= 0.001) continue;

    // find local max for this band (for normalization within band)
    let amp = 1e-6;
    for (let i = 0; i < d.length; i++) {
      const v = Math.abs(d[i]);
      if (v > amp) amp = v;
    }

    const yMid = innerTop + (row + 0.5) * rowH;
    const halfH = rowH * 0.42;
    const barW = W / d.length;

    // baseline
    ctx.strokeStyle = GRID_COLOR;
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(0, yMid);
    ctx.lineTo(W, yMid);
    ctx.stroke();

    const alpha = 0.25 + 0.7 * fade;
    ctx.fillStyle = `rgba(63, 163, 154, ${alpha.toFixed(3)})`;
    for (let i = 0; i < d.length; i++) {
      const v = d[i] / amp;
      const h = v * halfH;
      const x = i * barW;
      // small inset for visible separation when bars are wide
      const inset = d.length <= 16 ? 1.5 : (d.length <= 64 ? 0.6 : 0);
      const drawW = Math.max(0.5, barW - inset);
      if (h >= 0) {
        ctx.fillRect(x, yMid - h, drawW, h);
      } else {
        ctx.fillRect(x, yMid, drawW, -h);
      }
    }

    // tiny scale label on the left
    ctx.fillStyle = HUD_DIM;
    ctx.font = '10px ui-monospace, monospace';
    ctx.textAlign = 'left';
    ctx.textBaseline = 'middle';
    const jLabel = LEVELS - 1 - dIdx; // coarsest = 0 in display
    ctx.fillText(`j=${jLabel}  (${d.length})`, 6, yMid);
  }
}

function drawHUD(ctx, currentLevelLabel, addedCount, modeLabel) {
  ctx.fillStyle = TEXT_COLOR;
  ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
  ctx.textAlign = 'left';
  ctx.textBaseline = 'top';
  ctx.fillText('Haar wavelet reconstruction', 10, 8);

  ctx.fillStyle = HUD_DIM;
  ctx.font = '11px ui-monospace, monospace';
  ctx.textAlign = 'left';
  ctx.fillText(`signal: ${signalLabel(signalKind)}  ยท  ${modeLabel}`, 10, 24);

  ctx.textAlign = 'right';
  ctx.fillText(currentLevelLabel, W - 10, 8);
  ctx.fillText(`bands: ${addedCount}/${LEVELS}`, W - 10, 22);
}

function tick({ ctx, dt, width, height, input }) {
  if (width !== W || height !== H) {
    W = width;
    H = height;
  }

  // ---- Input handling ----
  // Click cycles through source signals.
  if (input && typeof input.consumeClicks === 'function') {
    const clicks = input.consumeClicks();
    if (clicks && clicks.length > 0) {
      signalKind = (signalKind + 1) % 3;
      rebuildForSignal();
      // Treat the click itself as user activity โ€” stay in scrub mode if mouse is over canvas.
      idleTime = 0;
      userActive = true;
    }
  }

  // Detect mouse movement (any change in mouseX/Y counts as activity).
  const mx = input ? input.mouseX : -1;
  const my = input ? input.mouseY : -1;
  if (mx !== lastMouseX || my !== lastMouseY) {
    if (lastMouseX !== -1 || lastMouseY !== -1) {
      // genuine movement, not the very first sample
      idleTime = 0;
      userActive = true;
    }
    lastMouseX = mx;
    lastMouseY = my;
  } else {
    idleTime += dt;
    if (idleTime > IDLE_THRESHOLD) userActive = false;
  }

  // ---- Determine band activation ----
  let fullBands = 0;
  let partialW = 0;
  let inSnap = false;
  let hudLabel = '';
  let modeLabel = '';

  if (userActive && mx >= 0 && mx <= W) {
    // Mouse-scrub mode: scale = floor((mouseX / W) * LEVELS).
    // We allow 0..LEVELS inclusive so the user can reach "full" at the right edge.
    const ratio = Math.max(0, Math.min(1, mx / Math.max(1, W)));
    const scale = Math.min(LEVELS, Math.floor(ratio * (LEVELS + 1)));
    fullBands = scale;
    partialW = 0;
    bandAdded[0] = 1;
    for (let k = 1; k <= LEVELS; k++) {
      bandAdded[k] = (k - 1) < fullBands ? 1 : 0;
    }
    hudLabel = scale === 0
      ? 'approx only'
      : scale >= LEVELS
        ? 'full reconstruction'
        : `up to j = ${scale - 1}  (${1 << (scale - 1)} coeffs)`;
    modeLabel = 'mode: scrub';
    // freeze the auto-anim timer so we resume cleanly when user disengages
    elapsed = 0;
  } else {
    // Auto-anim fallback (original behavior).
    elapsed += dt;
    if (elapsed > T_TOTAL) elapsed -= T_TOTAL;

    if (elapsed < T_ADD) {
      const t = elapsed / T_ADD;
      const eased = easeInOut(t);
      const f = eased * LEVELS;
      fullBands = Math.floor(f);
      partialW = f - fullBands;
      if (fullBands >= LEVELS) {
        fullBands = LEVELS;
        partialW = 0;
      }
      bandAdded[0] = 1;
      for (let k = 1; k <= LEVELS; k++) {
        const step = k - 1;
        if (step < fullBands) bandAdded[k] = 1;
        else if (step === fullBands) bandAdded[k] = partialW;
        else bandAdded[k] = 0;
      }
      const curStep = Math.min(fullBands, LEVELS - 1);
      hudLabel = `adding j = ${curStep}  (${1 << curStep} coeffs)`;
    } else if (elapsed < T_ADD + T_HOLD) {
      fullBands = LEVELS;
      partialW = 0;
      for (let k = 0; k <= LEVELS; k++) bandAdded[k] = 1;
      hudLabel = 'full reconstruction';
    } else {
      inSnap = true;
      fullBands = 0;
      partialW = 0;
      for (let k = 1; k <= LEVELS; k++) bandAdded[k] = 0;
      bandAdded[0] = 1;
      hudLabel = 'reset';
    }
    modeLabel = 'mode: auto';
  }

  // recompute reconstruction
  recon = reconstructPartial(approx, details, fullBands, partialW > 0, partialW);

  // layout
  const topH = Math.round(H * 0.5);
  drawTopPanel(ctx, 0, topH);
  drawBottomPanel(ctx, topH, H);

  // separator
  ctx.strokeStyle = 'rgba(60, 70, 80, 0.55)';
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.moveTo(0, topH + 0.5);
  ctx.lineTo(W, topH + 0.5);
  ctx.stroke();

  // Scrub indicator: vertical line at mouseX while in scrub mode.
  if (userActive && mx >= 0 && mx <= W) {
    ctx.strokeStyle = 'rgba(95, 168, 211, 0.45)';
    ctx.lineWidth = 1;
    ctx.beginPath();
    ctx.moveTo(mx + 0.5, 0);
    ctx.lineTo(mx + 0.5, H);
    ctx.stroke();
  }

  const addedCount = Math.min(LEVELS, fullBands + (partialW > 0 ? 1 : 0));
  drawHUD(ctx, hudLabel, addedCount, modeLabel);

  if (inSnap) {
    // gentle dim overlay during snap
    ctx.fillStyle = 'rgba(12, 16, 20, 0.35)';
    ctx.fillRect(0, 0, W, H);
  }
}

Comments (0)

Log in to comment.