Files
image-similarity/web/index.html
T

1309 lines
61 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<title>Fucking around with perceptual hashes</title>
<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" />
<script src="highlight/highlight.min.js"></script>
</head>
<body>
<div id="bg"><img src="bg.jpeg" alt="" /></div>
<div id="wrapper">
<article>
<h1>Fucking around with perceptual hashes</h1>
<p>
Quite some time ago I started a research project: Near-copy
detection of images using 64-bit image descriptors. For
various reasons, some personal and some technical, this
research project never saw the light of day. One of the
technical reasons is that I discovered halfway in that my
best (and only) original idea mostly already existed under
the name pHash
<sup class="fn"><a href="#ref-phash">[5]</a></sup>
</p>
<p>
I finally decided to get all of this out of my system and
write it up as a little blog post. So here it is: Perceptual
hashes, what is it, how they work, and what I fucked around
with.
</p>
<aside class="explainer">
The literature calls this object a <em>descriptor</em>,
<em>fingerprint</em>, <em>signature</em> or
<em>perceptual hash</em> depending on the context and
apparently the position of the planets, and calls the task
<em>image copy detection</em>,
<em>digital multimedia security</em>,
<em>near-duplicate detection</em>, or
<em>perceptual hashing</em>. Simply put: putting images that
are perceptually identical into the same bucket.
</aside>
<h2>Isn't that a <em>bit</em> small?</h2>
<p>
Putting an image into a 64-bit string is quite the
challenge. A typical image can vary from a couple hundred
KiB to several MiB. Putting all that in a 64-bit value is
like compressing an image to ~0.0005% of its original size.
</p>
<p>
Even disregarding the challenges of trying to capture that
much information in that few bits, you very quickly run into
the
<a href="https://en.wikipedia.org/wiki/Pigeonhole_principle"
>pigeonhole principle</a
>. We are trying to put an infinite amount of pigeons into
2<sup>64</sup> pigeonholes, which is going to involve
creative bookkeeping, a lot of trimmed feathers, and
possibly the violation of some animal welfare laws.
</p>
<p>
Our saving grace is that we don't really need to reconstruct
the original image from our 64-bit string representation.
What we want is some representation of the image that is
going to be resistant to <em>small</em> changes in the
input. We want to be able to say that two images are the
same (or very similar) when the Hamming distance
<math><mi>d</mi></math> between their representations is
small:
</p>
<math display="block">
<mrow>
<mi>d</mi><mo>(</mo><mi>a</mi><mo>,</mo><mi>b</mi
><mo>)</mo>
<mo>=</mo>
<mi>popcount</mi><mo>(</mo><mi>a</mi><mo></mo><mi>b</mi
><mo>)</mo>
</mrow>
</math>
<p>
I.e. how many bits are different between a and b? The
advantage of this simple distance function is that one
compare is a one <code>xor</code> and one
<code>popcnt</code> on x86. Depending on your CPU that means
you can easily compare upwards of a million of these pairs
per second in a single thread.
</p>
<aside class="explainer">
<code>popcnt</code> was added as an operation to x86 in
SSE4.2, which has been around for quite a while. The story
on ARM is
<a
href="https://developer.arm.com/documentation/du-102264/latest"
>a little more complicated</a
>, but the point is that the distance calculation is cheap.
</aside>
<p>
Why limit ourselves when currently there are datacenters
floating around the earth that are processing terabytes of
data per second to generate a picture of a kitten falling
over?
</p>
<p>
Part of it is that competing with
<a href="https://doi.org/10.1145/3727880"
>all those big scary algorithms</a
>
is too intimidating for me, but also working around such a
constraint is fun for me, as well as that I like my software
to be efficient. More seriously, every machine language,
database, and OS has their own corresponding primitive
64-bit value (usually some type of integer). This makes a
64-bit hash a natural fit for almost any system or algorithm
that might want to use it.
</p>
<p>
Comparing two hashes costs a couple of instructions. A
million images index into 8 MB of RAM, and the whole thing
can run on your phone, an old netbook or anything else you
can find.
</p>
<p>
The hash is even small enough to use as a filename. 64
characters in a binary string converts to 16 characters hex
or 11 characters in
<a href="https://en.wikipedia.org/wiki/Base58">base58</a>,
while keeping compatibility with most filesystems. This
works because
<math
><mi>2<sup>64</sup></mi
>=<mi>16<sup>16</sup></mi></math
>
and
<math
><mi>58<sup>11</sup></mi
>><mi>2<sup>64</sup></mi
>><mi>58<sup>10</sup></mi></math
>.
</p>
<p>
That covers comparing two images. Searching a whole
collection with one query is its own problem, with its own
clever data structure, and gets its own chapter near the
bottom, once we have built a hash worth searching with.
</p>
</article>
<article id="demo">
<h2>The demo image</h2>
<p>
Every demo on this page runs on one image, live in your
browser. A sample is preselected. Swap in another sample or
one of your own whenever you like. Selecting a new image
will recalculate all examples in this demo with the new
image.
</p>
<p>
This entire demo runs in your browser. It is the original
code compiled to WebAssembly, no server involved, and I
never get your image.
</p>
<p><span id="sample-images"></span></p>
<p>
<input
type="file"
id="dctimage"
accept="image/png, image/jpeg"
/>
</p>
<p class="attribution">
Most samples come from the MIRFLICKR-25000 collection<sup
class="fn"
><a href="#ref-mirflickr">[2]</a></sup
>, the same set used for the experiments at the bottom. The
first (and default) image is one I took myself of my cat
Spook, who was the best boy ever.
</p>
<div id="base">
<div class="image-original"></div>
</div>
</article>
<article id="demo-md5" class="needs-image">
<h2>Why a normal hash gets you nowhere</h2>
<p>
The naive approach is to use any standard hash function that
you would normally use to validate file equality. One of the
most well-known examples is MD5, which is a
<em>cryptographic hash</em>, which is a fancy way of saying
it is easy to calculate but hard to reverse*.
</p>
<p>
Cryptographic hashes find byte-identical copies of files and
nothing else. Hashes built for integrity checking are
engineered for the <em>avalanche effect</em>: flip one input
bit and every output bit flips with probability one half.
Avalanche is exactly what you want when verifying a download
or storing a secret, and exactly what you do not want when
looking for pictures.
</p>
<p>
To demonstrate this, the above image was altered slightly:
we did +1 to the red channel of the single pixel at the
center. A change so small that on a typical image without a
magnifying glass you cannot see it. MD5 outputs a 128
"digest". Here are the first 64 bits of the original and
altered images' MD5 digest (as 16 characters of hex):
</p>
<div class="hash-panel">
<div>
<h3>MD5, original</h3>
<div id="md5-bits-a" class="bit-grid"></div>
<code id="md5-hex-a" class="hash-value small"></code>
</div>
<div>
<h3>MD5, one pixel changed</h3>
<div id="md5-bits-b" class="bit-grid"></div>
<code id="md5-hex-b" class="hash-value small"></code>
</div>
<div>
<p>Changed bits are marked red.</p>
<p class="big-number" id="md5-diff"></p>
<p>
For every change, the changed output bit is an
independent coin flip, so we expect around 32
changed bits from the original. If you got a result
that significantly differs from 32 changed bits, you
might have gotten (un)lucky!
</p>
</div>
</div>
<p>
Any operation on the original image, such as a
recompression, adjustment of the metadata, or the changing
of a single pixel, will completely change the hash value.
</p>
<aside class="explainer">
*MD5 is cryptographically broken and should sign nothing,
but it remains the first tool programmers reach for when
deduplicating files, which is the job we are trying to do
better. Other (better) hashes display the same avalanching
properties.
</aside>
</article>
<article id="demo-lsh" class="needs-image">
<h2>Locality sensitive hashing</h2>
<p>
Hashes that don't display this avalanche behavior are called
locality-sensitive hashes (LSH). It is still a hash
function, i.e. it maps some arbitrary input domain to a
fixed-size output domain, but by some measure of similarity
the function clusters similar input.
</p>
<p>
Unlike a cryptographic hash, LSH's are not designed to be
irreversible (though they may be).
</p>
<p>
We will introduce the <em>perceptual hashing</em> algorithm
that I developed in the next steps, but as a preview
example, here is that perceptual hash applied to three
images. The first image is the original, the second is the
same image "hue rotated", and the third is an unrelated
photo.
</p>
<div class="hash-panel">
<div>
<h3>Your image</h3>
<div class="lsh-thumb">
<img id="lsh-img-a" alt="the selected image" />
</div>
<div id="lsh-bits-a" class="bit-grid"></div>
</div>
<div>
<h3>Hue shifted 90&deg;</h3>
<div class="lsh-thumb">
<img
id="lsh-img-b"
alt="the selected image with shifted colors"
/>
</div>
<div id="lsh-bits-b" class="bit-grid"></div>
<p class="lsh-distance">
distance <span id="lsh-d-b"></span>
</p>
</div>
<div>
<h3>Unrelated photo</h3>
<div class="lsh-thumb">
<img id="lsh-img-c" alt="an unrelated photo" />
</div>
<div id="lsh-bits-c" class="bit-grid"></div>
<p class="lsh-distance">
distance <span id="lsh-d-c"></span>
</p>
</div>
</div>
<p>
Note that the second image has
<em>every pixel</em> different from the original, something
the md5 would definitely see as a change. But our perceptual
hash treats it as almost the same image. Depending on your
input image you should expect an exact match or at least a
very small distance.
</p>
<p>
For the unrelated image we can see the same type of
difference as we were getting before, depending on the
visual similarity to the input image we would expect at
least upwards of 10 bits difference, but more likely about
half of the total 64 bits again.
</p>
<p>
The thing to note here is that distance between hashes
suddenly carries meaning.
</p>
<p>
So how do you build a perceptual hash? Every bit is a yes/no
question about the image. The art is picking questions whose
answers survive "mutation". We will discuss mutation in a
later chapter, but it can be any operation on the image that
preserves the perceptual similarity: recompression,
rotation, mirroring, resizing, color grading, contrast
adjustment, and more.
</p>
</article>
<article id="demo-resize" class="needs-image">
<h2>Step 1: throw almost everything away</h2>
<p>Scale down to 8x8. Fuck aspect ratio.</p>
<p>
As it turns out, how you convert to grayscale matters. Luma
weights:
</p>
<math display="block">
<mrow>
<mi>Y</mi><mo>=</mo> <mn>0.2126</mn><mi>R</mi><mo>+</mo>
<mn>0.7152</mn><mi>G</mi><mo>+</mo> <mn>0.0722</mn
><mi>B</mi>
</mrow>
</math>
<p>
64 pixels left, which is also our bit-budget. How can that
now? What a wild coinkydink.
</p>
<p>
Your original image, compared to what the hash gets to work
with:
</p>
<div
class="compare"
id="resize-compare"
data-label-a="original"
data-label-b="8&times;8"
></div>
</article>
<article id="demo-median" class="needs-image">
<h2>Step 2: Median threshold</h2>
<p>
Take the median of the 64 pixels and emit one bit per pixel:
</p>
<math display="block">
<mrow>
<msub><mi>b</mi><mi>i</mi></msub>
<mo>=</mo>
<mo>[</mo>
<msub><mi>p</mi><mi>i</mi></msub>
<mo>&gt;</mo>
<mi>median</mi><mo>(</mo><mi>p</mi><mo>)</mo>
<mo>]</mo>
</mrow>
</math>
<p>
1 when the condition holds, 0 otherwise. Median from Thomee
et al.<sup class="fn"><a href="#ref-thomee">[1]</a></sup>
Does a couple of things well. Brightness, gamma, and color
shifts.
</p>
<div class="hash-panel">
<div>
<h3>8&times;8 input</h3>
<div id="median-input" class="input-thumb"></div>
</div>
<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>
The bits are tied to pixel positions, so a mirrored copy
scrambles them completely. See the shitty flip recall below.
Also unrelated images might share this course layout.
Imagine that many landscape fotographs might have the same
general silhouette.
</p>
</article>
<article id="demo-dct" class="needs-image">
<h2>Step 3: DCT</h2>
<p>
The discrete cosine transform rewrites the 8&times;8
thumbnail as a weighted sum of 64 fixed cosine patterns. The
weights are the coefficients:
</p>
<math display="block" id="dct-formula">
<mrow>
<mi>C</mi><mo>(</mo><mi class="var-u">u</mi><mo>,</mo
><mi class="var-v">v</mi><mo>)</mo>
<mo>=</mo>
<mfrac>
<mrow>
<mi>α</mi><mo>(</mo><mi class="var-u">u</mi
><mo>)</mo><mi>α</mi><mo>(</mo
><mi class="var-v">v</mi><mo>)</mo>
</mrow>
<mn>4</mn>
</mfrac>
<munderover
><mo></mo
><mrow><mi>x</mi><mo>=</mo><mn>0</mn></mrow
><mn>7</mn></munderover
>
<munderover
><mo></mo
><mrow><mi>y</mi><mo>=</mo><mn>0</mn></mrow
><mn>7</mn></munderover
>
<mo>(</mo
><msub
><mi>p</mi><mrow><mi>x</mi><mi>y</mi></mrow></msub
><mo></mo><mn>128</mn><mo>)</mo>
<mrow class="cos-h"
><mi>cos</mi
><mrow
><mo>(</mo
><mfrac
><mrow
><mo>(</mo><mn>2</mn><mi>x</mi><mo>+</mo
><mn>1</mn><mo>)</mo
><mi class="var-u">u</mi
><mi>π</mi></mrow
><mn>16</mn></mfrac
><mo>)</mo></mrow
></mrow
>
<mrow class="cos-v"
><mi>cos</mi
><mrow
><mo>(</mo
><mfrac
><mrow
><mo>(</mo><mn>2</mn><mi>y</mi><mo>+</mo
><mn>1</mn><mo>)</mo
><mi class="var-v">v</mi
><mi>π</mi></mrow
><mn>16</mn></mfrac
><mo>)</mo></mrow
></mrow
>
<mrow class="formula-result"
><mo>=</mo><mn class="formula-value"></mn
></mrow>
</mrow>
</math>
<p>
TODO: Dit is waarschijnlijk beter als het meer simpel is.
Hoop van die termen doen er niet echt toe hier.
</p>
<aside class="explainer">
In plain words: each coefficient answers
<em>how much of this pattern is in my 8&times;8 block?</em>
for one of the 64 patterns below. Multiply block and pattern
pixel by pixel and sum, that is the whole formula. Strongly
positive (green): the block looks like the pattern. Strongly
negative (red): like its inverse. Near zero: that pattern is
simply not present. Hover a pattern to see the formula
filled in, with the coefficient it produces for your image.
Try a coefficient that is strongly green, and determine for
yourself if that corresponding pattern is in the original
image.
</aside>
<div class="hash-panel">
<div>
<h3>The 64 patterns</h3>
<div id="dct-basis-wrap">
<canvas
id="dct-basis"
width="216"
height="216"
></canvas>
</div>
</div>
<div>
<h3>8&times;8 input</h3>
<div id="dct-input" class="input-thumb"></div>
</div>
<div>
<h3>Your coefficients</h3>
<div class="grid-overlay">
<div id="image-dct" class="heat-grid"></div>
<svg
id="dct-zigzag"
viewBox="0 0 256 256"
width="256"
height="256"
></svg>
</div>
<p>
<button id="zigzag-replay" style="display: none">
Trace the zigzag
</button>
</p>
</div>
</div>
<p>
Top-left is average brightness. Horizontal frequency
increases to the right, vertical frequency increases
downward.
</p>
<p>
One of the key observations of JPEG image compression is
that typically speaking most images have most of their
"energy" concentrated in the lower-frequency patterns. That
is why the zigzag ordering is so effective: it effectively
gives a MSB ordering of the coefficients. After that, JPEG
uses a quantization table to reduce the higher-frequency
components to less bits. For our case, we can just discard
the LSBs.
</p>
<h3>Putting it back together</h3>
<p>
The proof is that the weighted patterns sum back to the
picture. Add them one at a time, in zigzag order:
</p>
<div class="recon-row">
<figure>
<div id="recon-original" class="input-thumb"></div>
<figcaption>the 8&times;8</figcaption>
</figure>
<figure>
<canvas id="recon-canvas" width="8" height="8"></canvas>
<figcaption>
first <span id="recon-count">8</span> of 64 patterns
</figcaption>
</figure>
<figure>
<div class="grid-overlay mini">
<div id="recon-grid" class="heat-grid mini"></div>
<svg
id="recon-zigzag"
viewBox="0 0 176 176"
width="176"
height="176"
></svg>
</div>
<figcaption>
coefficients added, in zigzag order
</figcaption>
</figure>
</div>
<p>
<button id="recon-play">Animate</button>
<input
type="range"
id="recon-k"
min="1"
max="64"
value="8"
/>
<span class="recon-note">the hash stops reading at 36</span>
</p>
<p>
Note that the general shape of the picture comes quite
quickly with the lower frequency components. The higher
frequency components simply refine existing shapes and
edges. That is why cutting the zigzag at 36 loses so little:
the animation pauses there, and the second half barely
changes the picture.
</p>
</article>
<article id="demo-bits" class="needs-image">
<h2>Step 4: 64 bits</h2>
<p>
Oké maar dat is dus nog steeds veel te veel data. 64 floats.
ja doei. Dus we doen signs:
</p>
<math display="block">
<mrow>
<msub><mi>s</mi><mi>k</mi></msub
><mo>=</mo> <mo>[</mo
><msub
><mover><mi>c</mi><mo>~</mo></mover
><mi>k</mi></msub
><mo>&lt;</mo><mn>0</mn><mo>]</mo>
</mrow>
</math>
<p>
En de rest dan op volgorde: Is het volgende patroontje
sterker dan de huidige?
</p>
<math display="block">
<mrow>
<msub><mi>o</mi><mi>k</mi></msub
><mo>=</mo>
<mo>[</mo>
<mrow
><mo>|</mo
><msub
><mi>c</mi
><mrow
><mi>k</mi><mo>+</mo><mn>1</mn></mrow
></msub
><mo>|</mo></mrow
>
<mo>&gt;</mo>
<mrow
><mo>|</mo><msub><mi>c</mi><mi>k</mi></msub
><mo>|</mo></mrow
>
<mo>]</mo>
</mrow>
</math>
<p>
36 + 28 = 64, qed. Dan nu het slimme.
<math
><mover><mi>c</mi><mo>~</mo></mover></math
>
als normalisatie stap. We gaan altijd uit van dat het eerste
patroon niet geinverteerd is. Als ie dat wel is draaien we
hem alsnog om. Dus dan maakt het niet uit of de patroontjes
omgedraaid zijn of niet.
</p>
<p>Snippet:</p>
<pre><code class="language-rust">let flip = dct[1].signum(); // sign of C(1,0)
for &amp;i in ZIGZAG.iter().take(36) {
let mut sign = dct[i].signum();
if i % 2 != 0 { sign *= flip } // odd horizontal frequency
sign_mask = sign_mask &lt;&lt; 1 | (sign &lt; 0.0) as u64;
}
for pair in ZIGZAG[..=28].windows(2) {
let bigger = dct[pair[1]].abs() &gt; dct[pair[0]].abs();
ordinal_mask = ordinal_mask &lt;&lt; 1 | bigger as u64;
}
let hash = sign_mask &lt;&lt; 28 | ordinal_mask;</code></pre>
<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>
<p>
Kim (2003)<sup class="fn"><a href="#ref-kim">[3]</a></sup>
is de Cosine baseline in Thomee et al.<sup class="fn"
><a href="#ref-thomee">[1]</a></sup
>
</p>
<p>
Dit is zo mogelijk het enige echt unieke aan mijn oplossing.
whoop.
</p>
<h3>What we remember</h3>
<p>
We can reconstruct the original 8x8 image from these values.
We store no values, only signs and cardinality. Still, we
can make a rough approximation of the 8x8 starting point:
</p>
<div class="recon-row">
<figure>
<div id="ghost-original" class="input-thumb"></div>
<figcaption>the 8&times;8</figcaption>
</figure>
<figure>
<canvas id="ghost-canvas" width="8" height="8"></canvas>
<figcaption>rebuilt from the 64 bits</figcaption>
</figure>
</div>
<p>
A ghost, but a recognizable one, out of 8 bytes. One quirk:
because of the flip normalization the bits genuinely cannot
tell left from right, so the ghost sometimes comes out
mirrored.
</p>
<p>
<button id="ghost-flip">Flip it back</button>
</p>
</article>
<article id="demo-phash" class="needs-image">
<h2>Step 5: how pHash does it</h2>
<p>
<!--- pHash<sup class="fn"><a href="#ref-phash">[5]</a></sup>
reaches frequency space along a different route: scale to
32&times;32 instead of 8&times;8, run the DCT, keep only the
top-left 8&times;8 block of low-frequency coefficients, and
compare each one against the median of the block. 64 bits
again. --->
</p>
<div class="hash-panel">
<div>
<h3>32&times;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>
main difference is the mid frequencies that get saved. same
idea, different execution. makes it better on rotations and logo
insertions. improvements to dct possible.
<!---
<table class="facts">
<tr>
<th></th>
<th>dct (this page)</th>
<th>phash</th>
</tr>
<tr>
<th>front end</th>
<td>8&times;8 box filter</td>
<td>32&times;32 box filter</td>
</tr>
<tr>
<th>transform</th>
<td>8&times;8 DCT</td>
<td>32&times;32 DCT, top 8&times;8 block kept</td>
</tr>
<tr>
<th>bit extraction</th>
<td>36 signs + 28 ordinals</td>
<td>64 comparisons against block median</td>
</tr>
<tr>
<th>flip invariant</th>
<td>yes</td>
<td>no</td>
</tr>
</table> --->
<p class="attribution">
pHash is the work of the
<a href="https://www.phash.org/">pHash.org</a> project,
which also hosts an
<a href="https://www.phash.org/demo/">online demo</a> in the
same spirit as this page. The implementation here follows
the widely used
<a href="https://github.com/JohannesBuchner/imagehash"
>imagehash</a
>
recipe.
</p>
</article>
<article id="demo-mutate" class="needs-image">
<h2>Mutations</h2>
<p>
<select id="mutator-kind"></select>
<input type="range" id="mutator-amount" />
<span id="mutator-amount-value"></span>
</p>
<div
class="compare"
id="mutate-compare"
data-label-a="original"
data-label-b="mutated"
></div>
<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>
Distances of 4 or less are green: [motivate this with PR
curves?]
</p>
</article>
<article id="demo-flip" class="needs-image">
<h2>The flip trick</h2>
<math display="block">
<mrow>
<mi>C</mi><mo>(</mo><mi>u</mi><mo>,</mo><mi>v</mi
><mo>)</mo>
<mo></mo>
<msup
><mrow
><mo>(</mo><mo></mo><mn>1</mn><mo>)</mo></mrow
><mi>u</mi></msup
>
<mi>C</mi><mo>(</mo><mi>u</mi><mo>,</mo><mi>v</mi
><mo>)</mo>
</mrow>
</math>
<p>
Magnitudes never change, so ordinal bits are flip-invariant
for free. Only the signs of odd horizontal frequencies flip,
and they all flip <em>together</em>. So normalize them
against one of their own:
</p>
<math display="block">
<mrow>
<mover><mi>c</mi><mo>~</mo></mover
><mo>(</mo><mi>u</mi><mo>,</mo><mi>v</mi><mo>)</mo>
<mo>=</mo>
<mi>C</mi><mo>(</mo><mi>u</mi><mo>,</mo><mi>v</mi
><mo>)</mo>
<mo>·</mo>
<msup>
<mrow
><mi>sign</mi><mo>(</mo><mi>C</mi><mo>(</mo
><mn>1</mn><mo>,</mo><mn>0</mn><mo>)</mo
><mo>)</mo></mrow
>
<mi>u</mi>
</msup>
</mrow>
</math>
<p>
<button id="flip-button">Flip it</button>
</p>
<div id="flip-stage">
<img id="flip-image" alt="the selected image, flippable" />
</div>
<table class="flip-table">
<tr>
<th></th>
<th>original</th>
<th>flipped</th>
<th>distance</th>
</tr>
<tr>
<th>dct</th>
<td>
<div id="flip-a-dct" class="bit-grid small"></div>
</td>
<td>
<div id="flip-b-dct" class="bit-grid small"></div>
</td>
<td id="flip-d-dct" class="flip-distance"></td>
</tr>
<tr>
<th>median</th>
<td>
<div
id="flip-a-median"
class="bit-grid small"
></div>
</td>
<td>
<div
id="flip-b-median"
class="bit-grid small"
></div>
</td>
<td id="flip-d-median" class="flip-distance"></td>
</tr>
<tr>
<th>phash</th>
<td>
<div id="flip-a-phash" class="bit-grid small"></div>
</td>
<td>
<div id="flip-b-phash" class="bit-grid small"></div>
</td>
<td id="flip-d-phash" class="flip-distance"></td>
</tr>
</table>
<p>Bits that changed under the flip are red.</p>
</article>
<article id="demo-ranking">
<h2>Find the copy</h2>
<p>
The pool below holds 1,111 images from MIRFLICKR-25000<sup
class="fn"
><a href="#ref-mirflickr">[2]</a></sup
>, hashed offline from the full-size originals. Click any
image to make it the query, or upload your own. Each method
then returns its ten nearest neighbours by Hamming distance.
</p>
<p>
Mutate the query:
<select id="ranking-mutation"></select>
<span>or query your own image:</span>
<input type="file" id="ranking-file" accept="image/*" />
</p>
<div id="ranking-pool" class="pool"></div>
<div class="ranking-results">
<div>
<h3>Query</h3>
<div id="ranking-query" class="thumb-row"></div>
</div>
<div>
<h3>dct</h3>
<div id="results-dct" class="thumb-row"></div>
</div>
<div>
<h3>median</h3>
<div id="results-median" class="thumb-row"></div>
</div>
<div>
<h3>phash</h3>
<div id="results-phash" class="thumb-row"></div>
</div>
</div>
<p>
no mutation should always yield d 0, as the algorithms are
deterministic. with mutation you can see that some
algorithms still find the original for some mutations. try
to figure out which algorithms are invariant to which
changes.
</p>
<p class="attribution">
Images: the
<a href="https://press.liacs.nl/mirflickr/"
>MIRFLICKR-25000</a
>
collection (Huiskes and Lew, MIR '08)<sup class="fn"
><a href="#ref-mirflickr">[2]</a></sup
>, Creative Commons photography collected from Flickr.
</p>
</article>
<article id="at-scale">
<h2>PR CURVES</h2>
<div class="pr-controls">
<div id="pr-legend"></div>
<label class="pr-slider"
>threshold t = <span id="pr-t-value">4</span>
<input
type="range"
id="pr-t"
min="0"
max="23"
value="4"
/>
</label>
</div>
<div class="pr-charts">
<figure>
<figcaption>dct</figcaption>
<div id="pr-chart-dct"></div>
</figure>
<figure>
<figcaption>phash</figcaption>
<div id="pr-chart-phash"></div>
</figure>
<figure>
<figcaption>median</figcaption>
<div id="pr-chart-median"></div>
</figure>
</div>
<div id="pr-tooltip" hidden></div>
<p class="attribution">
Rendered live from the 25,000-image run of 2026-07-04; line
style marks the mutation category. The dots mark the
slider's threshold. Data: pr-data.json, extracted from the
run log by pr_to_json.py.
</p>
</article>
<article id="retrieval" class="needs-image">
<h2>Retrieval</h2>
<p>
Comparing two is nice, but ranking is the real deal. Two
questions: When do we declare two hashes to be the same
image? And how do we collect that subset without comparing
the query against every hash we have?
</p>
<h3>When are two images the same?</h3>
<p>
pick a threshold <math><mi>t</mi></math> and call everything
at distance <math><mi>t</mi></math> or less the same image.
your image against two groups: its own 16 mutated copies
from the experiment suite, and the 1,111 unrelated pool
images from the ranking demo.
</p>
<figure class="dist-figure">
<div id="dist-chart"></div>
<figcaption>
Hamming distance from your image to its 16 mutated
copies (green, hover for the mutation) and to the 1,111
pool images (gray bars, square-root count scale).
</figcaption>
</figure>
<p>
<label class="pr-slider"
>threshold t = <span id="dist-t-value">4</span>
<input
type="range"
id="dist-t"
min="0"
max="32"
value="4"
/>
</label>
<span id="dist-readout" class="dist-readout"></span>
</p>
<p>
The two groups keep a comfortable distance from each other.
Copies near zero, strangers pile up just under half of the 64
bits*.
In between the kingdom of mutated hits and the people's republic of unrelated images is the eehm.. the.. Federated Islands of false positives.
When a mutation goes too far, the hash distance increases and the fingerprint gets banished from the kingdom and might end up in the republic of strangers.
Set your threshold too high and you start catching some of these strangers.
The tradeoff then is how serious you want to be about it: do you accept some false positives or would you rather get false negatives?
It all depends on what you might use these copy-detectors for.
If it is the first step in reducing an image set in order for the Big Guns to take over, you might be more willing to accept false positives.
If you want to quickly see if a given image is already in your folder of holiday pictures, you might not mind a false negative or two, but would rather not have a false positive.
The threshold is what makes this tradeoff, and the PR-curve is where it shows.
</p>
<aside class="explainer">
*This is not just random data. These are real photos of real things.
As such, these things tend to share certain features.
This means that two unrelated images aren't *fully* unrelated just by virtue of
photographs being taken in the same way sometimes and the general way that the world is.
or something.
</aside>
<h3>Skipping most of the work</h3>
<p>
The obvious retrieval algorithm is a linear scan: compare
the query against all <math><mi>N</mi></math> stored hashes,
keep everything within <math><mi>t</mi></math
>. At a million comparisons per second per thread that is
genuinely fine for a while. But the cost grows with every
stored image and is paid again on every query, and "compare
against everything" should offend you a little when the
answer is almost always "no".
</p>
<p>
The
<a href="https://en.wikipedia.org/wiki/BK-tree"
>Burkhard-Keller tree</a
><sup class="fn"><a href="#ref-bktree">[6]</a></sup> fixes
this for any metric distance, and Hamming distance is one.
Pick any stored hash as the root. Every other hash goes into
a subtree based on its distance to the root: all hashes at
distance 7 from the root share subtree 7, and inside each
subtree the same rule repeats. To search, compare the query
to the root, giving some distance <math><mi>d</mi></math
>. A match can only hide in a subtree whose label lies
between
<math
><mrow><mi>d</mi><mo></mo><mi>t</mi></mrow></math
>
and
<math
><mrow><mi>d</mi><mo>+</mo><mi>t</mi></mrow></math
>, that is the triangle inequality doing its thing. Recurse
into the surviving subtrees, ignore the rest forever.
</p>
<p class="toy-controls">
<label class="pr-slider"
>radius t = <span id="toy-t-value">4</span>
<input
type="range"
id="toy-t"
min="0"
max="16"
value="4"
/>
</label>
<button id="toy-run">Animate the search</button>
<span id="toy-stats" class="toy-stats"></span>
</p>
<figure class="toy-figure">
<div id="toy-tree"></div>
<figcaption>
A BK-tree over the 16 mutant hashes of your image plus
16 pool strangers. Mutants with identical hashes
collapse into one node, so the tree is usually smaller
than 32. Edge labels are distances to the parent. Query:
your image's hash. Green: within the radius. Outlined:
compared, too far. Dimmed: pruned, never even looked at.
</figcaption>
</figure>
<p>
At small <math><mi>t</mi></math> the search drops straight
into the branch where the copies cluster and skips most
stranger branches without computing a single distance in
them. Raise <math><mi>t</mi></math> and the band
<math
><mrow><mi>d</mi><mo>±</mo><mi>t</mi></mrow></math
>
widens, fewer branches get pruned, and the search slowly
degrades back into visiting everyone.
</p>
<h3>Does it scale?</h3>
<p>
The toy tree has 32 hashes, the experiment above produced
425,000 (25,000 images, 16 mutants each). Those collapse to
239,689 distinct hashes, identical images simply share one
entry. Below, all of them sit in a BK-tree in your browser's
memory, and your image queries it live.
</p>
<div class="hash-panel big-stats">
<div>
<h3>In the tree</h3>
<p class="big-number" id="big-size"></p>
<p class="stat-note">distinct hashes</p>
</div>
<div>
<h3>Found</h3>
<p class="big-number" id="big-found"></p>
<p class="stat-note" id="big-found-note"></p>
</div>
<div>
<h3>Compared</h3>
<p class="big-number" id="big-compared"></p>
<p class="stat-note" id="big-times"></p>
</div>
<div>
<h3>Compared vs radius</h3>
<div id="big-curve"></div>
</div>
</div>
<p>
<label class="pr-slider"
>radius t = <span id="big-t-value">4</span>
<input
type="range"
id="big-t"
min="0"
max="16"
value="4"
/>
</label>
</p>
<p>
Query with one of the MIRFLICKR samples from the strip at
the top and its mutants come right back. Query with Spook
and nothing comes back, because my cat is not among the
25,000.
</p>
<p>
At
<math
><mrow><mi>t</mi><mo>=</mo><mn>4</mn></mrow></math
>
the tree answers after touching a few percent of the hashes,
roughly a 30&times; saving in comparisons. Now look at the
stopwatch: the dumb scan is still competitive, and at wide
radii it wins outright. Marching sequentially through memory
doing one <code>xor</code> and one <code>popcnt</code> per
hash is about the kindest thing you can do to a CPU, while
the tree spends its savings on hopping through pointers.
<span class="rewrite-target"
>The comparisons saved only turn into time saved when
the collection outgrows this demo by an order of
magnitude or two, since the scan grows linearly and the
visited slice of the tree does not.</span
>
Also note how quickly the pruning decays in the curve:
perceptual hashes cluster, so a wide radius keeps almost
every branch alive. A BK-tree only earns its keep at small
radii, which is conveniently the only place our threshold
wants to be.
</p>
<h3>Do we expect it to be perfect?</h3>
<p>
No, and it does not have to be. The histogram showed copies
that drift out of reach and the occasional stranger inside
the radius, the precision-recall curves put numbers on both.
If a wrong answer is expensive, treat the whole thing as a
preselection filter: the hash plus BK-tree reduces 425,000
candidates to a handful in microseconds, and whatever
heavyweight comparison you actually trust (full-resolution
diffing, feature matching, a neural embedding, a human) only
runs on that handful. A cheap filter in front of an
expensive judge is a classic setup, and a 64-bit hash is
about the cheapest filter there is.
</p>
</article>
<!--- <article>
<h2>Loose ends</h2>
<ul class="loose-ends">
<li>
Vertical flips and 180° rotations fall to the same
normalization applied to odd <em>vertical</em>
frequencies against C(0,1), at the price of one more
constant bit.
</li>
<li>
The obvious hybrid: my bit extraction on pHash's
32&times;32 front end. It should inherit the rotation
and logo robustness while keeping flip invariance.
Unmeasured so far.
</li>
<li>
Sign bits of near-zero coefficients are coin flips. A
dead zone around zero, or weighting bits by coefficient
stability, might recover the last percent of recall.
</li>
<li>
Crops move every coefficient at once and need a
different tool entirely: overlapping tiles, or local
keypoints.
</li>
<li>
Video is tempting: hash keyframes, or temporally
averaged frames, or go 3D-DCT.
</li>
<li>
The original itch: a daemon that hashes every image my
machine ever displays and answers
<em>have I seen this before, and where?</em>
</li>
</ul>
</article> --->
<article id="references">
<h2>References</h2>
<ol class="references">
<li id="ref-thomee">
B. Thomee, M. Huiskes, E. Bakker, M. Lew.
<em>Large scale image copy detection evaluation.</em>
MIR '08. The mutation taxonomy and the Median and Cosine
baselines come from here.
</li>
<li id="ref-mirflickr">
M. Huiskes, M. Lew.
<em>The MIR Flickr retrieval evaluation.</em> MIR '08.
<a href="https://press.liacs.nl/mirflickr/"
>press.liacs.nl/mirflickr</a
>
</li>
<li id="ref-kim">
C. Kim. <em>Content-based image copy detection.</em>
Signal Processing: Image Communication, 2003. Ordinal
measures over DCT coefficients.
</li>
<li id="ref-charikar">
M. Charikar.
<em
>Similarity estimation techniques from rounding
algorithms.</em
>
STOC '02. The random hyperplane bound.
</li>
<li id="ref-phash">
<a href="https://www.phash.org/">pHash.org</a> and the
Python
<a href="https://github.com/JohannesBuchner/imagehash"
>imagehash</a
>
library.
</li>
<li id="ref-bktree">
W. Burkhard, R. Keller.
<em>Some approaches to best-match file searching.</em>
Communications of the ACM, 1973. The BK-tree.
</li>
</ol>
<p class="rewrite-target">
Everything on this page is Rust compiled to WebAssembly plus
plain JS and CSS. Source: link to repo.
</p>
</article>
</div>
<script type="module" src="script.js"></script>
</body>
</html>