301 lines
9.4 KiB
JavaScript
301 lines
9.4 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));
|
|
}
|
|
};
|
|
|
|
// Helpers
|
|
|
|
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
|
|
function bitGrid(container, hash, bits = 64) {
|
|
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';
|
|
container.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
// 8x8 grid of coefficient magnitudes, log scale, red negative / blue positive
|
|
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 : 215;
|
|
cell.style.background = `hsl(${hue} 70% ${15 + strength * 45}%)`;
|
|
cell.title = value.toFixed(1);
|
|
container.appendChild(cell);
|
|
}
|
|
}
|
|
|
|
function show(section) {
|
|
document.querySelectorAll('.needs-image').forEach((el) => el.classList.add('visible'));
|
|
if (section) document.getElementById(section).scrollIntoView({ behavior: 'smooth' });
|
|
}
|
|
|
|
// Demo state
|
|
let baseBuffer = null; // ArrayBuffer of the selected image
|
|
let baseUrl = null;
|
|
let baseHashes = null; // BigUint64Array [dct, median, phash]
|
|
|
|
// Pipeline demo
|
|
|
|
const imageContainers = document.getElementsByClassName('image-original');
|
|
const formImage = document.getElementById('dctimage');
|
|
|
|
function reset() {
|
|
for (const container of imageContainers) {
|
|
container.replaceChildren();
|
|
}
|
|
document.getElementById('image-resize').replaceChildren();
|
|
document.getElementById('resize').classList.remove('resize');
|
|
}
|
|
|
|
async function loadImage(buffer, url) {
|
|
reset();
|
|
baseBuffer = buffer;
|
|
baseUrl = url;
|
|
for (const container of imageContainers) {
|
|
const img = document.createElement('img');
|
|
img.src = url;
|
|
container.appendChild(img);
|
|
}
|
|
|
|
const data = await rpc('pipeline', { buffer });
|
|
baseHashes = data.hashes;
|
|
const [dct, median, phash] = data.hashes;
|
|
|
|
// resize step
|
|
const resized = document.createElement('img');
|
|
resized.src = pngUrl(data.resize8);
|
|
document.getElementById('image-resize').replaceChildren(resized);
|
|
const original = document.getElementById('resize-original').children[0];
|
|
if (original && original.width) {
|
|
original.width = original.width;
|
|
original.height = original.height;
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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);
|
|
|
|
show();
|
|
resetMutation();
|
|
runMutation();
|
|
}
|
|
|
|
formImage.addEventListener('change', () => {
|
|
const file = formImage.files[0];
|
|
if (!file) return;
|
|
file.arrayBuffer().then((buffer) => loadImage(buffer, URL.createObjectURL(file)));
|
|
});
|
|
|
|
document.getElementById('resize-button').addEventListener('click', () => {
|
|
document.getElementById('resize').classList.add('resize');
|
|
});
|
|
|
|
// Sample images, also used to seed the ranking pool
|
|
const samples = ['img/moon1.jpg', 'img/moon2.jpg', 'img/sunflower1.jpg', 'img/sunflower2.jpg'];
|
|
const sampleContainer = document.getElementById('sample-images');
|
|
for (const src of samples) {
|
|
fetch(src)
|
|
.then((response) => (response.ok ? response.arrayBuffer() : Promise.reject(response.status)))
|
|
.then((buffer) => {
|
|
const button = document.createElement('img');
|
|
button.src = src;
|
|
button.className = 'sample';
|
|
button.addEventListener('click', () => loadImage(buffer, src));
|
|
sampleContainer.appendChild(button);
|
|
addToPool(src.split('/').pop(), buffer, src);
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
// 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;
|
|
async function runMutation() {
|
|
if (!baseBuffer) return;
|
|
const kind = kindSelect.value;
|
|
const amount = amountSlider.disabled ? 0 : Number(amountSlider.value);
|
|
amountValue.innerText = amountSlider.disabled ? '' : amount;
|
|
const data = await rpc('mutate', { buffer: baseBuffer, kind, amount });
|
|
|
|
document.getElementById('compare-a').src = baseUrl;
|
|
document.getElementById('compare-b').src = pngUrl(data.png);
|
|
|
|
const names = ['dct', 'median', 'phash'];
|
|
names.forEach((name, i) => {
|
|
document.getElementById(`dist-${name}`).innerText = hamming(baseHashes[i], data.hashes[i]);
|
|
});
|
|
}
|
|
|
|
function scheduleMutation() {
|
|
clearTimeout(mutationTimer);
|
|
mutationTimer = setTimeout(runMutation, 120);
|
|
}
|
|
|
|
kindSelect.addEventListener('change', () => {
|
|
resetMutation();
|
|
scheduleMutation();
|
|
});
|
|
amountSlider.addEventListener('input', scheduleMutation);
|
|
resetMutation();
|
|
|
|
document.getElementById('compare-slider').addEventListener('input', (e) => {
|
|
document.getElementById('compare-b').style.clipPath = `inset(0 0 0 ${e.target.value}%)`;
|
|
});
|
|
|
|
// Ranking demo
|
|
|
|
const pool = []; // {name, url, hashes}
|
|
const poolContainer = document.getElementById('ranking-pool');
|
|
const resultsContainer = document.getElementById('ranking-results');
|
|
const algoSelect = document.getElementById('ranking-algo');
|
|
let queryIndex = null;
|
|
|
|
async function addToPool(name, buffer, url) {
|
|
const { hashes } = await rpc('hashes', { buffer });
|
|
const index = pool.length;
|
|
pool.push({ name, url, hashes });
|
|
|
|
const thumb = document.createElement('figure');
|
|
const img = document.createElement('img');
|
|
img.src = url;
|
|
const caption = document.createElement('figcaption');
|
|
caption.innerText = name;
|
|
thumb.append(img, caption);
|
|
thumb.addEventListener('click', () => {
|
|
queryIndex = index;
|
|
rank();
|
|
});
|
|
poolContainer.appendChild(thumb);
|
|
}
|
|
|
|
function rank() {
|
|
if (queryIndex === null) return;
|
|
const algo = Number(algoSelect.value);
|
|
const query = pool[queryIndex];
|
|
const ranked = pool
|
|
.map((entry) => ({ entry, distance: hamming(query.hashes[algo], entry.hashes[algo]) }))
|
|
.sort((a, b) => a.distance - b.distance)
|
|
.slice(0, 10);
|
|
|
|
resultsContainer.replaceChildren();
|
|
for (const { entry, distance } of ranked) {
|
|
const thumb = document.createElement('figure');
|
|
const img = document.createElement('img');
|
|
img.src = entry.url;
|
|
const caption = document.createElement('figcaption');
|
|
caption.innerText = `${distance} ${entry.name}`;
|
|
if (entry === query) thumb.className = 'query';
|
|
thumb.append(img, caption);
|
|
resultsContainer.appendChild(thumb);
|
|
}
|
|
}
|
|
|
|
algoSelect.addEventListener('change', rank);
|
|
|
|
document.getElementById('ranking-files').addEventListener('change', (e) => {
|
|
for (const file of e.target.files) {
|
|
file.arrayBuffer().then((buffer) => addToPool(file.name, buffer, URL.createObjectURL(file)));
|
|
}
|
|
});
|