<!-- Image pyramid blending demo — controls and three canvases -->
<div class="controls">
<label for="levels">Pyramid levels <strong id="lvlVal">4</strong></label>
<input id="levels" type="range" min="1" max="6" value="4" step="1">
<button id="btnBlend" type="button">Blend</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div class="canvases">
<div class="panel">
<div class="panel-label">Image A</div>
<canvas id="cA" width="128" height="128"></canvas>
</div>
<div class="panel">
<div class="panel-label">Image B</div>
<canvas id="cB" width="128" height="128"></canvas>
</div>
<div class="panel">
<div class="panel-label">Blended result</div>
<canvas id="cOut" width="128" height="128"></canvas>
</div>
</div>
<div id="status" class="status">Ready — click Blend to start.</div>
<div class="note">Left half comes from Image A, right half from Image B. More pyramid levels = smoother transition across the seam.</div>
/* Demo layout and canvas styling */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .8rem; }
label { font-size: .9rem; }
input[type=range] { width: 120px; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.canvases { display: flex; flex-wrap: wrap; gap: 1rem; margin: .5rem 0; }
.panel { display: flex; flex-direction: column; align-items: center; gap: .3rem; }
.panel-label { font-size: .8rem; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #ccc; border-radius: 4px; image-rendering: pixelated; width: 128px; height: 128px; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; }
.status.ok { color: #0a7d33; }
.note { font-size: .82rem; color: #555; line-height: 1.45; max-width: 420px; }
/* Laplacian pyramid blend — vanilla JS, no libraries */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const W = 128, H = 128;
/* Draw two synthetic source images onto their canvases */
function drawImageA(ctx) {
/* Image A — warm orange circle with highlight (sun-like) */
ctx.fillStyle = '#e8a020';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#c06000';
ctx.beginPath(); ctx.arc(64, 64, 44, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffe070';
ctx.beginPath(); ctx.arc(52, 52, 16, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#faf0c8';
ctx.beginPath(); ctx.arc(48, 48, 6, 0, Math.PI * 2); ctx.fill();
}
function drawImageB(ctx) {
/* Image B — cool blue grid with bright central circle */
ctx.fillStyle = '#2060c0';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#0830a0';
for (let y = 0; y < H; y += 16) {
for (let x = 0; x < W; x += 16) {
if ((x / 16 + y / 16) % 2 === 0) { ctx.fillRect(x, y, 16, 16); }
}
}
ctx.fillStyle = '#80c0ff';
ctx.beginPath(); ctx.arc(64, 64, 32, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(60, 58, 10, 0, Math.PI * 2); ctx.fill();
}
/* Extract RGBA pixel array from a canvas */
function getPixels(canvas) {
const ctx = canvas.getContext('2d');
return ctx.getImageData(0, 0, W, H).data;
}
/* Separable 5-tap Gaussian blur (kernel [1,4,6,4,1]) */
function gaussianBlur(data, w, h) {
const k = [1, 4, 6, 4, 1];
const kSum = 16;
const tmp = new Float32Array(data.length);
/* Horizontal pass */
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
for (let c = 0; c < 4; c++) {
let s = 0, ws = 0;
for (let d = -2; d <= 2; d++) {
const xx = Math.min(Math.max(x + d, 0), w - 1);
s += k[d + 2] * data[(y * w + xx) * 4 + c];
ws += k[d + 2];
}
tmp[(y * w + x) * 4 + c] = s / ws;
}
}
}
const out = new Float32Array(data.length);
/* Vertical pass */
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
for (let c = 0; c < 4; c++) {
let s = 0, ws = 0;
for (let d = -2; d <= 2; d++) {
const yy = Math.min(Math.max(y + d, 0), h - 1);
s += k[d + 2] * tmp[(yy * w + x) * 4 + c];
ws += k[d + 2];
}
out[(y * w + x) * 4 + c] = s / ws;
}
}
}
return out;
}
/* Reduce — blur then subsample by 2 in each dimension */
function reduce(data, w, h) {
const nw = Math.floor(w / 2), nh = Math.floor(h / 2);
const blurred = gaussianBlur(data, w, h);
const out = new Float32Array(nw * nh * 4);
for (let y = 0; y < nh; y++) {
for (let x = 0; x < nw; x++) {
for (let c = 0; c < 4; c++) {
out[(y * nw + x) * 4 + c] = blurred[((y * 2) * w + x * 2) * 4 + c];
}
}
}
return { data: out, w: nw, h: nh };
}
/* Expand — upsample by 2 then blur (interpolation) */
function expand(data, sw, sh, tw, th) {
const blurred = gaussianBlur(data, sw, sh);
const out = new Float32Array(tw * th * 4);
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
for (let c = 0; c < 4; c++) {
if (y * 2 < th && x * 2 < tw) out[((y * 2) * tw + x * 2) * 4 + c] = blurred[(y * sw + x) * 4 + c] * 4;
}
}
}
/* Normalize after expansion */
return gaussianBlur(out, tw, th);
}
/* Build a Gaussian pyramid with the requested number of levels */
function buildGaussianPyramid(pixels, levels) {
const pyr = [{ data: new Float32Array(pixels), w: W, h: H }];
for (let i = 1; i < levels; i++) {
const prev = pyr[i - 1];
if (prev.w < 2 || prev.h < 2) break;
pyr.push(reduce(prev.data, prev.w, prev.h));
}
return pyr;
}
/* Build a Laplacian pyramid from a Gaussian pyramid */
function buildLaplacianPyramid(gPyr) {
const lap = [];
for (let i = 0; i < gPyr.length - 1; i++) {
const cur = gPyr[i];
const up = expand(gPyr[i + 1].data, gPyr[i + 1].w, gPyr[i + 1].h, cur.w, cur.h);
const diff = new Float32Array(cur.data.length);
for (let j = 0; j < diff.length; j++) diff[j] = cur.data[j] - up[j];
lap.push({ data: diff, w: cur.w, h: cur.h });
}
lap.push(gPyr[gPyr.length - 1]);
return lap;
}
/* Blend two Laplacian pyramids level-by-level using the Gaussian mask pyramid */
function blendPyramids(lapA, lapB, gMask) {
return lapA.map((la, i) => {
const lb = lapB[i], gm = gMask[i];
const out = new Float32Array(la.data.length);
for (let j = 0; j < la.data.length; j += 4) {
const m = gm.data[j] / 255;
for (let c = 0; c < 4; c++) out[j + c] = m * la.data[j + c] + (1 - m) * lb.data[j + c];
}
return { data: out, w: la.w, h: la.h };
});
}
/* Reconstruct an image by collapsing the Laplacian pyramid */
function reconstruct(lapPyr) {
let cur = lapPyr[lapPyr.length - 1];
for (let i = lapPyr.length - 2; i >= 0; i--) {
const target = lapPyr[i];
const up = expand(cur.data, cur.w, cur.h, target.w, target.h);
const out = new Float32Array(target.data.length);
for (let j = 0; j < out.length; j++) out[j] = target.data[j] + up[j];
cur = { data: out, w: target.w, h: target.h };
}
return cur.data;
}
/* Write a Float32 pixel array back to a canvas, clamping to [0,255] */
function putPixels(canvas, floatData) {
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let i = 0; i < img.data.length; i++) {
img.data[i] = Math.min(255, Math.max(0, Math.round(floatData[i])));
}
ctx.putImageData(img, 0, 0);
}
/* Build the Gaussian pyramid of a hard left/right binary mask */
function buildMask(levels) {
const maskPix = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const v = x < W / 2 ? 255 : 0;
const i = (y * W + x) * 4;
maskPix[i] = v; maskPix[i + 1] = v; maskPix[i + 2] = v; maskPix[i + 3] = 255;
}
}
return buildGaussianPyramid(maskPix, levels);
}
/* Initialize canvases and UI */
const cA = document.getElementById('cA');
const cB = document.getElementById('cB');
const cOut = document.getElementById('cOut');
const ctxA = cA.getContext('2d');
const ctxB = cB.getContext('2d');
drawImageA(ctxA);
drawImageB(ctxB);
const lvlSlider = document.getElementById('levels');
const lvlVal = document.getElementById('lvlVal');
const statusEl = document.getElementById('status');
lvlSlider.addEventListener('input', () => { lvlVal.textContent = lvlSlider.value; });
/* Run the full pyramid blend pipeline */
function doBlend() {
const levels = parseInt(lvlSlider.value, 10);
statusEl.textContent = 'Blending…';
statusEl.className = 'status';
/* Yield to the browser so the status updates before computation */
setTimeout(() => {
const pixA = getPixels(cA);
const pixB = getPixels(cB);
const gA = buildGaussianPyramid(pixA, levels + 1);
const gB = buildGaussianPyramid(pixB, levels + 1);
const gMask = buildMask(levels + 1);
const lapA = buildLaplacianPyramid(gA);
const lapB = buildLaplacianPyramid(gB);
const blended = blendPyramids(lapA, lapB, gMask);
const result = reconstruct(blended);
putPixels(cOut, result);
statusEl.textContent = i18nFmt('Blended with %n% pyramid level(s).', { n: levels });
statusEl.className = 'status ok';
}, 0);
}
document.getElementById('btnBlend').addEventListener('click', doBlend);
document.getElementById('btnReset').addEventListener('click', () => {
const ctx = cOut.getContext('2d');
ctx.clearRect(0, 0, W, H);
statusEl.textContent = 'Ready — click Blend to start.';
statusEl.className = 'status';
});
doBlend();
<!-- Image pyramid blending demo — controls and three canvases -->
<div class="controls">
<label for="levels">Pyramid levels <strong id="lvlVal">4</strong></label>
<input id="levels" type="range" min="1" max="6" value="4" step="1">
<button id="btnBlend" type="button">Blend</button>
<button id="btnReset" type="button" class="ghost">Reset</button>
</div>
<div class="canvases">
<div class="panel">
<div class="panel-label">Image A</div>
<canvas id="cA" width="128" height="128"></canvas>
</div>
<div class="panel">
<div class="panel-label">Image B</div>
<canvas id="cB" width="128" height="128"></canvas>
</div>
<div class="panel">
<div class="panel-label">Blended result</div>
<canvas id="cOut" width="128" height="128"></canvas>
</div>
</div>
<div id="status" class="status">Ready — click Blend to start.</div>
<div class="note">Left half comes from Image A, right half from Image B. More pyramid levels = smoother transition across the seam.</div>
/* Demo layout and canvas styling */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; }
.controls { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; margin-bottom: .8rem; }
label { font-size: .9rem; }
input[type=range] { width: 120px; }
button { font: 600 14px system-ui; padding: .4rem .85rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
.canvases { display: flex; flex-wrap: wrap; gap: 1rem; margin: .5rem 0; }
.panel { display: flex; flex-direction: column; align-items: center; gap: .3rem; }
.panel-label { font-size: .8rem; font-weight: 600; color: #555; text-transform: uppercase; letter-spacing: .04em; }
canvas { border: 1px solid #ccc; border-radius: 4px; image-rendering: pixelated; width: 128px; height: 128px; }
.status { font-size: .95rem; font-weight: 600; min-height: 1.4em; margin: .3rem 0; }
.status.ok { color: #0a7d33; }
.note { font-size: .82rem; color: #555; line-height: 1.45; max-width: 420px; }
/* Laplacian pyramid blend — vanilla JS, no libraries */
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
const W = 128, H = 128;
/* Draw two synthetic source images onto their canvases */
function drawImageA(ctx) {
/* Image A — warm orange circle with highlight (sun-like) */
ctx.fillStyle = '#e8a020';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#c06000';
ctx.beginPath(); ctx.arc(64, 64, 44, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffe070';
ctx.beginPath(); ctx.arc(52, 52, 16, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#faf0c8';
ctx.beginPath(); ctx.arc(48, 48, 6, 0, Math.PI * 2); ctx.fill();
}
function drawImageB(ctx) {
/* Image B — cool blue grid with bright central circle */
ctx.fillStyle = '#2060c0';
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#0830a0';
for (let y = 0; y < H; y += 16) {
for (let x = 0; x < W; x += 16) {
if ((x / 16 + y / 16) % 2 === 0) { ctx.fillRect(x, y, 16, 16); }
}
}
ctx.fillStyle = '#80c0ff';
ctx.beginPath(); ctx.arc(64, 64, 32, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#ffffff';
ctx.beginPath(); ctx.arc(60, 58, 10, 0, Math.PI * 2); ctx.fill();
}
/* Extract RGBA pixel array from a canvas */
function getPixels(canvas) {
const ctx = canvas.getContext('2d');
return ctx.getImageData(0, 0, W, H).data;
}
/* Separable 5-tap Gaussian blur (kernel [1,4,6,4,1]) */
function gaussianBlur(data, w, h) {
const k = [1, 4, 6, 4, 1];
const kSum = 16;
const tmp = new Float32Array(data.length);
/* Horizontal pass */
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
for (let c = 0; c < 4; c++) {
let s = 0, ws = 0;
for (let d = -2; d <= 2; d++) {
const xx = Math.min(Math.max(x + d, 0), w - 1);
s += k[d + 2] * data[(y * w + xx) * 4 + c];
ws += k[d + 2];
}
tmp[(y * w + x) * 4 + c] = s / ws;
}
}
}
const out = new Float32Array(data.length);
/* Vertical pass */
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
for (let c = 0; c < 4; c++) {
let s = 0, ws = 0;
for (let d = -2; d <= 2; d++) {
const yy = Math.min(Math.max(y + d, 0), h - 1);
s += k[d + 2] * tmp[(yy * w + x) * 4 + c];
ws += k[d + 2];
}
out[(y * w + x) * 4 + c] = s / ws;
}
}
}
return out;
}
/* Reduce — blur then subsample by 2 in each dimension */
function reduce(data, w, h) {
const nw = Math.floor(w / 2), nh = Math.floor(h / 2);
const blurred = gaussianBlur(data, w, h);
const out = new Float32Array(nw * nh * 4);
for (let y = 0; y < nh; y++) {
for (let x = 0; x < nw; x++) {
for (let c = 0; c < 4; c++) {
out[(y * nw + x) * 4 + c] = blurred[((y * 2) * w + x * 2) * 4 + c];
}
}
}
return { data: out, w: nw, h: nh };
}
/* Expand — upsample by 2 then blur (interpolation) */
function expand(data, sw, sh, tw, th) {
const blurred = gaussianBlur(data, sw, sh);
const out = new Float32Array(tw * th * 4);
for (let y = 0; y < sh; y++) {
for (let x = 0; x < sw; x++) {
for (let c = 0; c < 4; c++) {
if (y * 2 < th && x * 2 < tw) out[((y * 2) * tw + x * 2) * 4 + c] = blurred[(y * sw + x) * 4 + c] * 4;
}
}
}
/* Normalize after expansion */
return gaussianBlur(out, tw, th);
}
/* Build a Gaussian pyramid with the requested number of levels */
function buildGaussianPyramid(pixels, levels) {
const pyr = [{ data: new Float32Array(pixels), w: W, h: H }];
for (let i = 1; i < levels; i++) {
const prev = pyr[i - 1];
if (prev.w < 2 || prev.h < 2) break;
pyr.push(reduce(prev.data, prev.w, prev.h));
}
return pyr;
}
/* Build a Laplacian pyramid from a Gaussian pyramid */
function buildLaplacianPyramid(gPyr) {
const lap = [];
for (let i = 0; i < gPyr.length - 1; i++) {
const cur = gPyr[i];
const up = expand(gPyr[i + 1].data, gPyr[i + 1].w, gPyr[i + 1].h, cur.w, cur.h);
const diff = new Float32Array(cur.data.length);
for (let j = 0; j < diff.length; j++) diff[j] = cur.data[j] - up[j];
lap.push({ data: diff, w: cur.w, h: cur.h });
}
lap.push(gPyr[gPyr.length - 1]);
return lap;
}
/* Blend two Laplacian pyramids level-by-level using the Gaussian mask pyramid */
function blendPyramids(lapA, lapB, gMask) {
return lapA.map((la, i) => {
const lb = lapB[i], gm = gMask[i];
const out = new Float32Array(la.data.length);
for (let j = 0; j < la.data.length; j += 4) {
const m = gm.data[j] / 255;
for (let c = 0; c < 4; c++) out[j + c] = m * la.data[j + c] + (1 - m) * lb.data[j + c];
}
return { data: out, w: la.w, h: la.h };
});
}
/* Reconstruct an image by collapsing the Laplacian pyramid */
function reconstruct(lapPyr) {
let cur = lapPyr[lapPyr.length - 1];
for (let i = lapPyr.length - 2; i >= 0; i--) {
const target = lapPyr[i];
const up = expand(cur.data, cur.w, cur.h, target.w, target.h);
const out = new Float32Array(target.data.length);
for (let j = 0; j < out.length; j++) out[j] = target.data[j] + up[j];
cur = { data: out, w: target.w, h: target.h };
}
return cur.data;
}
/* Write a Float32 pixel array back to a canvas, clamping to [0,255] */
function putPixels(canvas, floatData) {
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(W, H);
for (let i = 0; i < img.data.length; i++) {
img.data[i] = Math.min(255, Math.max(0, Math.round(floatData[i])));
}
ctx.putImageData(img, 0, 0);
}
/* Build the Gaussian pyramid of a hard left/right binary mask */
function buildMask(levels) {
const maskPix = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const v = x < W / 2 ? 255 : 0;
const i = (y * W + x) * 4;
maskPix[i] = v; maskPix[i + 1] = v; maskPix[i + 2] = v; maskPix[i + 3] = 255;
}
}
return buildGaussianPyramid(maskPix, levels);
}
/* Initialize canvases and UI */
const cA = document.getElementById('cA');
const cB = document.getElementById('cB');
const cOut = document.getElementById('cOut');
const ctxA = cA.getContext('2d');
const ctxB = cB.getContext('2d');
drawImageA(ctxA);
drawImageB(ctxB);
const lvlSlider = document.getElementById('levels');
const lvlVal = document.getElementById('lvlVal');
const statusEl = document.getElementById('status');
lvlSlider.addEventListener('input', () => { lvlVal.textContent = lvlSlider.value; });
/* Run the full pyramid blend pipeline */
function doBlend() {
const levels = parseInt(lvlSlider.value, 10);
statusEl.textContent = 'Blending…';
statusEl.className = 'status';
/* Yield to the browser so the status updates before computation */
setTimeout(() => {
const pixA = getPixels(cA);
const pixB = getPixels(cB);
const gA = buildGaussianPyramid(pixA, levels + 1);
const gB = buildGaussianPyramid(pixB, levels + 1);
const gMask = buildMask(levels + 1);
const lapA = buildLaplacianPyramid(gA);
const lapB = buildLaplacianPyramid(gB);
const blended = blendPyramids(lapA, lapB, gMask);
const result = reconstruct(blended);
putPixels(cOut, result);
statusEl.textContent = i18nFmt('Blended with %n% pyramid level(s).', { n: levels });
statusEl.className = 'status ok';
}, 0);
}
document.getElementById('btnBlend').addEventListener('click', doBlend);
document.getElementById('btnReset').addEventListener('click', () => {
const ctx = cOut.getContext('2d');
ctx.clearRect(0, 0, W, H);
statusEl.textContent = 'Ready — click Blend to start.';
statusEl.className = 'status';
});
doBlend();
Comments
Loading comments...