11

Perona-Malik: Edge-Preserving Diffusion

drag Y to scrub K · click to reseed

Perona and Malik's 1990 nonlinear diffusion replaces the heat equation's uniform smoothing with a flux that locally switches itself off near edges. Given an image , evolve with the edge-stopping coefficient — large in flat regions so they smooth, small where so edges survive. The parameter is the gradient magnitude that separates 'noise' from 'feature.' Solved here on a grid by explicit Euler with a 4-neighbor flux, evaluated per directed edge so the discrete scheme stays a true divergence. Side by side the same noisy initial image is evolved under the linear heat equation — same time step, same number of iterations — and the contrast is the whole point: heat blurs everything, Perona-Malik clears the noise inside each region while leaving the boundaries crisp. Re-noised every ten seconds so the cycle stays visible. Inspired by Gabriel Peyré's diffusion-and-image-processing visualizations.

idle
208 lines · vanilla
view source
// Perona-Malik anisotropic diffusion vs. linear (isotropic) heat smoothing.
//
//   d_t u = div( g(|grad u|) grad u ),   g(s) = 1 / (1 + (s/K)^2)
//
// vs. the plain heat equation d_t u = Lap(u). Same noisy initial image on
// both sides; PM preserves edges, heat smears them.
//
// Inspired by Gabriel Peyre's diffusion-and-image-processing visualizations.

const GW = 200;
const GH = 150;
const N = GW * GH;

// Numerical parameters.
const K_DEFAULT = 0.12;     // edge threshold for g(s)
const K_MIN = 0.02;         // strongest edges only
const K_MAX = 0.50;         // blurs almost everything
let K = K_DEFAULT;
let K2 = K * K;
const DT = 0.18;            // step in the PDE time (dx = 1)
const SUBSTEPS = 2;         // sim steps per render frame
const RESEED_SECONDS = 10;  // re-noise + restart every ~10 s

// Two fields evolved in parallel from the same noisy seed.
let uPM;       // Perona-Malik
let uPMNext;
let uLin;      // linear heat
let uLinNext;

// Pixel buffers (one per panel).
let bufPM, bufPMCtx, bufPMImg;
let bufLin, bufLinCtx, bufLinImg;

let W, H;
let simTime;
let secondsSinceReseed;

// Last computed panel layout, kept around so the click handler at the top
// of tick() can decide whether a click landed on a panel (either one).
let lastLayout = null;

// Synthetic noisy image: a few overlapping disks with distinct intensities
// on a flat background, plus Gaussian noise. Range roughly in [0, 1].
function makeNoisyImage(target) {
  // Background level.
  const bg = 0.45;

  // Disks: (cx, cy, r, intensity). Coordinates in grid units.
  const disks = [
    { cx: 65,  cy: 55,  r: 32, v: 0.85 },
    { cx: 130, cy: 70,  r: 38, v: 0.18 },
    { cx: 95,  cy: 105, r: 26, v: 0.62 },
    { cx: 160, cy: 30,  r: 18, v: 0.95 },
    { cx: 40,  cy: 110, r: 20, v: 0.08 },
  ];

  for (let y = 0; y < GH; y++) {
    for (let x = 0; x < GW; x++) {
      let v = bg;
      // Last-disk-wins painting, so overlaps produce sharp boundaries.
      for (let d = 0; d < disks.length; d++) {
        const dx = x - disks[d].cx;
        const dy = y - disks[d].cy;
        if (dx * dx + dy * dy <= disks[d].r * disks[d].r) {
          v = disks[d].v;
        }
      }
      // Gaussian noise via Box-Muller.
      const u1 = Math.random() || 1e-9;
      const u2 = Math.random();
      const n = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
      v += n * 0.06;
      // Soft clamp; we let the diffusion eat any residual overshoot.
      if (v < 0) v = 0;
      else if (v > 1) v = 1;
      target[y * GW + x] = v;
    }
  }
}

function reseed() {
  makeNoisyImage(uPM);
  // Copy PM's seed into the linear field so both sides see identical noise.
  uLin.set(uPM);
  simTime = 0;
  secondsSinceReseed = 0;
}

// Perona-Malik step on a Neumann-boundary grid.
//
// Flux form: u^{n+1} = u^n + dt * sum_{neighbor n} g(|du|) * du,
// where du = u_n - u_c and g(s^2) = 1/(1 + s^2/K^2). Using s^2 directly
// saves a sqrt and the result is the same.
function stepPM(dtSim) {
  for (let y = 0; y < GH; y++) {
    const y0 = y * GW;
    const ym = y === 0 ? y0 : (y - 1) * GW;       // Neumann (reflect)
    const yp = y === GH - 1 ? y0 : (y + 1) * GW;
    for (let x = 0; x < GW; x++) {
      const xm = x === 0 ? x : x - 1;
      const xp = x === GW - 1 ? x : x + 1;
      const c = uPM[y0 + x];
      const dN = uPM[ym + x] - c;
      const dS = uPM[yp + x] - c;
      const dE = uPM[y0 + xp] - c;
      const dW = uPM[y0 + xm] - c;
      const gN = 1 / (1 + (dN * dN) / K2);
      const gS = 1 / (1 + (dS * dS) / K2);
      const gE = 1 / (1 + (dE * dE) / K2);
      const gWv = 1 / (1 + (dW * dW) / K2);
      uPMNext[y0 + x] = c + dtSim * (gN * dN + gS * dS + gE * dE + gWv * dW);
    }
  }
  const tmp = uPM;
  uPM = uPMNext;
  uPMNext = tmp;
}

// Linear heat equation, same Neumann BC, same dt. With dt=0.18 we're a bit
// over the strict 1/4 explicit-Euler limit, but the field is bounded and
// the visual is the right one (edges smear away).
function stepLin(dtSim) {
  for (let y = 0; y < GH; y++) {
    const y0 = y * GW;
    const ym = y === 0 ? y0 : (y - 1) * GW;
    const yp = y === GH - 1 ? y0 : (y + 1) * GW;
    for (let x = 0; x < GW; x++) {
      const xm = x === 0 ? x : x - 1;
      const xp = x === GW - 1 ? x : x + 1;
      const c = uLin[y0 + x];
      const lap = uLin[ym + x] + uLin[yp + x] + uLin[y0 + xp] + uLin[y0 + xm] - 4 * c;
      uLinNext[y0 + x] = c + dtSim * 0.22 * lap;
    }
  }
  const tmp = uLin;
  uLin = uLinNext;
  uLinNext = tmp;
}

// Grayscale colormap with a slight gamma so mid-tones don't go muddy.
function grayByte(v) {
  if (v < 0) v = 0;
  else if (v > 1) v = 1;
  // mild gamma to keep contrast Peyre-ish
  const g = Math.pow(v, 0.95);
  return Math.round(g * 255);
}

function paintInto(field, bufCtx, bufImg) {
  const data = bufImg.data;
  let off = 0;
  for (let i = 0; i < N; i++) {
    const g = grayByte(field[i]);
    data[off] = g;
    data[off + 1] = g;
    data[off + 2] = g;
    data[off + 3] = 255;
    off += 4;
  }
  bufCtx.putImageData(bufImg, 0, 0);
}

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

  uPM = new Float32Array(N);
  uPMNext = new Float32Array(N);
  uLin = new Float32Array(N);
  uLinNext = new Float32Array(N);

  bufPM = new OffscreenCanvas(GW, GH);
  bufPMCtx = bufPM.getContext('2d');
  bufPMImg = bufPMCtx.createImageData(GW, GH);

  bufLin = new OffscreenCanvas(GW, GH);
  bufLinCtx = bufLin.getContext('2d');
  bufLinImg = bufLinCtx.createImageData(GW, GH);

  reseed();

  ctx.fillStyle = '#f4f3ee';
  ctx.fillRect(0, 0, W, H);
}

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

  // Scrub K from mouseY. Top of canvas → K_MIN (only the strongest edges
  // survive), bottom → K_MAX (everything blurs). We let the diffusion keep
  // evolving with the new K — watching edge sensitivity drift mid-flight is
  // the point.
  if (input && typeof input.mouseY === 'number' && H > 0) {
    const t = Math.max(0, Math.min(1, input.mouseY / H));
    K = K_MIN + t * (K_MAX - K_MIN);
    K2 = K * K;
  }

  // Click anywhere on either panel → fresh noise on both sides.
  if (input && typeof input.consumeClicks === 'function') {
    const clicks = input.consumeClicks();
    if (clicks && clicks.length && lastLayout) {
      const { xLeft, xRight, yTop, drawW, drawH } = lastLayout;
      for (let i = 0; i < clicks.length; i++) {
        const cx = clicks[i].x;
        const cy = clicks[i].y;
        const onLeft = cx >= xLeft && cx <= xLeft + drawW && cy >= yTop && cy <= yTop + drawH;
        const onRight = cx >= xRight && cx <= xRight + drawW && cy >= yTop && cy <= yTop + drawH;
        if (onLeft || onRight) {
          reseed();
          break;
        }
      }
    }
  }

  // Advance both PDEs on the same clock.
  for (let s = 0; s < SUBSTEPS; s++) {
    stepPM(DT);
    stepLin(DT);
    simTime += DT;
  }

  secondsSinceReseed += dt;
  if (secondsSinceReseed >= RESEED_SECONDS) {
    reseed();
  }

  paintInto(uPM, bufPMCtx, bufPMImg);
  paintInto(uLin, bufLinCtx, bufLinImg);

  // Layout: two panels side by side with a thin gutter and a band of
  // page-cream above for labels.
  const labelH = 22;
  const gutter = 10;
  const margin = 12;
  const availW = W - 2 * margin - gutter;
  const panelW = Math.floor(availW / 2);
  const availH = H - labelH - 2 * margin;
  // Honor the 4:3 image aspect (GW:GH = 4:3).
  let drawH = availH;
  let drawW = Math.floor(drawH * (GW / GH));
  if (drawW > panelW) {
    drawW = panelW;
    drawH = Math.floor(drawW * (GH / GW));
  }
  const yTop = margin + labelH + Math.floor((availH - drawH) / 2);
  const xLeft = margin + Math.floor((panelW - drawW) / 2);
  const xRight = margin + panelW + gutter + Math.floor((panelW - drawW) / 2);

  // Stash for the next frame's click hit-test.
  lastLayout = { xLeft, xRight, yTop, drawW, drawH };

  // Background.
  ctx.fillStyle = '#f4f3ee';
  ctx.fillRect(0, 0, W, H);

  // Thin frames.
  ctx.imageSmoothingEnabled = true;
  ctx.imageSmoothingQuality = 'high';
  ctx.drawImage(bufPM, xLeft, yTop, drawW, drawH);
  ctx.drawImage(bufLin, xRight, yTop, drawW, drawH);

  ctx.strokeStyle = 'rgba(20, 20, 20, 0.7)';
  ctx.lineWidth = 1;
  ctx.strokeRect(xLeft - 0.5, yTop - 0.5, drawW + 1, drawH + 1);
  ctx.strokeRect(xRight - 0.5, yTop - 0.5, drawW + 1, drawH + 1);

  // Labels above each panel.
  ctx.fillStyle = 'rgba(20, 20, 20, 0.85)';
  ctx.font = '13px ui-sans-serif, system-ui, sans-serif';
  ctx.textBaseline = 'alphabetic';
  ctx.textAlign = 'center';
  ctx.fillText('Perona-Malik (edge-preserving)', xLeft + drawW / 2, yTop - 6);
  ctx.fillText('Linear heat (isotropic)', xRight + drawW / 2, yTop - 6);

  // HUD bottom-left: K and elapsed time.
  ctx.textAlign = 'left';
  ctx.fillStyle = 'rgba(20, 20, 20, 0.65)';
  ctx.font = '12px ui-sans-serif, system-ui, sans-serif';
  ctx.textBaseline = 'bottom';
  ctx.fillText('K = ' + K.toFixed(3) + '    t = ' + simTime.toFixed(1) + '    (drag Y · click to reseed)', margin, H - 6);
}

Comments (0)

Log in to comment.