1309 lines
61 KiB
HTML
1309 lines
61 KiB
HTML
<!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°</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×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>></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×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×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×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×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×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><</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>></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 &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 << 1 | (sign < 0.0) as u64;
|
||
}
|
||
for pair in ZIGZAG[..=28].windows(2) {
|
||
let bigger = dct[pair[1]].abs() > dct[pair[0]].abs();
|
||
ordinal_mask = ordinal_mask << 1 | bigger as u64;
|
||
}
|
||
let hash = sign_mask << 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×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×32 instead of 8×8, run the DCT, keep only the
|
||
top-left 8×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×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×8 box filter</td>
|
||
<td>32×32 box filter</td>
|
||
</tr>
|
||
<tr>
|
||
<th>transform</th>
|
||
<td>8×8 DCT</td>
|
||
<td>32×32 DCT, top 8×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× 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×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>
|