Fucking around with perceptual hashes

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 [5]

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.

Isn't that a bit small?

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.

Even disregarding the challenges of trying to capture that much information in that few bits, you very quickly run into the pigeonhole principle. We are trying to put an infinite amount of pigeons into 264 pigeonholes, which is going to involve creative bookkeeping, a lot of trimmed feathers, and possibly the violation of some animal welfare laws.

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 small changes in the input. We want to be able to say that two images are the same (or very similar) when the Hamming distance d between their representations is small:

d(a,b) = popcount(ab)

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 xor and one popcnt 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.

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?

Part of it is that competing with all those big scary algorithms 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.

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.

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 base58, while keeping compatibility with most filesystems. This works because 264=1616 and 5811>264>5810.

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.

The demo image

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.

This entire demo runs in your browser. It is the original code compiled to WebAssembly, no server involved, and I never get your image.

Most samples come from the MIRFLICKR-25000 collection[2], 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.

Why a normal hash gets you nowhere

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 cryptographic hash, which is a fancy way of saying it is easy to calculate but hard to reverse*.

Cryptographic hashes find byte-identical copies of files and nothing else. Hashes built for integrity checking are engineered for the avalanche effect: 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.

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):

MD5, original

MD5, one pixel changed

Changed bits are marked red.

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!

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.

Locality sensitive hashing

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.

Unlike a cryptographic hash, LSH's are not designed to be irreversible (though they may be).

We will introduce the perceptual hashing 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.

Your image

the selected image

Hue shifted 90°

the selected image with shifted colors

distance

Unrelated photo

an unrelated photo

distance

Note that the second image has every pixel 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.

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.

The thing to note here is that distance between hashes suddenly carries meaning.

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.

Step 1: throw almost everything away

Scale down to 8x8. Fuck aspect ratio.

As it turns out, how you convert to grayscale matters. Luma weights:

Y= 0.2126R+ 0.7152G+ 0.0722B

64 pixels left, which is also our bit-budget. How can that now? What a wild coinkydink.

Your original image, compared to what the hash gets to work with:

Step 2: Median threshold

Take the median of the 64 pixels and emit one bit per pixel:

bi = [ pi > median(p) ]

1 when the condition holds, 0 otherwise. Median from Thomee et al.[1] Does a couple of things well. Brightness, gamma, and color shifts.

8×8 input

Bits

Hash

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.

Step 3: DCT

The discrete cosine transform rewrites the 8×8 thumbnail as a weighted sum of 64 fixed cosine patterns. The weights are the coefficients:

C(u,v) = α(u)α(v) 4 x=07 y=07 (pxy128) cos((2x+1)uπ16) cos((2y+1)vπ16) =

TODO: Dit is waarschijnlijk beter als het meer simpel is. Hoop van die termen doen er niet echt toe hier.

The 64 patterns

8×8 input

Your coefficients

Top-left is average brightness. Horizontal frequency increases to the right, vertical frequency increases downward.

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.

Putting it back together

The proof is that the weighted patterns sum back to the picture. Add them one at a time, in zigzag order:

the 8×8
first 8 of 64 patterns
coefficients added, in zigzag order

the hash stops reading at 36

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.

Step 4: 64 bits

Oké maar dat is dus nog steeds veel te veel data. 64 floats. ja doei. Dus we doen signs:

sk= [c~k<0]

En de rest dan op volgorde: Is het volgende patroontje sterker dan de huidige?

ok= [ |ck+1| > |ck| ]

36 + 28 = 64, qed. Dan nu het slimme. c~ 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.

Snippet:

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;

Sign mask (36)

Ordinal mask (28)

Hash

Kim (2003)[3] is de Cosine baseline in Thomee et al.[1]

Dit is zo mogelijk het enige echt unieke aan mijn oplossing. whoop.

What we remember

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:

the 8×8
rebuilt from the 64 bits

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.

Step 5: how pHash does it

32×32

Low-frequency block

Bits

Hash

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.

pHash is the work of the pHash.org project, which also hosts an online demo in the same spirit as this page. The implementation here follows the widely used imagehash recipe.

Mutations

dct median phash
Hamming distance

Distances of 4 or less are green: [motivate this with PR curves?]

The flip trick

C(u,v) (1)u C(u,v)

Magnitudes never change, so ordinal bits are flip-invariant for free. Only the signs of odd horizontal frequencies flip, and they all flip together. So normalize them against one of their own:

c~(u,v) = C(u,v) · sign(C(1,0)) u

the selected image, flippable
original flipped distance
dct
median
phash

Bits that changed under the flip are red.

Find the copy

The pool below holds 1,111 images from MIRFLICKR-25000[2], 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.

Mutate the query: or query your own image:

Query

dct

median

phash

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.

Images: the MIRFLICKR-25000 collection (Huiskes and Lew, MIR '08)[2], Creative Commons photography collected from Flickr.

PR CURVES

dct
phash
median

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.

Retrieval

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?

When are two images the same?

pick a threshold t and call everything at distance t 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.

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).

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.

Skipping most of the work

The obvious retrieval algorithm is a linear scan: compare the query against all N stored hashes, keep everything within t. 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".

The Burkhard-Keller tree[6] 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 d. A match can only hide in a subtree whose label lies between dt and d+t, that is the triangle inequality doing its thing. Recurse into the surviving subtrees, ignore the rest forever.

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.

At small t the search drops straight into the branch where the copies cluster and skips most stranger branches without computing a single distance in them. Raise t and the band d±t widens, fewer branches get pruned, and the search slowly degrades back into visiting everyone.

Does it scale?

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.

In the tree

distinct hashes

Found

Compared

Compared vs radius

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.

At t=4 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 xor and one popcnt per hash is about the kindest thing you can do to a CPU, while the tree spends its savings on hopping through pointers. 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. 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.

Do we expect it to be perfect?

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.

References

  1. B. Thomee, M. Huiskes, E. Bakker, M. Lew. Large scale image copy detection evaluation. MIR '08. The mutation taxonomy and the Median and Cosine baselines come from here.
  2. M. Huiskes, M. Lew. The MIR Flickr retrieval evaluation. MIR '08. press.liacs.nl/mirflickr
  3. C. Kim. Content-based image copy detection. Signal Processing: Image Communication, 2003. Ordinal measures over DCT coefficients.
  4. M. Charikar. Similarity estimation techniques from rounding algorithms. STOC '02. The random hyperplane bound.
  5. pHash.org and the Python imagehash library.
  6. W. Burkhard, R. Keller. Some approaches to best-match file searching. Communications of the ACM, 1973. The BK-tree.

Everything on this page is Rust compiled to WebAssembly plus plain JS and CSS. Source: link to repo.