TRENDING
Rows of identical brass-colored apartment mailboxes with small locks and name labels along an orange corridor wall
October 9, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
Street-level upward view of the Monetary Authority of Singapore building and neighbouring office towers under a pale sky
October 9, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
Cast-iron late Qing dynasty coin minting press with a large flywheel, displayed in a museum case
October 9, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google
Rows of closed oak library card catalog drawers, each with a brass pull and a blank label holder
October 9, 2026
How to Encrypt PII in Python and Keep It Searchable With Blind Indexes
Close-up of a vintage Western Electric manual telephone switchboard with orange lamps, red patch cords plugged into jacks, a rotary dial and a black handset
October 9, 2026
Microsoft’s Agent Lightning v1.0 Turns Agent Training Into a Sample-Accounting Problem
09 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two orange safety relief valves on grey pressure vessels in an industrial plant
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
Yellow diamond-shaped merging traffic warning sign showing a side road joining a main road
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A lugworm lying on wet sand and mud at low tide
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 232 Posts
News 234 Posts
Learning Hub 204 Posts
Home/Learning Hub/How to Detect Duplicate and Near-Duplicate Images With Perceptual Hashing in Python
Learning Hub

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.

August 16, 2026 20 Min Read
51

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.py module 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 imagehash reference 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:

  1. Shrink the image down to a tiny grid, 8×8 pixels by default, throwing away high-frequency detail on purpose.
  2. Convert to grayscale, so color shifts do not affect the result.
  3. Compute the average brightness of those 64 pixels.
  4. 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:

  1. Shrink to a 9×8 grid, one column wider than the target hash size.
  2. For each row, compare each pixel to the pixel immediately to its left.
  3. 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:

  1. Shrink to a larger grid than the final hash, 32×32 by default (8×8 hash size times a 4x high-frequency factor).
  2. Run a 2D DCT across that grid.
  3. Keep only the top-left 8×8 block of DCT coefficients: the lowest frequencies.
  4. Compute the median of those 64 coefficients.
  5. 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:

  1. Run python step_crossvalidate.py and confirm all 12 lines say match=True. If any say False, re-check the perceptual_hash function against Step 6 exactly, since a stray norm="ortho" is the most likely culprit.
  2. Run python find_duplicates.py images --threshold 6 and confirm it reports exactly 1 duplicate group containing 8 images, with different.png, rotated_90.png, and watermarked.png left out.
  3. Run it again with --threshold 12 and confirm the group grows to 9 images, picking up watermarked.png while different.png and rotated_90.png are 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.

Tags:

Computer VisionDeduplicationImage ProcessingPerceptual HashingPython

Share

A draft horse pulls a plow through a field while a farmer walks behind holding the reins
Previous Post

Google’s Gemini 3.7 Flash Turns AI Pricing Into a Compression Race

A municipal water tower in Minnesota against an overcast sky, representing the type of water infrastructure targeted in the attacks
Next Post

Iran’s Water Utility Hacks Turn Exposed PLCs Into a National Security Problem

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Oct
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
08 Oct
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
Trending
October 8, 2026
How to Add Backpressure and Load Shedding to a Python Service Before Overload Takes It Down
October 8, 2026
GitHub’s Git Rebuild Turns Repository Durability and Read Scale Into Two Separate Problems
October 8, 2026
A Compromised Admin Account Put the Shai-Hulud Worm Into AI Sandbox Maker Tensorlake’s npm SDK
October 8, 2026
How to Prevent Broken Object Level Authorization (IDOR) in a FastAPI App
October 8, 2026
Singapore’s AI Guidelines Turn Independent Review Into a Question of Who Sets the Risk Rating
October 8, 2026
Attackers Hijacked the .gh, .sl and .as Country Domains and Minted HTTPS Certificates for Google

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026