The demo below generates a synthetic grayscale image of a bright disk on a dark background. Drag the threshold slider to see manual binarization, then press Run Otsu to let the algorithm pick the threshold automatically by maximizing between-class variance.
<!-- Otsu thresholding demo — grayscale image binarization -->
<p class="hint">A synthetic image (bright disk, dark background) is shown left. Drag the <b>threshold slider</b> to binarize manually — then press <b>Run Otsu</b> to find the optimal threshold automatically. The variance curve shows between-class variance at every possible threshold; Otsu's method picks the peak.</p>
<div class="canvases">
<div class="canvas-wrap">
<div class="canvas-label">Original</div>
<canvas id="cvOrig" width="160" height="120"></canvas>
</div>
<div class="canvas-wrap">
<div class="canvas-label">Binary</div>
<canvas id="cvBin" width="160" height="120"></canvas>
</div>
<div class="canvas-wrap">
<div class="canvas-label">Variance</div>
<canvas id="cvVar" width="160" height="120"></canvas>
</div>
</div>
<div class="slider-row">
<label for="thresh">Threshold: <span id="threshVal">128</span></label>
<input type="range" id="thresh" min="0" max="255" value="128">
</div>
<div class="status" id="status"></div>
<div class="btns">
<button id="btnOtsu" type="button">Run Otsu</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* Layout and palette for the Otsu thresholding demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.canvases { display: flex; gap: .6rem; flex-wrap: wrap; margin-bottom: .7rem; }
.canvas-wrap { display: flex; flex-direction: column; align-items: center; gap: .2rem; }
.canvas-label { font-size: .78rem; font-weight: 600; color: #556; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block; }
.slider-row { display: flex; align-items: center; gap: .7rem; margin: .4rem 0; }
.slider-row label { font-size: .9rem; white-space: nowrap; min-width: 10em; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .4rem 0; }
.status.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
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] ?? ''));
// Generate a synthetic grayscale image — a bright disk on a dark background
const W = 160, H = 120;
const cvOrig = document.getElementById('cvOrig');
const cvBin = document.getElementById('cvBin');
const cvVar = document.getElementById('cvVar');
const ctxO = cvOrig.getContext('2d');
const ctxB = cvBin.getContext('2d');
const ctxV = cvVar.getContext('2d');
const slider = document.getElementById('thresh');
const threshVal = document.getElementById('threshVal');
const statusEl = document.getElementById('status');
// Fill pixel array: disk pixels are bright, background is dark, add noise
const pixels = new Uint8Array(W * H);
const cx = W / 2, cy = H / 2, r = 38;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = x - cx, dy = y - cy;
const disk = dx * dx + dy * dy < r * r;
// add Gaussian-like noise to make the histogram realistic
const base = disk ? 180 : 50;
const noise = Math.floor((Math.random() - 0.5) * 40);
pixels[y * W + x] = Math.max(0, Math.min(255, base + noise));
}
}
// Draw the original grayscale image onto the canvas
function drawOriginal() {
const img = ctxO.createImageData(W, H);
for (let i = 0; i < pixels.length; i++) {
const g = pixels[i];
img.data[i * 4] = g;
img.data[i * 4 + 1] = g;
img.data[i * 4 + 2] = g;
img.data[i * 4 + 3] = 255;
}
ctxO.putImageData(img, 0, 0);
}
// Build normalized histogram from pixel values
function buildHistogram() {
const hist = new Float64Array(256);
for (let i = 0; i < pixels.length; i++) hist[pixels[i]]++;
const n = pixels.length;
for (let i = 0; i < 256; i++) hist[i] /= n;
return hist;
}
const hist = buildHistogram();
// Compute between-class variance curve using cumulative sums — between-class variance for all thresholds
function computeVarianceCurve(hist) {
const varCurve = new Float64Array(256);
let mu = 0;
for (let i = 0; i < 256; i++) mu += i * hist[i];
let w0 = 0, mu0 = 0;
for (let t = 0; t < 256; t++) {
w0 += hist[t];
mu0 += t * hist[t];
const w1 = 1 - w0;
if (w0 <= 0 || w1 <= 0) { varCurve[t] = 0; continue; }
const m0 = mu0 / w0;
const m1 = (mu - mu0) / w1;
varCurve[t] = w0 * w1 * (m0 - m1) * (m0 - m1);
}
return varCurve;
}
const varCurve = computeVarianceCurve(hist);
// Find the threshold that maximizes between-class variance — index of maximum between-class variance
function otsuThreshold(varCurve) {
let best = -1, bestT = 0;
for (let t = 0; t < 256; t++) {
if (varCurve[t] > best) { best = varCurve[t]; bestT = t; }
}
return bestT;
}
const OTSU_T = otsuThreshold(varCurve);
// Draw binarized image using the given threshold
function drawBinary(t) {
const img = ctxB.createImageData(W, H);
for (let i = 0; i < pixels.length; i++) {
const v = pixels[i] > t ? 255 : 0;
img.data[i * 4] = v;
img.data[i * 4 + 1] = v;
img.data[i * 4 + 2] = v;
img.data[i * 4 + 3] = 255;
}
ctxB.putImageData(img, 0, 0);
}
// Draw the variance curve canvas with current threshold marker
function drawVarianceCurve(t) {
ctxV.clearRect(0, 0, W, H);
ctxV.fillStyle = '#f0f4f8';
ctxV.fillRect(0, 0, W, H);
const maxV = Math.max(...varCurve);
// Draw variance curve line
ctxV.beginPath();
ctxV.strokeStyle = '#1d6fa4';
ctxV.lineWidth = 1.5;
for (let i = 0; i < 256; i++) {
const px = (i / 255) * W;
const py = H - (varCurve[i] / maxV) * (H - 4) - 2;
i === 0 ? ctxV.moveTo(px, py) : ctxV.lineTo(px, py);
}
ctxV.stroke();
// Draw red dashed line for the current manual threshold
const tx = (t / 255) * W;
ctxV.beginPath();
ctxV.strokeStyle = '#e63946';
ctxV.lineWidth = 1.5;
ctxV.setLineDash([3, 3]);
ctxV.moveTo(tx, 0);
ctxV.lineTo(tx, H);
ctxV.stroke();
ctxV.setLineDash([]);
// Draw green dashed line for the Otsu optimal threshold
const ox = (OTSU_T / 255) * W;
ctxV.beginPath();
ctxV.strokeStyle = '#0a7d33';
ctxV.lineWidth = 1.5;
ctxV.setLineDash([2, 2]);
ctxV.moveTo(ox, 0);
ctxV.lineTo(ox, H);
ctxV.stroke();
ctxV.setLineDash([]);
}
// Refresh all visuals and status message
function update(t) {
threshVal.textContent = t;
drawBinary(t);
drawVarianceCurve(t);
const isOtsu = t === OTSU_T;
statusEl.className = 'status' + (isOtsu ? ' ok' : '');
statusEl.textContent = isOtsu
? i18nFmt('✓ Optimal Otsu threshold: T = %t%', { t: OTSU_T })
: i18nFmt('Manual T = %t% (Otsu optimal: %opt%)', { t: t, opt: OTSU_T });
}
// Wire up slider and button events
slider.addEventListener('input', () => update(Number(slider.value)));
document.getElementById('btnOtsu').addEventListener('click', () => {
slider.value = OTSU_T;
update(OTSU_T);
});
document.getElementById('btnReset').addEventListener('click', () => {
slider.value = 128;
update(128);
});
// Initialize the demo
drawOriginal();
update(128);
<!-- Otsu thresholding demo — grayscale image binarization -->
<p class="hint">A synthetic image (bright disk, dark background) is shown left. Drag the <b>threshold slider</b> to binarize manually — then press <b>Run Otsu</b> to find the optimal threshold automatically. The variance curve shows between-class variance at every possible threshold; Otsu's method picks the peak.</p>
<div class="canvases">
<div class="canvas-wrap">
<div class="canvas-label">Original</div>
<canvas id="cvOrig" width="160" height="120"></canvas>
</div>
<div class="canvas-wrap">
<div class="canvas-label">Binary</div>
<canvas id="cvBin" width="160" height="120"></canvas>
</div>
<div class="canvas-wrap">
<div class="canvas-label">Variance</div>
<canvas id="cvVar" width="160" height="120"></canvas>
</div>
</div>
<div class="slider-row">
<label for="thresh">Threshold: <span id="threshVal">128</span></label>
<input type="range" id="thresh" min="0" max="255" value="128">
</div>
<div class="status" id="status"></div>
<div class="btns">
<button id="btnOtsu" type="button">Run Otsu</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
/* Layout and palette for the Otsu thresholding demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.hint { font-size: .88rem; color: #444; margin: 0 0 .8rem; line-height: 1.45; }
.canvases { display: flex; gap: .6rem; flex-wrap: wrap; margin-bottom: .7rem; }
.canvas-wrap { display: flex; flex-direction: column; align-items: center; gap: .2rem; }
.canvas-label { font-size: .78rem; font-weight: 600; color: #556; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block; }
.slider-row { display: flex; align-items: center; gap: .7rem; margin: .4rem 0; }
.slider-row label { font-size: .9rem; white-space: nowrap; min-width: 10em; }
input[type=range] { flex: 1; accent-color: #1d3557; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .4rem 0; }
.status.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; }
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] ?? ''));
// Generate a synthetic grayscale image — a bright disk on a dark background
const W = 160, H = 120;
const cvOrig = document.getElementById('cvOrig');
const cvBin = document.getElementById('cvBin');
const cvVar = document.getElementById('cvVar');
const ctxO = cvOrig.getContext('2d');
const ctxB = cvBin.getContext('2d');
const ctxV = cvVar.getContext('2d');
const slider = document.getElementById('thresh');
const threshVal = document.getElementById('threshVal');
const statusEl = document.getElementById('status');
// Fill pixel array: disk pixels are bright, background is dark, add noise
const pixels = new Uint8Array(W * H);
const cx = W / 2, cy = H / 2, r = 38;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const dx = x - cx, dy = y - cy;
const disk = dx * dx + dy * dy < r * r;
// add Gaussian-like noise to make the histogram realistic
const base = disk ? 180 : 50;
const noise = Math.floor((Math.random() - 0.5) * 40);
pixels[y * W + x] = Math.max(0, Math.min(255, base + noise));
}
}
// Draw the original grayscale image onto the canvas
function drawOriginal() {
const img = ctxO.createImageData(W, H);
for (let i = 0; i < pixels.length; i++) {
const g = pixels[i];
img.data[i * 4] = g;
img.data[i * 4 + 1] = g;
img.data[i * 4 + 2] = g;
img.data[i * 4 + 3] = 255;
}
ctxO.putImageData(img, 0, 0);
}
// Build normalized histogram from pixel values
function buildHistogram() {
const hist = new Float64Array(256);
for (let i = 0; i < pixels.length; i++) hist[pixels[i]]++;
const n = pixels.length;
for (let i = 0; i < 256; i++) hist[i] /= n;
return hist;
}
const hist = buildHistogram();
// Compute between-class variance curve using cumulative sums — between-class variance for all thresholds
function computeVarianceCurve(hist) {
const varCurve = new Float64Array(256);
let mu = 0;
for (let i = 0; i < 256; i++) mu += i * hist[i];
let w0 = 0, mu0 = 0;
for (let t = 0; t < 256; t++) {
w0 += hist[t];
mu0 += t * hist[t];
const w1 = 1 - w0;
if (w0 <= 0 || w1 <= 0) { varCurve[t] = 0; continue; }
const m0 = mu0 / w0;
const m1 = (mu - mu0) / w1;
varCurve[t] = w0 * w1 * (m0 - m1) * (m0 - m1);
}
return varCurve;
}
const varCurve = computeVarianceCurve(hist);
// Find the threshold that maximizes between-class variance — index of maximum between-class variance
function otsuThreshold(varCurve) {
let best = -1, bestT = 0;
for (let t = 0; t < 256; t++) {
if (varCurve[t] > best) { best = varCurve[t]; bestT = t; }
}
return bestT;
}
const OTSU_T = otsuThreshold(varCurve);
// Draw binarized image using the given threshold
function drawBinary(t) {
const img = ctxB.createImageData(W, H);
for (let i = 0; i < pixels.length; i++) {
const v = pixels[i] > t ? 255 : 0;
img.data[i * 4] = v;
img.data[i * 4 + 1] = v;
img.data[i * 4 + 2] = v;
img.data[i * 4 + 3] = 255;
}
ctxB.putImageData(img, 0, 0);
}
// Draw the variance curve canvas with current threshold marker
function drawVarianceCurve(t) {
ctxV.clearRect(0, 0, W, H);
ctxV.fillStyle = '#f0f4f8';
ctxV.fillRect(0, 0, W, H);
const maxV = Math.max(...varCurve);
// Draw variance curve line
ctxV.beginPath();
ctxV.strokeStyle = '#1d6fa4';
ctxV.lineWidth = 1.5;
for (let i = 0; i < 256; i++) {
const px = (i / 255) * W;
const py = H - (varCurve[i] / maxV) * (H - 4) - 2;
i === 0 ? ctxV.moveTo(px, py) : ctxV.lineTo(px, py);
}
ctxV.stroke();
// Draw red dashed line for the current manual threshold
const tx = (t / 255) * W;
ctxV.beginPath();
ctxV.strokeStyle = '#e63946';
ctxV.lineWidth = 1.5;
ctxV.setLineDash([3, 3]);
ctxV.moveTo(tx, 0);
ctxV.lineTo(tx, H);
ctxV.stroke();
ctxV.setLineDash([]);
// Draw green dashed line for the Otsu optimal threshold
const ox = (OTSU_T / 255) * W;
ctxV.beginPath();
ctxV.strokeStyle = '#0a7d33';
ctxV.lineWidth = 1.5;
ctxV.setLineDash([2, 2]);
ctxV.moveTo(ox, 0);
ctxV.lineTo(ox, H);
ctxV.stroke();
ctxV.setLineDash([]);
}
// Refresh all visuals and status message
function update(t) {
threshVal.textContent = t;
drawBinary(t);
drawVarianceCurve(t);
const isOtsu = t === OTSU_T;
statusEl.className = 'status' + (isOtsu ? ' ok' : '');
statusEl.textContent = isOtsu
? i18nFmt('✓ Optimal Otsu threshold: T = %t%', { t: OTSU_T })
: i18nFmt('Manual T = %t% (Otsu optimal: %opt%)', { t: t, opt: OTSU_T });
}
// Wire up slider and button events
slider.addEventListener('input', () => update(Number(slider.value)));
document.getElementById('btnOtsu').addEventListener('click', () => {
slider.value = OTSU_T;
update(OTSU_T);
});
document.getElementById('btnReset').addEventListener('click', () => {
slider.value = 128;
update(128);
});
// Initialize the demo
drawOriginal();
update(128);
Watch the variance curve above the slider: Otsu's threshold lands exactly at its peak. Moving the slider away from that peak always increases the overlap between the two brightness classes — the algorithm provably finds the global maximum in a single left-to-right scan.
Comments
Loading comments...