1403 lines
49 KiB
JavaScript
1403 lines
49 KiB
JavaScript
// Plumbing for the demos. All hashing happens in the worker (wasm).
|
|
|
|
const worker = new Worker('worker.js', { type: 'module' });
|
|
let nextId = 0;
|
|
const pending = new Map();
|
|
|
|
function rpc(op, args) {
|
|
return new Promise((resolve, reject) => {
|
|
const id = nextId++;
|
|
pending.set(id, { resolve, reject });
|
|
worker.postMessage({ id, op, args });
|
|
});
|
|
}
|
|
|
|
worker.onmessage = (e) => {
|
|
const { id, ok, result, error } = e.data;
|
|
const promise = pending.get(id);
|
|
pending.delete(id);
|
|
if (!promise) return;
|
|
if (ok) {
|
|
promise.resolve(result);
|
|
} else {
|
|
promise.reject(new Error(error));
|
|
}
|
|
};
|
|
|
|
if (window.hljs) hljs.highlightAll();
|
|
|
|
// Background fade-in, same trick as the landing page
|
|
{
|
|
const bg = document.querySelector('#bg img');
|
|
if (bg.complete) bg.classList.add('loaded');
|
|
else bg.addEventListener('load', () => bg.classList.add('loaded'));
|
|
}
|
|
|
|
// Helpers
|
|
|
|
function cssColor(name) {
|
|
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
}
|
|
|
|
function pngUrl(bytes) {
|
|
return URL.createObjectURL(new Blob([bytes], { type: 'image/png' }));
|
|
}
|
|
|
|
function hamming(a, b) {
|
|
let x = a ^ b;
|
|
let count = 0;
|
|
while (x) {
|
|
x &= x - 1n;
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
function hex64(hash) {
|
|
return hash.toString(16).padStart(16, '0');
|
|
}
|
|
|
|
// Renders the top `bits` bits of a hash, msb first.
|
|
// Bits set in `diff` get marked as changed.
|
|
function bitGrid(container, hash, bits = 64, diff = 0n) {
|
|
container.replaceChildren();
|
|
for (let i = bits - 1; i >= 0; i--) {
|
|
const cell = document.createElement('div');
|
|
cell.className = (hash >> BigInt(i)) & 1n ? 'bit on' : 'bit off';
|
|
if ((diff >> BigInt(i)) & 1n) cell.classList.add('diff');
|
|
container.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
// 8x8 grid of coefficient magnitudes, log scale.
|
|
// Green positive, red negative (sign is the semantic part), brightness is magnitude.
|
|
function heatGrid(container, values) {
|
|
container.replaceChildren();
|
|
const max = Math.max(...values.map(Math.abs), 1e-9);
|
|
for (const value of values) {
|
|
const cell = document.createElement('div');
|
|
const strength = Math.log1p(Math.abs(value)) / Math.log1p(max);
|
|
const hue = value < 0 ? 4 : 140;
|
|
cell.style.background = `hsl(${hue} 55% ${10 + strength * 48}%)`;
|
|
cell.title = value.toFixed(1);
|
|
container.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
function pixelatedImg(url, size = 176) {
|
|
const img = document.createElement('img');
|
|
img.src = url;
|
|
img.className = 'pixelated';
|
|
img.width = size;
|
|
img.height = size;
|
|
return img;
|
|
}
|
|
|
|
function show() {
|
|
document.querySelectorAll('.needs-image').forEach((el) => el.classList.add('visible'));
|
|
}
|
|
|
|
// MD5, for the avalanche demo. crypto.subtle dropped it long ago, so a plain
|
|
// implementation (verified against md5sum). Returns 16 bytes.
|
|
function md5(bytes) {
|
|
const K = new Uint32Array(64);
|
|
for (let i = 0; i < 64; i++) K[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32);
|
|
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
|
|
const n = bytes.length;
|
|
const total = (((n + 8) >> 6) + 1) << 6;
|
|
const buf = new Uint8Array(total);
|
|
buf.set(bytes);
|
|
buf[n] = 0x80;
|
|
const dv = new DataView(buf.buffer);
|
|
dv.setUint32(total - 8, (n * 8) >>> 0, true);
|
|
dv.setUint32(total - 4, Math.floor(n / 536870912), true);
|
|
let a0 = 0x67452301, b0 = 0xefcdab89, c0 = 0x98badcfe, d0 = 0x10325476;
|
|
const M = new Uint32Array(16);
|
|
for (let off = 0; off < total; off += 64) {
|
|
for (let j = 0; j < 16; j++) M[j] = dv.getUint32(off + j * 4, true);
|
|
let A = a0, B = b0, C = c0, D = d0;
|
|
for (let i = 0; i < 64; i++) {
|
|
let F, g;
|
|
if (i < 16) { F = (B & C) | (~B & D); g = i; }
|
|
else if (i < 32) { F = (D & B) | (~D & C); g = (5 * i + 1) % 16; }
|
|
else if (i < 48) { F = B ^ C ^ D; g = (3 * i + 5) % 16; }
|
|
else { F = C ^ (B | ~D); g = (7 * i) % 16; }
|
|
F = (F + A + K[i] + M[g]) | 0;
|
|
A = D; D = C; C = B;
|
|
const s = S[((i >> 4) << 2) | (i & 3)];
|
|
B = (B + ((F << s) | (F >>> (32 - s)))) | 0;
|
|
}
|
|
a0 = (a0 + A) | 0; b0 = (b0 + B) | 0; c0 = (c0 + C) | 0; d0 = (d0 + D) | 0;
|
|
}
|
|
const out = new Uint8Array(16);
|
|
const odv = new DataView(out.buffer);
|
|
odv.setUint32(0, a0 >>> 0, true);
|
|
odv.setUint32(4, b0 >>> 0, true);
|
|
odv.setUint32(8, c0 >>> 0, true);
|
|
odv.setUint32(12, d0 >>> 0, true);
|
|
return out;
|
|
}
|
|
|
|
// Comparison slider: two stacked images, a draggable vertical divider.
|
|
|
|
function makeCompare(container, { pixelated = false } = {}) {
|
|
const a = document.createElement('img');
|
|
const b = document.createElement('img');
|
|
a.className = 'compare-a';
|
|
b.className = 'compare-b';
|
|
if (pixelated) b.classList.add('pixelated');
|
|
const labelA = document.createElement('span');
|
|
const labelB = document.createElement('span');
|
|
labelA.className = 'compare-label a';
|
|
labelB.className = 'compare-label b';
|
|
labelA.innerText = container.dataset.labelA || '';
|
|
labelB.innerText = container.dataset.labelB || '';
|
|
const divider = document.createElement('div');
|
|
divider.className = 'compare-divider';
|
|
const handle = document.createElement('div');
|
|
handle.className = 'compare-handle';
|
|
handle.innerText = '↔';
|
|
divider.appendChild(handle);
|
|
container.append(a, b, labelA, labelB, divider);
|
|
|
|
let pos = 50;
|
|
let sweeping = null;
|
|
function apply() {
|
|
b.style.clipPath = `inset(0 0 0 ${pos}%)`;
|
|
divider.style.left = `${pos}%`;
|
|
}
|
|
apply();
|
|
|
|
function positionFromEvent(e) {
|
|
const rect = container.getBoundingClientRect();
|
|
return Math.max(0, Math.min(100, ((e.clientX - rect.left) / rect.width) * 100));
|
|
}
|
|
container.addEventListener('pointerdown', (e) => {
|
|
cancelAnimationFrame(sweeping);
|
|
container.setPointerCapture(e.pointerId);
|
|
pos = positionFromEvent(e);
|
|
apply();
|
|
});
|
|
container.addEventListener('pointermove', (e) => {
|
|
if (!container.hasPointerCapture(e.pointerId)) return;
|
|
pos = positionFromEvent(e);
|
|
apply();
|
|
});
|
|
|
|
// One slow reveal sweep so it is obvious the thing moves
|
|
function sweep() {
|
|
cancelAnimationFrame(sweeping);
|
|
const start = performance.now();
|
|
const duration = 1200;
|
|
function tick(now) {
|
|
const t = Math.min(1, (now - start) / duration);
|
|
const ease = 1 - Math.pow(1 - t, 3);
|
|
pos = 2 + ease * 48;
|
|
apply();
|
|
if (t < 1) sweeping = requestAnimationFrame(tick);
|
|
}
|
|
sweeping = requestAnimationFrame(tick);
|
|
}
|
|
|
|
// The reveal sweep only plays when the base image changes, not on
|
|
// every update of the overlaid result.
|
|
let lastA = null;
|
|
return {
|
|
set(aUrl, bUrl) {
|
|
a.src = aUrl;
|
|
b.src = bUrl;
|
|
if (aUrl !== lastA) sweep();
|
|
lastA = aUrl;
|
|
},
|
|
};
|
|
}
|
|
|
|
// DCT helpers shared by the reconstruction demos
|
|
|
|
// Full 64-entry zigzag, matching the order in dct.rs (which stops at 36)
|
|
const FULL_ZIGZAG = (() => {
|
|
const order = [];
|
|
for (let s = 0; s < 15; s++) {
|
|
const diagonal = [];
|
|
for (let u = 0; u <= s; u++) {
|
|
const v = s - u;
|
|
if (u < 8 && v < 8) diagonal.push(v * 8 + u);
|
|
}
|
|
if (s % 2 === 1) diagonal.reverse();
|
|
order.push(...diagonal);
|
|
}
|
|
return order;
|
|
})();
|
|
const ZIGZAG = FULL_ZIGZAG.slice(0, 36);
|
|
|
|
// Inverse of the transform in dct.rs: orthonormal scaling, +128 recentering
|
|
function idct(values) {
|
|
const out = new Array(64);
|
|
for (let k = 0; k < 64; k++) {
|
|
const x = k % 8;
|
|
const y = Math.floor(k / 8);
|
|
let sum = 0;
|
|
for (let u = 0; u < 8; u++) {
|
|
for (let v = 0; v < 8; v++) {
|
|
let alpha = 1;
|
|
if (u === 0) alpha /= Math.SQRT2;
|
|
if (v === 0) alpha /= Math.SQRT2;
|
|
sum += alpha * values[v * 8 + u] *
|
|
Math.cos(((2 * x + 1) * u * Math.PI) / 16) *
|
|
Math.cos(((2 * y + 1) * v * Math.PI) / 16);
|
|
}
|
|
}
|
|
out[k] = Math.max(0, Math.min(255, Math.round(128 + 0.25 * sum)));
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function drawPixels(canvas, pixels) {
|
|
const ctx = canvas.getContext('2d');
|
|
const data = ctx.createImageData(8, 8);
|
|
for (let i = 0; i < 64; i++) {
|
|
data.data[i * 4] = pixels[i];
|
|
data.data[i * 4 + 1] = pixels[i];
|
|
data.data[i * 4 + 2] = pixels[i];
|
|
data.data[i * 4 + 3] = 255;
|
|
}
|
|
ctx.putImageData(data, 0, 0);
|
|
}
|
|
|
|
// Demo state
|
|
|
|
let baseBuffer = null; // ArrayBuffer of the selected image
|
|
let baseUrl = null;
|
|
let baseHashes = null; // [dct, median, phash] as BigInt
|
|
let baseCoefficients = null; // 64 DCT coefficients of the 8x8
|
|
let loadToken = 0;
|
|
|
|
// Image picker and pipeline
|
|
|
|
const imageContainers = document.getElementsByClassName('image-original');
|
|
const formImage = document.getElementById('dctimage');
|
|
const resizeCompare = makeCompare(document.getElementById('resize-compare'), { pixelated: true });
|
|
const mutateCompare = makeCompare(document.getElementById('mutate-compare'));
|
|
|
|
function reset() {
|
|
for (const container of imageContainers) {
|
|
container.replaceChildren();
|
|
}
|
|
for (const name of ['dct', 'median', 'phash']) {
|
|
document.getElementById(`flip-b-${name}`).replaceChildren();
|
|
document.getElementById(`flip-d-${name}`).innerText = '';
|
|
}
|
|
document.getElementById('flip-image').classList.remove('flipped');
|
|
flipHashes = null;
|
|
}
|
|
|
|
async function loadImage(buffer, url) {
|
|
const token = ++loadToken;
|
|
reset();
|
|
baseBuffer = buffer;
|
|
baseUrl = url;
|
|
for (const container of imageContainers) {
|
|
const img = document.createElement('img');
|
|
img.src = url;
|
|
container.appendChild(img);
|
|
}
|
|
document.getElementById('flip-image').src = url;
|
|
|
|
const data = await rpc('pipeline', { buffer });
|
|
if (token !== loadToken) return;
|
|
baseHashes = data.hashes;
|
|
baseCoefficients = data.coefficients;
|
|
const [dct, median, phash] = data.hashes;
|
|
|
|
// resize step
|
|
const resize8Url = pngUrl(data.resize8);
|
|
resizeCompare.set(url, resize8Url);
|
|
|
|
// small 8x8 reference thumbs sprinkled through the sections
|
|
for (const id of ['median-input', 'dct-input', 'recon-original', 'ghost-original']) {
|
|
document.getElementById(id).replaceChildren(pixelatedImg(resize8Url));
|
|
}
|
|
|
|
// median
|
|
bitGrid(document.getElementById('median-bits'), median);
|
|
document.getElementById('median-hash').innerText = hex64(median);
|
|
|
|
// dct
|
|
heatGrid(document.getElementById('image-dct'), data.coefficients);
|
|
bitGrid(document.getElementById('dct-sign-bits'), data.masks[0], 36);
|
|
bitGrid(document.getElementById('dct-ordinal-bits'), data.masks[1], 28);
|
|
document.getElementById('dct-hash').innerText = hex64(dct);
|
|
heatGrid(document.getElementById('recon-grid'), data.coefficients);
|
|
renderReconstruction();
|
|
renderGhost(data.masks);
|
|
|
|
// phash
|
|
const small = document.createElement('img');
|
|
small.src = pngUrl(data.resize32);
|
|
small.className = 'pixelated med';
|
|
document.getElementById('phash-resize').replaceChildren(small);
|
|
heatGrid(document.getElementById('phash-lowfreq'), data.lowfreq);
|
|
bitGrid(document.getElementById('phash-bits'), phash);
|
|
document.getElementById('phash-hash').innerText = hex64(phash);
|
|
|
|
// flip demo, original column
|
|
const names = ['dct', 'median', 'phash'];
|
|
names.forEach((name, i) => bitGrid(document.getElementById(`flip-a-${name}`), data.hashes[i]));
|
|
|
|
show();
|
|
runMd5Demo(buffer, token);
|
|
runLshDemo(buffer, token);
|
|
runRetrieval(buffer, token);
|
|
resetMutation();
|
|
runMutation();
|
|
}
|
|
|
|
formImage.addEventListener('change', () => {
|
|
const file = formImage.files[0];
|
|
if (!file) return;
|
|
file.arrayBuffer().then((buffer) => loadImage(buffer, URL.createObjectURL(file)));
|
|
});
|
|
|
|
// Sample images. The first one doubles as the preselected default.
|
|
const samples = [
|
|
'img/spook.png',
|
|
'img/im21050.jpg',
|
|
'img/im21644.jpg',
|
|
'img/im21068.jpg',
|
|
'img/im21123.jpg',
|
|
'img/im21694.jpg',
|
|
'img/moon1.jpg',
|
|
'img/moon2.jpg',
|
|
'img/sunflower1.jpg',
|
|
'img/sunflower2.jpg',
|
|
];
|
|
const sampleContainer = document.getElementById('sample-images');
|
|
for (const src of samples) {
|
|
// buttons are created up front so the strip keeps a deterministic order
|
|
const button = document.createElement('img');
|
|
button.className = 'sample';
|
|
button.title = src.split('/').pop();
|
|
sampleContainer.appendChild(button);
|
|
fetch(src)
|
|
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status)))
|
|
.then((buffer) => {
|
|
button.src = src;
|
|
button.addEventListener('click', () => loadImage(buffer, src));
|
|
if (src === samples[0] && baseBuffer === null) loadImage(buffer, src);
|
|
})
|
|
.catch(() => button.remove());
|
|
}
|
|
|
|
// MD5 avalanche demo: the same PNG twice, one pixel one unit apart.
|
|
// Also quietly hashes both files perceptually for the payoff in the mutation section.
|
|
|
|
async function runMd5Demo(buffer, token) {
|
|
const bitmap = await createImageBitmap(new Blob([buffer]));
|
|
const scale = Math.min(1, 1024 / Math.max(bitmap.width, bitmap.height));
|
|
const width = Math.max(1, Math.round(bitmap.width * scale));
|
|
const height = Math.max(1, Math.round(bitmap.height * scale));
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
ctx.drawImage(bitmap, 0, 0, width, height);
|
|
bitmap.close();
|
|
|
|
const toPng = () =>
|
|
new Promise((resolve) => canvas.toBlob((blob) => resolve(blob.arrayBuffer()), 'image/png'));
|
|
|
|
const pngA = await toPng();
|
|
const data = ctx.getImageData(0, 0, width, height);
|
|
const center = (Math.floor(height / 2) * width + Math.floor(width / 2)) * 4;
|
|
data.data[center] = data.data[center] === 255 ? 254 : data.data[center] + 1;
|
|
ctx.putImageData(data, 0, 0);
|
|
const pngB = await toPng();
|
|
|
|
const digest = (bytes) => new DataView(md5(new Uint8Array(bytes)).buffer).getBigUint64(0);
|
|
const md5A = digest(pngA);
|
|
const md5B = digest(pngB);
|
|
const [hashesA, hashesB] = await Promise.all([
|
|
rpc('hashes', { buffer: pngA }),
|
|
rpc('hashes', { buffer: pngB }),
|
|
]);
|
|
if (token !== loadToken) return;
|
|
|
|
const diff = md5A ^ md5B;
|
|
bitGrid(document.getElementById('md5-bits-a'), md5A);
|
|
bitGrid(document.getElementById('md5-bits-b'), md5B, 64, diff);
|
|
document.getElementById('md5-hex-a').innerText = hex64(md5A) + '…';
|
|
document.getElementById('md5-hex-b').innerText = hex64(md5B) + '…';
|
|
document.getElementById('md5-diff').innerText = `${hamming(md5A, md5B)} of 64 bits changed`;
|
|
|
|
// the payoff, delivered where the perceptual hashes have been introduced.
|
|
// The target span lives in prose that is sometimes commented out.
|
|
const payoff = document.getElementById('onepixel');
|
|
if (payoff) {
|
|
const distances = [0, 1, 2].map((i) => hamming(hashesA.hashes[i], hashesB.hashes[i]));
|
|
payoff.innerText =
|
|
`The three hashes built on this page put them at distance ` +
|
|
`${distances[0]}, ${distances[1]} and ${distances[2]}.`;
|
|
}
|
|
}
|
|
|
|
// LSH demo: the hash we are about to build, as a black box.
|
|
// Near copy agrees on almost all bits, a stranger on about half.
|
|
|
|
const strangerCache = new Map();
|
|
async function runLshDemo(buffer, token) {
|
|
// pick a stranger that cannot be the current sample
|
|
const stranger = baseUrl === 'img/im21694.jpg' ? 'img/sunflower1.jpg' : 'img/im21694.jpg';
|
|
if (!strangerCache.has(stranger)) {
|
|
const response = await fetch(stranger);
|
|
strangerCache.set(stranger, await response.arrayBuffer());
|
|
}
|
|
const [hue, other] = await Promise.all([
|
|
rpc('mutate', { buffer, kind: 'hue', amount: 90 }),
|
|
rpc('hashes', { buffer: strangerCache.get(stranger) }),
|
|
]);
|
|
if (token !== loadToken) return;
|
|
|
|
document.getElementById('lsh-img-a').src = baseUrl;
|
|
document.getElementById('lsh-img-b').src = pngUrl(hue.png);
|
|
document.getElementById('lsh-img-c').src = stranger;
|
|
const base = baseHashes[0];
|
|
bitGrid(document.getElementById('lsh-bits-a'), base);
|
|
bitGrid(document.getElementById('lsh-bits-b'), hue.hashes[0], 64, base ^ hue.hashes[0]);
|
|
bitGrid(document.getElementById('lsh-bits-c'), other.hashes[0], 64, base ^ other.hashes[0]);
|
|
for (const [id, hash] of [['lsh-d-b', hue.hashes[0]], ['lsh-d-c', other.hashes[0]]]) {
|
|
const distance = hamming(base, hash);
|
|
const cell = document.getElementById(id);
|
|
cell.innerText = distance;
|
|
cell.className = distance <= 4 ? 'match' : 'nomatch';
|
|
}
|
|
}
|
|
|
|
// The 64 DCT basis patterns, rendered once
|
|
|
|
(function basisGrid() {
|
|
const canvas = document.getElementById('dct-basis');
|
|
const ctx = canvas.getContext('2d');
|
|
const block = 27;
|
|
const cell = 3;
|
|
|
|
function channel(css, fallback) {
|
|
const value = css.match(/#([0-9a-f]{6})/i);
|
|
if (!value) return fallback;
|
|
const n = parseInt(value[1], 16);
|
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
}
|
|
const bg = channel(cssColor('--background'), [16, 16, 24]);
|
|
const fg = channel(cssColor('--foreground'), [225, 225, 218]);
|
|
|
|
for (let u = 0; u < 8; u++) {
|
|
for (let v = 0; v < 8; v++) {
|
|
for (let x = 0; x < 8; x++) {
|
|
for (let y = 0; y < 8; y++) {
|
|
const value =
|
|
Math.cos(((2 * x + 1) * u * Math.PI) / 16) *
|
|
Math.cos(((2 * y + 1) * v * Math.PI) / 16);
|
|
const t = (value + 1) / 2;
|
|
const rgb = bg.map((b, i) => Math.round(b + (fg[i] - b) * t));
|
|
ctx.fillStyle = `rgb(${rgb[0]} ${rgb[1]} ${rgb[2]})`;
|
|
ctx.fillRect(u * block + x * cell, v * block + y * cell, cell, cell);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
})();
|
|
|
|
// Hovering a basis pattern fills its (u,v) into the transform formula,
|
|
// together with the coefficient it produces for the current image.
|
|
|
|
(function basisHover() {
|
|
const wrap = document.getElementById('dct-basis-wrap');
|
|
const canvas = document.getElementById('dct-basis');
|
|
const formula = document.getElementById('dct-formula');
|
|
const us = formula.querySelectorAll('.var-u');
|
|
const vs = formula.querySelectorAll('.var-v');
|
|
const value = formula.querySelector('.formula-value');
|
|
const box = document.createElement('div');
|
|
box.className = 'basis-hover';
|
|
wrap.appendChild(box);
|
|
let markedCell = null;
|
|
|
|
canvas.addEventListener('mousemove', (e) => {
|
|
const rect = canvas.getBoundingClientRect();
|
|
const u = Math.min(7, Math.floor(((e.clientX - rect.left) / rect.width) * 8));
|
|
const v = Math.min(7, Math.floor(((e.clientY - rect.top) / rect.height) * 8));
|
|
const block = rect.width / 8;
|
|
box.style.display = 'block';
|
|
box.style.left = `${u * block}px`;
|
|
box.style.top = `${v * block}px`;
|
|
box.style.width = `${block}px`;
|
|
box.style.height = `${block}px`;
|
|
us.forEach((n) => (n.textContent = u));
|
|
vs.forEach((n) => (n.textContent = v));
|
|
if (baseCoefficients) value.textContent = baseCoefficients[v * 8 + u].toFixed(1);
|
|
formula.classList.add('live');
|
|
// mark the matching cell in the coefficient grid
|
|
markedCell?.classList.remove('hover-cell');
|
|
markedCell = document.getElementById('image-dct').children[v * 8 + u] || null;
|
|
markedCell?.classList.add('hover-cell');
|
|
});
|
|
canvas.addEventListener('mouseleave', () => {
|
|
box.style.display = 'none';
|
|
us.forEach((n) => (n.textContent = 'u'));
|
|
vs.forEach((n) => (n.textContent = 'v'));
|
|
formula.classList.remove('live');
|
|
markedCell?.classList.remove('hover-cell');
|
|
markedCell = null;
|
|
});
|
|
})();
|
|
|
|
// Zigzag trace over the coefficient grid
|
|
|
|
(function zigzagTrace() {
|
|
const svg = document.getElementById('dct-zigzag');
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
const points = ZIGZAG.map((i) => `${(i % 8) * 32 + 16},${Math.floor(i / 8) * 32 + 16}`);
|
|
line.setAttribute('points', points.join(' '));
|
|
line.setAttribute('fill', 'none');
|
|
svg.appendChild(line);
|
|
const length = line.getTotalLength() || 1200;
|
|
line.style.strokeDasharray = length;
|
|
line.style.strokeDashoffset = length;
|
|
|
|
function play() {
|
|
line.classList.remove('play');
|
|
line.style.strokeDashoffset = length;
|
|
void line.getBoundingClientRect();
|
|
line.classList.add('play');
|
|
line.style.strokeDashoffset = 0;
|
|
}
|
|
|
|
document.getElementById('zigzag-replay').addEventListener('click', play);
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
play();
|
|
observer.disconnect();
|
|
}
|
|
});
|
|
observer.observe(svg);
|
|
})();
|
|
|
|
// Progressive reconstruction: sum the patterns back up in zigzag order.
|
|
// A copy of the coefficient grid traces the zigzag along, so image space
|
|
// and frequency space fill up together.
|
|
|
|
const reconSlider = document.getElementById('recon-k');
|
|
const reconCanvas = document.getElementById('recon-canvas');
|
|
|
|
// Two polylines over the recon grid: the 36 coefficients the hash reads,
|
|
// and the tail it ignores.
|
|
const reconZigzag = (() => {
|
|
const svg = document.getElementById('recon-zigzag');
|
|
const cell = 176 / 8;
|
|
const points = FULL_ZIGZAG.map((i) => [(i % 8) * cell + cell / 2, Math.floor(i / 8) * cell + cell / 2]);
|
|
const cumulative = [0];
|
|
for (let i = 1; i < 64; i++) {
|
|
cumulative.push(cumulative[i - 1] + Math.hypot(points[i][0] - points[i - 1][0], points[i][1] - points[i - 1][1]));
|
|
}
|
|
function polyline(pts, className) {
|
|
const line = document.createElementNS('http://www.w3.org/2000/svg', 'polyline');
|
|
line.setAttribute('points', pts.map(([x, y]) => `${x},${y}`).join(' '));
|
|
line.setAttribute('fill', 'none');
|
|
line.setAttribute('class', className);
|
|
svg.appendChild(line);
|
|
return line;
|
|
}
|
|
const head = polyline(points.slice(0, 36), 'zig-head');
|
|
const tail = polyline(points.slice(35), 'zig-tail');
|
|
const headLength = cumulative[35];
|
|
const tailLength = cumulative[63] - cumulative[35];
|
|
head.style.strokeDasharray = headLength;
|
|
tail.style.strokeDasharray = tailLength;
|
|
|
|
return function update(k) {
|
|
const drawn = cumulative[k - 1];
|
|
head.style.strokeDashoffset = headLength - Math.min(drawn, headLength);
|
|
tail.style.strokeDashoffset = tailLength - Math.max(0, drawn - headLength);
|
|
};
|
|
})();
|
|
|
|
function renderReconstruction() {
|
|
if (!baseCoefficients) return;
|
|
const k = Number(reconSlider.value);
|
|
document.getElementById('recon-count').innerText = k;
|
|
const partial = new Array(64).fill(0);
|
|
for (let i = 0; i < k; i++) {
|
|
partial[FULL_ZIGZAG[i]] = baseCoefficients[FULL_ZIGZAG[i]];
|
|
}
|
|
drawPixels(reconCanvas, idct(partial));
|
|
const grid = document.getElementById('recon-grid');
|
|
for (let i = 0; i < 64; i++) {
|
|
const cell = grid.children[FULL_ZIGZAG[i]];
|
|
if (cell) cell.style.opacity = i < k ? 1 : 0.15;
|
|
}
|
|
reconZigzag(k);
|
|
}
|
|
|
|
let reconPlayToken = 0;
|
|
|
|
reconSlider.addEventListener('input', () => {
|
|
reconPlayToken++;
|
|
renderReconstruction();
|
|
});
|
|
|
|
// Slow pass over the 36 coefficients the hash uses, a beat at the cut,
|
|
// then the leftovers: they barely change the picture.
|
|
document.getElementById('recon-play').addEventListener('click', async () => {
|
|
const token = ++reconPlayToken;
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
for (let k = 1; k <= 64; k++) {
|
|
if (token !== reconPlayToken) return;
|
|
reconSlider.value = k;
|
|
renderReconstruction();
|
|
if (k === 36) await sleep(900);
|
|
else await sleep(k < 36 ? 150 : 90);
|
|
}
|
|
});
|
|
|
|
// The ghost: reconstruction from the 64 hash bits alone.
|
|
// Signs come from the sign mask; magnitudes are guessed by walking the
|
|
// ordinal chain (each bit says bigger/smaller than the previous, so the
|
|
// walk gives every coefficient a relative rank).
|
|
|
|
function renderGhost(masks) {
|
|
const [signMask, ordinalMask] = masks;
|
|
const ranks = [0];
|
|
for (let k = 0; k < 28; k++) {
|
|
const bigger = (ordinalMask >> BigInt(27 - k)) & 1n;
|
|
ranks.push(ranks[k] + (bigger ? 1 : -1));
|
|
}
|
|
const floor = Math.min(...ranks) - 1;
|
|
const coefficients = new Array(64).fill(0);
|
|
for (let k = 0; k < 36; k++) {
|
|
const negative = (signMask >> BigInt(35 - k)) & 1n;
|
|
const rank = k < ranks.length ? ranks[k] : floor;
|
|
const magnitude = Math.min(400, 24 * Math.pow(1.35, rank));
|
|
coefficients[ZIGZAG[k]] = (negative ? -1 : 1) * magnitude;
|
|
}
|
|
drawPixels(document.getElementById('ghost-canvas'), idct(coefficients));
|
|
}
|
|
|
|
document.getElementById('ghost-flip').addEventListener('click', () => {
|
|
document.getElementById('ghost-canvas').classList.toggle('flipped');
|
|
});
|
|
|
|
// Mutation demo
|
|
|
|
const mutators = {
|
|
flip: { label: 'Horizontal flip' },
|
|
jpeg: { label: 'JPEG quality', min: 1, max: 100, step: 1, value: 50 },
|
|
blur: { label: 'Gaussian blur', min: 0, max: 10, step: 0.1, value: 1.5 },
|
|
sharpen: { label: 'Unsharp mask', min: 0, max: 10, step: 0.1, value: 1.5 },
|
|
scale: { label: 'Rescale %', min: 5, max: 100, step: 1, value: 50 },
|
|
crop: { label: 'Crop, keep %', min: 50, max: 100, step: 1, value: 90 },
|
|
rotate: { label: 'Rotate deg', min: -45, max: 45, step: 0.5, value: 2 },
|
|
hue: { label: 'Hue shift deg', min: 0, max: 180, step: 1, value: 30 },
|
|
brightness: { label: 'Brightness', min: -100, max: 100, step: 1, value: 30 },
|
|
contrast: { label: 'Contrast', min: -100, max: 100, step: 1, value: 25 },
|
|
letterbox: { label: 'Letterbox bar %', min: 1, max: 40, step: 1, value: 10 },
|
|
logo: { label: 'Logo size %', min: 1, max: 60, step: 1, value: 10 },
|
|
noise: { label: 'Gaussian noise', min: 0, max: 50, step: 1, value: 10 },
|
|
};
|
|
|
|
const kindSelect = document.getElementById('mutator-kind');
|
|
const amountSlider = document.getElementById('mutator-amount');
|
|
const amountValue = document.getElementById('mutator-amount-value');
|
|
|
|
for (const [kind, config] of Object.entries(mutators)) {
|
|
const option = document.createElement('option');
|
|
option.value = kind;
|
|
option.innerText = config.label;
|
|
kindSelect.appendChild(option);
|
|
}
|
|
|
|
function resetMutation() {
|
|
const config = mutators[kindSelect.value];
|
|
if (config.min === undefined) {
|
|
amountSlider.disabled = true;
|
|
amountValue.innerText = '';
|
|
} else {
|
|
amountSlider.disabled = false;
|
|
amountSlider.min = config.min;
|
|
amountSlider.max = config.max;
|
|
amountSlider.step = config.step;
|
|
amountSlider.value = config.value;
|
|
amountValue.innerText = config.value;
|
|
}
|
|
}
|
|
|
|
let mutationTimer = null;
|
|
let mutationToken = 0;
|
|
async function runMutation() {
|
|
if (!baseBuffer) return;
|
|
const token = ++mutationToken;
|
|
const kind = kindSelect.value;
|
|
const amount = amountSlider.disabled ? 0 : Number(amountSlider.value);
|
|
amountValue.innerText = amountSlider.disabled ? '' : amount;
|
|
const busy = [document.getElementById('mutate-compare'), document.querySelector('.distances')];
|
|
busy.forEach((el) => el.classList.add('busy'));
|
|
const data = await rpc('mutate', { buffer: baseBuffer, kind, amount });
|
|
if (token !== mutationToken || !baseHashes) return;
|
|
busy.forEach((el) => el.classList.remove('busy'));
|
|
|
|
mutateCompare.set(baseUrl, pngUrl(data.png));
|
|
|
|
const names = ['dct', 'median', 'phash'];
|
|
names.forEach((name, i) => {
|
|
const distance = hamming(baseHashes[i], data.hashes[i]);
|
|
const cell = document.getElementById(`dist-${name}`);
|
|
cell.innerText = distance;
|
|
cell.className = distance <= 4 ? 'match' : 'nomatch';
|
|
});
|
|
}
|
|
|
|
function scheduleMutation() {
|
|
clearTimeout(mutationTimer);
|
|
mutationTimer = setTimeout(runMutation, 120);
|
|
}
|
|
|
|
kindSelect.addEventListener('change', () => {
|
|
resetMutation();
|
|
scheduleMutation();
|
|
});
|
|
amountSlider.addEventListener('input', scheduleMutation);
|
|
resetMutation();
|
|
|
|
// Flip invariance demo
|
|
|
|
let flipHashes = null;
|
|
document.getElementById('flip-button').addEventListener('click', async () => {
|
|
if (!baseBuffer) return;
|
|
document.getElementById('flip-image').classList.toggle('flipped');
|
|
if (flipHashes) return;
|
|
const token = loadToken;
|
|
const data = await rpc('mutate', { buffer: baseBuffer, kind: 'flip', amount: 0 });
|
|
if (token !== loadToken) return;
|
|
flipHashes = data.hashes;
|
|
const names = ['dct', 'median', 'phash'];
|
|
names.forEach((name, i) => {
|
|
const diff = baseHashes[i] ^ flipHashes[i];
|
|
bitGrid(document.getElementById(`flip-b-${name}`), flipHashes[i], 64, diff);
|
|
const distance = hamming(baseHashes[i], flipHashes[i]);
|
|
const cell = document.getElementById(`flip-d-${name}`);
|
|
cell.innerText = distance;
|
|
cell.className = 'flip-distance ' + (distance <= 4 ? 'match' : 'nomatch');
|
|
});
|
|
});
|
|
|
|
// Ranking demo over the precomputed MIRFLICKR pool
|
|
|
|
const rankingMutations = [
|
|
{ key: 'none', label: 'no mutation' },
|
|
{ key: 'flip', label: 'horizontal flip', amount: 0 },
|
|
{ key: 'jpeg', label: 'JPEG quality 30', amount: 30 },
|
|
{ key: 'blur', label: 'Gaussian blur 3', amount: 3 },
|
|
{ key: 'hue', label: 'hue shift 60', amount: 60 },
|
|
{ key: 'scale', label: 'rescale to 25%', amount: 25 },
|
|
{ key: 'crop', label: 'crop to 80%', amount: 80 },
|
|
{ key: 'logo', label: 'logo insert 15%', amount: 15 },
|
|
{ key: 'noise', label: 'Gaussian noise 20', amount: 20 },
|
|
];
|
|
|
|
const pool = []; // {name, url, hashes: [dct, median, phash]}
|
|
const poolContainer = document.getElementById('ranking-pool');
|
|
const rankingMutationSelect = document.getElementById('ranking-mutation');
|
|
const queryContainer = document.getElementById('ranking-query');
|
|
const bufferCache = new Map();
|
|
let currentQuery = null; // {name, url, hashes, buffer?}
|
|
let rankToken = 0;
|
|
|
|
for (const { key, label } of rankingMutations) {
|
|
const option = document.createElement('option');
|
|
option.value = key;
|
|
option.innerText = label;
|
|
rankingMutationSelect.appendChild(option);
|
|
}
|
|
|
|
const poolReady = fetch('flickr/hashes.json')
|
|
.then((response) => response.json())
|
|
.then((entries) => {
|
|
const fragment = document.createDocumentFragment();
|
|
for (const [name, dct, median, phash] of entries) {
|
|
const url = `flickr/thumbs/${name}`;
|
|
const index = pool.length;
|
|
pool.push({
|
|
name,
|
|
url,
|
|
hashes: [BigInt('0x' + dct), BigInt('0x' + median), BigInt('0x' + phash)],
|
|
});
|
|
const img = document.createElement('img');
|
|
img.src = url;
|
|
img.loading = 'lazy';
|
|
img.decoding = 'async';
|
|
img.title = name;
|
|
img.dataset.index = index;
|
|
fragment.appendChild(img);
|
|
}
|
|
poolContainer.appendChild(fragment);
|
|
return pool;
|
|
})
|
|
.catch(() => null);
|
|
|
|
poolContainer.addEventListener('click', (e) => {
|
|
const index = e.target.dataset?.index;
|
|
if (index === undefined) return;
|
|
poolContainer.querySelector('.query')?.classList.remove('query');
|
|
e.target.classList.add('query');
|
|
currentQuery = pool[Number(index)];
|
|
runRanking();
|
|
});
|
|
|
|
document.getElementById('ranking-file').addEventListener('change', async (e) => {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
const buffer = await file.arrayBuffer();
|
|
const { hashes } = await rpc('hashes', { buffer });
|
|
poolContainer.querySelector('.query')?.classList.remove('query');
|
|
currentQuery = { name: file.name, url: URL.createObjectURL(file), hashes, buffer };
|
|
runRanking();
|
|
});
|
|
|
|
rankingMutationSelect.addEventListener('change', runRanking);
|
|
|
|
async function queryBuffer() {
|
|
if (currentQuery.buffer) return currentQuery.buffer;
|
|
if (!bufferCache.has(currentQuery.url)) {
|
|
const response = await fetch(currentQuery.url);
|
|
bufferCache.set(currentQuery.url, await response.arrayBuffer());
|
|
}
|
|
return bufferCache.get(currentQuery.url);
|
|
}
|
|
|
|
function thumbFigure(url, caption, hit = false) {
|
|
const figure = document.createElement('figure');
|
|
const img = document.createElement('img');
|
|
img.src = url;
|
|
img.loading = 'lazy';
|
|
const figcaption = document.createElement('figcaption');
|
|
figcaption.innerText = caption;
|
|
if (hit) figure.className = 'hit';
|
|
figure.append(img, figcaption);
|
|
return figure;
|
|
}
|
|
|
|
async function runRanking() {
|
|
if (!currentQuery) return;
|
|
const token = ++rankToken;
|
|
const mutation = rankingMutations.find((m) => m.key === rankingMutationSelect.value);
|
|
|
|
let queryHashes = currentQuery.hashes;
|
|
let mutatedUrl = null;
|
|
if (mutation.key !== 'none') {
|
|
const buffer = await queryBuffer();
|
|
const data = await rpc('mutate', { buffer, kind: mutation.key, amount: mutation.amount });
|
|
if (token !== rankToken) return;
|
|
queryHashes = data.hashes;
|
|
mutatedUrl = pngUrl(data.png);
|
|
}
|
|
|
|
queryContainer.replaceChildren(thumbFigure(currentQuery.url, currentQuery.name));
|
|
if (mutatedUrl) queryContainer.appendChild(thumbFigure(mutatedUrl, mutation.label));
|
|
|
|
['dct', 'median', 'phash'].forEach((name, algo) => {
|
|
const ranked = pool
|
|
.map((entry) => ({ entry, distance: hamming(queryHashes[algo], entry.hashes[algo]) }))
|
|
.sort((a, b) => a.distance - b.distance)
|
|
.slice(0, 10);
|
|
const container = document.getElementById(`results-${name}`);
|
|
container.replaceChildren();
|
|
for (const { entry, distance } of ranked) {
|
|
const caption = `${distance} · ${entry.name.replace('.jpg', '')}`;
|
|
container.appendChild(thumbFigure(entry.url, caption, entry.name === currentQuery.name));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Precision-recall charts, rendered from the run data.
|
|
//
|
|
// 16 series cannot get 16 distinguishable colors, least of all from a pywal
|
|
// palette that changes with the wallpaper. So color encodes the mutation
|
|
// *category* (fixed assignment) and a per-category dash pattern is the
|
|
// secondary encoding; individual mutators are identified by the legend,
|
|
// hover isolation and the tooltip, never by color alone.
|
|
|
|
(async function prCharts() {
|
|
let data;
|
|
try {
|
|
data = await (await fetch('pr-data.json')).json();
|
|
} catch {
|
|
return;
|
|
}
|
|
|
|
const CATEGORIES = [
|
|
{ name: 'recoding & resampling', color: '--color2', dash: '', muts: ['JPEG quality 90', 'JPEG quality 50', 'JPEG quality 20', 'Rescale 50%'] },
|
|
{ name: 'content processing', color: '--color4', dash: '7 4', muts: ['Hue shift 30', 'Gaussian blur 1.5', 'Unsharp mask', 'Contrast +25', 'Brightness +30', 'Gaussian noise 10'] },
|
|
{ name: 'framing', color: '--color3', dash: '2 4', muts: ['Crop to 90%', 'Crop to 70%', 'Letterbox 10%'] },
|
|
{ name: 'insertion', color: '--color1', dash: '11 4 2 4', muts: ['Logo insert 10%'] },
|
|
{ name: 'flip & rotation', color: '--foreground', dash: '15 5', muts: ['Horizontal flip', 'Rotate 2'] },
|
|
];
|
|
const styleOf = {};
|
|
for (const category of CATEGORIES) {
|
|
for (const mut of category.muts) styleOf[mut] = category;
|
|
}
|
|
|
|
const ALGOS = ['dct', 'phash', 'median'];
|
|
const SIZE = 330;
|
|
const MARGIN = { left: 42, right: 12, top: 10, bottom: 38 };
|
|
const px = (r) => MARGIN.left + r * (SIZE - MARGIN.left - MARGIN.right);
|
|
const py = (p) => SIZE - MARGIN.bottom - p * (SIZE - MARGIN.top - MARGIN.bottom);
|
|
const NS = 'http://www.w3.org/2000/svg';
|
|
const tooltip = document.getElementById('pr-tooltip');
|
|
const tSlider = document.getElementById('pr-t');
|
|
const series = []; // {algo, mut, line, dot, points}
|
|
|
|
function el(name, attrs, parent) {
|
|
const node = document.createElementNS(NS, name);
|
|
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
|
if (parent) parent.appendChild(node);
|
|
return node;
|
|
}
|
|
|
|
for (const algo of ALGOS) {
|
|
const svg = el('svg', { viewBox: `0 0 ${SIZE} ${SIZE}`, class: 'pr-svg' });
|
|
document.getElementById(`pr-chart-${algo}`).appendChild(svg);
|
|
|
|
// recessive grid and axes, text in text tokens
|
|
for (const tick of [0, 0.25, 0.5, 0.75, 1]) {
|
|
el('line', { x1: px(tick), y1: py(0), x2: px(tick), y2: py(1), class: 'pr-grid' }, svg);
|
|
el('line', { x1: px(0), y1: py(tick), x2: px(1), y2: py(tick), class: 'pr-grid' }, svg);
|
|
const xLabel = el('text', { x: px(tick), y: py(0) + 14, class: 'pr-tick', 'text-anchor': 'middle' }, svg);
|
|
xLabel.textContent = tick;
|
|
const yLabel = el('text', { x: px(0) - 5, y: py(tick) + 3, class: 'pr-tick', 'text-anchor': 'end' }, svg);
|
|
yLabel.textContent = tick;
|
|
}
|
|
const xTitle = el('text', { x: px(0.5), y: SIZE - 8, class: 'pr-axis', 'text-anchor': 'middle' }, svg);
|
|
xTitle.textContent = 'recall';
|
|
const yTitle = el('text', {
|
|
x: 12, y: py(0.5), class: 'pr-axis', 'text-anchor': 'middle',
|
|
transform: `rotate(-90 12 ${py(0.5)})`,
|
|
}, svg);
|
|
yTitle.textContent = 'precision';
|
|
|
|
for (const [mut, points] of Object.entries(data[algo])) {
|
|
const category = styleOf[mut];
|
|
if (!category) continue;
|
|
const color = cssColor(category.color);
|
|
const line = el('polyline', {
|
|
points: points.map(([r, p]) => `${px(r)},${py(p)}`).join(' '),
|
|
class: 'pr-line',
|
|
stroke: color,
|
|
'stroke-dasharray': category.dash,
|
|
}, svg);
|
|
const dot = el('circle', { r: 4, class: 'pr-dot', fill: color }, svg);
|
|
series.push({ algo, mut, line, dot, points });
|
|
}
|
|
|
|
svg.addEventListener('mousemove', (event) => {
|
|
const rect = svg.getBoundingClientRect();
|
|
const x = ((event.clientX - rect.left) / rect.width) * SIZE;
|
|
const y = ((event.clientY - rect.top) / rect.height) * SIZE;
|
|
let best = null;
|
|
for (const s of series) {
|
|
if (s.algo !== algo || s.line.classList.contains('faded')) continue;
|
|
s.points.forEach(([r, p], t) => {
|
|
const distance = Math.hypot(px(r) - x, py(p) - y);
|
|
if (distance < 14 && (!best || distance < best.distance)) {
|
|
best = { distance, mut: s.mut, t, r, p };
|
|
}
|
|
});
|
|
}
|
|
if (best) {
|
|
tooltip.hidden = false;
|
|
tooltip.innerText =
|
|
`${best.mut} · t=${best.t} · ` +
|
|
`recall ${(best.r * 100).toFixed(1)}% · precision ${(best.p * 100).toFixed(1)}%`;
|
|
tooltip.style.left = `${event.clientX + 14}px`;
|
|
tooltip.style.top = `${event.clientY + 14}px`;
|
|
} else {
|
|
tooltip.hidden = true;
|
|
}
|
|
});
|
|
svg.addEventListener('mouseleave', () => {
|
|
tooltip.hidden = true;
|
|
});
|
|
}
|
|
|
|
// legend: grouped by category, hover isolates, click pins
|
|
const legend = document.getElementById('pr-legend');
|
|
const pinned = new Set();
|
|
let hovered = null;
|
|
const chips = new Map();
|
|
|
|
function applyHighlight() {
|
|
const active = pinned.size ? pinned : hovered ? new Set([hovered]) : null;
|
|
for (const s of series) {
|
|
s.line.classList.toggle('faded', Boolean(active && !active.has(s.mut)));
|
|
s.dot.classList.toggle('faded', Boolean(active && !active.has(s.mut)));
|
|
}
|
|
for (const [mut, chip] of chips) {
|
|
chip.classList.toggle('dim', Boolean(active && !active.has(mut)));
|
|
chip.classList.toggle('pinned', pinned.has(mut));
|
|
}
|
|
}
|
|
|
|
for (const category of CATEGORIES) {
|
|
const group = document.createElement('span');
|
|
group.className = 'pr-group';
|
|
for (const mut of category.muts) {
|
|
const chip = document.createElement('button');
|
|
chip.className = 'pr-chip';
|
|
chip.type = 'button';
|
|
const swatch = el('svg', { width: 26, height: 10, class: 'pr-swatch' });
|
|
el('line', {
|
|
x1: 1, y1: 5, x2: 25, y2: 5,
|
|
stroke: cssColor(category.color),
|
|
'stroke-width': 2,
|
|
'stroke-dasharray': category.dash,
|
|
}, swatch);
|
|
const label = document.createElement('span');
|
|
label.innerText = mut;
|
|
chip.append(swatch, label);
|
|
chip.addEventListener('mouseenter', () => {
|
|
hovered = mut;
|
|
applyHighlight();
|
|
});
|
|
chip.addEventListener('mouseleave', () => {
|
|
hovered = null;
|
|
applyHighlight();
|
|
});
|
|
chip.addEventListener('click', () => {
|
|
if (pinned.has(mut)) pinned.delete(mut);
|
|
else pinned.add(mut);
|
|
applyHighlight();
|
|
});
|
|
chips.set(mut, chip);
|
|
group.appendChild(chip);
|
|
}
|
|
legend.appendChild(group);
|
|
}
|
|
|
|
function placeDots() {
|
|
const t = Number(tSlider.value);
|
|
document.getElementById('pr-t-value').innerText = t;
|
|
for (const s of series) {
|
|
const [r, p] = s.points[Math.min(t, s.points.length - 1)];
|
|
s.dot.setAttribute('cx', px(r));
|
|
s.dot.setAttribute('cy', py(p));
|
|
}
|
|
}
|
|
|
|
tSlider.addEventListener('input', placeDots);
|
|
placeDots();
|
|
})();
|
|
|
|
// Retrieval demos: threshold histogram, toy BK-tree, and the full index.
|
|
// The trees themselves live in wasm memory inside the worker; this side
|
|
// only draws structures and traces it gets back.
|
|
|
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
|
|
function svgNode(name, attrs, parent) {
|
|
const node = document.createElementNS(SVG_NS, name);
|
|
for (const [key, value] of Object.entries(attrs)) node.setAttribute(key, value);
|
|
if (parent) parent.appendChild(node);
|
|
return node;
|
|
}
|
|
|
|
function fmtTime(ms) {
|
|
return ms < 1 ? `${Math.round(ms * 1000)} µs` : `${ms.toFixed(1)} ms`;
|
|
}
|
|
|
|
// The 425k-hash index is built once, off the critical path
|
|
const bigReady = fetch('flickr/keys.bin')
|
|
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status)))
|
|
.then((buffer) => rpc('big_build', { buffer }))
|
|
.then(({ size }) => size)
|
|
.catch(() => null);
|
|
|
|
const retrieval = {
|
|
suite: null, // [{hash, label}] of the current image's 16 mutants
|
|
labelOf: new Map(), // hex hash -> human name, for toy node tooltips
|
|
toy: null, // {byKey, size} of the visualized tree
|
|
profile: null, // compared/found counts per radius on the big tree
|
|
bigSize: null,
|
|
};
|
|
|
|
const distSlider = document.getElementById('dist-t');
|
|
const toySlider = document.getElementById('toy-t');
|
|
const bigSlider = document.getElementById('big-t');
|
|
|
|
async function runRetrieval(buffer, token) {
|
|
const data = await rpc('suite', { buffer });
|
|
if (token !== loadToken) return;
|
|
retrieval.suite = Array.from(data.hashes).map((hash, i) => ({ hash, label: data.labels[i] }));
|
|
|
|
const poolData = await poolReady;
|
|
if (token !== loadToken) return;
|
|
if (poolData) renderHistogram();
|
|
|
|
// Toy tree: 16 spread-out pool strangers go in first (so the root sits
|
|
// in stranger country), then the mutants, which cluster in one branch
|
|
const strangers = poolData ? poolData.filter((_, i) => i % 70 === 0).slice(0, 16) : [];
|
|
retrieval.labelOf = new Map();
|
|
for (const entry of strangers) retrieval.labelOf.set(entry.hashes[0].toString(16), entry.name);
|
|
for (const { hash, label } of retrieval.suite) {
|
|
const hex = hash.toString(16);
|
|
const existing = retrieval.labelOf.get(hex);
|
|
retrieval.labelOf.set(hex, existing ? `${existing} / ${label}` : label);
|
|
}
|
|
const keys = [...strangers.map((e) => e.hashes[0]), ...retrieval.suite.map((m) => m.hash)];
|
|
const built = await rpc('toy_build', { keys });
|
|
if (token !== loadToken) return;
|
|
renderToyTree(built.structure, built.size);
|
|
runToyQuery(false);
|
|
|
|
const size = await bigReady;
|
|
if (token !== loadToken || size === null) return;
|
|
retrieval.bigSize = size;
|
|
document.getElementById('big-size').innerText = size.toLocaleString('en');
|
|
retrieval.profile = await rpc('big_profile', { key: baseHashes[0], max: Number(bigSlider.max) });
|
|
if (token !== loadToken) return;
|
|
renderBigCurve();
|
|
runBigQuery();
|
|
}
|
|
|
|
// Distance histogram: strangers as bars, copies as dots, threshold as a line
|
|
|
|
function renderHistogram() {
|
|
if (!retrieval.suite || !baseHashes || !pool.length) return;
|
|
const t = Number(distSlider.value);
|
|
document.getElementById('dist-t-value').innerText = t;
|
|
const base = baseHashes[0];
|
|
|
|
const strangerCounts = new Array(65).fill(0);
|
|
for (const entry of pool) strangerCounts[hamming(base, entry.hashes[0])]++;
|
|
const copies = retrieval.suite.map((m) => ({ d: hamming(base, m.hash), label: m.label }));
|
|
|
|
const W = 640, H = 210;
|
|
const M = { left: 34, right: 10, top: 16, bottom: 30 };
|
|
const step = (W - M.left - M.right) / 65;
|
|
const x = (d) => M.left + (d + 0.5) * step;
|
|
const maxCount = Math.max(...strangerCounts, 1);
|
|
// square root scale, so the lone stranger bars in the overlap zone stay visible
|
|
const y = (c) => H - M.bottom - Math.sqrt(c / maxCount) * (H - M.top - M.bottom);
|
|
|
|
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, class: 'pr-svg dist-svg' });
|
|
svgNode('rect', {
|
|
x: M.left, y: M.top,
|
|
width: Math.max(0, x(t) + step / 2 - M.left),
|
|
height: H - M.top - M.bottom,
|
|
class: 'dist-region',
|
|
}, svg);
|
|
for (let d = 0; d <= 64; d += 8) {
|
|
const label = svgNode('text', { x: x(d), y: H - M.bottom + 14, class: 'pr-tick', 'text-anchor': 'middle' }, svg);
|
|
label.textContent = d;
|
|
}
|
|
const axis = svgNode('text', { x: x(32), y: H - 2, class: 'pr-axis', 'text-anchor': 'middle' }, svg);
|
|
axis.textContent = 'hamming distance';
|
|
svgNode('line', { x1: M.left, y1: H - M.bottom, x2: W - M.right, y2: H - M.bottom, class: 'pr-grid' }, svg);
|
|
|
|
for (let d = 0; d <= 64; d++) {
|
|
if (!strangerCounts[d]) continue;
|
|
const bar = svgNode('rect', {
|
|
x: x(d) - step * 0.4, y: y(strangerCounts[d]),
|
|
width: step * 0.8, height: H - M.bottom - y(strangerCounts[d]),
|
|
class: 'dist-bar',
|
|
}, svg);
|
|
svgNode('title', {}, bar).textContent = `${strangerCounts[d]} pool images at distance ${d}`;
|
|
}
|
|
const stacked = new Map();
|
|
for (const copy of copies) {
|
|
const k = stacked.get(copy.d) || 0;
|
|
stacked.set(copy.d, k + 1);
|
|
const dot = svgNode('circle', {
|
|
cx: x(copy.d), cy: H - M.bottom - 6 - k * 11, r: 4.5, class: 'dist-copy',
|
|
}, svg);
|
|
svgNode('title', {}, dot).textContent = `${copy.label}, distance ${copy.d}`;
|
|
}
|
|
svgNode('line', {
|
|
x1: x(t) + step / 2, y1: M.top - 4, x2: x(t) + step / 2, y2: H - M.bottom,
|
|
class: 'dist-thresh',
|
|
}, svg);
|
|
|
|
document.getElementById('dist-chart').replaceChildren(svg);
|
|
const copiesIn = copies.filter((c) => c.d <= t).length;
|
|
const strangersIn = strangerCounts.slice(0, t + 1).reduce((a, b) => a + b, 0);
|
|
document.getElementById('dist-readout').innerText =
|
|
`catches ${copiesIn} of ${copies.length} copies, lets in ${strangersIn} of ${pool.length} strangers`;
|
|
}
|
|
|
|
distSlider.addEventListener('input', renderHistogram);
|
|
|
|
// Toy BK-tree: drawn from the wasm tree's own structure dump,
|
|
// colored by the visit trace of a real query
|
|
|
|
function renderToyTree(structure, size) {
|
|
const byKey = new Map();
|
|
let root = null;
|
|
for (let i = 0; i < structure.length; i += 3) {
|
|
const [key, parent, distance] = [structure[i], structure[i + 1], structure[i + 2]];
|
|
const node = { key, distance: Number(distance), children: [], parent: null, el: {} };
|
|
if (root === null) {
|
|
root = node;
|
|
} else {
|
|
node.parent = byKey.get(parent.toString(16));
|
|
node.parent.children.push(node);
|
|
}
|
|
byKey.set(key.toString(16), node);
|
|
}
|
|
retrieval.toy = { byKey, size };
|
|
if (!root) return;
|
|
|
|
for (const node of byKey.values()) node.children.sort((a, b) => a.distance - b.distance);
|
|
let nextLeaf = 0;
|
|
let maxDepth = 0;
|
|
(function layout(node, depth) {
|
|
node.depth = depth;
|
|
maxDepth = Math.max(maxDepth, depth);
|
|
if (!node.children.length) {
|
|
node.slot = nextLeaf++;
|
|
return;
|
|
}
|
|
node.children.forEach((child) => layout(child, depth + 1));
|
|
node.slot = (node.children[0].slot + node.children[node.children.length - 1].slot) / 2;
|
|
})(root, 0);
|
|
|
|
const W = 960;
|
|
const rowH = 58;
|
|
const H = maxDepth * rowH + 60;
|
|
const px = (node) => 30 + (node.slot * (W - 60)) / Math.max(nextLeaf - 1, 1);
|
|
const py = (node) => 30 + node.depth * rowH;
|
|
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, class: 'pr-svg toy-svg' });
|
|
|
|
// edges under nodes
|
|
for (const node of byKey.values()) {
|
|
if (!node.parent) continue;
|
|
node.el.edge = svgNode('line', {
|
|
x1: px(node.parent), y1: py(node.parent), x2: px(node), y2: py(node), class: 'toy-edge',
|
|
}, svg);
|
|
node.el.edgeLabel = svgNode('text', {
|
|
x: (px(node.parent) + px(node)) / 2,
|
|
y: (py(node.parent) + py(node)) / 2 - 3,
|
|
class: 'toy-edge-label', 'text-anchor': 'middle',
|
|
}, svg);
|
|
node.el.edgeLabel.textContent = node.distance;
|
|
}
|
|
for (const node of byKey.values()) {
|
|
node.el.circle = svgNode('circle', { cx: px(node), cy: py(node), r: 8, class: 'toy-node' }, svg);
|
|
const hex = node.key.toString(16);
|
|
svgNode('title', {}, node.el.circle).textContent =
|
|
`${retrieval.labelOf.get(hex) || 'pool image'} · ${hex64(node.key)}`;
|
|
}
|
|
document.getElementById('toy-tree').replaceChildren(svg);
|
|
}
|
|
|
|
function setToyState(node, state) {
|
|
node.el.circle?.setAttribute('class', `toy-node ${state}`);
|
|
const edgeState = state === 'pruned' ? ' pruned' : '';
|
|
node.el.edge?.setAttribute('class', `toy-edge${edgeState}`);
|
|
node.el.edgeLabel?.setAttribute('class', `toy-edge-label${edgeState}`);
|
|
}
|
|
|
|
let toyPaintToken = 0;
|
|
|
|
async function runToyQuery(animate) {
|
|
if (!retrieval.toy || !baseHashes) return;
|
|
const token = loadToken;
|
|
const paintToken = ++toyPaintToken;
|
|
const t = Number(toySlider.value);
|
|
document.getElementById('toy-t-value').innerText = t;
|
|
const { found, visited } = await rpc('toy_query', { key: baseHashes[0], radius: t });
|
|
if (token !== loadToken || paintToken !== toyPaintToken) return;
|
|
|
|
const foundSet = new Set();
|
|
for (let i = 0; i < found.length; i += 2) foundSet.add(found[i].toString(16));
|
|
document.getElementById('toy-stats').innerText =
|
|
`compared ${visited.length} of ${retrieval.toy.size} hashes, found ${found.length / 2}`;
|
|
|
|
for (const node of retrieval.toy.byKey.values()) setToyState(node, 'pruned');
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
for (const key of visited) {
|
|
if (animate) await sleep(140);
|
|
if (paintToken !== toyPaintToken) return;
|
|
const hex = key.toString(16);
|
|
setToyState(retrieval.toy.byKey.get(hex), foundSet.has(hex) ? 'match' : 'seen');
|
|
}
|
|
}
|
|
|
|
let toyTimer = null;
|
|
toySlider.addEventListener('input', () => {
|
|
clearTimeout(toyTimer);
|
|
toyTimer = setTimeout(() => runToyQuery(false), 120);
|
|
});
|
|
document.getElementById('toy-run').addEventListener('click', () => runToyQuery(true));
|
|
|
|
// The full index: instant numbers from the precomputed profile,
|
|
// timings measured in the worker on demand
|
|
|
|
function renderBigCurve() {
|
|
const { compared } = retrieval.profile;
|
|
const size = retrieval.bigSize;
|
|
const W = 260, H = 150;
|
|
const M = { left: 40, right: 10, top: 8, bottom: 26 };
|
|
const maxT = compared.length - 1;
|
|
const x = (t) => M.left + (t * (W - M.left - M.right)) / maxT;
|
|
const y = (frac) => H - M.bottom - frac * (H - M.top - M.bottom);
|
|
|
|
const svg = svgNode('svg', { viewBox: `0 0 ${W} ${H}`, class: 'pr-svg big-curve-svg' });
|
|
for (const frac of [0, 0.5, 1]) {
|
|
svgNode('line', { x1: M.left, y1: y(frac), x2: W - M.right, y2: y(frac), class: 'pr-grid' }, svg);
|
|
const label = svgNode('text', { x: M.left - 4, y: y(frac) + 3, class: 'pr-tick', 'text-anchor': 'end' }, svg);
|
|
label.textContent = `${frac * 100}%`;
|
|
}
|
|
for (const t of [0, 4, 8, 12, 16]) {
|
|
if (t > maxT) continue;
|
|
const label = svgNode('text', { x: x(t), y: H - M.bottom + 13, class: 'pr-tick', 'text-anchor': 'middle' }, svg);
|
|
label.textContent = t;
|
|
}
|
|
const axis = svgNode('text', { x: x(maxT / 2), y: H - 2, class: 'pr-axis', 'text-anchor': 'middle' }, svg);
|
|
axis.textContent = 'radius t';
|
|
svgNode('polyline', {
|
|
points: compared.map((c, t) => `${x(t)},${y(c / size)}`).join(' '),
|
|
class: 'pr-line big-curve-line',
|
|
}, svg);
|
|
retrieval.curveDot = svgNode('circle', { r: 4, class: 'pr-dot big-curve-dot' }, svg);
|
|
retrieval.curveX = x;
|
|
retrieval.curveY = y;
|
|
document.getElementById('big-curve').replaceChildren(svg);
|
|
}
|
|
|
|
let bigTimer = null;
|
|
let bigQueryToken = 0;
|
|
|
|
async function runBigQuery() {
|
|
if (!retrieval.profile || !baseHashes) return;
|
|
const t = Number(bigSlider.value);
|
|
document.getElementById('big-t-value').innerText = t;
|
|
const { compared, found } = retrieval.profile;
|
|
const size = retrieval.bigSize;
|
|
document.getElementById('big-found').innerText = found[t].toLocaleString('en');
|
|
document.getElementById('big-found-note').innerText = `hashes within distance ${t}`;
|
|
document.getElementById('big-compared').innerText = compared[t].toLocaleString('en');
|
|
const pct = (100 * compared[t]) / size;
|
|
document.getElementById('big-times').innerText =
|
|
`${pct < 10 ? pct.toFixed(1) : Math.round(pct)}% of the tree`;
|
|
retrieval.curveDot?.setAttribute('cx', retrieval.curveX(t));
|
|
retrieval.curveDot?.setAttribute('cy', retrieval.curveY(compared[t] / size));
|
|
|
|
const token = ++bigQueryToken;
|
|
const outer = loadToken;
|
|
const data = await rpc('big_query', { key: baseHashes[0], radius: t });
|
|
if (token !== bigQueryToken || outer !== loadToken) return;
|
|
document.getElementById('big-times').innerText =
|
|
`${pct < 10 ? pct.toFixed(1) : Math.round(pct)}% of the tree · tree ${fmtTime(data.treeMs)}, scan ${fmtTime(data.scanMs)}`;
|
|
}
|
|
|
|
bigSlider.addEventListener('input', () => {
|
|
clearTimeout(bigTimer);
|
|
bigTimer = setTimeout(runBigQuery, 120);
|
|
});
|