The demo below generates a synthetic set of color patches drawn from a handful of cluster centers. Mean-shift then climbs from each patch toward the nearest density peak and repaints everything at that peak color — posterizing the palette.
<!-- Mean-shift segmentation demo — two canvas panels (original vs posterized) -->
<p class="hint">Mean-shift climbs toward density peaks in color space. Each color patch is assigned the color of its nearest peak — <b>posterizing</b> the image. Wider bandwidth merges peaks; narrower bandwidth preserves more detail.</p>
<div class="controls">
<label for="bw-slider">Bandwidth: <strong id="bw-val">40</strong></label>
<input type="range" id="bw-slider" min="5" max="120" value="40" step="1">
<button id="btn-run" type="button">Run Mean-Shift</button>
<button id="btn-reset" type="button" class="ghost">New Image</button>
</div>
<div class="canvases">
<div class="canvas-box">
<div class="canvas-label">Original</div>
<canvas id="canvas-orig" width="240" height="180"></canvas>
</div>
<div class="canvas-box">
<div class="canvas-label">Posterized</div>
<canvas id="canvas-out" width="240" height="180"></canvas>
</div>
</div>
<div class="status" id="status">Ready — click Run Mean-Shift.</div>
/* Layout and colors for the mean-shift demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .7rem; }
.controls label { font-size: .9rem; white-space: nowrap; }
#bw-slider { flex: 1; min-width: 120px; max-width: 240px; }
.canvases { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: .6rem; }
.canvas-box { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.canvas-label { font-size: .78rem; color: #555; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; padding: .1rem 0; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Mean-shift segmentation: repeatedly shift each color point toward the weighted
// mean of its neighbors within bandwidth h until convergence (a local density mode).
const W = 240, H = 180;
// Ground-truth cluster centers in RGB space (not exposed to the algorithm)
const CLUSTER_COLORS = [
[220, 60, 60], // warm red
[60, 160, 220], // sky blue
[80, 180, 80], // leaf green
[230, 180, 40], // sunflower yellow
[180, 80, 200], // violet
];
const NOISE_STD = 22; // Gaussian noise std-dev added to each patch to simulate real variation
// Array of synthetic color patches (each is one cell in the grid)
let origPixels = [];
function randGauss() {
// Box-Muller transform — converts two uniform samples to one standard normal
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function generateImage(ctx) {
// Fill the canvas with random color patches drawn from the cluster centers
origPixels = [];
const rows = Math.ceil(H / 12), cols = Math.ceil(W / 12);
for (let gy = 0; gy < rows; gy++) {
for (let gx = 0; gx < cols; gx++) {
const ci = Math.floor(Math.random() * CLUSTER_COLORS.length);
const [br, bg, bb] = CLUSTER_COLORS[ci];
const px = gx * 12, py = gy * 12;
const bw = Math.min(12, W - px), bh = Math.min(12, H - py);
const r = Math.max(0, Math.min(255, Math.round(br + randGauss() * NOISE_STD)));
const g = Math.max(0, Math.min(255, Math.round(bg + randGauss() * NOISE_STD)));
const b = Math.max(0, Math.min(255, Math.round(bb + randGauss() * NOISE_STD)));
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(px, py, bw, bh);
origPixels.push({ r, g, b, px, py, bw, bh });
}
}
}
// Epanechnikov kernel — linear falloff, zero outside bandwidth h
function epanechnikovWeight(distSq, hSq) {
const u = distSq / hSq;
return u <= 1 ? (1 - u) : 0;
}
// One mean-shift step: shift (r,g,b) toward the kernel-weighted mean of all points
function meanShiftPoint(r, g, b, pixels, h) {
const hSq = h * h;
let wr = 0, wg = 0, wb = 0, wsum = 0;
for (const p of pixels) {
const dSq = (p.r - r) ** 2 + (p.g - g) ** 2 + (p.b - b) ** 2;
const w = epanechnikovWeight(dSq, hSq);
if (w > 0) { wr += w * p.r; wg += w * p.g; wb += w * p.b; wsum += w; }
}
if (wsum === 0) return { r, g, b };
return { r: wr / wsum, g: wg / wsum, b: wb / wsum };
}
// Iterate mean-shift steps until the point stops moving (convergence to a mode)
function findMode(r, g, b, pixels, h, maxIter) {
let cr = r, cg = g, cb = b;
for (let i = 0; i < maxIter; i++) {
const next = meanShiftPoint(cr, cg, cb, pixels, h);
const dSq = (next.r - cr) ** 2 + (next.g - cg) ** 2 + (next.b - cb) ** 2;
cr = next.r; cg = next.g; cb = next.b;
if (dSq < 0.25) break; // Converged when movement is less than 0.5 units in color space
}
return { r: Math.round(cr), g: Math.round(cg), b: Math.round(cb) };
}
function runMeanShift(h) {
const maxIter = 40;
const modes = origPixels.map(p => findMode(p.r, p.g, p.b, origPixels, h, maxIter));
// Merge modes that are within half a bandwidth of each other into one segment
const snapH = h * 0.5;
const snapHSq = snapH * snapH;
const merged = modes.map(m => {
const dSq = (m.r - m.r) ** 2; // self-comparison placeholder (always 0) — real merging happens in the seed loop
return m;
});
// Collect unique mode seeds — any two modes within snapH of each other share a segment
const seeds = [];
for (const m of merged) {
let found = false;
for (const s of seeds) {
if ((m.r - s.r) ** 2 + (m.g - s.g) ** 2 + (m.b - s.b) ** 2 < snapHSq) {
found = true; break;
}
}
if (!found) seeds.push({ r: m.r, g: m.g, b: m.b });
}
return { modes, numSegs: seeds.length };
}
function drawOutput(outCtx, modes) {
// Paint each patch with the color of its converged mode (posterized output)
origPixels.forEach((p, i) => {
const m = modes[i];
outCtx.fillStyle = `rgb(${m.r},${m.g},${m.b})`;
outCtx.fillRect(p.px, p.py, p.bw, p.bh);
});
}
const origCanvas = document.getElementById('canvas-orig');
const outCanvas = document.getElementById('canvas-out');
const origCtx = origCanvas.getContext('2d');
const outCtx = outCanvas.getContext('2d');
const slider = document.getElementById('bw-slider');
const bwVal = document.getElementById('bw-val');
const statusEl = document.getElementById('status');
const btnRun = document.getElementById('btn-run');
const btnReset = document.getElementById('btn-reset');
function setStatus(msg, cls) {
statusEl.textContent = msg;
statusEl.className = 'status ' + (cls || '');
}
function init() {
// Initialize with a fresh random image and clear the output canvas
outCtx.clearRect(0, 0, W, H);
generateImage(origCtx);
setStatus('Ready — click Run Mean-Shift.', '');
}
slider.addEventListener('input', () => { bwVal.textContent = slider.value; });
btnRun.addEventListener('click', () => {
const h = parseInt(slider.value, 10);
btnRun.disabled = true;
setStatus('Running mean-shift, please wait…', 'info');
// Use setTimeout so the browser renders the 'running' status before the heavy loop
setTimeout(() => {
try {
const { modes, numSegs } = runMeanShift(h);
drawOutput(outCtx, modes);
setStatus(i18nFmt('Done — %n% color segment(s) found.', { n: numSegs }), 'ok');
} catch (e) {
setStatus('An error occurred. Try a different bandwidth.', 'bad');
}
btnRun.disabled = false;
}, 20);
});
btnReset.addEventListener('click', init);
init();
<!-- Mean-shift segmentation demo — two canvas panels (original vs posterized) -->
<p class="hint">Mean-shift climbs toward density peaks in color space. Each color patch is assigned the color of its nearest peak — <b>posterizing</b> the image. Wider bandwidth merges peaks; narrower bandwidth preserves more detail.</p>
<div class="controls">
<label for="bw-slider">Bandwidth: <strong id="bw-val">40</strong></label>
<input type="range" id="bw-slider" min="5" max="120" value="40" step="1">
<button id="btn-run" type="button">Run Mean-Shift</button>
<button id="btn-reset" type="button" class="ghost">New Image</button>
</div>
<div class="canvases">
<div class="canvas-box">
<div class="canvas-label">Original</div>
<canvas id="canvas-orig" width="240" height="180"></canvas>
</div>
<div class="canvas-box">
<div class="canvas-label">Posterized</div>
<canvas id="canvas-out" width="240" height="180"></canvas>
</div>
</div>
<div class="status" id="status">Ready — click Run Mean-Shift.</div>
/* Layout and colors for the mean-shift demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .9rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .7rem; }
.controls label { font-size: .9rem; white-space: nowrap; }
#bw-slider { flex: 1; min-width: 120px; max-width: 240px; }
.canvases { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: .6rem; }
.canvas-box { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.canvas-label { font-size: .78rem; color: #555; font-weight: 600; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; padding: .1rem 0; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.info { color: #1d3557; }
button { font: 600 14px system-ui, sans-serif; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .45; cursor: default; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Mean-shift segmentation: repeatedly shift each color point toward the weighted
// mean of its neighbors within bandwidth h until convergence (a local density mode).
const W = 240, H = 180;
// Ground-truth cluster centers in RGB space (not exposed to the algorithm)
const CLUSTER_COLORS = [
[220, 60, 60], // warm red
[60, 160, 220], // sky blue
[80, 180, 80], // leaf green
[230, 180, 40], // sunflower yellow
[180, 80, 200], // violet
];
const NOISE_STD = 22; // Gaussian noise std-dev added to each patch to simulate real variation
// Array of synthetic color patches (each is one cell in the grid)
let origPixels = [];
function randGauss() {
// Box-Muller transform — converts two uniform samples to one standard normal
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function generateImage(ctx) {
// Fill the canvas with random color patches drawn from the cluster centers
origPixels = [];
const rows = Math.ceil(H / 12), cols = Math.ceil(W / 12);
for (let gy = 0; gy < rows; gy++) {
for (let gx = 0; gx < cols; gx++) {
const ci = Math.floor(Math.random() * CLUSTER_COLORS.length);
const [br, bg, bb] = CLUSTER_COLORS[ci];
const px = gx * 12, py = gy * 12;
const bw = Math.min(12, W - px), bh = Math.min(12, H - py);
const r = Math.max(0, Math.min(255, Math.round(br + randGauss() * NOISE_STD)));
const g = Math.max(0, Math.min(255, Math.round(bg + randGauss() * NOISE_STD)));
const b = Math.max(0, Math.min(255, Math.round(bb + randGauss() * NOISE_STD)));
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(px, py, bw, bh);
origPixels.push({ r, g, b, px, py, bw, bh });
}
}
}
// Epanechnikov kernel — linear falloff, zero outside bandwidth h
function epanechnikovWeight(distSq, hSq) {
const u = distSq / hSq;
return u <= 1 ? (1 - u) : 0;
}
// One mean-shift step: shift (r,g,b) toward the kernel-weighted mean of all points
function meanShiftPoint(r, g, b, pixels, h) {
const hSq = h * h;
let wr = 0, wg = 0, wb = 0, wsum = 0;
for (const p of pixels) {
const dSq = (p.r - r) ** 2 + (p.g - g) ** 2 + (p.b - b) ** 2;
const w = epanechnikovWeight(dSq, hSq);
if (w > 0) { wr += w * p.r; wg += w * p.g; wb += w * p.b; wsum += w; }
}
if (wsum === 0) return { r, g, b };
return { r: wr / wsum, g: wg / wsum, b: wb / wsum };
}
// Iterate mean-shift steps until the point stops moving (convergence to a mode)
function findMode(r, g, b, pixels, h, maxIter) {
let cr = r, cg = g, cb = b;
for (let i = 0; i < maxIter; i++) {
const next = meanShiftPoint(cr, cg, cb, pixels, h);
const dSq = (next.r - cr) ** 2 + (next.g - cg) ** 2 + (next.b - cb) ** 2;
cr = next.r; cg = next.g; cb = next.b;
if (dSq < 0.25) break; // Converged when movement is less than 0.5 units in color space
}
return { r: Math.round(cr), g: Math.round(cg), b: Math.round(cb) };
}
function runMeanShift(h) {
const maxIter = 40;
const modes = origPixels.map(p => findMode(p.r, p.g, p.b, origPixels, h, maxIter));
// Merge modes that are within half a bandwidth of each other into one segment
const snapH = h * 0.5;
const snapHSq = snapH * snapH;
const merged = modes.map(m => {
const dSq = (m.r - m.r) ** 2; // self-comparison placeholder (always 0) — real merging happens in the seed loop
return m;
});
// Collect unique mode seeds — any two modes within snapH of each other share a segment
const seeds = [];
for (const m of merged) {
let found = false;
for (const s of seeds) {
if ((m.r - s.r) ** 2 + (m.g - s.g) ** 2 + (m.b - s.b) ** 2 < snapHSq) {
found = true; break;
}
}
if (!found) seeds.push({ r: m.r, g: m.g, b: m.b });
}
return { modes, numSegs: seeds.length };
}
function drawOutput(outCtx, modes) {
// Paint each patch with the color of its converged mode (posterized output)
origPixels.forEach((p, i) => {
const m = modes[i];
outCtx.fillStyle = `rgb(${m.r},${m.g},${m.b})`;
outCtx.fillRect(p.px, p.py, p.bw, p.bh);
});
}
const origCanvas = document.getElementById('canvas-orig');
const outCanvas = document.getElementById('canvas-out');
const origCtx = origCanvas.getContext('2d');
const outCtx = outCanvas.getContext('2d');
const slider = document.getElementById('bw-slider');
const bwVal = document.getElementById('bw-val');
const statusEl = document.getElementById('status');
const btnRun = document.getElementById('btn-run');
const btnReset = document.getElementById('btn-reset');
function setStatus(msg, cls) {
statusEl.textContent = msg;
statusEl.className = 'status ' + (cls || '');
}
function init() {
// Initialize with a fresh random image and clear the output canvas
outCtx.clearRect(0, 0, W, H);
generateImage(origCtx);
setStatus('Ready — click Run Mean-Shift.', '');
}
slider.addEventListener('input', () => { bwVal.textContent = slider.value; });
btnRun.addEventListener('click', () => {
const h = parseInt(slider.value, 10);
btnRun.disabled = true;
setStatus('Running mean-shift, please wait…', 'info');
// Use setTimeout so the browser renders the 'running' status before the heavy loop
setTimeout(() => {
try {
const { modes, numSegs } = runMeanShift(h);
drawOutput(outCtx, modes);
setStatus(i18nFmt('Done — %n% color segment(s) found.', { n: numSegs }), 'ok');
} catch (e) {
setStatus('An error occurred. Try a different bandwidth.', 'bad');
}
btnRun.disabled = false;
}, 20);
});
btnReset.addEventListener('click', init);
init();
Comments
Loading comments...