<div class="demo-wrap">
<div class="panel">
<div class="label">Input image <span class="badge">8×8</span></div>
<canvas id="imgCanvas" width="192" height="192"></canvas>
</div>
<div class="panel">
<div class="label">Filter <span class="badge">3×3 edge detector</span></div>
<canvas id="filterCanvas" width="72" height="72"></canvas>
<div class="filter-vals" id="filterVals"></div>
</div>
<div class="panel">
<div class="label">Output (convolution) <span class="badge">6×6</span></div>
<canvas id="outCanvas" width="144" height="144"></canvas>
</div>
</div>
<div class="controls">
<label>Filter position:
<input type="range" id="posSlider" min="0" max="35" value="0">
<span id="posLabel">row 0, col 0</span>
</label>
<button id="scanBtn" type="button">Auto-scan</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="info" id="info">Move the slider or click Auto-scan to watch convolution in action.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; font-size: 14px; }
.demo-wrap { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start; margin-bottom: 10px; }
.panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.label { font-size: .78rem; font-weight: 600; color: #555; }
.badge { background: #e8eef3; color: #1d3557; border-radius: 4px; padding: 1px 5px; font-size: .72rem; font-weight: 500; margin-left: 4px; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; image-rendering: pixelated; display: block; }
.filter-vals { display: grid; grid-template-columns: repeat(3, 24px); gap: 2px; font: 600 11px ui-monospace, monospace; color: #1d3557; }
.filter-vals span { text-align: center; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 6px; }
label { font-size: .85rem; color: #333; display: flex; align-items: center; gap: 6px; }
input[type=range] { width: 140px; }
#posLabel { font-size: .78rem; color: #666; min-width: 80px; }
button { font: 600 13px system-ui, sans-serif; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.info { font-size: .85rem; color: #444; min-height: 1.4em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Discrete Laplacian edge-detector kernel (3×3)
// Highlights areas where pixel values change rapidly.
const KERNEL = [
[ 0, -1, 0],
[-1, 4, -1],
[ 0, -1, 0]
];
// 8×8 grayscale image: bright cross on dark background (values 0–255)
const SIZE = 8;
const IMG = [
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
[200,200,200,200,200,200,200,200],
[200,200,200,200,200,200,200,200],
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
];
// Convolution output is (SIZE-2) x (SIZE-2) = 6x6 (no padding)
const OUT_SIZE = SIZE - 2;
// Compute full convolution output
function convolve() {
const out = [];
for (let r = 0; r < OUT_SIZE; r++) {
out.push([]);
for (let c = 0; c < OUT_SIZE; c++) {
let val = 0;
for (let kr = 0; kr < 3; kr++)
for (let kc = 0; kc < 3; kc++)
val += IMG[r + kr][c + kc] * KERNEL[kr][kc];
out[r].push(val);
}
}
return out;
}
const OUTPUT = convolve();
// Normalize output for display: map min..max -> 0..255
function normalizeOutput(out) {
let min = Infinity, max = -Infinity;
for (const row of out) for (const v of row) { if (v < min) min = v; if (v > max) max = v; }
const range = max - min || 1;
return out.map(row => row.map(v => Math.round((v - min) / range * 255)));
}
const NORM = normalizeOutput(OUTPUT);
// Canvas helpers
const CELL_IMG = 24; // pixels per input cell
const CELL_OUT = 24; // pixels per output cell
const CELL_FIL = 24; // pixels per kernel cell
const imgCtx = document.getElementById('imgCanvas').getContext('2d');
const outCtx = document.getElementById('outCanvas').getContext('2d');
const filCtx = document.getElementById('filterCanvas').getContext('2d');
const posSlider = document.getElementById('posSlider');
const posLabel = document.getElementById('posLabel');
const infoEl = document.getElementById('info');
const filterValsEl = document.getElementById('filterVals');
function drawImage(highlightR, highlightC) {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const v = IMG[r][c];
// Highlight the 3x3 patch the filter is currently on
const inPatch = highlightR !== null &&
r >= highlightR && r < highlightR + 3 &&
c >= highlightC && c < highlightC + 3;
imgCtx.fillStyle = inPatch
? `rgba(255,200,0,${0.5 + v/510})`
: `rgb(${v},${v},${v})`;
imgCtx.fillRect(c * CELL_IMG, r * CELL_IMG, CELL_IMG, CELL_IMG);
imgCtx.strokeStyle = inPatch ? '#e6a800' : '#cdd9e3';
imgCtx.lineWidth = 0.5;
imgCtx.strokeRect(c * CELL_IMG, r * CELL_IMG, CELL_IMG, CELL_IMG);
}
}
}
function drawOutput(activeR, activeC) {
for (let r = 0; r < OUT_SIZE; r++) {
for (let c = 0; c < OUT_SIZE; c++) {
const v = NORM[r][c];
const isActive = r === activeR && c === activeC;
outCtx.fillStyle = isActive ? `rgba(255,150,0,0.9)` : `rgb(${v},${v},${v})`;
outCtx.fillRect(c * CELL_OUT, r * CELL_OUT, CELL_OUT, CELL_OUT);
outCtx.strokeStyle = isActive ? '#e6a800' : '#cdd9e3';
outCtx.lineWidth = 0.5;
outCtx.strokeRect(c * CELL_OUT, r * CELL_OUT, CELL_OUT, CELL_OUT);
}
}
}
function drawFilter() {
filCtx.clearRect(0, 0, 72, 72);
filterValsEl.innerHTML = '';
for (let kr = 0; kr < 3; kr++) {
for (let kc = 0; kc < 3; kc++) {
const v = KERNEL[kr][kc];
const bright = v > 0 ? 60 : v < 0 ? 200 : 160;
filCtx.fillStyle = v > 0 ? '#1d3557' : v < 0 ? '#e63946' : '#aaa';
filCtx.fillRect(kc * CELL_FIL, kr * CELL_FIL, CELL_FIL, CELL_FIL);
filCtx.strokeStyle = '#555';
filCtx.lineWidth = 0.5;
filCtx.strokeRect(kc * CELL_FIL, kr * CELL_FIL, CELL_FIL, CELL_FIL);
filCtx.fillStyle = '#fff';
filCtx.font = '600 11px ui-monospace, monospace';
filCtx.textAlign = 'center';
filCtx.textBaseline = 'middle';
filCtx.fillText(v, kc * CELL_FIL + CELL_FIL/2, kr * CELL_FIL + CELL_FIL/2);
const sp = document.createElement('span');
sp.textContent = v > 0 ? '+' + v : v;
filterValsEl.appendChild(sp);
}
}
}
function posFromIndex(idx) {
return { r: Math.floor(idx / OUT_SIZE), c: idx % OUT_SIZE };
}
function update(idx) {
const { r, c } = posFromIndex(idx);
drawImage(r, c);
drawOutput(r, c);
posLabel.textContent = fmt('row %r%, col %c%', { r: r, c: c });
const rawVal = OUTPUT[r][c];
infoEl.textContent = fmt('Filter at (%r%,%c%): dot-product = %rawVal% → output pixel brightness %v%/255.%v2%', { r: r, c: c, rawVal: rawVal, v: NORM[r][c], v2: Math.abs(rawVal) > 100 ? ' Strong edge detected!' : rawVal === 0 ? ' Flat region — no edge.' : ' Weak edge.' });
}
// Auto-scan animation
let scanTimer = null;
document.getElementById('scanBtn').addEventListener('click', () => {
if (scanTimer) { clearInterval(scanTimer); scanTimer = null; document.getElementById('scanBtn').textContent = 'Auto-scan'; return; }
document.getElementById('scanBtn').textContent = 'Stop';
let idx = 0;
posSlider.value = 0;
update(0);
scanTimer = setInterval(() => {
idx = (idx + 1) % (OUT_SIZE * OUT_SIZE);
posSlider.value = idx;
update(idx);
if (idx === OUT_SIZE * OUT_SIZE - 1) {
clearInterval(scanTimer); scanTimer = null;
document.getElementById('scanBtn').textContent = 'Auto-scan';
}
}, 180);
});
posSlider.addEventListener('input', () => {
if (scanTimer) { clearInterval(scanTimer); scanTimer = null; document.getElementById('scanBtn').textContent = 'Auto-scan'; }
update(parseInt(posSlider.value, 10));
});
document.getElementById('resetBtn').addEventListener('click', () => {
if (scanTimer) { clearInterval(scanTimer); scanTimer = null; document.getElementById('scanBtn').textContent = 'Auto-scan'; }
posSlider.value = 0;
update(0);
});
// Initial draw
drawFilter();
update(0);
// Verification (logged to console for node test)
console.log('Convolution output (6x6):');
OUTPUT.forEach((row, r) => console.log(" " + fmt('row %r%: [%row%]', { r: r, row: row.join(', ') })));
console.log('Normalized:', NORM.map(row => `[${row.join(',')}]`).join(' '));
// Spot-checks: corner (0,0) should be 0*IMG[0..2][0..2] convolved with kernel
// For top-left: 0*0 + (-1)*0 + 0*0 + (-1)*0 + 4*0 + (-1)*0 + 0*0 + (-1)*0 + 0*200 = 0
// For (1,2) patch includes cross arm — should be nonzero
console.assert(OUTPUT[0][0] === 0, 'Corner should be 0 (all dark pixels)');
console.assert(OUTPUT[1][2] !== 0, 'Near cross arm should be nonzero edge');
console.log('Assertions passed.');
<div class="demo-wrap">
<div class="panel">
<div class="label">Input image <span class="badge">8×8</span></div>
<canvas id="imgCanvas" width="192" height="192"></canvas>
</div>
<div class="panel">
<div class="label">Filter <span class="badge">3×3 edge detector</span></div>
<canvas id="filterCanvas" width="72" height="72"></canvas>
<div class="filter-vals" id="filterVals"></div>
</div>
<div class="panel">
<div class="label">Output (convolution) <span class="badge">6×6</span></div>
<canvas id="outCanvas" width="144" height="144"></canvas>
</div>
</div>
<div class="controls">
<label>Filter position:
<input type="range" id="posSlider" min="0" max="35" value="0">
<span id="posLabel">row 0, col 0</span>
</label>
<button id="scanBtn" type="button">Auto-scan</button>
<button id="resetBtn" type="button" class="ghost">Reset</button>
</div>
<div class="info" id="info">Move the slider or click Auto-scan to watch convolution in action.</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #222; font-size: 14px; }
.demo-wrap { display: flex; gap: 16px; flex-wrap: wrap; align-items: flex-start; margin-bottom: 10px; }
.panel { display: flex; flex-direction: column; align-items: center; gap: 4px; }
.label { font-size: .78rem; font-weight: 600; color: #555; }
.badge { background: #e8eef3; color: #1d3557; border-radius: 4px; padding: 1px 5px; font-size: .72rem; font-weight: 500; margin-left: 4px; }
canvas { border: 1px solid #cdd9e3; border-radius: 6px; image-rendering: pixelated; display: block; }
.filter-vals { display: grid; grid-template-columns: repeat(3, 24px); gap: 2px; font: 600 11px ui-monospace, monospace; color: #1d3557; }
.filter-vals span { text-align: center; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 6px; }
label { font-size: .85rem; color: #333; display: flex; align-items: center; gap: 6px; }
input[type=range] { width: 140px; }
#posLabel { font-size: .78rem; color: #666; min-width: 80px; }
button { font: 600 13px system-ui, sans-serif; padding: .35rem .75rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 7px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.info { font-size: .85rem; color: #444; min-height: 1.4em; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Discrete Laplacian edge-detector kernel (3×3)
// Highlights areas where pixel values change rapidly.
const KERNEL = [
[ 0, -1, 0],
[-1, 4, -1],
[ 0, -1, 0]
];
// 8×8 grayscale image: bright cross on dark background (values 0–255)
const SIZE = 8;
const IMG = [
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
[200,200,200,200,200,200,200,200],
[200,200,200,200,200,200,200,200],
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
[ 0, 0, 0,200,200, 0, 0, 0],
];
// Convolution output is (SIZE-2) x (SIZE-2) = 6x6 (no padding)
const OUT_SIZE = SIZE - 2;
// Compute full convolution output
function convolve() {
const out = [];
for (let r = 0; r < OUT_SIZE; r++) {
out.push([]);
for (let c = 0; c < OUT_SIZE; c++) {
let val = 0;
for (let kr = 0; kr < 3; kr++)
for (let kc = 0; kc < 3; kc++)
val += IMG[r + kr][c + kc] * KERNEL[kr][kc];
out[r].push(val);
}
}
return out;
}
const OUTPUT = convolve();
// Normalize output for display: map min..max -> 0..255
function normalizeOutput(out) {
let min = Infinity, max = -Infinity;
for (const row of out) for (const v of row) { if (v < min) min = v; if (v > max) max = v; }
const range = max - min || 1;
return out.map(row => row.map(v => Math.round((v - min) / range * 255)));
}
const NORM = normalizeOutput(OUTPUT);
// Canvas helpers
const CELL_IMG = 24; // pixels per input cell
const CELL_OUT = 24; // pixels per output cell
const CELL_FIL = 24; // pixels per kernel cell
const imgCtx = document.getElementById('imgCanvas').getContext('2d');
const outCtx = document.getElementById('outCanvas').getContext('2d');
const filCtx = document.getElementById('filterCanvas').getContext('2d');
const posSlider = document.getElementById('posSlider');
const posLabel = document.getElementById('posLabel');
const infoEl = document.getElementById('info');
const filterValsEl = document.getElementById('filterVals');
function drawImage(highlightR, highlightC) {
for (let r = 0; r < SIZE; r++) {
for (let c = 0; c < SIZE; c++) {
const v = IMG[r][c];
// Highlight the 3x3 patch the filter is currently on
const inPatch = highlightR !== null &&
r >= highlightR && r < highlightR + 3 &&
c >= highlightC && c < highlightC + 3;
imgCtx.fillStyle = inPatch
? `rgba(255,200,0,${0.5 + v/510})`
: `rgb(${v},${v},${v})`;
imgCtx.fillRect(c * CELL_IMG, r * CELL_IMG, CELL_IMG, CELL_IMG);
imgCtx.strokeStyle = inPatch ? '#e6a800' : '#cdd9e3';
imgCtx.lineWidth = 0.5;
imgCtx.strokeRect(c * CELL_IMG, r * CELL_IMG, CELL_IMG, CELL_IMG);
}
}
}
function drawOutput(activeR, activeC) {
for (let r = 0; r < OUT_SIZE; r++) {
for (let c = 0; c < OUT_SIZE; c++) {
const v = NORM[r][c];
const isActive = r === activeR && c === activeC;
outCtx.fillStyle = isActive ? `rgba(255,150,0,0.9)` : `rgb(${v},${v},${v})`;
outCtx.fillRect(c * CELL_OUT, r * CELL_OUT, CELL_OUT, CELL_OUT);
outCtx.strokeStyle = isActive ? '#e6a800' : '#cdd9e3';
outCtx.lineWidth = 0.5;
outCtx.strokeRect(c * CELL_OUT, r * CELL_OUT, CELL_OUT, CELL_OUT);
}
}
}
function drawFilter() {
filCtx.clearRect(0, 0, 72, 72);
filterValsEl.innerHTML = '';
for (let kr = 0; kr < 3; kr++) {
for (let kc = 0; kc < 3; kc++) {
const v = KERNEL[kr][kc];
const bright = v > 0 ? 60 : v < 0 ? 200 : 160;
filCtx.fillStyle = v > 0 ? '#1d3557' : v < 0 ? '#e63946' : '#aaa';
filCtx.fillRect(kc * CELL_FIL, kr * CELL_FIL, CELL_FIL, CELL_FIL);
filCtx.strokeStyle = '#555';
filCtx.lineWidth = 0.5;
filCtx.strokeRect(kc * CELL_FIL, kr * CELL_FIL, CELL_FIL, CELL_FIL);
filCtx.fillStyle = '#fff';
filCtx.font = '600 11px ui-monospace, monospace';
filCtx.textAlign = 'center';
filCtx.textBaseline = 'middle';
filCtx.fillText(v, kc * CELL_FIL + CELL_FIL/2, kr * CELL_FIL + CELL_FIL/2);
const sp = document.createElement('span');
sp.textContent = v > 0 ? '+' + v : v;
filterValsEl.appendChild(sp);
}
}
}
function posFromIndex(idx) {
return { r: Math.floor(idx / OUT_SIZE), c: idx % OUT_SIZE };
}
function update(idx) {
const { r, c } = posFromIndex(idx);
drawImage(r, c);
drawOutput(r, c);
posLabel.textContent = fmt('row %r%, col %c%', { r: r, c: c });
const rawVal = OUTPUT[r][c];
infoEl.textContent = fmt('Filter at (%r%,%c%): dot-product = %rawVal% → output pixel brightness %v%/255.%v2%', { r: r, c: c, rawVal: rawVal, v: NORM[r][c], v2: Math.abs(rawVal) > 100 ? ' Strong edge detected!' : rawVal === 0 ? ' Flat region — no edge.' : ' Weak edge.' });
}
// Auto-scan animation
let scanTimer = null;
document.getElementById('scanBtn').addEventListener('click', () => {
if (scanTimer) { clearInterval(scanTimer); scanTimer = null; document.getElementById('scanBtn').textContent = 'Auto-scan'; return; }
document.getElementById('scanBtn').textContent = 'Stop';
let idx = 0;
posSlider.value = 0;
update(0);
scanTimer = setInterval(() => {
idx = (idx + 1) % (OUT_SIZE * OUT_SIZE);
posSlider.value = idx;
update(idx);
if (idx === OUT_SIZE * OUT_SIZE - 1) {
clearInterval(scanTimer); scanTimer = null;
document.getElementById('scanBtn').textContent = 'Auto-scan';
}
}, 180);
});
posSlider.addEventListener('input', () => {
if (scanTimer) { clearInterval(scanTimer); scanTimer = null; document.getElementById('scanBtn').textContent = 'Auto-scan'; }
update(parseInt(posSlider.value, 10));
});
document.getElementById('resetBtn').addEventListener('click', () => {
if (scanTimer) { clearInterval(scanTimer); scanTimer = null; document.getElementById('scanBtn').textContent = 'Auto-scan'; }
posSlider.value = 0;
update(0);
});
// Initial draw
drawFilter();
update(0);
// Verification (logged to console for node test)
console.log('Convolution output (6x6):');
OUTPUT.forEach((row, r) => console.log(" " + fmt('row %r%: [%row%]', { r: r, row: row.join(', ') })));
console.log('Normalized:', NORM.map(row => `[${row.join(',')}]`).join(' '));
// Spot-checks: corner (0,0) should be 0*IMG[0..2][0..2] convolved with kernel
// For top-left: 0*0 + (-1)*0 + 0*0 + (-1)*0 + 4*0 + (-1)*0 + 0*0 + (-1)*0 + 0*200 = 0
// For (1,2) patch includes cross arm — should be nonzero
console.assert(OUTPUT[0][0] === 0, 'Corner should be 0 (all dark pixels)');
console.assert(OUTPUT[1][2] !== 0, 'Near cross arm should be nonzero edge');
console.log('Assertions passed.');
Comments
Loading comments...