The canvas below shows a synthetic image with a sharp edge and additive noise. Adjust the spatial radius (how far the filter reaches) and the color threshold (how different a neighbor must be before it is ignored). Then press Apply filter to see the result.
<!-- Demo layout — two canvases (noisy input | filtered output) + sliders -->
<div class="controls">
<label>Spatial radius: <span id="radius-val">5</span>
<input type="range" id="radius" min="1" max="12" value="5">
</label>
<label>Color threshold (σ): <span id="color-val">30</span>
<input type="range" id="color-thresh" min="5" max="100" value="30">
</label>
</div>
<div class="canvas-row">
<figure>
<figcaption>Noisy input</figcaption>
<canvas id="c-noisy" width="200" height="200"></canvas>
</figure>
<figure>
<figcaption>Result</figcaption>
<canvas id="c-result" width="200" height="200"></canvas>
</figure>
</div>
<div class="btns">
<button id="btn-bilateral" type="button">Apply bilateral filter</button>
<button id="btn-gaussian" type="button" class="ghost">Apply Gaussian blur</button>
<button id="btn-reset" type="button" class="ghost">New noise</button>
</div>
<div id="status" class="status"></div>
/* Responsive two-canvas layout with controls */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .7rem; }
label { font-size: .88rem; color: #333; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label span { font-weight: 700; min-width: 2ch; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
.canvas-row { display: flex; gap: 12px; flex-wrap: wrap; margin: .5rem 0; }
figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
figcaption { font-size: .8rem; color: #555; font-weight: 600; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block;
image-rendering: pixelated; width: 200px; height: 200px; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem; }
button { font: 600 13px 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; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Bilateral filter demo — shows edge-preserving vs plain Gaussian smoothing
const W = 100, H = 100;
let noisyData = null;
// Generate a synthetic noisy image with a hard vertical edge
function generateNoisyImage() {
const data = new Uint8Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
// Piecewise-constant base intensity — left half dark, right half bright
const base = x < W / 2 ? 60 : 180;
// Add random noise — uniform noise in range [-30, 30]
const noise = Math.round((Math.random() - 0.5) * 60);
data[y * W + x] = Math.max(0, Math.min(255, base + noise));
}
}
return data;
}
// Render a grayscale pixel array to a canvas element
function drawToCanvas(canvasId, data) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let i = 0; i < W * H; i++) {
const v = data[i];
img.data[i * 4] = v;
img.data[i * 4 + 1] = v;
img.data[i * 4 + 2] = v;
img.data[i * 4 + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// Bilateral filter — weighted by spatial AND intensity distance
// Output pixel p = sum_q [ G_s(|p-q|) * G_r(|I_p - I_q|) * I_q ]
// / sum_q [ G_s(|p-q|) * G_r(|I_p - I_q|) ]
function bilateralFilter(src, radius, colorSigma) {
const out = new Uint8Array(W * H);
const sigmaS = radius / 2;
const sigmaR = colorSigma;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const center = src[y * W + x];
let sumW = 0, sumI = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const ny = y + dy, nx = x + dx;
if (ny < 0 || ny >= H || nx < 0 || nx >= W) continue;
const neighbor = src[ny * W + nx];
// Spatial Gaussian weight — Gaussian on Euclidean distance
const spatialDist2 = dx * dx + dy * dy;
const wS = Math.exp(-spatialDist2 / (2 * sigmaS * sigmaS));
// Range (intensity) Gaussian weight — Gaussian on intensity difference
const diff = center - neighbor;
const wR = Math.exp(-(diff * diff) / (2 * sigmaR * sigmaR));
const w = wS * wR;
sumW += w;
sumI += w * neighbor;
}
}
out[y * W + x] = Math.round(sumI / sumW);
}
}
return out;
}
// Plain Gaussian blur for comparison — ignores intensity difference
function gaussianFilter(src, radius) {
const out = new Uint8Array(W * H);
const sigma = radius / 2;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let sumW = 0, sumI = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const ny = y + dy, nx = x + dx;
if (ny < 0 || ny >= H || nx < 0 || nx >= W) continue;
const spatialDist2 = dx * dx + dy * dy;
const w = Math.exp(-spatialDist2 / (2 * sigma * sigma));
sumW += w;
sumI += w * src[ny * W + nx];
}
}
out[y * W + x] = Math.round(sumI / sumW);
}
}
return out;
}
function getParams() {
const radius = parseInt(document.getElementById('radius').value, 10);
const colorThresh = parseInt(document.getElementById('color-thresh').value, 10);
return { radius, colorThresh };
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
function applyBilateral() {
const { radius, colorThresh } = getParams();
setStatus('Computing…');
setTimeout(() => {
const result = bilateralFilter(noisyData, radius, colorThresh);
drawToCanvas('c-result', result);
setStatus(i18nFmt('Bilateral filter applied — radius %r%, color σ=%c%.', { r: radius, c: colorThresh }));
}, 10);
}
function applyGaussian() {
const { radius } = getParams();
setStatus('Computing…');
setTimeout(() => {
const result = gaussianFilter(noisyData, radius);
drawToCanvas('c-result', result);
setStatus(i18nFmt('Gaussian blur applied — radius %r%.', { r: radius }));
}, 10);
}
function reset() {
noisyData = generateNoisyImage();
drawToCanvas('c-noisy', noisyData);
drawToCanvas('c-result', noisyData);
setStatus('Click a button to filter the image.');
}
// Live slider value display
document.getElementById('radius').addEventListener('input', function () {
document.getElementById('radius-val').textContent = this.value;
});
document.getElementById('color-thresh').addEventListener('input', function () {
document.getElementById('color-val').textContent = this.value;
});
document.getElementById('btn-bilateral').addEventListener('click', applyBilateral);
document.getElementById('btn-gaussian').addEventListener('click', applyGaussian);
document.getElementById('btn-reset').addEventListener('click', reset);
reset();
<!-- Demo layout — two canvases (noisy input | filtered output) + sliders -->
<div class="controls">
<label>Spatial radius: <span id="radius-val">5</span>
<input type="range" id="radius" min="1" max="12" value="5">
</label>
<label>Color threshold (σ): <span id="color-val">30</span>
<input type="range" id="color-thresh" min="5" max="100" value="30">
</label>
</div>
<div class="canvas-row">
<figure>
<figcaption>Noisy input</figcaption>
<canvas id="c-noisy" width="200" height="200"></canvas>
</figure>
<figure>
<figcaption>Result</figcaption>
<canvas id="c-result" width="200" height="200"></canvas>
</figure>
</div>
<div class="btns">
<button id="btn-bilateral" type="button">Apply bilateral filter</button>
<button id="btn-gaussian" type="button" class="ghost">Apply Gaussian blur</button>
<button id="btn-reset" type="button" class="ghost">New noise</button>
</div>
<div id="status" class="status"></div>
/* Responsive two-canvas layout with controls */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .7rem; }
label { font-size: .88rem; color: #333; display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; }
label span { font-weight: 700; min-width: 2ch; }
input[type=range] { flex: 1; min-width: 120px; accent-color: #1d3557; }
.canvas-row { display: flex; gap: 12px; flex-wrap: wrap; margin: .5rem 0; }
figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 4px; }
figcaption { font-size: .8rem; color: #555; font-weight: 600; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; display: block;
image-rendering: pixelated; width: 200px; height: 200px; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-top: .4rem; }
button { font: 600 13px 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; }
.status { font-size: .9rem; font-weight: 600; margin-top: .5rem; min-height: 1.3em; color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Bilateral filter demo — shows edge-preserving vs plain Gaussian smoothing
const W = 100, H = 100;
let noisyData = null;
// Generate a synthetic noisy image with a hard vertical edge
function generateNoisyImage() {
const data = new Uint8Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
// Piecewise-constant base intensity — left half dark, right half bright
const base = x < W / 2 ? 60 : 180;
// Add random noise — uniform noise in range [-30, 30]
const noise = Math.round((Math.random() - 0.5) * 60);
data[y * W + x] = Math.max(0, Math.min(255, base + noise));
}
}
return data;
}
// Render a grayscale pixel array to a canvas element
function drawToCanvas(canvasId, data) {
const canvas = document.getElementById(canvasId);
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let i = 0; i < W * H; i++) {
const v = data[i];
img.data[i * 4] = v;
img.data[i * 4 + 1] = v;
img.data[i * 4 + 2] = v;
img.data[i * 4 + 3] = 255;
}
ctx.putImageData(img, 0, 0);
}
// Bilateral filter — weighted by spatial AND intensity distance
// Output pixel p = sum_q [ G_s(|p-q|) * G_r(|I_p - I_q|) * I_q ]
// / sum_q [ G_s(|p-q|) * G_r(|I_p - I_q|) ]
function bilateralFilter(src, radius, colorSigma) {
const out = new Uint8Array(W * H);
const sigmaS = radius / 2;
const sigmaR = colorSigma;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const center = src[y * W + x];
let sumW = 0, sumI = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const ny = y + dy, nx = x + dx;
if (ny < 0 || ny >= H || nx < 0 || nx >= W) continue;
const neighbor = src[ny * W + nx];
// Spatial Gaussian weight — Gaussian on Euclidean distance
const spatialDist2 = dx * dx + dy * dy;
const wS = Math.exp(-spatialDist2 / (2 * sigmaS * sigmaS));
// Range (intensity) Gaussian weight — Gaussian on intensity difference
const diff = center - neighbor;
const wR = Math.exp(-(diff * diff) / (2 * sigmaR * sigmaR));
const w = wS * wR;
sumW += w;
sumI += w * neighbor;
}
}
out[y * W + x] = Math.round(sumI / sumW);
}
}
return out;
}
// Plain Gaussian blur for comparison — ignores intensity difference
function gaussianFilter(src, radius) {
const out = new Uint8Array(W * H);
const sigma = radius / 2;
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
let sumW = 0, sumI = 0;
for (let dy = -radius; dy <= radius; dy++) {
for (let dx = -radius; dx <= radius; dx++) {
const ny = y + dy, nx = x + dx;
if (ny < 0 || ny >= H || nx < 0 || nx >= W) continue;
const spatialDist2 = dx * dx + dy * dy;
const w = Math.exp(-spatialDist2 / (2 * sigma * sigma));
sumW += w;
sumI += w * src[ny * W + nx];
}
}
out[y * W + x] = Math.round(sumI / sumW);
}
}
return out;
}
function getParams() {
const radius = parseInt(document.getElementById('radius').value, 10);
const colorThresh = parseInt(document.getElementById('color-thresh').value, 10);
return { radius, colorThresh };
}
function setStatus(msg) {
document.getElementById('status').textContent = msg;
}
function applyBilateral() {
const { radius, colorThresh } = getParams();
setStatus('Computing…');
setTimeout(() => {
const result = bilateralFilter(noisyData, radius, colorThresh);
drawToCanvas('c-result', result);
setStatus(i18nFmt('Bilateral filter applied — radius %r%, color σ=%c%.', { r: radius, c: colorThresh }));
}, 10);
}
function applyGaussian() {
const { radius } = getParams();
setStatus('Computing…');
setTimeout(() => {
const result = gaussianFilter(noisyData, radius);
drawToCanvas('c-result', result);
setStatus(i18nFmt('Gaussian blur applied — radius %r%.', { r: radius }));
}, 10);
}
function reset() {
noisyData = generateNoisyImage();
drawToCanvas('c-noisy', noisyData);
drawToCanvas('c-result', noisyData);
setStatus('Click a button to filter the image.');
}
// Live slider value display
document.getElementById('radius').addEventListener('input', function () {
document.getElementById('radius-val').textContent = this.value;
});
document.getElementById('color-thresh').addEventListener('input', function () {
document.getElementById('color-val').textContent = this.value;
});
document.getElementById('btn-bilateral').addEventListener('click', applyBilateral);
document.getElementById('btn-gaussian').addEventListener('click', applyGaussian);
document.getElementById('btn-reset').addEventListener('click', reset);
reset();
Notice the key trade-off: a large color threshold lets dissimilar pixels contribute, blurring edges but also removing more noise. A small threshold respects edges but may leave some noise behind. The spatial radius controls the smoothing neighborhood — bigger radius averages more pixels. Together these two parameters give you precise control that a plain Gaussian blur never offers.
Comments
Loading comments...