Introduction

Imagine pouring water into a hilly landscape. Each valley fills independently until the rising pools meet at a ridge — and that ridge becomes a boundary. Watershed segmentation applies exactly this idea to images.

Every pixel has an intensity: dark pixels form valleys, bright pixels form ridges. The algorithm treats the image as terrain, plants seed markers in the regions you want to separate, and simulates a flood that rises simultaneously from each seed. When two floods touch, the algorithm draws a boundary line — the watershed line — between them.

The result is a clean partition of the image into labeled regions, each one "owned" by a single seed marker. Unlike edge detectors that chase gradients pixel by pixel, watershed segmentation reasons about the global topology of the brightness landscape, producing closed, connected region boundaries even when individual edges are noisy.

The algorithm is a classic of mathematical morphology, developed by Serge Beucher and Fernand Meyer at the École des Mines de Paris in the 1970s and 1990s, and it remains central to medical imaging, materials science, and computer vision today.

Try It

Below is a tiny grayscale image with two overlapping bright blobs on a dark background. The bright cores form valleys when the image is inverted (as the algorithm requires), and the dim background forms ridges.

Click Run Watershed to flood the scene from the two seed markers (colored dots). Watch each basin expand until the floods meet — the boundary drawn there is the watershed line.

<!-- Watershed segmentation demo: two blobs, two seed markers, one watershed boundary. -->
<p class="hint">Two bright blobs sit on a dark background. The algorithm treats inverted brightness as elevation and floods from each seed marker — the line where two floods meet is the watershed boundary.</p>
<div class="canvas-wrap">
  <canvas id="imgCanvas" width="200" height="200"></canvas>
  <canvas id="ovCanvas" width="200" height="200"></canvas>
</div>
<div class="legend">
  <span class="dot dot-a"></span> Region A (red)
  <span class="dot dot-b"></span> Region B (teal)
</div>
<div class="status" id="status">Click Run Watershed to flood the image from the seed markers.</div>
<div class="btns">
  <button id="runBtn" type="button">Run Watershed</button>
  <button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
/* Canvas overlay sits on top of the grayscale image canvas. */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .6rem; line-height: 1.45; }
.canvas-wrap { position: relative; display: inline-block; cursor: crosshair; }
#imgCanvas { display: block; border-radius: 6px; border: 1px solid #cdd9e3; image-rendering: pixelated; width: 200px; height: 200px; }
#ovCanvas { position: absolute; top: 0; left: 0; border-radius: 6px; width: 200px; height: 200px; }
.legend { margin: .4rem 0; font-size: .85rem; display: flex; gap: 1rem; align-items: center; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; }
.dot-a { background: #e63946; }
.dot-b { background: #2a9d8f; }
.status { font-size: 1rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.done { color: #0a7d33; }
.status.info { color: #1d3557; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .3rem; }
button { font: 600 14px system-ui, sans-serif; padding: .45rem .9rem;
         border: 1px solid #1d3557; background: #1d3557; color: #fff;
         border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Watershed segmentation: flood from marker seeds, draw boundary where floods meet.

const W = 40, H = 40;
const imgC = document.getElementById('imgCanvas');
const ovC  = document.getElementById('ovCanvas');
const iCtx = imgC.getContext('2d');
const oCtx = ovC.getContext('2d');
imgC.width = ovC.width = W;
imgC.height = ovC.height = H;

// Generate a synthetic grayscale image with two overlapping Gaussian blobs.
function makeImage() {
  const data = new Uint8Array(W * H);
  const blobs = [
    { cx: 14, cy: 18, r: 9 },
    { cx: 26, cy: 22, r: 9 }
  ];
  for (let y = 0; y < H; y++) {
    for (let x = 0; x < W; x++) {
      let v = 20;
      for (const b of blobs) {
        const d2 = (x - b.cx) ** 2 + (y - b.cy) ** 2;
        const bv = Math.max(0, 220 - d2 * 2.6);
        if (bv > v) v = bv;
      }
      data[y * W + x] = Math.min(255, Math.round(v));
    }
  }
  return data;
}

let imgData = makeImage();

// Render the grayscale image onto the background canvas.
function drawImage() {
  const id = iCtx.createImageData(W, H);
  for (let i = 0; i < W * H; i++) {
    const v = imgData[i];
    id.data[i * 4]     = v;
    id.data[i * 4 + 1] = v;
    id.data[i * 4 + 2] = v;
    id.data[i * 4 + 3] = 255;
  }
  iCtx.putImageData(id, 0, 0);
}

// Default seed markers — one inside each blob.
const DEFAULT_SEEDS = [
  { x: 14, y: 15, label: 0 },
  { x: 26, y: 25, label: 1 }
];
let seeds = DEFAULT_SEEDS.map(s => ({ ...s }));

const COLORS = [
  [230, 57,  70,  140],
  [ 42, 157, 143, 140]
];
const BORDER_COLOR = [255, 220, 0, 255];

function drawOverlay(labels) {
  oCtx.clearRect(0, 0, W, H);
  if (labels) {
    // Color each pixel by its region label; -1 means boundary.
    const od = oCtx.createImageData(W, H);
    for (let i = 0; i < W * H; i++) {
      const l = labels[i];
      if (l === -1) {
        od.data[i * 4]     = BORDER_COLOR[0];
        od.data[i * 4 + 1] = BORDER_COLOR[1];
        od.data[i * 4 + 2] = BORDER_COLOR[2];
        od.data[i * 4 + 3] = BORDER_COLOR[3];
      } else if (l >= 0) {
        const c = COLORS[l];
        od.data[i * 4]     = c[0];
        od.data[i * 4 + 1] = c[1];
        od.data[i * 4 + 2] = c[2];
        od.data[i * 4 + 3] = c[3];
      }
    }
    oCtx.putImageData(od, 0, 0);
  }
  // Draw seed markers on top.
  for (const s of seeds) {
    oCtx.beginPath();
    oCtx.arc(s.x + 0.5, s.y + 0.5, 2.5, 0, Math.PI * 2);
    const [r, g, b] = COLORS[s.label];
    oCtx.fillStyle = `rgb(${r},${g},${b})`;
    oCtx.strokeStyle = '#fff';
    oCtx.lineWidth = 0.8;
    oCtx.fill();
    oCtx.stroke();
  }
}

// Priority-queue watershed flood. Labels: -2=unvisited, -1=boundary, >=0=region.
function watershed(img, seeds) {
  const labels = new Int16Array(W * H).fill(-2);
  // Min-heap sorted by inverted intensity (dark pixels = high elevation, bright = low).
  const pq = [];
  const pqPush = (item) => {
    pq.push(item);
    let i = pq.length - 1;
    while (i > 0) {
      const p = (i - 1) >> 1;
      if (pq[p].pri <= pq[i].pri) break;
      [pq[p], pq[i]] = [pq[i], pq[p]]; i = p;
    }
  };
  const pqPop = () => {
    const top = pq[0];
    const last = pq.pop();
    if (pq.length) {
      pq[0] = last; let i = 0;
      for (;;) {
        let s = i, l = 2*i+1, r = 2*i+2;
        if (l < pq.length && pq[l].pri < pq[s].pri) s = l;
        if (r < pq.length && pq[r].pri < pq[s].pri) s = r;
        if (s === i) break;
        [pq[i], pq[s]] = [pq[s], pq[i]]; i = s;
      }
    }
    return top;
  };

  // Invert so bright blobs become valleys that fill first.
  const inv = img.map(v => 255 - v);

  for (const s of seeds) {
    const idx = s.y * W + s.x;
    labels[idx] = s.label;
    pqPush({ idx, label: s.label, pri: inv[idx] });
  }

  const dx = [-1, 1, 0, 0];
  const dy = [0, 0, -1, 1];

  while (pq.length) {
    const { idx, label } = pqPop();
    if (labels[idx] === -1) continue;
    const cx = idx % W, cy = (idx / W) | 0;
    for (let d = 0; d < 4; d++) {
      const nx = cx + dx[d], ny = cy + dy[d];
      if (nx < 0 || nx >= W || ny < 0 || ny >= H) continue;
      const ni = ny * W + nx;
      if (labels[ni] === -2) {
        labels[ni] = label;
        pqPush({ idx: ni, label, pri: inv[ni] });
      } else if (labels[ni] >= 0 && labels[ni] !== label) {
        labels[idx] = -1;
      }
    }
  }
  return labels;
}

let currentLabels = null;
let activeSeed = null;

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

function run() {
  currentLabels = watershed(imgData, seeds);
  drawImage();
  drawOverlay(currentLabels);
  const regions = new Set(currentLabels.filter(l => l >= 0)).size;
  setStatus(i18nFmt('Done — %n% region(s) labeled. Yellow pixels are the watershed boundary.', { n: regions }), 'done');
}

function reset() {
  seeds = DEFAULT_SEEDS.map(s => ({ ...s }));
  currentLabels = null;
  drawImage();
  drawOverlay(null);
  setStatus('Click Run Watershed to flood the image from the seed markers.', 'info');
}

// Canvas click — reserved for future interactive seed placement.
ovC.addEventListener('click', (e) => {
  const rect = ovC.getBoundingClientRect();
  const scaleX = W / rect.width;
  const scaleY = H / rect.height;
  const px = Math.floor((e.clientX - rect.left) * scaleX);
  const py = Math.floor((e.clientY - rect.top)  * scaleY);
  if (px < 0 || px >= W || py < 0 || py >= H) return;
  if (activeSeed !== null) {
    seeds[activeSeed].x = px;
    seeds[activeSeed].y = py;
    activeSeed = null;
    currentLabels = null;
    drawImage();
    drawOverlay(null);
    setStatus('Seed placed. Click Run Watershed to flood.', 'info');
  }
});

document.getElementById('runBtn').addEventListener('click', run);
document.getElementById('resetBtn').addEventListener('click', reset);

drawImage();
drawOverlay(null);
setStatus('Click Run Watershed to flood the image from the seed markers.', 'info');
Result
Console

Try clicking Reset and placing your own seeds by clicking inside each blob before running the flood. The boundary shifts depending on where you plant the markers — that is the power and the subtlety of marker-controlled watershed segmentation.

The Real Complexity

The flooding step itself is surprisingly efficient.

  • Linear time. The standard algorithm (Beucher & Meyer, 1993) processes each pixel once using a priority queue sorted by intensity. Total cost is O(nlog⁡n)O(n \log n) in the number of pixels nn, or even O(n)O(n) with a bucket queue when intensities are integers — as fast as any single-pass image scan.
  • The hard part is not the flood. It is choosing the markers. Too few seeds and distinct regions merge; too many and every tiny local minimum spawns its own basin — over-segmentation. Picking good markers automatically (using morphological operators, distance transforms, or learned detectors) is an active research problem.
  • Marker-controlled watershed (the modern standard) sidesteps the worst over-segmentation by requiring the user or a pre-processing step to supply one seed per target region, so the algorithm never creates more regions than there are markers.
  • Relation to graph algorithms. The flood can also be framed as computing a minimum spanning tree on the pixel adjacency graph, where edge weights are intensity differences — a connection that links watershed segmentation to classical graph theory.

So watershed segmentation sits in a comfortable complexity class: the flood is linear, but the intelligence lies entirely in the marker placement, which can range from a human click to a deep neural network.

Where It Matters

Whenever you need to split an image into meaningful regions — not just edges — watershed segmentation is the natural first tool:

  • Medical imaging: separating touching cell nuclei in fluorescence microscopy is a textbook watershed application. Biologists count thousands of cells per image; manual annotation is impossible.
  • Materials science: ore samples imaged under a microscope show mineral grains pressed together. Watershed cleanly separates them so researchers can measure grain size and composition.
  • Road scene analysis: splitting a satellite or aerial photo into road, building, vegetation, and water regions often starts with a watershed pass over color or elevation data.
  • Industrial inspection: detecting cracks, bubbles, or inclusions in manufactured parts requires isolating each defect as a separate region — watershed handles touching defects that edge detection misses.
  • Document layout analysis: page regions (columns, figures, captions) can be extracted by running watershed on a document's whitespace map.

The algorithm connects naturally to dimensionality reduction pipelines where compact region descriptors replace raw pixel grids, and to graph-based methods where each watershed region becomes a node.

Conclusion

Watershed segmentation is one of those rare algorithms where the mental model — water flooding a landscape — is also the precise mathematical description. Treat brightness as altitude, plant markers in each target region, let the flood rise, and the ridges between colliding pools become your boundaries.

The flood itself costs linear time and is embarrassingly simple to implement. The depth lies in choosing markers wisely: too many and the image shatters into fragments; too few and regions bleed together. That tension between simplicity and judgment is what makes watershed a rich research topic five decades after its invention — and a daily workhorse in labs that count cells, measure grains, or map terrain from pixels.

Share this article

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

Comments

Loading comments...

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