Introduction

When you look at a photograph, your eye effortlessly traces the contour of a face, a road, a leaf. Computers have to earn that ability. For decades, the best answer to the question "where are the edges in this image?" has been a four-step pipeline published by John Canny in 1986 — a pipeline so elegant that it still ships in almost every modern vision system.

An edge is simply a place where pixel brightness changes sharply. Smooth regions change slowly; boundaries between objects change fast. Canny's insight was to make that notion of "fast change" precise: compute the gradient of the image (how brightness varies in every direction), thin it down to single-pixel ridges, and then decide which ridges are real edges with a double threshold called hysteresis.

The result is a map of the image's skeleton — the outlines of every object, drawn with one-pixel-wide curves, without gaps in important lines and without noise masquerading as signal.

Try It

Paint a simple shape on the pixel grid below, then step through the Canny pipeline to watch edges emerge. Each button advances one stage.

<!-- Canvas-based Canny edge detection demo -->
<p class="hint">Paint pixels on the grid (click or drag), then step through the Canny pipeline: <b>Gradient</b> finds intensity changes, <b>Non-Max Suppression</b> thins them to single-pixel ridges, and <b>Hysteresis</b> keeps only the true edges.</p>
<div class="controls top-controls">
  <button id="btn-paint" class="active" type="button">Paint</button>
  <button id="btn-erase" type="button">Erase</button>
  <button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div id="grid-wrap">
  <canvas id="canvas" width="240" height="240"></canvas>
</div>
<div class="pipeline-row">
  <button id="btn-gradient" type="button">1. Gradient</button>
  <button id="btn-nms" type="button" disabled>2. Non-Max Suppression</button>
  <button id="btn-hysteresis" type="button" disabled>3. Hysteresis</button>
</div>
<div class="status" id="status">Draw a shape, then click Gradient.</div>
/* Base reset and layout */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: 4px; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
#grid-wrap { display: inline-block; border: 2px solid #8899aa; border-radius: 6px; overflow: hidden; cursor: crosshair; margin: .4rem 0; }
canvas { display: block; image-rendering: pixelated; }
.controls { display: flex; gap: .4rem; flex-wrap: wrap; margin-bottom: .4rem; }
.pipeline-row { display: flex; gap: .4rem; flex-wrap: wrap; margin: .4rem 0; }
button { font: 600 13px system-ui, sans-serif; padding: .4rem .75rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; transition: opacity .15s; }
button.ghost { background: #fff; color: #1d3557; }
button.active { outline: 2px solid #e63946; outline-offset: 2px; }
button:disabled { opacity: .35; cursor: default; }
.status { font-size: .9rem; font-weight: 600; min-height: 1.4em; margin-top: .35rem; color: #1d3557; }
.status.done { color: #0a7d33; }
/* Canny pipeline: Sobel gradient → non-maximum suppression → hysteresis thresholding */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));

/* Grid dimensions — 12x12 cells, each 20px */
const COLS = 12, ROWS = 12, CELL = 20;
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
canvas.width = COLS * CELL;
canvas.height = ROWS * CELL;

/* Pixel state array (0=empty, 255=filled) */
const px = new Uint8Array(ROWS * COLS); // 0=empty 255=filled
let mode = 'paint'; // paint or erase
let stage = 'draw'; // draw | gradient | nms | edge

/* Computed pipeline stage data */
let gradMag = null; // normalized 0-255 gradient magnitude
let nmsMap = null;  // binary: 1=ridge pixel
let edgeMap = null; // binary: 1=confirmed edge

/* Color palette for each pipeline stage */
const COL_BG   = '#f0f4f8';
const COL_FILL = '#1d3557';
const COL_GRAD = '#e07b39';
const COL_NMS  = '#9333ea';
const COL_EDGE = '#e63946';

function idx(r, c) { return r * COLS + c; }

/* Render the canvas according to current pipeline stage */
function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (let r = 0; r < ROWS; r++) {
    for (let c = 0; c < COLS; c++) {
      const x = c * CELL, y = r * CELL;
      if (stage === 'draw') {
        ctx.fillStyle = px[idx(r, c)] ? COL_FILL : COL_BG;
        ctx.fillRect(x, y, CELL, CELL);
      } else if (stage === 'gradient' && gradMag) {
        const v = gradMag[idx(r, c)];
        const t = Math.min(v / 255, 1);
        ctx.fillStyle = lerpColor(COL_BG, COL_GRAD, t);
        ctx.fillRect(x, y, CELL, CELL);
      } else if (stage === 'nms' && nmsMap) {
        ctx.fillStyle = nmsMap[idx(r, c)] ? COL_NMS : COL_BG;
        ctx.fillRect(x, y, CELL, CELL);
      } else if (stage === 'edge' && edgeMap) {
        ctx.fillStyle = edgeMap[idx(r, c)] ? COL_EDGE : COL_BG;
        ctx.fillRect(x, y, CELL, CELL);
      }
      /* Faint grid lines */
      ctx.strokeStyle = 'rgba(100,120,140,0.25)';
      ctx.lineWidth = 0.5;
      ctx.strokeRect(x, y, CELL, CELL);
    }
  }
}

/* Linearly interpolate between two hex colors */
function lerpColor(a, b, t) {
  const parse = h => [parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)];
  const [ar,ag,ab] = parse(a), [br,bg,bb] = parse(b);
  const r = Math.round(ar + (br-ar)*t), g = Math.round(ag + (bg-ag)*t), bl = Math.round(ab + (bb-ab)*t);
  return `rgb(${r},${g},${bl})`;
}

/* Mouse and touch painting handlers */
let painting = false;
canvas.addEventListener('mousedown', e => { painting = true; applyBrush(e); });
canvas.addEventListener('mousemove', e => { if (painting) applyBrush(e); });
canvas.addEventListener('mouseup', () => { painting = false; });
canvas.addEventListener('mouseleave', () => { painting = false; });
canvas.addEventListener('touchstart', e => { e.preventDefault(); painting = true; applyBrush(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchmove', e => { e.preventDefault(); if (painting) applyBrush(e.touches[0]); }, { passive: false });
canvas.addEventListener('touchend', () => { painting = false; });

function applyBrush(e) {
  if (stage !== 'draw') return;
  const rect = canvas.getBoundingClientRect();
  const c = Math.floor((e.clientX - rect.left) / CELL);
  const r = Math.floor((e.clientY - rect.top) / CELL);
  if (r < 0 || r >= ROWS || c < 0 || c >= COLS) return;
  px[idx(r, c)] = mode === 'paint' ? 255 : 0;
  draw();
}

/* Sobel operator — approximates image gradient magnitude and angle */
function sobelGradient() {
  const Gx = [-1,0,1,-2,0,2,-1,0,1];
  const Gy = [-1,-2,-1, 0, 0, 0, 1, 2, 1];
  const mag = new Float32Array(ROWS * COLS);
  const ang = new Float32Array(ROWS * COLS);
  for (let r = 1; r < ROWS-1; r++) {
    for (let c = 1; c < COLS-1; c++) {
      let gx = 0, gy = 0, ki = 0;
      for (let dr = -1; dr <= 1; dr++) {
        for (let dc = -1; dc <= 1; dc++) {
          const val = px[idx(r+dr, c+dc)];
          gx += Gx[ki] * val;
          gy += Gy[ki] * val;
          ki++;
        }
      }
      mag[idx(r,c)] = Math.sqrt(gx*gx + gy*gy);
      ang[idx(r,c)] = Math.atan2(gy, gx);
    }
  }
  return { mag, ang };
}

/* Non-maximum suppression — keep only local gradient maxima along edge direction */
function nonMaxSuppression(mag, ang) {
  const out = new Uint8Array(ROWS * COLS);
  const maxMag = Math.max(...mag);
  if (maxMag === 0) return out;
  const norm = new Float32Array(mag.length);
  for (let i = 0; i < mag.length; i++) norm[i] = mag[i] / maxMag * 255;

  for (let r = 1; r < ROWS-1; r++) {
    for (let c = 1; c < COLS-1; c++) {
      const a = ang[idx(r,c)];
      /* Quantize gradient angle to 4 directions */
      const deg = ((a * 180 / Math.PI) % 180 + 180) % 180;
      let r1, c1, r2, c2;
      if (deg < 22.5 || deg >= 157.5)       { r1=0; c1=1; r2=0; c2=-1; }
      else if (deg < 67.5)                   { r1=-1; c1=1; r2=1; c2=-1; }
      else if (deg < 112.5)                  { r1=-1; c1=0; r2=1; c2=0; }
      else                                   { r1=-1; c1=-1; r2=1; c2=1; }
      const m = norm[idx(r,c)];
      const m1 = norm[idx(r+r1, c+c1)];
      const m2 = norm[idx(r+r2, c+c2)];
      out[idx(r,c)] = (m >= m1 && m >= m2 && m > 10) ? 1 : 0;
    }
  }
  return out;
}

/* Hysteresis thresholding — flood-fill from strong edges to connected weak edges */
function hysteresis(nms, mag) {
  const maxMag = Math.max(...mag);
  if (maxMag === 0) return new Uint8Array(ROWS * COLS);
  const norm = new Float32Array(mag.length);
  for (let i = 0; i < mag.length; i++) norm[i] = mag[i] / maxMag * 255;
  const TH = 80, TL = 25; // high and low thresholds (normalized 0-255)
  const strong = new Uint8Array(ROWS * COLS);
  const weak   = new Uint8Array(ROWS * COLS);
  for (let i = 0; i < ROWS * COLS; i++) {
    if (!nms[i]) continue;
    if (norm[i] >= TH) strong[i] = 1;
    else if (norm[i] >= TL) weak[i] = 1;
  }
  /* BFS flood-fill from strong edge pixels */
  const out = new Uint8Array(ROWS * COLS);
  const queue = [];
  for (let i = 0; i < ROWS * COLS; i++) { if (strong[i]) { out[i] = 1; queue.push(i); } }
  while (queue.length) {
    const i = queue.pop();
    const r = Math.floor(i / COLS), c = i % COLS;
    for (let dr = -1; dr <= 1; dr++) for (let dc = -1; dc <= 1; dc++) {
      const nr = r+dr, nc = c+dc;
      if (nr < 0 || nr >= ROWS || nc < 0 || nc >= COLS) continue;
      const ni = idx(nr, nc);
      if (weak[ni] && !out[ni]) { out[ni] = 1; queue.push(ni); }
    }
  }
  return out;
}

/* Wire up toolbar buttons */
document.getElementById('btn-paint').addEventListener('click', () => {
  mode = 'paint';
  document.getElementById('btn-paint').classList.add('active');
  document.getElementById('btn-erase').classList.remove('active');
});
document.getElementById('btn-erase').addEventListener('click', () => {
  mode = 'erase';
  document.getElementById('btn-erase').classList.add('active');
  document.getElementById('btn-paint').classList.remove('active');
});
document.getElementById('btn-reset').addEventListener('click', () => {
  px.fill(0); gradMag = null; nmsMap = null; edgeMap = null;
  stage = 'draw';
  document.getElementById('btn-nms').disabled = true;
  document.getElementById('btn-hysteresis').disabled = true;
  setStatus('Draw a shape, then click Gradient.', '');
  draw();
});

document.getElementById('btn-gradient').addEventListener('click', () => {
  const { mag, ang } = sobelGradient();
  const maxMag = Math.max(...mag);
  gradMag = new Uint8Array(ROWS * COLS);
  for (let i = 0; i < mag.length; i++) gradMag[i] = maxMag > 0 ? Math.round(mag[i] / maxMag * 255) : 0;
  /* Store raw angle and magnitude for subsequent stages */
  canvas._ang = ang; canvas._rawMag = mag;
  stage = 'gradient';
  document.getElementById('btn-nms').disabled = false;
  const nonZero = [...gradMag].filter(v => v > 10).length;
  setStatus(i18nFmt('Gradient computed — %n% active pixels. Click Non-Max Suppression.', { n: nonZero }), '');
  draw();
});

document.getElementById('btn-nms').addEventListener('click', () => {
  nmsMap = nonMaxSuppression(canvas._rawMag, canvas._ang);
  stage = 'nms';
  document.getElementById('btn-hysteresis').disabled = false;
  const edgePx = [...nmsMap].filter(v => v).length;
  setStatus(i18nFmt('Suppressed to %n% ridge pixel(s). Click Hysteresis.', { n: edgePx }), '');
  draw();
});

document.getElementById('btn-hysteresis').addEventListener('click', () => {
  edgeMap = hysteresis(nmsMap, canvas._rawMag);
  stage = 'edge';
  const edgePx = [...edgeMap].filter(v => v).length;
  setStatus(i18nFmt('Edges found: %n% pixel(s) after hysteresis thresholding.', { n: edgePx }), 'done');
  draw();
});

function setStatus(t, cls) {
  const s = document.getElementById('status'); s.textContent = t; s.className = 'status ' + cls;
}

/* Initialize display */
setStatus('Draw a shape, then click Gradient.', '');
draw();
Result
Console

Notice that non-maximum suppression thins broad gradient ridges into crisp one-pixel lines, and hysteresis keeps weak pixels that are connected to strong ones — bridging gaps without being fooled by isolated noise.

The Real Complexity

Canny did not just build a detector that works — he defined what a perfect edge detector should do, then proved his pipeline satisfies all three criteria at once.

The three optimality criteria:

  • Good detection. The detector must not miss real edges and must not invent fake ones. Formally, it maximises the signal-to-noise ratio of the response.
  • Good localisation. Detected edges must sit as close as possible to the true boundary — Canny minimises the expected distance between a detected edge and the actual edge.
  • Minimal response. Each physical edge should produce exactly one detected edge; no double responses. Non-maximum suppression enforces this by keeping only the local peak of the gradient magnitude along each ridge direction.

Runtime: the pipeline is O(n)O(n) in the number of pixels. Every step — Gaussian blur, gradient computation, non-maximum suppression, hysteresis — scans the image a constant number of times with fixed-size kernels.

The two thresholds (high ThT_h and low TlT_l) are the only free parameters, and setting Th/Tl≈2–3T_h / T_l \approx 2\text{–}3 is a robust rule of thumb. Unlike many algorithms, the sensitivity to these parameters is predictable: raising ThT_h removes weaker edges; lowering TlT_l keeps more of the chains connected to strong edges.

For a deeper look at how gradient-based reasoning powers many algorithms, see the article on Dijkstra's algorithm — both exploit the idea of propagating information along a "steepest direction."

Where It Matters

Edge maps are the lingua franca of computer vision. Almost every pipeline that understands images starts with edges:

  • Medical imaging: CT and MRI scans use edge detection to delineate organs, tumours, and bone boundaries before any higher-level analysis. Canny's precision matters here — a missed boundary can change a diagnosis.
  • Autonomous vehicles: lane-keeping systems find road markings by detecting the sharp contrast between asphalt and paint. Edge maps are cheap to compute on embedded hardware.
  • Face and object detection: classical detectors like Viola–Jones use Haar features that respond to intensity differences — essentially oriented edge energy — to locate faces in real time.
  • Document scanning: apps that straighten a photographed page first detect the four edges of the document, then warp the perspective.
  • Industrial inspection: detecting cracks, scratches, or missing features on a manufactured part is a high-precision edge-finding problem where Canny's localization guarantee saves money.

The algorithm is also a conceptual ancestor of the learned feature detectors inside modern neural networks. The first layer of a convolutional network trained on natural images learns filters that look almost exactly like oriented Canny-style edge detectors — the network rediscovers what Canny proved optimal in 1986.

Conclusion

Canny edge detection is a rare algorithm that was both practically useful the day it was published and theoretically justified — it provably satisfies three simultaneous optimality criteria. Its O(n)O(n) runtime, single-pixel-wide output, and predictable sensitivity to thresholds make it the default choice whenever a vision system needs to find outlines.

The deeper lesson is about what "optimal" even means in image processing: Canny forced the field to write down what a good edge detector should do before building one, and that discipline produced something still copied by neural networks four decades later.

If edge detection is about finding the fastest-changing direction in a field of values, you are never far from ideas like Dijkstra's algorithm, which propagates along the cheapest path in a weighted graph — both are fundamentally about following gradients.

Share this article

Pick a channel — or use your device's native share sheet.

Comments

Loading comments...

https://www.kipuhub.com/en/article/canny-edge-detection/Content licensed under CC BY-NC 4.0.