The canvas below shows a synthetic image — geometric shapes with clean edges and uniform regions. Click Add noise to corrupt it with Gaussian grain, then click Denoise (NLM) to watch the algorithm sweep through every pixel, find similar patches anywhere in the image, and vote the grain away.
<!-- Main UI – hint paragraph, canvas, status line, action buttons -->
<p class="hint">Each pixel is denoised by averaging other pixels whose surrounding patch looks similar — anywhere in the image, not just next door. Add noise first, then run NLM to watch grain disappear while edges stay sharp.</p>
<div class="canvas-wrap">
<canvas id="cnv" width="200" height="200"></canvas>
</div>
<div class="status" id="status">Draw a clean image and add noise to begin.</div>
<div class="btns">
<button id="btn-noise" type="button">Add noise</button>
<button id="btn-denoise" type="button">Denoise (NLM)</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Layout and visual styling for the NLM 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; }
.canvas-wrap { display: inline-block; border: 1px solid #cdd9e3; border-radius: 6px; overflow: hidden; margin-bottom: .5rem; }
canvas { display: block; image-rendering: pixelated; width: 200px; height: 200px; }
.status { font-size: 1rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.busy { color: #775500; }
.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:disabled { opacity: .5; cursor: not-allowed; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Canvas dimensions: 200×200 pixels (scaled up 1× in CSS) */
const W = 200, H = 200;
const cnv = document.getElementById('cnv');
const ctx = cnv.getContext('2d');
/* Image state: clean | noisy | denoised */
let cleanPixels = null;
let currentPixels = null;
let state = 'clean'; // clean | noisy | denoised
/* Draw synthetic scene with geometric shapes and uniform regions */
function drawScene() {
ctx.fillStyle = '#e8eef3';
ctx.fillRect(0, 0, W, H);
/* Blue rectangle — top-left */
ctx.fillStyle = '#1d6fa3';
ctx.fillRect(20, 20, 70, 70);
/* Red rectangle — top-right */
ctx.fillStyle = '#e63946';
ctx.fillRect(110, 30, 70, 70);
/* Green circle — bottom-left */
ctx.fillStyle = '#2a9d45';
ctx.beginPath();
ctx.arc(70, 150, 35, 0, Math.PI * 2);
ctx.fill();
/* Orange diamond — bottom-right */
ctx.fillStyle = '#f4a261';
ctx.beginPath();
ctx.moveTo(150, 110);
ctx.lineTo(185, 150);
ctx.lineTo(150, 190);
ctx.lineTo(115, 150);
ctx.closePath();
ctx.fill();
}
/* Read current canvas pixels into a Uint8ClampedArray */
function snapshot() {
const d = ctx.getImageData(0, 0, W, H);
return new Uint8ClampedArray(d.data);
}
function putPixels(arr) {
const d = ctx.createImageData(W, H);
d.data.set(arr);
ctx.putImageData(d, 0, 0);
}
/* Add Gaussian noise using the Box-Muller transform */
function addGaussianNoise(src, sigma) {
const out = new Uint8ClampedArray(src);
for (let i = 0; i < out.length; i += 4) {
/* Box-Muller transform produces a standard-normal sample z */
const u1 = Math.random() || 1e-10;
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
const n = Math.round(z * sigma);
out[i] = Math.max(0, Math.min(255, out[i] + n));
out[i + 1] = Math.max(0, Math.min(255, out[i + 1] + n));
out[i + 2] = Math.max(0, Math.min(255, out[i + 2] + n));
/* Alpha channel stays at 255 (fully opaque) */
}
return out;
}
/* Non-local means: for each pixel p, accumulate weighted average over all q in the search window. Weight = exp(-patch_distance^2 / h^2). */
function nlmDenoise(src, h, patchR, searchR) {
const h2 = h * h;
const out = new Uint8ClampedArray(src.length);
/* Outer loop over every target pixel p */
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
let wR = 0, wG = 0, wB = 0, wSum = 0;
/* Inner loop over candidate pixels q within the search window */
for (let qy = Math.max(0, py - searchR); qy <= Math.min(H - 1, py + searchR); qy++) {
for (let qx = Math.max(0, px - searchR); qx <= Math.min(W - 1, px + searchR); qx++) {
/* Squared Euclidean distance between patch around p and patch around q (all 3 channels) */
let dist2 = 0;
for (let dy = -patchR; dy <= patchR; dy++) {
for (let dx = -patchR; dx <= patchR; dx++) {
const sy = Math.min(H - 1, Math.max(0, py + dy));
const sx = Math.min(W - 1, Math.max(0, px + dx));
const ty = Math.min(H - 1, Math.max(0, qy + dy));
const tx = Math.min(W - 1, Math.max(0, qx + dx));
const ip = (sy * W + sx) * 4;
const iq = (ty * W + tx) * 4;
const dr = src[ip] - src[iq];
const dg = src[ip + 1] - src[iq + 1];
const db = src[ip + 2] - src[iq + 2];
dist2 += dr * dr + dg * dg + db * db;
}
}
/* Gaussian weight decays with patch distance */
const w = Math.exp(-dist2 / h2);
const qi = (qy * W + qx) * 4;
wR += w * src[qi];
wG += w * src[qi + 1];
wB += w * src[qi + 2];
wSum += w;
}
}
/* Weighted average normalized by total weight */
const oi = (py * W + px) * 4;
out[oi] = Math.round(wR / wSum);
out[oi + 1] = Math.round(wG / wSum);
out[oi + 2] = Math.round(wB / wSum);
out[oi + 3] = 255;
}
}
return out;
}
/* Initialize — draw scene and take the clean snapshot */
drawScene();
cleanPixels = snapshot();
currentPixels = new Uint8ClampedArray(cleanPixels);
setStatus('Draw a clean image and add noise to begin.', '');
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
function setButtons(busy) {
document.getElementById('btn-noise').disabled = busy;
document.getElementById('btn-denoise').disabled = busy;
document.getElementById('btn-reset').disabled = busy;
}
document.getElementById('btn-noise').addEventListener('click', function () {
if (state === 'denoised') {
currentPixels = new Uint8ClampedArray(cleanPixels);
putPixels(currentPixels);
}
/* Standard deviation of Gaussian noise (higher = more grain) */
currentPixels = addGaussianNoise(currentPixels, 35);
putPixels(currentPixels);
state = 'noisy';
setStatus('Noise added. Click Denoise (NLM) to clean it.', 'bad');
});
document.getElementById('btn-denoise').addEventListener('click', function () {
if (state !== 'noisy') {
setStatus('Add noise first, then denoise.', 'bad');
return;
}
setStatus('Running NLM — this may take a moment...', 'busy');
setButtons(true);
/* Yield to the browser so the status text renders before the heavy computation */
setTimeout(function () {
/* NLM params: h=3000 (controls smoothing), patchR=2 (patch half-size), searchR=15 (search window half-size) */
const denoised = nlmDenoise(currentPixels, 3000, 2, 15);
currentPixels = denoised;
putPixels(denoised);
state = 'denoised';
setStatus('Denoised! Edges sharp, grain gone.', 'ok');
setButtons(false);
}, 30);
});
document.getElementById('btn-reset').addEventListener('click', function () {
drawScene();
cleanPixels = snapshot();
currentPixels = new Uint8ClampedArray(cleanPixels);
state = 'clean';
setStatus('Draw a clean image and add noise to begin.', '');
});
<!-- Main UI – hint paragraph, canvas, status line, action buttons -->
<p class="hint">Each pixel is denoised by averaging other pixels whose surrounding patch looks similar — anywhere in the image, not just next door. Add noise first, then run NLM to watch grain disappear while edges stay sharp.</p>
<div class="canvas-wrap">
<canvas id="cnv" width="200" height="200"></canvas>
</div>
<div class="status" id="status">Draw a clean image and add noise to begin.</div>
<div class="btns">
<button id="btn-noise" type="button">Add noise</button>
<button id="btn-denoise" type="button">Denoise (NLM)</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
/* Layout and visual styling for the NLM 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; }
.canvas-wrap { display: inline-block; border: 1px solid #cdd9e3; border-radius: 6px; overflow: hidden; margin-bottom: .5rem; }
canvas { display: block; image-rendering: pixelated; width: 200px; height: 200px; }
.status { font-size: 1rem; font-weight: 600; margin: .4rem 0; min-height: 1.4em; }
.status.ok { color: #0a7d33; }
.status.bad { color: #c92f3c; }
.status.busy { color: #775500; }
.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:disabled { opacity: .5; cursor: not-allowed; }
button.ghost { background: #fff; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
/* Canvas dimensions: 200×200 pixels (scaled up 1× in CSS) */
const W = 200, H = 200;
const cnv = document.getElementById('cnv');
const ctx = cnv.getContext('2d');
/* Image state: clean | noisy | denoised */
let cleanPixels = null;
let currentPixels = null;
let state = 'clean'; // clean | noisy | denoised
/* Draw synthetic scene with geometric shapes and uniform regions */
function drawScene() {
ctx.fillStyle = '#e8eef3';
ctx.fillRect(0, 0, W, H);
/* Blue rectangle — top-left */
ctx.fillStyle = '#1d6fa3';
ctx.fillRect(20, 20, 70, 70);
/* Red rectangle — top-right */
ctx.fillStyle = '#e63946';
ctx.fillRect(110, 30, 70, 70);
/* Green circle — bottom-left */
ctx.fillStyle = '#2a9d45';
ctx.beginPath();
ctx.arc(70, 150, 35, 0, Math.PI * 2);
ctx.fill();
/* Orange diamond — bottom-right */
ctx.fillStyle = '#f4a261';
ctx.beginPath();
ctx.moveTo(150, 110);
ctx.lineTo(185, 150);
ctx.lineTo(150, 190);
ctx.lineTo(115, 150);
ctx.closePath();
ctx.fill();
}
/* Read current canvas pixels into a Uint8ClampedArray */
function snapshot() {
const d = ctx.getImageData(0, 0, W, H);
return new Uint8ClampedArray(d.data);
}
function putPixels(arr) {
const d = ctx.createImageData(W, H);
d.data.set(arr);
ctx.putImageData(d, 0, 0);
}
/* Add Gaussian noise using the Box-Muller transform */
function addGaussianNoise(src, sigma) {
const out = new Uint8ClampedArray(src);
for (let i = 0; i < out.length; i += 4) {
/* Box-Muller transform produces a standard-normal sample z */
const u1 = Math.random() || 1e-10;
const u2 = Math.random();
const z = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
const n = Math.round(z * sigma);
out[i] = Math.max(0, Math.min(255, out[i] + n));
out[i + 1] = Math.max(0, Math.min(255, out[i + 1] + n));
out[i + 2] = Math.max(0, Math.min(255, out[i + 2] + n));
/* Alpha channel stays at 255 (fully opaque) */
}
return out;
}
/* Non-local means: for each pixel p, accumulate weighted average over all q in the search window. Weight = exp(-patch_distance^2 / h^2). */
function nlmDenoise(src, h, patchR, searchR) {
const h2 = h * h;
const out = new Uint8ClampedArray(src.length);
/* Outer loop over every target pixel p */
for (let py = 0; py < H; py++) {
for (let px = 0; px < W; px++) {
let wR = 0, wG = 0, wB = 0, wSum = 0;
/* Inner loop over candidate pixels q within the search window */
for (let qy = Math.max(0, py - searchR); qy <= Math.min(H - 1, py + searchR); qy++) {
for (let qx = Math.max(0, px - searchR); qx <= Math.min(W - 1, px + searchR); qx++) {
/* Squared Euclidean distance between patch around p and patch around q (all 3 channels) */
let dist2 = 0;
for (let dy = -patchR; dy <= patchR; dy++) {
for (let dx = -patchR; dx <= patchR; dx++) {
const sy = Math.min(H - 1, Math.max(0, py + dy));
const sx = Math.min(W - 1, Math.max(0, px + dx));
const ty = Math.min(H - 1, Math.max(0, qy + dy));
const tx = Math.min(W - 1, Math.max(0, qx + dx));
const ip = (sy * W + sx) * 4;
const iq = (ty * W + tx) * 4;
const dr = src[ip] - src[iq];
const dg = src[ip + 1] - src[iq + 1];
const db = src[ip + 2] - src[iq + 2];
dist2 += dr * dr + dg * dg + db * db;
}
}
/* Gaussian weight decays with patch distance */
const w = Math.exp(-dist2 / h2);
const qi = (qy * W + qx) * 4;
wR += w * src[qi];
wG += w * src[qi + 1];
wB += w * src[qi + 2];
wSum += w;
}
}
/* Weighted average normalized by total weight */
const oi = (py * W + px) * 4;
out[oi] = Math.round(wR / wSum);
out[oi + 1] = Math.round(wG / wSum);
out[oi + 2] = Math.round(wB / wSum);
out[oi + 3] = 255;
}
}
return out;
}
/* Initialize — draw scene and take the clean snapshot */
drawScene();
cleanPixels = snapshot();
currentPixels = new Uint8ClampedArray(cleanPixels);
setStatus('Draw a clean image and add noise to begin.', '');
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
function setButtons(busy) {
document.getElementById('btn-noise').disabled = busy;
document.getElementById('btn-denoise').disabled = busy;
document.getElementById('btn-reset').disabled = busy;
}
document.getElementById('btn-noise').addEventListener('click', function () {
if (state === 'denoised') {
currentPixels = new Uint8ClampedArray(cleanPixels);
putPixels(currentPixels);
}
/* Standard deviation of Gaussian noise (higher = more grain) */
currentPixels = addGaussianNoise(currentPixels, 35);
putPixels(currentPixels);
state = 'noisy';
setStatus('Noise added. Click Denoise (NLM) to clean it.', 'bad');
});
document.getElementById('btn-denoise').addEventListener('click', function () {
if (state !== 'noisy') {
setStatus('Add noise first, then denoise.', 'bad');
return;
}
setStatus('Running NLM — this may take a moment...', 'busy');
setButtons(true);
/* Yield to the browser so the status text renders before the heavy computation */
setTimeout(function () {
/* NLM params: h=3000 (controls smoothing), patchR=2 (patch half-size), searchR=15 (search window half-size) */
const denoised = nlmDenoise(currentPixels, 3000, 2, 15);
currentPixels = denoised;
putPixels(denoised);
state = 'denoised';
setStatus('Denoised! Edges sharp, grain gone.', 'ok');
setButtons(false);
}, 30);
});
document.getElementById('btn-reset').addEventListener('click', function () {
drawScene();
cleanPixels = snapshot();
currentPixels = new Uint8ClampedArray(cleanPixels);
state = 'clean';
setStatus('Draw a clean image and add noise to begin.', '');
});
Comments
Loading comments...