<p class="hint">A <b>2D latent space</b>. Drag the colored anchors (A and B) to different positions, then slide the interpolation handle to walk a straight path between them. The decoder maps each latent coordinate to a generated pattern โ smoothly, because a VAE fills the whole space with meaningful representations.</p>
<div class="demo-wrap">
<canvas id="latent" width="200" height="200"></canvas>
<div class="right-panel">
<div class="output-label">Generated output at <span id="t-label">t = 0.50</span></div>
<canvas id="output" width="120" height="120"></canvas>
<label class="slider-label">Interpolation <span id="t-val">0.50</span></label>
<input type="range" id="slider" min="0" max="100" value="50">
<button id="reset" type="button" class="ghost">Reset anchors</button>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.demo-wrap { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
#latent { border: 1px solid #cdd9e3; border-radius: 8px; background: #f3f6f9; cursor: crosshair; touch-action: none; }
.right-panel { display: flex; flex-direction: column; gap: 8px; min-width: 130px; }
.output-label { font-size: .8rem; color: #555; }
#output { border: 1px solid #cdd9e3; border-radius: 8px; background: #fff; display: block; }
.slider-label { font-size: .8rem; color: #555; }
input[type=range] { width: 100%; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .4rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// VAE Latent Space Interpolation Demo
// Two anchor points A and B live in a 2D latent space [-3, 3]^2.
// A simple deterministic "decoder" maps (z1, z2) to a 10x10 pixel pattern
// using smooth trigonometric basis functions โ approximating what a real VAE
// decoder would learn. Interpolation is linear: z(t) = (1-t)*zA + t*zB.
// The smoothness of the output as t varies demonstrates the key VAE property:
// nearby latent codes produce similar outputs.
const LC = document.getElementById('latent');
const OC = document.getElementById('output');
const slider = document.getElementById('slider');
const tLabel = document.getElementById('t-label');
const tVal = document.getElementById('t-val');
const lctx = LC.getContext('2d');
const octx = OC.getContext('2d');
// Latent space: [-3, 3]^2 mapped to 200x200 canvas
const SCALE = 200 / 6; // pixels per unit
const OFFSET = 100; // canvas center = latent (0,0)
function toCanvas(z1, z2) {
return [z1 * SCALE + OFFSET, -z2 * SCALE + OFFSET];
}
function toLatent(px, py) {
return [(px - OFFSET) / SCALE, -(py - OFFSET) / SCALE];
}
// Initial anchor positions in latent space
let anchors = [
{ z: [-1.5, 1.0], color: '#e63946', label: 'A' },
{ z: [ 1.5, -1.0], color: '#457b9d', label: 'B' }
];
let t = 0.5;
let dragging = null;
// โโ Decoder: maps (z1,z2) -> 10x10 pixel grid in [0,1] โโโโโโโโโโโโโโโโโโโโโ
// Uses a fixed set of 2D cosine basis functions whose weights are derived from the latent coordinates, giving smooth and varied patterns.
const GRID = 10;
function decode(z1, z2) {
const pixels = new Float32Array(GRID * GRID);
// Four basis "neurons" with different spatial frequencies
const features = [
Math.tanh(z1 * 0.8 + 0.3),
Math.tanh(z2 * 0.8 - 0.3),
Math.tanh((z1 - z2) * 0.6),
Math.tanh((z1 + z2) * 0.5 - 0.2)
];
for (let row = 0; row < GRID; row++) {
for (let col = 0; col < GRID; col++) {
const fx = col / (GRID - 1); // 0..1
const fy = row / (GRID - 1);
// Weighted combination of spatial patterns
let v = 0;
v += features[0] * Math.cos(Math.PI * fx * 1.5);
v += features[1] * Math.cos(Math.PI * fy * 1.5);
v += features[2] * Math.cos(Math.PI * (fx + fy));
v += features[3] * (Math.sin(Math.PI * fx * 2) * Math.sin(Math.PI * fy * 2));
pixels[row * GRID + col] = (Math.tanh(v) + 1) / 2; // normalize to [0,1]
}
}
return pixels;
}
// โโ Draw latent canvas โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function drawLatent() {
lctx.clearRect(0, 0, 200, 200);
// Grid lines
lctx.strokeStyle = '#d0dae3';
lctx.lineWidth = 0.5;
for (let v = -3; v <= 3; v++) {
const [x0] = toCanvas(v, -3);
const [, y0] = toCanvas(-3, v);
lctx.beginPath(); lctx.moveTo(x0, 0); lctx.lineTo(x0, 200); lctx.stroke();
lctx.beginPath(); lctx.moveTo(0, y0); lctx.lineTo(200, y0); lctx.stroke();
}
// Axes
lctx.strokeStyle = '#a0b4c4'; lctx.lineWidth = 1;
lctx.beginPath(); lctx.moveTo(OFFSET, 0); lctx.lineTo(OFFSET, 200); lctx.stroke();
lctx.beginPath(); lctx.moveTo(0, OFFSET); lctx.lineTo(200, OFFSET); lctx.stroke();
// Interpolation line
const [ax, ay] = toCanvas(...anchors[0].z);
const [bx, by] = toCanvas(...anchors[1].z);
lctx.strokeStyle = '#888'; lctx.lineWidth = 1; lctx.setLineDash([4, 3]);
lctx.beginPath(); lctx.moveTo(ax, ay); lctx.lineTo(bx, by); lctx.stroke();
lctx.setLineDash([]);
// Interpolated point
const iz = [
anchors[0].z[0] * (1 - t) + anchors[1].z[0] * t,
anchors[0].z[1] * (1 - t) + anchors[1].z[1] * t
];
const [ix, iy] = toCanvas(...iz);
lctx.fillStyle = '#2a9d8f'; lctx.beginPath();
lctx.arc(ix, iy, 5, 0, Math.PI * 2); lctx.fill();
// Anchors
anchors.forEach(a => {
const [cx, cy] = toCanvas(...a.z);
lctx.fillStyle = a.color;
lctx.beginPath(); lctx.arc(cx, cy, 8, 0, Math.PI * 2); lctx.fill();
lctx.fillStyle = '#fff'; lctx.font = 'bold 10px system-ui';
lctx.textAlign = 'center'; lctx.textBaseline = 'middle';
lctx.fillText(a.label, cx, cy);
});
}
// โโ Draw output canvas โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function drawOutput() {
const iz = [
anchors[0].z[0] * (1 - t) + anchors[1].z[0] * t,
anchors[0].z[1] * (1 - t) + anchors[1].z[1] * t
];
const pixels = decode(iz[0], iz[1]);
const cellW = OC.width / GRID;
const cellH = OC.height / GRID;
octx.clearRect(0, 0, OC.width, OC.height);
for (let i = 0; i < GRID; i++) {
for (let j = 0; j < GRID; j++) {
const v = pixels[i * GRID + j];
// Map [0,1] -> dark blue (#1d3557) to white (#fff)
const r = Math.round(29 + (255 - 29) * v);
const g = Math.round(53 + (255 - 53) * v);
const b = Math.round(87 + (255 - 87) * v);
octx.fillStyle = `rgb(${r},${g},${b})`;
octx.fillRect(j * cellW, i * cellH, cellW, cellH);
}
}
}
function updateT(val) {
t = val / 100;
const ts = t.toFixed(2);
tLabel.textContent = 't = ' + ts;
tVal.textContent = ts;
}
function render() { drawLatent(); drawOutput(); }
slider.addEventListener('input', () => { updateT(+slider.value); render(); });
// โโ Drag handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function getPos(e) {
const r = LC.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return [src.clientX - r.left, src.clientY - r.top];
}
function findAnchor(px, py) {
for (let i = 0; i < anchors.length; i++) {
const [cx, cy] = toCanvas(...anchors[i].z);
if (Math.hypot(px - cx, py - cy) < 14) return i;
}
return -1;
}
LC.addEventListener('pointerdown', e => {
const [px, py] = getPos(e);
dragging = findAnchor(px, py);
if (dragging >= 0) LC.setPointerCapture(e.pointerId);
});
LC.addEventListener('pointermove', e => {
if (dragging < 0) return;
const [px, py] = getPos(e);
// Clamp to canvas
const cpx = Math.max(0, Math.min(200, px));
const cpy = Math.max(0, Math.min(200, py));
anchors[dragging].z = toLatent(cpx, cpy);
render();
});
LC.addEventListener('pointerup', () => { dragging = -1; });
document.getElementById('reset').addEventListener('click', () => {
anchors[0].z = [-1.5, 1.0];
anchors[1].z = [ 1.5, -1.0];
slider.value = 50; updateT(50); render();
});
updateT(50);
render();
// โโ Verification (run with node to check decode smoothness) โโโโโโโโโโโโโโโโโโ
// Node.js verification: interpolation between two points produces smoothly varying pixel values (max pixel-wise change per step < 0.15).
if (typeof module !== 'undefined') {
const steps = 20;
let prevPixels = null;
let maxJump = 0;
for (let i = 0; i <= steps; i++) {
const tv = i / steps;
const z1 = -1.5 * (1 - tv) + 1.5 * tv;
const z2 = 1.0 * (1 - tv) + (-1.0) * tv;
const px = decode(z1, z2);
if (prevPixels) {
for (let k = 0; k < px.length; k++) {
maxJump = Math.max(maxJump, Math.abs(px[k] - prevPixels[k]));
}
}
prevPixels = px;
}
console.log(fmt('Max pixel jump per step: %maxJump%', { maxJump: maxJump.toFixed(4) }));
console.assert(maxJump < 0.20, 'FAIL: interpolation is not smooth (jump >= 0.20)');
// Verify boundary: decode(0,0) should give values in [0,1]
const center = decode(0, 0);
const allInRange = center.every(v => v >= 0 && v <= 1);
console.assert(allInRange, 'FAIL: decode(0,0) produced values outside [0,1]');
console.log(fmt('decode(0,0) in [0,1]: %allInRange%', { allInRange: allInRange }));
console.log('All checks passed.');
module.exports = { decode };
}
<p class="hint">A <b>2D latent space</b>. Drag the colored anchors (A and B) to different positions, then slide the interpolation handle to walk a straight path between them. The decoder maps each latent coordinate to a generated pattern โ smoothly, because a VAE fills the whole space with meaningful representations.</p>
<div class="demo-wrap">
<canvas id="latent" width="200" height="200"></canvas>
<div class="right-panel">
<div class="output-label">Generated output at <span id="t-label">t = 0.50</span></div>
<canvas id="output" width="120" height="120"></canvas>
<label class="slider-label">Interpolation <span id="t-val">0.50</span></label>
<input type="range" id="slider" min="0" max="100" value="50">
<button id="reset" type="button" class="ghost">Reset anchors</button>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.hint { font-size: .85rem; color: #444; margin: 0 0 .7rem; line-height: 1.45; }
.demo-wrap { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
#latent { border: 1px solid #cdd9e3; border-radius: 8px; background: #f3f6f9; cursor: crosshair; touch-action: none; }
.right-panel { display: flex; flex-direction: column; gap: 8px; min-width: 130px; }
.output-label { font-size: .8rem; color: #555; }
#output { border: 1px solid #cdd9e3; border-radius: 8px; background: #fff; display: block; }
.slider-label { font-size: .8rem; color: #555; }
input[type=range] { width: 100%; accent-color: #1d3557; }
button { font: 600 13px system-ui; padding: .4rem .8rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// VAE Latent Space Interpolation Demo
// Two anchor points A and B live in a 2D latent space [-3, 3]^2.
// A simple deterministic "decoder" maps (z1, z2) to a 10x10 pixel pattern
// using smooth trigonometric basis functions โ approximating what a real VAE
// decoder would learn. Interpolation is linear: z(t) = (1-t)*zA + t*zB.
// The smoothness of the output as t varies demonstrates the key VAE property:
// nearby latent codes produce similar outputs.
const LC = document.getElementById('latent');
const OC = document.getElementById('output');
const slider = document.getElementById('slider');
const tLabel = document.getElementById('t-label');
const tVal = document.getElementById('t-val');
const lctx = LC.getContext('2d');
const octx = OC.getContext('2d');
// Latent space: [-3, 3]^2 mapped to 200x200 canvas
const SCALE = 200 / 6; // pixels per unit
const OFFSET = 100; // canvas center = latent (0,0)
function toCanvas(z1, z2) {
return [z1 * SCALE + OFFSET, -z2 * SCALE + OFFSET];
}
function toLatent(px, py) {
return [(px - OFFSET) / SCALE, -(py - OFFSET) / SCALE];
}
// Initial anchor positions in latent space
let anchors = [
{ z: [-1.5, 1.0], color: '#e63946', label: 'A' },
{ z: [ 1.5, -1.0], color: '#457b9d', label: 'B' }
];
let t = 0.5;
let dragging = null;
// โโ Decoder: maps (z1,z2) -> 10x10 pixel grid in [0,1] โโโโโโโโโโโโโโโโโโโโโ
// Uses a fixed set of 2D cosine basis functions whose weights are derived from the latent coordinates, giving smooth and varied patterns.
const GRID = 10;
function decode(z1, z2) {
const pixels = new Float32Array(GRID * GRID);
// Four basis "neurons" with different spatial frequencies
const features = [
Math.tanh(z1 * 0.8 + 0.3),
Math.tanh(z2 * 0.8 - 0.3),
Math.tanh((z1 - z2) * 0.6),
Math.tanh((z1 + z2) * 0.5 - 0.2)
];
for (let row = 0; row < GRID; row++) {
for (let col = 0; col < GRID; col++) {
const fx = col / (GRID - 1); // 0..1
const fy = row / (GRID - 1);
// Weighted combination of spatial patterns
let v = 0;
v += features[0] * Math.cos(Math.PI * fx * 1.5);
v += features[1] * Math.cos(Math.PI * fy * 1.5);
v += features[2] * Math.cos(Math.PI * (fx + fy));
v += features[3] * (Math.sin(Math.PI * fx * 2) * Math.sin(Math.PI * fy * 2));
pixels[row * GRID + col] = (Math.tanh(v) + 1) / 2; // normalize to [0,1]
}
}
return pixels;
}
// โโ Draw latent canvas โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function drawLatent() {
lctx.clearRect(0, 0, 200, 200);
// Grid lines
lctx.strokeStyle = '#d0dae3';
lctx.lineWidth = 0.5;
for (let v = -3; v <= 3; v++) {
const [x0] = toCanvas(v, -3);
const [, y0] = toCanvas(-3, v);
lctx.beginPath(); lctx.moveTo(x0, 0); lctx.lineTo(x0, 200); lctx.stroke();
lctx.beginPath(); lctx.moveTo(0, y0); lctx.lineTo(200, y0); lctx.stroke();
}
// Axes
lctx.strokeStyle = '#a0b4c4'; lctx.lineWidth = 1;
lctx.beginPath(); lctx.moveTo(OFFSET, 0); lctx.lineTo(OFFSET, 200); lctx.stroke();
lctx.beginPath(); lctx.moveTo(0, OFFSET); lctx.lineTo(200, OFFSET); lctx.stroke();
// Interpolation line
const [ax, ay] = toCanvas(...anchors[0].z);
const [bx, by] = toCanvas(...anchors[1].z);
lctx.strokeStyle = '#888'; lctx.lineWidth = 1; lctx.setLineDash([4, 3]);
lctx.beginPath(); lctx.moveTo(ax, ay); lctx.lineTo(bx, by); lctx.stroke();
lctx.setLineDash([]);
// Interpolated point
const iz = [
anchors[0].z[0] * (1 - t) + anchors[1].z[0] * t,
anchors[0].z[1] * (1 - t) + anchors[1].z[1] * t
];
const [ix, iy] = toCanvas(...iz);
lctx.fillStyle = '#2a9d8f'; lctx.beginPath();
lctx.arc(ix, iy, 5, 0, Math.PI * 2); lctx.fill();
// Anchors
anchors.forEach(a => {
const [cx, cy] = toCanvas(...a.z);
lctx.fillStyle = a.color;
lctx.beginPath(); lctx.arc(cx, cy, 8, 0, Math.PI * 2); lctx.fill();
lctx.fillStyle = '#fff'; lctx.font = 'bold 10px system-ui';
lctx.textAlign = 'center'; lctx.textBaseline = 'middle';
lctx.fillText(a.label, cx, cy);
});
}
// โโ Draw output canvas โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function drawOutput() {
const iz = [
anchors[0].z[0] * (1 - t) + anchors[1].z[0] * t,
anchors[0].z[1] * (1 - t) + anchors[1].z[1] * t
];
const pixels = decode(iz[0], iz[1]);
const cellW = OC.width / GRID;
const cellH = OC.height / GRID;
octx.clearRect(0, 0, OC.width, OC.height);
for (let i = 0; i < GRID; i++) {
for (let j = 0; j < GRID; j++) {
const v = pixels[i * GRID + j];
// Map [0,1] -> dark blue (#1d3557) to white (#fff)
const r = Math.round(29 + (255 - 29) * v);
const g = Math.round(53 + (255 - 53) * v);
const b = Math.round(87 + (255 - 87) * v);
octx.fillStyle = `rgb(${r},${g},${b})`;
octx.fillRect(j * cellW, i * cellH, cellW, cellH);
}
}
}
function updateT(val) {
t = val / 100;
const ts = t.toFixed(2);
tLabel.textContent = 't = ' + ts;
tVal.textContent = ts;
}
function render() { drawLatent(); drawOutput(); }
slider.addEventListener('input', () => { updateT(+slider.value); render(); });
// โโ Drag handling โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function getPos(e) {
const r = LC.getBoundingClientRect();
const src = e.touches ? e.touches[0] : e;
return [src.clientX - r.left, src.clientY - r.top];
}
function findAnchor(px, py) {
for (let i = 0; i < anchors.length; i++) {
const [cx, cy] = toCanvas(...anchors[i].z);
if (Math.hypot(px - cx, py - cy) < 14) return i;
}
return -1;
}
LC.addEventListener('pointerdown', e => {
const [px, py] = getPos(e);
dragging = findAnchor(px, py);
if (dragging >= 0) LC.setPointerCapture(e.pointerId);
});
LC.addEventListener('pointermove', e => {
if (dragging < 0) return;
const [px, py] = getPos(e);
// Clamp to canvas
const cpx = Math.max(0, Math.min(200, px));
const cpy = Math.max(0, Math.min(200, py));
anchors[dragging].z = toLatent(cpx, cpy);
render();
});
LC.addEventListener('pointerup', () => { dragging = -1; });
document.getElementById('reset').addEventListener('click', () => {
anchors[0].z = [-1.5, 1.0];
anchors[1].z = [ 1.5, -1.0];
slider.value = 50; updateT(50); render();
});
updateT(50);
render();
// โโ Verification (run with node to check decode smoothness) โโโโโโโโโโโโโโโโโโ
// Node.js verification: interpolation between two points produces smoothly varying pixel values (max pixel-wise change per step < 0.15).
if (typeof module !== 'undefined') {
const steps = 20;
let prevPixels = null;
let maxJump = 0;
for (let i = 0; i <= steps; i++) {
const tv = i / steps;
const z1 = -1.5 * (1 - tv) + 1.5 * tv;
const z2 = 1.0 * (1 - tv) + (-1.0) * tv;
const px = decode(z1, z2);
if (prevPixels) {
for (let k = 0; k < px.length; k++) {
maxJump = Math.max(maxJump, Math.abs(px[k] - prevPixels[k]));
}
}
prevPixels = px;
}
console.log(fmt('Max pixel jump per step: %maxJump%', { maxJump: maxJump.toFixed(4) }));
console.assert(maxJump < 0.20, 'FAIL: interpolation is not smooth (jump >= 0.20)');
// Verify boundary: decode(0,0) should give values in [0,1]
const center = decode(0, 0);
const allInRange = center.every(v => v >= 0 && v <= 1);
console.assert(allInRange, 'FAIL: decode(0,0) produced values outside [0,1]');
console.log(fmt('decode(0,0) in [0,1]: %allInRange%', { allInRange: allInRange }));
console.log('All checks passed.');
module.exports = { decode };
}
Drag the anchor points to different regions of the space and watch the generated output change smoothly. This is the core promise of a VAE: because the encoder was trained to produce overlapping Gaussian clouds โ not isolated points โ the entire space is filled with meaningful representations, and interpolation between any two codes produces coherent intermediate samples.
Comments
Loading comments...