@@ -0,0 +1,10 @@
|
||||
/* Fallback palette for when the wal-generated colors.css is absent.
|
||||
colors.css loads after this file and overrides everything here. */
|
||||
:root {
|
||||
--background: #0f1017;
|
||||
--foreground: #e3e2da;
|
||||
--color1: #c9a227;
|
||||
--color2: #b0552f;
|
||||
--color3: #97742d;
|
||||
--color4: #85902c;
|
||||
}
|
||||
+144
-26
@@ -2,6 +2,7 @@
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta content="text/html;charset=utf-8" http-equiv="Content-Type"/>
|
||||
<link rel="stylesheet" href="colors-default.css" type="text/css">
|
||||
<link rel="stylesheet" href="colors.css" type="text/css">
|
||||
<link rel="stylesheet" href="style.css" type="text/css">
|
||||
<link rel="stylesheet" href="/highlight.css">
|
||||
@@ -12,18 +13,19 @@
|
||||
<article>
|
||||
<h1>Fucking around with perceptual hashes</h1>
|
||||
<h2>Introduction</h2>
|
||||
<p class="todo">TODO: why this project exists, the master's thesis that wasn't, discovering pHash after the fact.</p>
|
||||
<p class="todo">TODO: the rules of the game: 64 bits per image, hamming distance, nothing else.</p>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h1>Perceptual hashing</h1>
|
||||
<h2>Locality-sensitive hashing</h2>
|
||||
<h3>Subjective image similarity</h3>
|
||||
</article>
|
||||
<article>
|
||||
<h1>Experimental setup</h1>
|
||||
<h2>Mutators</h2>
|
||||
<h2>Descriptor</h2>
|
||||
<h2>Experiments</h2>
|
||||
<p class="todo">TODO: cryptographic hash vs perceptual hash. One flipped pixel: md5 avalanches, a perceptual hash shrugs.</p>
|
||||
<h2>Terminology</h2>
|
||||
<p class="todo">TODO: descriptor / fingerprint / perceptual hash / signature all mean roughly the same thing depending on which corner of the literature you are standing in. Copy detection vs near-duplicate detection vs LSH.</p>
|
||||
<h2>Subjective image similarity</h2>
|
||||
<p class="todo">TODO: what "the same image" even means. Same pixels? Same scene? Same vibe?</p>
|
||||
</article>
|
||||
|
||||
<article id="demo">
|
||||
<h1>Demo</h1>
|
||||
<p>Select an image to run this demo with. Don't worry, nothing will be sent to any server! All calculations are done in the browser.</p>
|
||||
@@ -32,12 +34,15 @@
|
||||
type="file"
|
||||
id="dctimage"
|
||||
accept="image/*" />
|
||||
<span>or pick a sample:</span>
|
||||
<span id="sample-images"></span>
|
||||
<div id="base">
|
||||
<div class="image-original"></div>
|
||||
</div>
|
||||
</article>
|
||||
<article id="demo-resize">
|
||||
<h2>Resize</h2>
|
||||
|
||||
<article id="demo-resize" class="needs-image">
|
||||
<h2>Step 1: Resize</h2>
|
||||
<p>
|
||||
The first step is to size the image down, and remove all color information.
|
||||
Converting the image to grayscale is done by simply averaging the pixels.
|
||||
@@ -58,22 +63,135 @@
|
||||
<div id="image-resize"></div>
|
||||
</div>
|
||||
</article>
|
||||
<article id="demo-dct">
|
||||
<h2>DCT</h2>
|
||||
<p>What is DCT?</p>
|
||||
<p>Frequency domain. Plaatje. Bla bla.</p>
|
||||
<div id="image-dct"></div>
|
||||
<div class="image-original" id="dct-original"></div>
|
||||
<p>Mauris semper ipsum libero. Praesent laoreet massa sagittis enim consequat malesuada. Nullam viverra nibh sit amet lacus volutpat sollicitudin. Nullam lacus sem, commodo ut vulputate nec, consectetur in erat. Quisque eget nunc ac felis condimentum ultrices eu sit amet arcu. Praesent imperdiet faucibus aliquam. Curabitur sit amet faucibus erat. </p>
|
||||
<p>Nam pulvinar, mi id sagittis laoreet, risus dolor pellentesque metus, sed mattis nunc erat sed urna. Ut facilisis, velit vel condimentum euismod, ex ante dictum leo, lobortis feugiat eros sem nec velit. Aliquam quis eros lacus. Duis venenatis purus at luctus tempor. Praesent gravida euismod ante, tristique placerat tortor mattis molestie. Duis viverra ex eget lectus tempus consectetur. Aliquam semper, ligula at molestie dapibus, sapien turpis rutrum massa, ac tristique lorem ex eget neque. Donec vel turpis odio. </p>
|
||||
<p>Mauris semper ipsum libero. Praesent laoreet massa sagittis enim consequat malesuada. Nullam viverra nibh sit amet lacus volutpat sollicitudin. Nullam lacus sem, commodo ut vulputate nec, consectetur in erat. Quisque eget nunc ac felis condimentum ultrices eu sit amet arcu. Praesent imperdiet faucibus aliquam. Curabitur sit amet faucibus erat. </p>
|
||||
<p>Mauris semper ipsum libero. Praesent laoreet massa sagittis enim consequat malesuada. Nullam viverra nibh sit amet lacus volutpat sollicitudin. Nullam lacus sem, commodo ut vulputate nec, consectetur in erat. Quisque eget nunc ac felis condimentum ultrices eu sit amet arcu. Praesent imperdiet faucibus aliquam. Curabitur sit amet faucibus erat. </p>
|
||||
<p>Mauris semper ipsum libero. Praesent laoreet massa sagittis enim consequat malesuada. Nullam viverra nibh sit amet lacus volutpat sollicitudin. Nullam lacus sem, commodo ut vulputate nec, consectetur in erat. Quisque eget nunc ac felis condimentum ultrices eu sit amet arcu. Praesent imperdiet faucibus aliquam. Curabitur sit amet faucibus erat. </p>
|
||||
<p>Mauris semper ipsum libero. Praesent laoreet massa sagittis enim consequat malesuada. Nullam viverra nibh sit amet lacus volutpat sollicitudin. Nullam lacus sem, commodo ut vulputate nec, consectetur in erat. Quisque eget nunc ac felis condimentum ultrices eu sit amet arcu. Praesent imperdiet faucibus aliquam. Curabitur sit amet faucibus erat. </p>
|
||||
<p>Nam pulvinar, mi id sagittis laoreet, risus dolor pellentesque metus, sed mattis nunc erat sed urna. Ut facilisis, velit vel condimentum euismod, ex ante dictum leo, lobortis feugiat eros sem nec velit. Aliquam quis eros lacus. Duis venenatis purus at luctus tempor. Praesent gravida euismod ante, tristique placerat tortor mattis molestie. Duis viverra ex eget lectus tempus consectetur. Aliquam semper, ligula at molestie dapibus, sapien turpis rutrum massa, ac tristique lorem ex eget neque. Donec vel turpis odio. </p>
|
||||
<p>Nam pulvinar, mi id sagittis laoreet, risus dolor pellentesque metus, sed mattis nunc erat sed urna. Ut facilisis, velit vel condimentum euismod, ex ante dictum leo, lobortis feugiat eros sem nec velit. Aliquam quis eros lacus. Duis venenatis purus at luctus tempor. Praesent gravida euismod ante, tristique placerat tortor mattis molestie. Duis viverra ex eget lectus tempus consectetur. Aliquam semper, ligula at molestie dapibus, sapien turpis rutrum massa, ac tristique lorem ex eget neque. Donec vel turpis odio. </p>
|
||||
<p>Nam pulvinar, mi id sagittis laoreet, risus dolor pellentesque metus, sed mattis nunc erat sed urna. Ut facilisis, velit vel condimentum euismod, ex ante dictum leo, lobortis feugiat eros sem nec velit. Aliquam quis eros lacus. Duis venenatis purus at luctus tempor. Praesent gravida euismod ante, tristique placerat tortor mattis molestie. Duis viverra ex eget lectus tempus consectetur. Aliquam semper, ligula at molestie dapibus, sapien turpis rutrum massa, ac tristique lorem ex eget neque. Donec vel turpis odio. </p>
|
||||
<p>Nam pulvinar, mi id sagittis laoreet, risus dolor pellentesque metus, sed mattis nunc erat sed urna. Ut facilisis, velit vel condimentum euismod, ex ante dictum leo, lobortis feugiat eros sem nec velit. Aliquam quis eros lacus. Duis venenatis purus at luctus tempor. Praesent gravida euismod ante, tristique placerat tortor mattis molestie. Duis viverra ex eget lectus tempus consectetur. Aliquam semper, ligula at molestie dapibus, sapien turpis rutrum massa, ac tristique lorem ex eget neque. Donec vel turpis odio. </p>
|
||||
|
||||
<article id="demo-median" class="needs-image">
|
||||
<h2>Step 2: The simplest hash that could possibly work</h2>
|
||||
<p class="todo">TODO: median hash: one bit per pixel, brighter than the median or not. This is the "Median" baseline from Thomee et al.</p>
|
||||
<div class="hash-panel">
|
||||
<div>
|
||||
<h3>Bits</h3>
|
||||
<div id="median-bits" class="bit-grid"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Hash</h3>
|
||||
<code id="median-hash" class="hash-value"></code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="todo">TODO: why this breaks: global median shifts, flips scramble everything. Foreshadow the moon/plate anecdote.</p>
|
||||
</article>
|
||||
|
||||
<article id="demo-dct" class="needs-image">
|
||||
<h2>Step 3: Frequency space</h2>
|
||||
<p class="todo">TODO: what the DCT does. JPEG uses the same trick. Low frequencies = global shape, high frequencies = detail we already threw away.</p>
|
||||
<div class="hash-panel">
|
||||
<div>
|
||||
<h3>DCT coefficients</h3>
|
||||
<div id="image-dct" class="heat-grid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="todo">TODO: reading the coefficient grid: top-left is the average, first row is horizontal waves, first column vertical waves.</p>
|
||||
<h3>From coefficients to bits</h3>
|
||||
<p class="todo">TODO: sign bits of the first 36 zigzag coefficients + 28 ordinal comparisons between neighbours in zigzag order. The odd-column signs get multiplied by the sign of coefficient (1,0), which buys horizontal flip invariance for one bit.</p>
|
||||
<div class="hash-panel">
|
||||
<div>
|
||||
<h3>Sign mask (36)</h3>
|
||||
<div id="dct-sign-bits" class="bit-grid wide"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Ordinal mask (28)</h3>
|
||||
<div id="dct-ordinal-bits" class="bit-grid wide"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Hash</h3>
|
||||
<code id="dct-hash" class="hash-value"></code>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="demo-phash" class="needs-image">
|
||||
<h2>Step 4: How pHash does it</h2>
|
||||
<p class="todo">TODO: same idea, different route: resize to 32x32 instead of 8x8, DCT, keep the top-left 8x8 block of low frequencies, threshold against the median coefficient.</p>
|
||||
<div class="hash-panel">
|
||||
<div>
|
||||
<h3>32×32</h3>
|
||||
<div id="phash-resize"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Low-frequency block</h3>
|
||||
<div id="phash-lowfreq" class="heat-grid"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Bits</h3>
|
||||
<div id="phash-bits" class="bit-grid"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Hash</h3>
|
||||
<code id="phash-hash" class="hash-value"></code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="todo">TODO: what's genuinely different between our dct hash and phash (bit extraction, flip invariance) and what isn't (everything else).</p>
|
||||
</article>
|
||||
|
||||
<article id="demo-mutate" class="needs-image">
|
||||
<h2>Mutations</h2>
|
||||
<p class="todo">TODO: a copy is rarely byte-identical. Recoding, resampling, content processing, framing, inserted logos (the Thomee et al. taxonomy). Sweep the slider and watch which hash survives what.</p>
|
||||
<p>
|
||||
<select id="mutator-kind"></select>
|
||||
<input type="range" id="mutator-amount" />
|
||||
<span id="mutator-amount-value"></span>
|
||||
</p>
|
||||
<div id="mutate-compare" class="compare">
|
||||
<img id="compare-a" alt="original" />
|
||||
<img id="compare-b" alt="mutated" />
|
||||
</div>
|
||||
<p>
|
||||
<input type="range" id="compare-slider" min="0" max="100" value="50" />
|
||||
</p>
|
||||
<table class="distances">
|
||||
<tr><th></th><th>dct</th><th>median</th><th>phash</th></tr>
|
||||
<tr>
|
||||
<th>hamming distance</th>
|
||||
<td id="dist-dct"></td>
|
||||
<td id="dist-median"></td>
|
||||
<td id="dist-phash"></td>
|
||||
</tr>
|
||||
</table>
|
||||
<p class="todo">TODO: what counts as "the same" now? Thresholds. pHash uses 22 of 64 bits, we will measure our own.</p>
|
||||
</article>
|
||||
|
||||
<article id="demo-ranking">
|
||||
<h2>Find the copy</h2>
|
||||
<p class="todo">TODO: the fun part: a tiny search engine. Add a pile of images, click one, get the nearest neighbours per algorithm. Mention the white plate that matched the moon.</p>
|
||||
<p>
|
||||
<input type="file" id="ranking-files" accept="image/*" multiple />
|
||||
<select id="ranking-algo">
|
||||
<option value="0">dct</option>
|
||||
<option value="1">median</option>
|
||||
<option value="2">phash</option>
|
||||
</select>
|
||||
</p>
|
||||
<div id="ranking-pool" class="thumb-grid"></div>
|
||||
<h3>Results</h3>
|
||||
<div id="ranking-results" class="thumb-grid"></div>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>At scale</h2>
|
||||
<p class="todo">TODO: the browser demo is anecdote, this section is data. 25k images, 16 mutations each, precision-recall over the hamming threshold.</p>
|
||||
<figure>
|
||||
<img src="" alt="PR curves for the dct hash" />
|
||||
<figcaption class="todo">TODO: dct-pr.png from the experiment run</figcaption>
|
||||
</figure>
|
||||
<figure>
|
||||
<img src="" alt="PR curves for median and phash" />
|
||||
<figcaption class="todo">TODO: median-pr.png and phash-pr.png</figcaption>
|
||||
</figure>
|
||||
<p class="todo">TODO: how the PR curves are computed, what a false positive means here, where the thresholds land per algorithm.</p>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2>Loose ends</h2>
|
||||
<p class="todo">TODO: vertical flips and 180 rotations (same trick, one more bit). Coefficient stability near zero. Video: keyframes vs temporally averaged frames vs 3D-DCT. The seen-images daemon idea. Sorting a folder by visual similarity.</p>
|
||||
</article>
|
||||
</div>
|
||||
<script type="module" src="script.js"></script>
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import init, { dctify, resize } from './pkg/image_similarity.js';
|
||||
|
||||
async function run() {
|
||||
await init();
|
||||
}
|
||||
run();
|
||||
|
||||
onmessage = (e) => {
|
||||
let resizedBuffer = resize(e.data);
|
||||
postMessage(resizedBuffer);
|
||||
};
|
||||
+283
-63
@@ -1,80 +1,300 @@
|
||||
import init, { dctify, resize } from './pkg/image_similarity.js';
|
||||
// Plumbing for the demos. All hashing happens in the worker (wasm).
|
||||
|
||||
async function run() {
|
||||
await init();
|
||||
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 });
|
||||
});
|
||||
}
|
||||
run();
|
||||
const resizeWorker = new Worker("resize.js", { type: 'module' });
|
||||
|
||||
let imageContainers = document.getElementsByClassName("image-original");
|
||||
let resizedImageContainer = document.getElementById("image-resize");
|
||||
let dctCoefficientContainer = document.getElementById("image-dct");
|
||||
let formImage = document.getElementById("dctimage");
|
||||
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));
|
||||
}
|
||||
};
|
||||
|
||||
let resizedBuffer = null;
|
||||
// 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');
|
||||
|
||||
// Resets all containers, deletes images, etc
|
||||
// Redefines the event handlers for the base image
|
||||
function reset() {
|
||||
//imageContainers.forEach((imageContainer) => {
|
||||
for (let imageContainer of imageContainers) {
|
||||
imageContainer.replaceChildren();
|
||||
for (const container of imageContainers) {
|
||||
container.replaceChildren();
|
||||
}
|
||||
resizedImageContainer.replaceChildren();
|
||||
dctCoefficientContainer.replaceChildren();
|
||||
document.getElementById("resize").classList.remove("resize");
|
||||
document.getElementById('image-resize').replaceChildren();
|
||||
document.getElementById('resize').classList.remove('resize');
|
||||
}
|
||||
|
||||
// Uses the resized buffer to get DCT coefficients
|
||||
function getDCT() {
|
||||
let buf = new Uint8Array(resizedBuffer)
|
||||
let dct = dctify(buf);
|
||||
|
||||
for (const c of dct) {
|
||||
let cdiv = document.createElement("div");
|
||||
cdiv.innerHTML = c.toFixed(1);
|
||||
dctCoefficientContainer.appendChild(cdiv);
|
||||
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();
|
||||
}
|
||||
|
||||
// Result from the resize worker, means we get resized buffer
|
||||
resizeWorker.onmessage = function (e) {
|
||||
console.log(e.data);
|
||||
resizedBuffer = e.data.buffer;
|
||||
const resizedImage = document.createElement("img");
|
||||
resizedImage.src = URL.createObjectURL(
|
||||
new Blob([resizedBuffer], { type: 'image/png' })
|
||||
);
|
||||
resizedImageContainer.appendChild(resizedImage);
|
||||
|
||||
// Set the width of the image explictly to help with the animation
|
||||
let image = document.getElementById("resize-original").children[0];
|
||||
image.width = image.width;
|
||||
image.height = image.height;
|
||||
}
|
||||
|
||||
document.getElementById("resize-button").addEventListener("click", function() {
|
||||
document.getElementById("resize").classList.add("resize");
|
||||
formImage.addEventListener('change', () => {
|
||||
const file = formImage.files[0];
|
||||
if (!file) return;
|
||||
file.arrayBuffer().then((buffer) => loadImage(buffer, URL.createObjectURL(file)));
|
||||
});
|
||||
|
||||
// User selected an image from disk. Start the demo.
|
||||
formImage.addEventListener("change", function() {
|
||||
reset();
|
||||
// Should only get one file from picker
|
||||
for (const file of formImage.files) {
|
||||
let originalUrl = URL.createObjectURL(file);
|
||||
for (let imageContainer of imageContainers) {
|
||||
let originalImage = document.createElement("img");
|
||||
originalImage.src = originalUrl;
|
||||
imageContainer.appendChild(originalImage);
|
||||
}
|
||||
document.getElementById('resize-button').addEventListener('click', () => {
|
||||
document.getElementById('resize').classList.add('resize');
|
||||
});
|
||||
|
||||
file.arrayBuffer().then((buf) => {
|
||||
buf = new Uint8Array(buf);
|
||||
resizeWorker.postMessage(buf);
|
||||
});
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Show the rest of the demo.
|
||||
document.getElementById("demo-resize").classList.add("visible");
|
||||
});
|
||||
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)));
|
||||
}
|
||||
});
|
||||
|
||||
+140
-6
@@ -2,7 +2,7 @@ body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
color: var(--foreground);
|
||||
background: url('bg.jpeg');
|
||||
background: var(--background) url('bg.jpeg');
|
||||
background-size: cover;
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
@@ -75,12 +75,12 @@ img {
|
||||
|
||||
/* Demo */
|
||||
|
||||
#demo-resize, #demo-dct {
|
||||
visibility: hidden;
|
||||
.needs-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#demo-resize.visible, #demo-dct.visible {
|
||||
visibility: visible;
|
||||
.needs-image.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#base .image-original img {
|
||||
@@ -209,4 +209,138 @@ figure {
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
/* Skeleton */
|
||||
|
||||
.todo {
|
||||
opacity: 0.55;
|
||||
font-style: italic;
|
||||
border-left: 3px solid var(--color3);
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
/* Hash visualisation */
|
||||
|
||||
.hash-panel {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 24px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.hash-value {
|
||||
font-size: 18px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.bit-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 20px);
|
||||
}
|
||||
|
||||
.bit-grid.wide {
|
||||
grid-template-columns: repeat(12, 20px);
|
||||
}
|
||||
|
||||
.bit-grid .bit {
|
||||
height: 20px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--background);
|
||||
}
|
||||
|
||||
.bit-grid .bit.on {
|
||||
background: var(--foreground);
|
||||
}
|
||||
|
||||
.bit-grid .bit.off {
|
||||
background: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.heat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 32px);
|
||||
}
|
||||
|
||||
.heat-grid > div {
|
||||
height: 32px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.pixelated, #image-resize img, #phash-resize img {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
}
|
||||
|
||||
#phash-resize img {
|
||||
width: 128px;
|
||||
height: 128px;
|
||||
}
|
||||
|
||||
.sample {
|
||||
height: 48px;
|
||||
margin: 0 4px;
|
||||
cursor: pointer;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Mutation compare */
|
||||
|
||||
.compare {
|
||||
position: relative;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.compare img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.compare #compare-b {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
clip-path: inset(0 0 0 50%);
|
||||
}
|
||||
|
||||
#compare-slider, #mutator-amount {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
.distances td {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
min-width: 64px;
|
||||
color: var(--color1);
|
||||
}
|
||||
|
||||
/* Ranking */
|
||||
|
||||
.thumb-grid {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.thumb-grid figure {
|
||||
width: 96px;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.thumb-grid figure.query {
|
||||
outline: 2px solid var(--color1);
|
||||
}
|
||||
|
||||
.thumb-grid img {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-grid figcaption {
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import init, { hash_all, resize_preview, dct_coefficients, dct_masks, phash_lowfreq, mutate } from './pkg/image_similarity.js';
|
||||
|
||||
const ready = init();
|
||||
|
||||
onmessage = async (e) => {
|
||||
const { id, op, args } = e.data;
|
||||
await ready;
|
||||
try {
|
||||
const bytes = new Uint8Array(args.buffer);
|
||||
let result;
|
||||
switch (op) {
|
||||
case 'pipeline':
|
||||
result = {
|
||||
resize8: resize_preview(bytes, 8),
|
||||
resize32: resize_preview(bytes, 32),
|
||||
coefficients: dct_coefficients(bytes),
|
||||
masks: dct_masks(bytes),
|
||||
lowfreq: phash_lowfreq(bytes),
|
||||
hashes: hash_all(bytes),
|
||||
};
|
||||
break;
|
||||
case 'hashes':
|
||||
result = { hashes: hash_all(bytes) };
|
||||
break;
|
||||
case 'mutate': {
|
||||
const png = mutate(bytes, args.kind, args.amount);
|
||||
result = { png, hashes: hash_all(png) };
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown op ${op}`);
|
||||
}
|
||||
postMessage({ id, ok: true, result });
|
||||
} catch (err) {
|
||||
postMessage({ id, ok: false, error: String(err) });
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user