Introduction

Imagine a field of dry grass shaped like a capital letter H. You light the entire boundary at once. The fire races inward from every edge at the same speed. Where two flame-fronts collide they cannot advance further — they stop and leave a faint charred line. That charred line is the medial axis: the skeleton of the shape.

The idea was formalised in 1967 by the biologist Harry Blum, who was looking for a compact way to describe biological shapes. His insight was elegant: instead of cataloguing every point on a boundary, describe a shape by the centers of all the largest disks that fit inside it without crossing the boundary. Each such center is a point on the medial axis, and the radius of its disk is the thickness of the shape at that point.

Equivalently — and this is the grassfire picture — a point pp lies on the medial axis if and only if the circle centered at pp that just touches the boundary does so in at least two places. A disk touching the boundary at exactly one point is not maximal; it can still grow. Only when it presses against two or more boundary points at once does it reach its largest possible size, and its center joins the skeleton.

The medial axis keeps the topology intact. A simply-connected blob gives a tree skeleton. A ring gives a loop. Punch three holes and you get a branching graph. Strip away the radii and you lose thickness information, but the connectivity — the fundamental shape — is preserved. See also convex hull for another way geometry encodes a shape's essence.

Try It: Watch the Skeleton Burn

The demo below runs a discrete grassfire on a pixel grid. Click {{btn_burn_label}} to ignite the boundary and watch wave-fronts spread inward. Pixels where two or more fronts collide at the same time are painted as the medial axis — the skeleton.

<!-- Medial axis demo: grassfire burn on a pixel grid -->
<div class="toolbar">
  <span class="label">Shape:</span>
  <button id="btn-h"    type="button">H</button>
  <button id="btn-ring" type="button">Ring</button>
  <button id="btn-star" type="button">Star</button>
  <button id="btn-rect" type="button">Rectangle</button>
</div>
<canvas id="cv" width="300" height="300" title="Grassfire simulation canvas"></canvas>
<div class="toolbar">
  <button id="btn-burn"  type="button">Burn inward</button>
  <button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div id="status" class="status">Choose a shape and press Burn inward.</div>
/* Styles for the medial axis grassfire demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; }
.toolbar { display: flex; gap: .4rem; align-items: center; margin: .4rem 0; flex-wrap: wrap; }
.label { font-size: .85rem; color: #555; margin-right: .2rem; }
button { font: 600 13px system-ui; padding: .38rem .75rem; border: 1px solid #1d3557;
         background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button.active { background: #e63946; border-color: #c92f3c; }
canvas { display: block; border: 1px solid #cdd9e3; border-radius: 8px;
         image-rendering: pixelated; max-width: 300px; }
.status { font-size: .9rem; font-weight: 600; margin: .4rem 0; min-height: 1.3em; color: #1d3557; }
.status.done { color: #0a7d33; }
// Medial axis via discrete grassfire (BFS from boundary pixels)
// Cell state constants
const C_EMPTY    = 0;   // outside the shape
const C_FILLED   = 1;   // interior, not yet burned
const C_BOUNDARY = 2;   // boundary pixels (fire starts here)
const C_AXIS     = 3;   // medial axis pixel (two wavefronts met)
const C_WAVE     = 4;   // burned interior, not on axis

const PALETTE = ['#f0f4f8','#1d3557','#e63946','#f4a261','#a8dadc'];
// empty, filled, boundary, axis (orange), wave (teal)

const W = 60, H = 60; // grid size in pixels
let grid = new Uint8Array(W * H);
let animId = null;

const canvas = document.getElementById('cv');
const ctx = canvas.getContext('2d');

// Shape drawing functions — fill interior pixels with C_FILLED
function drawH() {
  grid.fill(C_EMPTY);
  // left vertical stroke
  for (let r = 10; r < 50; r++) for (let c = 8;  c < 22; c++) grid[r*W+c] = C_FILLED;
  // right vertical stroke
  for (let r = 10; r < 50; r++) for (let c = 38; c < 52; c++) grid[r*W+c] = C_FILLED;
  // horizontal crossbar
  for (let r = 27; r < 33; r++) for (let c = 8;  c < 52; c++) grid[r*W+c] = C_FILLED;
}
function drawRing() {
  grid.fill(C_EMPTY);
  const cx = 30, cy = 30, ro = 22, ri = 14;
  for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
    const d2 = (r-cy)*(r-cy)+(c-cx)*(c-cx);
    if (d2 <= ro*ro && d2 >= ri*ri) grid[r*W+c] = C_FILLED;
  }
}
function drawStar() {
  // 5-pointed star using polar interpolation
  grid.fill(C_EMPTY);
  const cx = 30, cy = 30, spokes = 5, R = 24, r = 10;
  for (let row = 0; row < H; row++) for (let col = 0; col < W; col++) {
    const dx = col - cx, dy = row - cy;
    const ang = Math.atan2(dy, dx);
    const dist = Math.sqrt(dx*dx + dy*dy);
    // interpolate radius between inner and outer for each spoke sector
    let inStar = false;
    for (let i = 0; i < spokes; i++) {
      const a0 = (2*Math.PI*i)/spokes - Math.PI/2;
      const a1 = (2*Math.PI*(i+0.5))/spokes - Math.PI/2;
      const a2 = (2*Math.PI*(i+1))/spokes - Math.PI/2;
      // fraction along this sector: 0=spoke tip, 1=next tip
      let t = (ang - a0) / (a2 - a0);
      if (t < 0) t += 1; if (t > 1) t -= 1;
      const rEdge = t <= 0.5 ? r + (R-r)*(t/0.5) : R - (R-r)*((t-0.5)/0.5);
      if (dist <= rEdge + 1) inStar = true;
    }
    if (inStar) grid[row*W+col] = C_FILLED;
  }
}
function drawRect() {
  grid.fill(C_EMPTY);
  for (let r = 10; r < 50; r++) for (let c = 8; c < 52; c++) grid[r*W+c] = C_FILLED;
}

// Mark boundary: filled cells adjacent to empty are C_BOUNDARY
function markBoundary() {
  for (let r = 0; r < H; r++) for (let c = 0; c < W; c++) {
    if (grid[r*W+c] !== C_FILLED) continue;
    const nb = [[r-1,c],[r+1,c],[r,c-1],[r,c+1]];
    if (nb.some(([nr,nc]) => nr<0||nr>=H||nc<0||nc>=W||grid[nr*W+nc]===C_EMPTY))
      grid[r*W+c] = C_BOUNDARY;
  }
}

// Render grid to canvas pixel-by-pixel
function render() {
  const img = ctx.createImageData(W, H);
  const hex = (h) => [parseInt(h.slice(1,3),16),parseInt(h.slice(3,5),16),parseInt(h.slice(5,7),16)];
  const colors = PALETTE.map(hex);
  for (let i = 0; i < W*H; i++) {
    const [rr,gg,bb] = colors[grid[i]];
    img.data[i*4]=rr; img.data[i*4+1]=gg; img.data[i*4+2]=bb; img.data[i*4+3]=255;
  }
  ctx.putImageData(img, 0, 0);
}

// Grassfire / BFS wavefront propagation
// Pixels are colored by the time their wavefront arrives; collisions mark the axis.
function startBurn() {
  if (animId) cancelAnimationFrame(animId);
  // distance (in BFS steps) from the boundary for each pixel
  const dist = new Int16Array(W * H).fill(-1);
  const queue = [];
  // seed the queue with all boundary pixels at distance 0
  for (let i = 0; i < W*H; i++) {
    if (grid[i] === C_BOUNDARY) { dist[i] = 0; queue.push(i); }
  }
  // BFS through interior pixels; record traversal order for animation
  const order = [];
  let head = 0;
  while (head < queue.length) {
    const i = queue[head++];
    const r = Math.floor(i / W), c = i % W;
    const nb = [];
    if (r>0)   nb.push(i-W);
    if (r<H-1) nb.push(i+W);
    if (c>0)   nb.push(i-1);
    if (c<W-1) nb.push(i+1);
    for (const j of nb) {
      if (grid[j] === C_FILLED && dist[j] === -1) {
        dist[j] = dist[i] + 1;
        queue.push(j);
        order.push(j);
      }
    }
  }
  // Detect axis pixels: a pixel is on the axis if a neighbor has the same distance
  // i.e. two wavefronts arrived at the same time from different directions
  const isAxis = new Uint8Array(W * H);
  for (const i of order) {
    const d = dist[i];
    // A pixel is on the medial axis if no cardinal neighbor has a strictly greater distance (local max)
    const cardinal = [i-W,i+W,i-1,i+1];
    let isLocalMax = true;
    for (const j of cardinal) {
      if (j>=0 && j<W*H && dist[j] > d) { isLocalMax = false; break; }
    }
    isAxis[i] = isLocalMax ? 1 : 0;
  }

  // Animate the burn frame by frame
  let step = 0;
  const CHUNK = 6; // pixels to color per frame
  const status = document.getElementById('status');
  status.textContent = 'Burning…';
  status.className = 'status';
  function frame() {
    for (let k = 0; k < CHUNK && step < order.length; k++, step++) {
      const i = order[step];
      grid[i] = isAxis[i] ? C_AXIS : C_WAVE;
    }
    render();
    if (step < order.length) {
      animId = requestAnimationFrame(frame);
    } else {
      status.textContent = 'Done — orange pixels are the medial axis skeleton.';
      status.className = 'status done';
    }
  }
  animId = requestAnimationFrame(frame);
}

// Preset button wiring and initial draw
let currentShape = 'h';
function setShape(id, fn) {
  if (animId) { cancelAnimationFrame(animId); animId = null; }
  currentShape = id;
  fn();
  markBoundary();
  render();
  document.getElementById('status').textContent = 'Choose a shape and press Burn inward.';
  document.getElementById('status').className = 'status';
  document.querySelectorAll('.toolbar button:not(.ghost)').forEach(b => b.classList.remove('active'));
  document.getElementById('btn-'+id).classList.add('active');
}

document.getElementById('btn-h').addEventListener('click',    () => setShape('h',    drawH));
document.getElementById('btn-ring').addEventListener('click', () => setShape('ring', drawRing));
document.getElementById('btn-star').addEventListener('click', () => setShape('star', drawStar));
document.getElementById('btn-rect').addEventListener('click', () => setShape('rect', drawRect));
document.getElementById('btn-burn').addEventListener('click', startBurn);
document.getElementById('btn-reset').addEventListener('click', () => {
  if (animId) { cancelAnimationFrame(animId); animId = null; }
  drawH(); markBoundary(); render();
  currentShape = 'h';
  document.getElementById('status').textContent = 'Choose a shape and press Burn inward.';
  document.getElementById('status').className = 'status';
  document.querySelectorAll('.toolbar button:not(.ghost)').forEach(b => b.classList.remove('active'));
  document.getElementById('btn-h').classList.add('active');
});

// Initial render
drawH();
markBoundary();
render();
document.getElementById('btn-h').classList.add('active');
Result
Console

Notice that thin bridges produce long skeleton branches and wide bulges produce thick ones. Change the shape with the preset buttons and rerun to see how the skeleton captures the essence of each form. The grassfire reaches every interior pixel in time proportional to the shape's inradius — the radius of the largest inscribed circle.

The Real Complexity

Status: solved. Computing the medial axis of a polygon with nn vertices is equivalent to computing its interior Voronoi diagram — each boundary edge and vertex acts as a Voronoi site, and the medial axis is precisely the Voronoi diagram restricted to the interior of the shape. Fortune's sweep-line algorithm achieves O(nlog⁡n)O(n \log n) time and O(n)O(n) space, which is also the lower bound for comparison-based geometry.

The real challenge is not asymptotic complexity but numerical stability. The medial axis is notoriously sensitive to small bumps on the boundary:

  • A tiny pimple on an otherwise smooth curve sprouts a long, thin skeletal branch that carries almost no geometric information.
  • This is not a bug — it is mathematically correct. Small boundary features create their own medial-axis branches, no matter how insignificant they look.
  • In practice, the medial axis must be pruned before use. The most common criterion is the significance measure or erosion thickness: a branch is kept only if it is generated by a maximal disk whose radius exceeds some threshold ε\varepsilon.

For curved shapes (smooth closed curves in the plane) the medial axis is still a graph, but computing it exactly requires handling parabolic arcs — the Voronoi edges between a point site and a line site are parabolas. Libraries such as CGAL and Boost.Polygon handle these. In 3D, the medial axis becomes a 2-dimensional surface (medial surface), and exact computation is substantially harder.

Despite the stability issue, the medial axis is central to pattern matching — matching two shapes becomes matching their skeletons, a far more tractable comparison.

Where It Matters

The medial axis appears wherever a shape must be understood, navigated, or compared:

  • Robot motion planning: a robot moving through a cluttered room can follow the medial axis of the free space — the path that keeps it maximally far from every obstacle at every step. This is the retraction method of motion planning.
  • Medical image analysis: the centerlines of blood vessels, airways, and bones are medial axes. Measuring a coronary artery's narrowing, tracking tumor growth, or analysing bone density all rely on first extracting the vessel's or bone's skeleton.
  • 3D printing and CNC machining: tool-path planning places the cutter head along the medial axis so the tool sweeps the interior with the fewest passes and avoids unnecessary boundary proximity.
  • Font rendering and calligraphy: the stroke model of a letter is its medial axis plus a varying thickness function — the exact data a calligraphic font needs to rescale cleanly at any size.
  • Shape retrieval and recognition: comparing two shapes by their medial-axis graphs is far more stable than comparing raw boundary contours. Database search for similar shapes (logos, biological cells, geographic outlines) routinely reduces to skeleton matching.
  • Finite element meshing: decomposing a complex region into simple triangles for simulation is easier when the medial axis guides the decomposition, ensuring elements are well-shaped near narrow passages.

Conclusion

Set a shape on fire from its boundary. Where the flames collide, the skeleton appears. That simple picture — Blum's grassfire from 1967 — carries a surprising amount of mathematical weight: it preserves topology, encodes thickness, and reduces shape comparison to graph matching.

Computing the exact skeleton is efficient (O(nlog⁡n)O(n \log n) via Voronoi diagrams), but using it wisely requires pruning away the noise that every real boundary introduces. Once pruned, the medial axis becomes one of the most versatile tools in computational geometry: it plans robot routes, measures arteries, guides 3D-printer heads, and gives calligraphic fonts their stroke.

The skeleton was hiding inside the shape all along. All it took was fire to reveal it.

Share this article

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

Comments

Loading comments...

https://www.kipuhub.com/en/article/medial-axis/Content licensed under CC BY-NC 4.0.