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.
<!-- 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');
<!-- 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');
Comments
Loading comments...