How to Detect Duplicate and Near-Duplicate Images With Perceptual Hashing in Python
Learn how to detect near-duplicate images by building average, difference, and perceptual hashing from scratch in Python, verified bit for bit against the imagehash library.
If you have ever built an image gallery, a content moderation queue, a scraped-photo archive, or a screenshot-testing pipeline, you have run into the same problem: the same picture keeps showing up more than once, but never as an exact byte-for-byte copy. It gets re-saved as a JPEG at a different quality setting, resized into a thumbnail, cropped by a few pixels, or stamped with a watermark. A regular file hash like MD5 or SHA-256 says these are completely unrelated files, because cryptographic hashes are designed to change completely when even a single bit changes. That is exactly the wrong property when what you actually want to know is “does this look like a picture I already have?”
Table Of Content
- What You Will Build
- Prerequisites
- Step 1: Set Up an Isolated Environment
- Step 2: Prove to Yourself That Cryptographic Hashes Are the Wrong Tool
- Step 3: Generate Reproducible Test Images
- Step 4: Build Average Hash (aHash) From Scratch
- Step 5: Build Difference Hash (dHash) From Scratch
- Step 6: Build Perceptual Hash (pHash) From Scratch, and a Real Bug You Will Probably Hit
- Cross-Checking Against the Reference Library Exposes a Real Bug
- Step 7: Measure What Each Algorithm Is Actually Sensitive To
- Step 8: Build a Real Duplicate Finder
- Tuning the Threshold Is a Real Tradeoff, Not a Constant
- Common Mistakes and Gotchas
- Comparing Hashes of Different Sizes Raises an Error
- Comparing Different Hash Algorithms Fails Silently
- None of These Algorithms Handle Rotation or Mirroring
- Threshold Selection Needs Real Data, Not a Guess
- Verifying It All Works End to End
- Next Steps
This tutorial teaches perceptual hashing: a family of algorithms that turn an image into a short fingerprint that stays similar when the image is resized, recompressed, or lightly edited, and changes a lot when the image is genuinely different. You will build three perceptual hash algorithms from scratch in Python (average hash, difference hash, and perceptual hash), verify your from-scratch code against the widely used imagehash library bit for bit, and finish with a working command-line tool that scans a folder and groups near-duplicate images together.
Every command and code sample below was run end to end in a real, isolated Python virtual environment while writing this tutorial, including a genuine bug that only showed up when comparing against the reference library. That bug, along with a few easy-to-miss mistakes covered in the “Common Mistakes and Gotchas” section, is called out explicitly, because these are the kind of thing you are likely to hit if you implement this yourself.
What You Will Build
By the end of this tutorial you will have:
- A small
phash_lib.pymodule implementing average hash, difference hash, and perceptual hash from scratch using only Pillow and NumPy - Proof that your implementation produces bit-for-bit identical output to the
imagehashreference library - A command-line tool,
find_duplicates.py, that scans a directory of images and groups near-duplicates by Hamming distance - A working understanding of what each algorithm is actually sensitive to (resizing, compression, cropping, brightness, watermarks, and rotation), backed by real measurements instead of guesses
Prerequisites
- A working Python 3.10 or later installation with
pip. This tutorial was written and tested against Python 3.13.14 on Windows, but the code is plain Python and runs the same way on macOS or Linux. - Comfort with basic Python (functions, loops, reading command-line arguments). No prior image-processing or signal-processing background is assumed; the math you need is explained as you go.
- No GPU, no external API keys, and no paid services. Everything in this tutorial runs locally.
Step 1: Set Up an Isolated Environment
Keep this experiment out of your system Python by creating a virtual environment first. Open a terminal and run:
python -m venv venv
venv\Scripts\activate # Windows
# source venv/bin/activate # macOS/Linux
pip install Pillow numpy scipy imagehash
Here is what each package is for:
- Pillow opens images and resizes them, which every hash algorithm below depends on as its first step.
- NumPy gives you fast array math for comparing pixel grids and computing bit patterns.
- scipy supplies a Discrete Cosine Transform (DCT) implementation, which the perceptual hash algorithm in Step 5 needs.
- imagehash is a well-established, widely deployed reference implementation. You will use it only to check your own code, not to replace it. Building the algorithms yourself is what makes the “why” stick.
Confirm everything installed correctly:
python -c "import PIL, numpy, scipy, imagehash; print('PIL', PIL.__version__); print('numpy', numpy.__version__); print('scipy', scipy.__version__)"
PIL 12.3.0
numpy 2.5.2
scipy 1.18.0
Step 2: Prove to Yourself That Cryptographic Hashes Are the Wrong Tool
Before building anything, it helps to see the actual failure this tutorial exists to fix. Cryptographic hashes like SHA-256 are built around the avalanche effect: changing a single bit of input should flip roughly half the output bits, on purpose, so the hash can not be used to infer anything about similarity. That is a great property for verifying a file was not tampered with. It is a terrible property for asking “is this the same picture, roughly?”
Save the following as step1_crypto_hash_fails.py. It hashes a base image alongside a few variants of it: an exact copy, a resized-and-back version, and a JPEG re-save at high quality.
"""Show that cryptographic hashes treat near-identical images as totally unrelated."""
import hashlib
import os
OUT = "images"
def sha256_of(path):
with open(path, "rb") as f:
return hashlib.sha256(f.read()).hexdigest()
files = ["base.png", "exact_copy.png", "resized.png", "jpeg_q95.jpg", "brighter.png"]
for f in files:
h = sha256_of(os.path.join(OUT, f))
print(f"{f:16s} sha256={h[:16]}...")
Run it:
python step1_crypto_hash_fails.py
base.png sha256=db5efcdd1c05d270...
exact_copy.png sha256=db5efcdd1c05d270...
resized.png sha256=d6200be117f81678...
jpeg_q95.jpg sha256=50904ef496de2135...
brighter.png sha256=49ecb6a2ad01a035...
Only the byte-for-byte exact copy shares a hash with the original. The resized version, the JPEG re-save at 95 percent quality (visually almost indistinguishable from the original), and a brightness tweak all produce completely unrelated hashes. If you were using SHA-256 to catch duplicate uploads, every one of those would sail right through as “new.” That is the gap perceptual hashing fills.
Step 3: Generate Reproducible Test Images
To measure how each algorithm behaves, you need a base image and a known set of transformations applied to it: resizing, recompression at several quality levels, cropping, brightness changes, a watermark, and a 90-degree rotation, plus one genuinely different image as a negative control. Using synthetic images (drawn in code rather than downloaded) means anyone following along gets the exact same pixels and results, with no licensing questions and no dependency on a photo you happen to have on hand.
Save this as make_test_images.py:
"""Generate a base test image plus a set of transformed variants."""
from PIL import Image, ImageDraw, ImageFilter
import os
import random
random.seed(42)
OUT = "images"
os.makedirs(OUT, exist_ok=True)
def make_base(path, size=(800, 600)):
img = Image.new("RGB", size)
px = img.load()
w, h = size
# Diagonal gradient background for real per-pixel variation.
for y in range(h):
for x in range(w):
r = int(255 * (x / w))
g = int(255 * (y / h))
b = int(255 * ((x + y) / (w + h)))
px[x, y] = (r, g, b)
draw = ImageDraw.Draw(img)
# A handful of solid shapes so edges exist for dHash gradients to catch.
draw.ellipse([100, 100, 350, 350], fill=(20, 20, 20))
draw.rectangle([450, 120, 700, 300], fill=(240, 240, 240))
draw.polygon([(400, 400), (600, 550), (250, 520)], fill=(10, 90, 160))
for _ in range(400):
x, y = random.randint(0, w - 1), random.randint(0, h - 1)
px[x, y] = (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255))
img = img.filter(ImageFilter.GaussianBlur(0.6))
img.save(path, "PNG")
return img
def make_different(path, size=(800, 600)):
img = Image.new("RGB", size, (30, 30, 30))
draw = ImageDraw.Draw(img)
for i in range(0, size[0], 40):
draw.line([(i, 0), (i, size[1])], fill=(200, 30, 30), width=6)
draw.ellipse([550, 50, 780, 280], fill=(250, 220, 40))
img.save(path, "PNG")
return img
base = make_base(os.path.join(OUT, "base.png"))
print("base.png size:", base.size, "mode:", base.mode)
# 1. Exact byte-for-byte copy
base.save(os.path.join(OUT, "exact_copy.png"), "PNG")
# 2. Resized down then back up (common thumbnail path)
small = base.resize((200, 150), Image.LANCZOS)
resized = small.resize((800, 600), Image.LANCZOS)
resized.save(os.path.join(OUT, "resized.png"), "PNG")
# 3. Re-saved as JPEG at a few quality levels (lossy recompression)
base.save(os.path.join(OUT, "jpeg_q95.jpg"), "JPEG", quality=95)
base.save(os.path.join(OUT, "jpeg_q50.jpg"), "JPEG", quality=50)
base.save(os.path.join(OUT, "jpeg_q10.jpg"), "JPEG", quality=10)
# 4. Slightly cropped (2% off each edge, then resized back to original dims)
w, h = base.size
crop_box = (int(w * 0.02), int(h * 0.02), int(w * 0.98), int(h * 0.98))
cropped = base.crop(crop_box).resize((w, h), Image.LANCZOS)
cropped.save(os.path.join(OUT, "cropped.png"), "PNG")
# 5. Brightness-shifted
from PIL import ImageEnhance
brighter = ImageEnhance.Brightness(base).enhance(1.35)
brighter.save(os.path.join(OUT, "brighter.png"), "PNG")
# 6. Watermarked (small text stamp in a corner, rest of image unchanged)
watermarked = base.copy()
d = ImageDraw.Draw(watermarked)
d.rectangle([600, 560, 800, 600], fill=(0, 0, 0))
d.text((610, 568), "SAMPLE WATERMARK", fill=(255, 255, 255))
watermarked.save(os.path.join(OUT, "watermarked.png"), "PNG")
# 7. Rotated 90 degrees (a known weak spot for these hash families)
rotated = base.rotate(90, expand=True).resize((w, h), Image.LANCZOS)
rotated.save(os.path.join(OUT, "rotated_90.png"), "PNG")
# 8. A genuinely different image
make_different(os.path.join(OUT, "different.png"))
for f in sorted(os.listdir(OUT)):
p = os.path.join(OUT, f)
print(f, os.path.getsize(p), "bytes")
Run it:
python make_test_images.py
base.png size: (800, 600) mode: RGB
base.png 46789 bytes
brighter.png 52745 bytes
cropped.png 54724 bytes
different.png 3808 bytes
exact_copy.png 46789 bytes
jpeg_q10.jpg 9646 bytes
jpeg_q50.jpg 14075 bytes
jpeg_q95.jpg 53438 bytes
resized.png 109110 bytes
rotated_90.png 56887 bytes
watermarked.png 47801 bytes
You now have an images/ folder with a base image and nine variants. Every measurement in the rest of this tutorial is computed from these exact files, so your numbers will match the ones shown here.
Step 4: Build Average Hash (aHash) From Scratch
Average hash is the simplest perceptual hash, and it is a good place to build intuition. The idea:
- Shrink the image down to a tiny grid, 8×8 pixels by default, throwing away high-frequency detail on purpose.
- Convert to grayscale, so color shifts do not affect the result.
- Compute the average brightness of those 64 pixels.
- Record one bit per pixel: 1 if that pixel is brighter than the average, 0 if it is darker or equal.
The result is a 64-bit fingerprint. Two images that look alike, even after mild edits, tend to keep most of those 64 bits the same, because shrinking to 8×8 wipes out fine detail (JPEG artifacts, sensor noise) while keeping the coarse light-and-dark layout intact.
Create phash_lib.py and start with this function:
from PIL import Image
import numpy as np
from scipy.fft import dct
def _load_gray(path_or_img):
img = path_or_img if isinstance(path_or_img, Image.Image) else Image.open(path_or_img)
return img.convert("L")
def average_hash(path_or_img, hash_size=8):
img = _load_gray(path_or_img).resize((hash_size, hash_size), Image.LANCZOS)
pixels = np.asarray(img, dtype=np.float64)
avg = pixels.mean()
bits = pixels > avg
return bits.flatten()
_load_gray accepts either a file path or an already-open Pillow Image, which keeps the rest of the code from re-opening the same file repeatedly. .convert("L") is Pillow’s grayscale mode. The comparison pixels > avg produces a NumPy boolean array in one step: no explicit loop needed, since NumPy applies the comparison to every element at once.
Step 5: Build Difference Hash (dHash) From Scratch
Average hash has a known weak spot: a uniform brightness or contrast shift can move enough pixels across the average line to flip several bits at once, even though the image did not meaningfully change. Difference hash fixes this by comparing neighboring pixels to each other instead of comparing every pixel to a single global average:
- Shrink to a 9×8 grid, one column wider than the target hash size.
- For each row, compare each pixel to the pixel immediately to its left.
- Record a 1 if the pixel is brighter than its left neighbor, 0 otherwise.
With 9 columns you get 8 left-to-right comparisons per row, and 8 rows gives you 64 bits again. Because it only cares about the direction of local brightness gradients (getting lighter or darker moving right), a global brightness shift barely moves the result. Add this function to phash_lib.py:
def difference_hash(path_or_img, hash_size=8):
# One extra column so each row yields hash_size left-to-right comparisons.
img = _load_gray(path_or_img).resize((hash_size + 1, hash_size), Image.LANCZOS)
pixels = np.asarray(img, dtype=np.float64)
bits = pixels[:, 1:] > pixels[:, :-1]
return bits.flatten()
pixels[:, 1:] is every column except the first; pixels[:, :-1] is every column except the last. Comparing those two slices element-wise is a vectorized way of comparing each pixel to its left neighbor across the whole grid in one call, without writing a nested loop.
Step 6: Build Perceptual Hash (pHash) From Scratch, and a Real Bug You Will Probably Hit
Average hash and difference hash both work directly on pixel brightness. Perceptual hash takes a different approach: it moves into the frequency domain using a Discrete Cosine Transform (DCT), the same underlying math JPEG compression itself uses. A DCT rewrites an image as a sum of cosine waves of increasing frequency. Low-frequency components capture broad shapes and gradients; high-frequency components capture fine detail, texture, and noise. Since JPEG recompression and resizing mostly destroy high-frequency detail while leaving low-frequency structure intact, a hash built only from the low frequencies survives those transformations better than one built from raw pixels.
The algorithm:
- Shrink to a larger grid than the final hash, 32×32 by default (8×8 hash size times a 4x high-frequency factor).
- Run a 2D DCT across that grid.
- Keep only the top-left 8×8 block of DCT coefficients: the lowest frequencies.
- Compute the median of those 64 coefficients.
- Record a 1 if a coefficient is above the median, 0 otherwise.
Here is a first implementation. Add it to phash_lib.py:
def perceptual_hash(path_or_img, hash_size=8, highfreq_factor=4):
img_size = hash_size * highfreq_factor
img = _load_gray(path_or_img).resize((img_size, img_size), Image.LANCZOS)
pixels = np.asarray(img, dtype=np.float64)
dct_full = dct(dct(pixels, axis=0, norm="ortho"), axis=1, norm="ortho")
dct_low = dct_full[:hash_size, :hash_size]
med = np.median(dct_low)
bits = dct_low > med
return bits.flatten()
This runs without errors and produces a plausible-looking 64-bit hash. It is also subtly wrong, in a way you would only catch by comparing against a second implementation, which is exactly what Step 7 does next.
Cross-Checking Against the Reference Library Exposes a Real Bug
Add hamming_distance and a hex-formatting helper to phash_lib.py, so hashes are easy to compare and print:
def hamming_distance(bits_a, bits_b):
return int(np.count_nonzero(bits_a != bits_b))
def bits_to_hex(bits):
bit_str = "".join("1" if b else "0" for b in bits)
return "%0*x" % (len(bit_str) // 4, int(bit_str, 2))
Now write a small script that runs your three functions against the imagehash library on the same files and checks whether the hex output matches exactly. Save this as step_crossvalidate.py:
import os
import imagehash
from PIL import Image
from phash_lib import average_hash, difference_hash, perceptual_hash, bits_to_hex
OUT = "images"
files = ["base.png", "jpeg_q50.jpg", "watermarked.png", "different.png"]
for f in files:
p = os.path.join(OUT, f)
img = Image.open(p)
mine_a = bits_to_hex(average_hash(p))
mine_d = bits_to_hex(difference_hash(p))
mine_p = bits_to_hex(perceptual_hash(p))
lib_a = str(imagehash.average_hash(img))
lib_d = str(imagehash.dhash(img))
lib_p = str(imagehash.phash(img))
print(f"{f}")
print(f" aHash mine={mine_a} imagehash={lib_a} match={mine_a == lib_a}")
print(f" dHash mine={mine_d} imagehash={lib_d} match={mine_d == lib_d}")
print(f" pHash mine={mine_p} imagehash={lib_p} match={mine_p == lib_p}")
python step_crossvalidate.py
base.png
aHash mine=00060f0f1f7fe7ff imagehash=00060f0f1f7fe7ff match=True
dHash mine=ffbc3c3c3fefcfd7 imagehash=ffbc3c3c3fefcfd7 match=True
pHash mine=b719676a48379364 imagehash=b619676a4c379364 match=False
jpeg_q50.jpg
aHash mine=00060f0f1f7fe7ff imagehash=00060f0f1f7fe7ff match=True
dHash mine=ffbc3c3c3fefcfd7 imagehash=ffbc3c3c3fefcfd7 match=True
pHash mine=b719676a4c379324 imagehash=b719676a4c379324 match=True
watermarked.png
aHash mine=00060f0f1f7fe7fe imagehash=00060f0f1f7fe7fe match=True
dHash mine=ffbc3c3c3fefcfd0 imagehash=ffbc3c3c3fefcfd0 match=True
pHash mine=b21d426e5837d3a5 imagehash=b21d426e5837d3a5 match=True
different.png
aHash mine=0207070300000000 imagehash=0207070300000000 match=True
dHash mine=0616161609060000 imagehash=0616161609060000 match=True
pHash mine=b5b55a4a4b59aa4a imagehash=b5b55a4a4b59aa4a match=True
Average hash and difference hash match the reference library exactly on every file. Perceptual hash matches on three files out of four, but disagrees with the reference library by a single bit on base.png itself. That is worth stopping and taking seriously: a hash implementation that is “close” to correct is still wrong, because the whole point is bit-for-bit comparison against other hashes.
The cause turned out to be the norm="ortho" argument passed to scipy.fft.dct in Step 6. Checking the imagehash library’s own source for phash shows it calls scipy.fftpack.dct(pixels, axis=0) with no normalization argument at all, which uses the unnormalized DCT-II, the same convention used in the original algorithm this technique is based on: Dr. Neal Krawetz’s “Looks Like It” write-up on the Hacker Factor blog, which lays out the mean (aHash), gradient (dHash), and DCT (pHash) approaches this tutorial builds. The “ortho” mode rescales each frequency position by a different factor rather than applying one uniform scale. Individually, each coefficient still relates to the others in roughly the same way, but the median in Step 6 is computed across 64 different frequency positions at once, and differential rescaling between positions is exactly the kind of change that can push one borderline coefficient to the other side of that shared median. That is a one-bit flip, and it is enough to make two independently-written implementations of “the same algorithm” disagree.
The fix is to drop the normalization argument so both DCT calls match the unnormalized convention. Update perceptual_hash in phash_lib.py:
def perceptual_hash(path_or_img, hash_size=8, highfreq_factor=4):
img_size = hash_size * highfreq_factor
img = _load_gray(path_or_img).resize((img_size, img_size), Image.LANCZOS)
pixels = np.asarray(img, dtype=np.float64)
# No norm="ortho" here: the classic Hackerfactor pHash algorithm (and the
# imagehash library) uses the unnormalized DCT-II. "ortho" rescales each
# frequency position by a different factor, which can shift a value to
# the other side of the shared median threshold below and disagree with
# every other pHash implementation by a bit or two.
dct_full = dct(dct(pixels, axis=0), axis=1)
dct_low = dct_full[:hash_size, :hash_size]
med = np.median(dct_low)
bits = dct_low > med
return bits.flatten()
Re-run step_crossvalidate.py:
base.png
aHash mine=00060f0f1f7fe7ff imagehash=00060f0f1f7fe7ff match=True
dHash mine=ffbc3c3c3fefcfd7 imagehash=ffbc3c3c3fefcfd7 match=True
pHash mine=b619676a4c379364 imagehash=b619676a4c379364 match=True
jpeg_q50.jpg
aHash mine=00060f0f1f7fe7ff imagehash=00060f0f1f7fe7ff match=True
dHash mine=ffbc3c3c3fefcfd7 imagehash=ffbc3c3c3fefcfd7 match=True
pHash mine=b719676a4c379324 imagehash=b719676a4c379324 match=True
watermarked.png
aHash mine=00060f0f1f7fe7fe imagehash=00060f0f1f7fe7fe match=True
dHash mine=ffbc3c3c3fefcfd0 imagehash=ffbc3c3c3fefcfd0 match=True
pHash mine=b21d426e5837d3a5 imagehash=b21d426e5837d3a5 match=True
different.png
aHash mine=0207070300000000 imagehash=0207070300000000 match=True
dHash mine=0616161609060000 imagehash=0616161609060000 match=True
pHash mine=b5b55a4a4b59aa4a imagehash=b5b55a4a4b59aa4a match=True
All four match exactly now. This is worth internalizing as a general lesson, not just a pHash quirk: when you implement an algorithm from a description rather than from a formal spec, cross-checking against a trusted second implementation on real inputs will catch mistakes that “the code runs and looks reasonable” never will.
Step 7: Measure What Each Algorithm Is Actually Sensitive To
With all three algorithms verified correct, you can now measure, rather than guess, how each one responds to the transformations generated in Step 3. Save this as step_compare.py:
import os
from phash_lib import average_hash, difference_hash, perceptual_hash, hamming_distance, bits_to_hex
OUT = "images"
BASE = os.path.join(OUT, "base.png")
variants = [
"exact_copy.png",
"resized.png",
"jpeg_q95.jpg",
"jpeg_q50.jpg",
"jpeg_q10.jpg",
"cropped.png",
"brighter.png",
"watermarked.png",
"rotated_90.png",
"different.png",
]
algos = {
"aHash": average_hash,
"dHash": difference_hash,
"pHash": perceptual_hash,
}
base_bits = {name: fn(BASE) for name, fn in algos.items()}
for name, bits in base_bits.items():
print(f"base {name} = {bits_to_hex(bits)} ({len(bits)} bits)")
print()
header = f"{'variant':16s}" + "".join(f"{n:>10s}" for n in algos)
print(header)
for v in variants:
row = f"{v:16s}"
for name, fn in algos.items():
d = hamming_distance(base_bits[name], fn(os.path.join(OUT, v)))
row += f"{d:>10d}"
print(row)
python step_compare.py
| Variant | aHash distance | dHash distance | pHash distance |
|---|---|---|---|
| exact_copy.png | 0 | 0 | 0 |
| resized.png | 0 | 0 | 0 |
| jpeg_q95.jpg | 0 | 0 | 0 |
| jpeg_q50.jpg | 0 | 0 | 2 |
| jpeg_q10.jpg | 0 | 0 | 2 |
| cropped.png (2% edge crop) | 1 | 1 | 4 |
| brighter.png (1.35x brightness) | 1 | 5 | 6 |
| watermarked.png | 1 | 3 | 12 |
| rotated_90.png | 32 | 29 | 34 |
| different.png (unrelated image) | 31 | 37 | 28 |
All distances are out of a maximum possible 64 bits, since each hash is 64 bits long. A few things in this table are worth calling out explicitly, because they are real measurements, not textbook claims:
- Resizing and light-to-moderate JPEG recompression are effectively free. All three algorithms scored a perfect 0 for the resize-down-and-back-up case and the 95 percent quality JPEG. Even the aggressive 10 percent quality JPEG only nudged pHash by 2 bits.
- A small 2 percent crop barely registers for aHash and dHash (1 bit each) but shifts pHash by 4. Cropping changes the low-frequency DCT structure a bit more than it changes the coarse pixel averages, since it is effectively a small re-framing of the whole image.
- Watermarking produced the most surprising result. A small opaque text stamp covering less than 2 percent of the image barely moved aHash (1 bit) and dHash (3 bits), but shifted pHash by 12 bits. This ran counter to my own expectation going in: pHash is built from low frequencies, which should represent broad structure, so a small localized edit “should” barely register. Digging into why: a solid rectangular block dropped onto a photo creates a sharp edge, and sharp edges are broadband in frequency terms, the visual equivalent of a step function, they contribute energy across many frequencies at once, including some of the low frequencies pHash inspects, not just the high frequencies you would intuitively expect a small local edit to hit. A smooth, gradual change (resizing, blur from recompression) stays concentrated in a narrower frequency band. This is exactly why “measure it” beats “assume it”: the theoretical intuition and the actual number disagreed here.
- Rotation breaks all three algorithms outright. A 90-degree rotation produced distances of 29 to 34 bits, close to the roughly-32-bit distance you would expect from two entirely unrelated 64-bit hashes chosen at random. None of these algorithms are rotation-invariant. If your use case includes rotated duplicates, you need a different technique entirely (more on this in the Next Steps section).
- A genuinely different image scored 28 to 37 bits across the three algorithms, comfortably separated from every real duplicate, which topped out at 12. That gap is what makes thresholding work in practice, and it is what Step 8 builds on.
Step 8: Build a Real Duplicate Finder
A hash and a distance number are only useful if you can turn them into an actual answer: given a folder of images, which ones are duplicates of which? Save this as find_duplicates.py:
"""Scan a directory for near-duplicate images using perceptual hashing."""
import argparse
import os
import sys
from phash_lib import perceptual_hash, hamming_distance
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
class UnionFind:
def __init__(self, items):
self.parent = {item: item for item in items}
def find(self, x):
while self.parent[x] != x:
self.parent[x] = self.parent[self.parent[x]]
x = self.parent[x]
return x
def union(self, a, b):
ra, rb = self.find(a), self.find(b)
if ra != rb:
self.parent[ra] = rb
def scan(directory, threshold):
paths = sorted(
os.path.join(directory, f)
for f in os.listdir(directory)
if os.path.splitext(f)[1].lower() in IMAGE_EXTS
)
hashes = {}
for p in paths:
try:
hashes[p] = perceptual_hash(p)
except Exception as exc:
print(f"skip {p}: {exc}", file=sys.stderr)
uf = UnionFind(list(hashes.keys()))
pairs = []
items = list(hashes.items())
for i in range(len(items)):
for j in range(i + 1, len(items)):
path_a, bits_a = items[i]
path_b, bits_b = items[j]
d = hamming_distance(bits_a, bits_b)
if d <= threshold:
pairs.append((path_a, path_b, d))
uf.union(path_a, path_b)
groups = {}
for p in hashes:
root = uf.find(p)
groups.setdefault(root, []).append(p)
return groups, pairs
def main():
ap = argparse.ArgumentParser(description="Find near-duplicate images with perceptual hashing.")
ap.add_argument("directory")
ap.add_argument("--threshold", type=int, default=6, help="max Hamming distance to treat as a duplicate (default: 6)")
args = ap.parse_args()
groups, pairs = scan(args.directory, args.threshold)
dup_groups = [g for g in groups.values() if len(g) > 1]
print(f"Scanned directory: {args.directory}")
print(f"Threshold: {args.threshold} bits (of 64)")
print(f"Images found: {sum(len(g) for g in groups.values())}")
print(f"Duplicate groups: {len(dup_groups)}\n")
for idx, group in enumerate(dup_groups, start=1):
print(f"Group {idx}: {len(group)} images")
for p in sorted(group):
print(f" - {os.path.basename(p)}")
singles = [g[0] for g in groups.values() if len(g) == 1]
if singles:
print(f"\nNo match found ({len(singles)}):")
for p in sorted(singles):
print(f" - {os.path.basename(p)}")
if __name__ == "__main__":
main()
A few implementation choices worth explaining:
- Union-Find (also called disjoint-set) is a classic data structure for grouping items transitively. If image A matches image B, and image B matches image C, Union-Find puts all three in the same group even if A and C were never directly compared as “close enough” to each other. Without it, you would need extra bookkeeping to merge overlapping pairs into groups by hand.
- This tool compares every image to every other image, which is O(n squared). For the 11 test images here that is 55 comparisons and instant. For a folder of 100,000 images that is roughly 5 billion comparisons, which will not finish in a reasonable time. The Next Steps section below covers how production systems avoid this.
- It uses pHash specifically (not aHash or dHash). Looking back at the Step 7 table, aHash and dHash both flatten to exactly 0 for four different transformations in a row (the exact copy, the resize, and two of the three JPEG re-saves), which means those algorithms cannot tell “identical” apart from “resized” or “lightly recompressed” using distance alone. pHash gives a graduated, mostly non-zero reading across the same transformations (0, 0, 0, 2, 2), which is a more useful signal to set a threshold against, and it is also the one this tutorial already verified bit-for-bit against a trusted reference implementation in Step 6. Note that this comes with a real tradeoff visible in the same table: pHash’s gap between its highest real-duplicate score (12) and its lowest unrelated-image score (28) is actually narrower than aHash’s gap (1 to 31) in this specific small test set, so “more graduated” is not automatically “more separated.” A production system comparing these algorithms on its own, larger dataset could reasonably land on a different choice.
Run it against the images folder from Step 3, with the default threshold of 6 bits:
python find_duplicates.py images --threshold 6
Scanned directory: images
Threshold: 6 bits (of 64)
Images found: 11
Duplicate groups: 1
Group 1: 8 images
- base.png
- brighter.png
- cropped.png
- exact_copy.png
- jpeg_q10.jpg
- jpeg_q50.jpg
- jpeg_q95.jpg
- resized.png
No match found (3):
- different.png
- rotated_90.png
- watermarked.png
Eight of the nine real variants are correctly grouped together. watermarked.png is left out at this threshold, which is a direct, visible consequence of the 12-bit pHash distance measured for it back in Step 7.
Tuning the Threshold Is a Real Tradeoff, Not a Constant
Recall from Step 7 that the watermarked variant sat at a pHash distance of 12, above the default threshold of 6. Raise the threshold and re-run:
python find_duplicates.py images --threshold 12
Scanned directory: images
Threshold: 12 bits (of 64)
Images found: 11
Duplicate groups: 1
Group 1: 9 images
- base.png
- brighter.png
- cropped.png
- exact_copy.png
- jpeg_q10.jpg
- jpeg_q50.jpg
- jpeg_q95.jpg
- resized.png
- watermarked.png
No match found (2):
- different.png
- rotated_90.png
Now the watermarked image joins the group, and the unrelated image and the rotated image are still correctly excluded, since their distances (28 and 34) sit far above 12. In this particular test set there happens to be a wide, comfortable gap between 12 (the highest real duplicate) and 28 (the closest false match), so raising the threshold to 12 catches more true duplicates without pulling in a false one. That gap will not always be that wide. A larger, more varied image collection can easily have unrelated photos that coincidentally land at a pHash distance of 10 or 15 from each other (two photos of similar-toned skies, for instance), so a threshold picked from one small test set does not automatically transfer to a different collection. Treat the default of 6 as a conservative starting point, and re-measure against a representative sample of your own data (using the approach from Step 7) before trusting a looser threshold in production.
Common Mistakes and Gotchas
Comparing Hashes of Different Sizes Raises an Error
If you compare an 8×8 hash against a 16×16 hash of the same image, NumPy will not silently produce a wrong answer, it raises an error, because the underlying arrays have different shapes:
>>> from phash_lib import average_hash, hamming_distance
>>> a8 = average_hash("images/base.png", hash_size=8)
>>> a16 = average_hash("images/base.png", hash_size=16)
>>> len(a8), len(a16)
(64, 256)
>>> hamming_distance(a8, a16)
ValueError: operands could not be broadcast together with shapes (64,) (256,)
That error is your friend: it fails loudly. The far more dangerous mistake is the next one, which does not raise anything at all.
Comparing Different Hash Algorithms Fails Silently
Average hash and difference hash both default to producing 64 bits. That means comparing an aHash against a dHash, even of the exact same image, runs without any error and returns a number that looks like a normal Hamming distance:
>>> from phash_lib import average_hash, difference_hash, hamming_distance
>>> a = average_hash("images/base.png")
>>> d = difference_hash("images/base.png")
>>> hamming_distance(a, d)
28
28 out of 64 bits different, for two hashes of the exact same file. That number is meaningless: aHash and dHash encode completely different properties of the image (raw brightness versus local gradient direction), so comparing them bit-for-bit is like comparing a phone number to a zip code because they happen to both be strings of digits. The fix is entirely a matter of discipline in your own code: store which algorithm produced a given hash alongside the hash itself, and only ever compare hashes that came from the same algorithm and the same hash_size.
None of These Algorithms Handle Rotation or Mirroring
Step 7’s measurement showed a 90-degree rotation lands in the same distance range as a completely unrelated image. If your duplicates might be rotated (scanned documents, photos re-uploaded in a different orientation) or mirrored, you need to generate hashes for multiple rotations or flips of each candidate image and compare against all of them, which multiplies your comparison cost by the number of orientations you check.
Threshold Selection Needs Real Data, Not a Guess
As Step 8 showed directly, the “right” threshold depends on both how aggressive the edits you need to catch are, and how visually similar the unrelated images in your specific collection tend to be. A stock photo library full of similar-looking product shots on white backgrounds needs a tighter threshold than a personal photo archive with visually distinct pictures.
Verifying It All Works End to End
To confirm your own setup is correct, run through this checklist against the images/ folder from Step 3:
- Run
python step_crossvalidate.pyand confirm all 12 lines saymatch=True. If any sayFalse, re-check theperceptual_hashfunction against Step 6 exactly, since a straynorm="ortho"is the most likely culprit. - Run
python find_duplicates.py images --threshold 6and confirm it reports exactly 1 duplicate group containing 8 images, withdifferent.png,rotated_90.png, andwatermarked.pngleft out. - Run it again with
--threshold 12and confirm the group grows to 9 images, picking upwatermarked.pngwhiledifferent.pngandrotated_90.pngare still excluded.
If all three checks match, your implementation is producing the same results documented in this tutorial.
Next Steps
- Scale past O(n squared). For large collections, look into a BK-tree (a data structure built specifically for fast nearest-neighbor search under a discrete distance metric like Hamming distance) or a vector similarity index such as FAISS, treating each 64-bit hash as a 64-dimensional binary vector. Both let you find “anything within distance 6” without comparing against every other image in the collection.
- Handle rotation and mirroring by hashing each of the 8 basic transformations of an image (4 rotations times a horizontal flip) and checking a candidate against all 8 stored hashes, or by using a rotation-invariant feature descriptor instead of these hash families entirely.
- Combine multiple algorithms. Since Step 7 showed aHash, dHash, and pHash disagree on exactly which edits they are most sensitive to, a production system can require two out of three algorithms to agree before flagging a duplicate, trading recall for a lower false-positive rate.
- Try it on your own photo library instead of the synthetic test images used here, and re-run the Step 7 measurement script against real, varied content to pick a threshold backed by your own data rather than this tutorial’s numbers.








No Comment! Be the first one.