Skip to content

pHash — Perceptual Hash

Shrink the image to 32×32, describe it as a sum of cosine patterns with the discrete cosine transform, and keep the 64 coarsest of them. Each bit records whether its pattern is present more strongly than the typical one. Where aHash and dHash compare pixels, pHash compares frequencies: the hash describes the large-scale layout of light and dark, and the detail finer than that never reaches it.

Call ph_compute_phash(), or PH_HASH_PHASH in ph_compute_multi()
Output 64-bit hash, compared with ph_hamming_distance()
Source the pHash library by Evan Klinger and David Starkweather, as documented by Christoph Zauner, 2010 (provenance)
Cost 0.21 ms on a 400×400 image, 23 ms on 20 Mpx, after decoding1

The steps

The steps below are those of the default sizes, a 32×32 grid and an 8×8 block; ph_context_set_phash_params() sets others, below. Every picture on this page is computed by the library from the same photograph, and the hash at the end is what ph_compute_phash() returns for it.

The five stages of pHash on the example photograph The five stages of pHash on the example photograph

1. Grayscale. Each pixel becomes one luminance value,

\[ Y = \left\lfloor \frac{38R + 75G + 15B}{128} \right\rfloor , \]

the same integer approximation of the ITU-R BT.601 weights as for every grayscale hash. ph_context_set_gray_weights() changes them; image preparation has why these weights.

2. Reduce to 32×32. The image is divided into a 32×32 grid of equal areas, regardless of its aspect ratio, and each cell becomes the exact mean of the pixels it covers, rounded to the nearest integer: the same area average as aHash's 8×8, on a finer grid.

3. Transform. The two-dimensional type-II DCT rewrites the 32×32 values \(X_{y,x}\) as 1024 coefficients \(F_{u,v}\), one for each cosine pattern with \(u\) half-periods down the image and \(v\) across it:

\[ F_{u,v} = c_u\, c_v \sum_{y=0}^{31} \sum_{x=0}^{31} X_{y,x} \cos\frac{\pi u\,(2y+1)}{64} \cos\frac{\pi v\,(2x+1)}{64} , \qquad c_0 = \sqrt{\tfrac{1}{32}},\; c_{k>0} = \sqrt{\tfrac{2}{32}} . \]

The scaling makes the transform orthonormal: the grid is exactly the sum of the 1024 patterns, each weighted by its coefficient. \(F_{0,0}\), the DC coefficient, is the flat pattern, \(32\) times the mean brightness; every other one, an AC coefficient, is positive where the image looks like its pattern and negative where it looks like the pattern's negative. The library computes it as two passes of matrix products in single precision, in a fixed order and with no vector path, so every build gives the same coefficients bit for bit.

Here are the 64 patterns the hash keeps, \(u\) and \(v\) from 0 to 7. The top-left one is flat; going right, the pattern alternates faster across the image, going down, faster down it.

The 64 cosine patterns of the 8×8 block: horizontal frequency 0 to 7 across, vertical frequency 0 to 7 down The 64 cosine patterns of the 8×8 block: horizontal frequency 0 to 7 across, vertical frequency 0 to 7 down

4. Keep the low frequencies. Of the 1024 coefficients, only the 8×8 block in the top left corner goes into the hash, \(F_{u,v}\) for \(u, v = 0 \dots 7\). These are the patterns with at most three and a half periods across the image: they say where the image is light and where dark at the scale of its main shapes. On the example they are also the largest coefficients, as the brighter corner of the map of all 1024 shows.

The magnitude of all 32×32 DCT coefficients of the example on a log scale, with the 8×8 block outlined The magnitude of all 32×32 DCT coefficients of the example on a log scale, with the 8×8 block outlined

Recompression, noise and resizing change the image mostly in fine detail, which lives in the coefficients outside the block. That is what pHash is built on.

5. Threshold. The threshold is the median \(m\) of the 63 AC coefficients of the block, raised by a tenth of a percent of their range:

\[ t = m + 0.001 \left( \max_{i \ge 1} F_i - \min_{i \ge 1} F_i \right) , \qquad b_i = \begin{cases} 1 & F_i > t \\ 0 & F_i \le t \end{cases} \qquad i = 0 \dots 63 , \]

with the block read row by row, \(F_i = F_{\lfloor i/8 \rfloor,\, i \bmod 8}\). With 63 values the median is the 32nd smallest of them, so at most 31 AC bits are set: those above the median, less any the margin catches. DC takes part in the comparison but not in choosing \(t\): it is the largest coefficient of any ordinary image, so its bit is always 1. Why the margin, and why DC is left out of the median but not out of the hash, is below.

The 8×8 block of coefficients, with the cells above the threshold highlighted and DC outlined The 8×8 block of coefficients, with the cells above the threshold highlighted and DC outlined

6. Pack the bits. Coefficient \(i\) sets bit \(i\), so DC goes into the least significant bit:

\[ h = \sum_{i=0}^{63} b_i \cdot 2^{\,i} . \]

The 64 bits of pHash on the example photograph as an 8×8 grid, a cell filled where its bit is set: where the coefficient is above the threshold The 64 bits of pHash on the example photograph as an 8×8 grid, a cell filled where its bit is set: where the coefficient is above the threshold

Why a margin above the median

Every bit is a comparison with the median, so a coefficient that lies close to the median is decided by whatever nudges it. On a photograph few do. On an image whose low frequencies hold little — a smooth gradient, a regular pattern, a near-flat field — most AC coefficients are close to zero, and so is their median: there, a bare median would decide many bits by rounding. The margin moves the threshold just above that crowd and sends all of it to 0 together.

Here are the 63 AC coefficients of the example and of the image of the tests' synthetic corpus where they crowd the most, sorted, as distances from their median in units of their range. The shaded band is the margin, and the coefficients in it are orange; the scale is linear inside the band and logarithmic outside, so both the band and the spread of the rest show.

The 63 AC coefficients of two images sorted by value, as distances from their median, with the margin shaded The 63 AC coefficients of two images sorted by value, as distances from their median, with the margin shaded

Image AC coefficients in the margin Hash Bits the margin clears
The example photograph 1 of 63 7eb66192bc394247 0
Synthetic image 3 of the tests' corpus 59 of 63 00000000000000ab 27

On the photograph only the median itself lies in the band, and the margin changes no bit. On the synthetic image nearly every coefficient does, and without the margin those bits would be the float residue of a transform of zeros. Measured over 800 photographs, the margin halves the share of different images that land within the distance that accepts 95 % of copies; the numbers, and the cost of it, are in provenance § 3.

Bit layout

Bit \(i\) is coefficient \(i\) of the block, row by row, from the least significant bit up. In hexadecimal the hash therefore reads the block backwards: the last hex digit holds DC and the next three coefficients of the top row, the first hex digit the last four of the bottom row. Since the DC bit is set, the last hex digit is odd for any ordinary image. Over the 2,240 images hashed for the charts of this page, both corpora's originals and their copies, the DC bit is set in every one.

Which bit each coefficient of the block sets Which bit each coefficient of the block sets

What changes the hash

The hash describes the coarse layout of light and dark. An edit that leaves the main shapes where they are leaves the low frequencies, and the hash, alone; one that moves the shapes moves every coefficient at once.

Bits that differ from the original under nine transforms, on the example photograph Bits that differ from the original under nine transforms, on the example photograph

  • Recompression, resizing, blur and noise act on fine detail, outside the block: the hash does not move, or moves by a bit at the strongest settings.
  • Brightness and contrast scale the AC coefficients, and the median and the margin with them, so the bits stay until values start to clip at white. Gamma bends the values instead of scaling them, and moves a few bits.
  • Rotation and cropping shift the shapes against the cosine patterns, and every coefficient changes with them. pHash follows them at least as steeply as aHash: it is not the algorithm for collections where images are rotated or reframed.

These are measurements on one photograph; the same edits over two whole corpora follow below.

The numbers behind the chart
Transform Strength Bits that differ
JPEG quality 95 0
JPEG quality 75 0
JPEG quality 50 0
JPEG quality 30 0
JPEG quality 15 0
JPEG quality 5 1
Downscale 0.75 0
Downscale 0.5 0
Downscale 0.25 0
Downscale 0.125 0
Rotation 1 2
Rotation 2 3
Rotation 5 6
Rotation 10 15
Rotation 20 22
Rotation 45 34
Brightness 0.5 0
Brightness 0.7 0
Brightness 0.85 0
Brightness 1.15 0
Brightness 1.3 3
Brightness 1.5 3
Contrast 0.5 0
Contrast 0.7 0
Contrast 0.85 0
Contrast 1.15 0
Contrast 1.3 3
Contrast 1.5 3
Gamma 0.5 4
Gamma 0.7 2
Gamma 0.85 1
Gamma 1.2 2
Gamma 1.5 2
Gamma 2 4
Gaussian blur 0.5 0
Gaussian blur 1 0
Gaussian blur 2 0
Gaussian blur 4 0
Gaussian blur 8 0
Noise 2 0
Noise 5 0
Noise 10 0
Noise 20 0
Noise 40 1
Crop 5 3
Crop 10 11
Crop 20 28
Crop 30 34
How this was measured

Each transform is applied alone to the photograph above, and the edited copy is saved losslessly, as PPM (as a JPEG of the given quality for that panel). The library hashes the original and every copy, and compares each copy with the original by ph_similarity_digest(). The chart shows \(64 \times (1 - \text{similarity})\), which is the Hamming distance: how many of the 64 bits the edit flipped. Below is the code that ran, not a copy of it.

The edits, in Python with Pillow:

tools/site/measure/transforms.py
def transforms():
    """(name, x-axis label, [(strength, PIL image -> (image, (file extension, quality)))]).

    Each edit is applied alone to the original. A variant is saved as PPM, which is
    lossless (and quick to write for a corpus of thousands of variants), so the edit is
    the only difference; JPEG quality is the one edit that is the saving itself.
    """
    def jpeg(q):
        return lambda im: (im, ("jpg", q))

    def scale(s):
        return lambda im: (im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))),
                                     Image.LANCZOS), ("ppm", None))

    def rotate(deg):
        # The corners the turn uncovers are filled with the image's mean color, so they
        # add no edge of their own.
        def f(im):
            fill = tuple(int(v) for v in np.asarray(im).reshape(-1, 3).mean(axis=0))
            return im.rotate(deg, resample=Image.BICUBIC, fillcolor=fill), ("ppm", None)
        return f

    def brightness(k):
        return lambda im: (ImageEnhance.Brightness(im).enhance(k), ("ppm", None))

    def contrast(k):
        return lambda im: (ImageEnhance.Contrast(im).enhance(k), ("ppm", None))

    def gamma(g):
        def f(im):
            a = (np.asarray(im).astype(np.float64) / 255.0) ** g
            return Image.fromarray((a * 255.0).round().astype(np.uint8)), ("ppm", None)
        return f

    def blur(r):
        return lambda im: (im.filter(ImageFilter.GaussianBlur(r)), ("ppm", None))

    def noise(sigma):
        # A fixed seed: the same noise on every build, so the chart does not move.
        def f(im):
            rng = np.random.default_rng(1)
            a = np.asarray(im).astype(np.float64) + rng.normal(0, sigma, (im.height, im.width, 3))
            return Image.fromarray(np.clip(a, 0, 255).round().astype(np.uint8)), ("ppm", None)
        return f

    def crop(p):
        def f(im):
            dx, dy = round(im.width * p / 2), round(im.height * p / 2)
            return im.crop((dx, dy, im.width - dx, im.height - dy)), ("ppm", None)
        return f

    return [
        ("JPEG quality", "quality", [(q, jpeg(q)) for q in (95, 75, 50, 30, 15, 5)]),
        ("Downscale", "scale", [(s, scale(s)) for s in (0.75, 0.5, 0.25, 0.125)]),
        ("Rotation", "degrees", [(d, rotate(d)) for d in (1, 2, 5, 10, 20, 45)]),
        ("Brightness", "factor", [(k, brightness(k)) for k in (0.5, 0.7, 0.85, 1.15, 1.3, 1.5)]),
        ("Contrast", "factor", [(k, contrast(k)) for k in (0.5, 0.7, 0.85, 1.15, 1.3, 1.5)]),
        ("Gamma", "exponent", [(g, gamma(g)) for g in (0.5, 0.7, 0.85, 1.2, 1.5, 2.0)]),
        ("Gaussian blur", "radius, px", [(r, blur(r)) for r in (0.5, 1, 2, 4, 8)]),
        ("Noise", "σ, gray levels", [(s, noise(s)) for s in (2, 5, 10, 20, 40)]),
        ("Crop", "border removed, %", [(p, crop(p / 100)) for p in (5, 10, 20, 30)]),
    ]

Writing the copies and handing them to the measuring tool:

tools/site/measure/robustness.py
def measure_variants(tool, base, ref):
    """{algorithm: {transform: [(strength, value)]}}: every edit of `base` (a Pillow
    image), of both groups of transforms.py, compared with `ref`, the same pixels saved
    losslessly.

    Every variant is written to a file, and `site_stages measure` hashes them with the
    library and compares each variant with the reference, by each algorithm's own
    metric; `value` is None where the comparison does not apply.
    """
    with tempfile.TemporaryDirectory() as tmp:
        files, keys = [], []
        for name, _, steps in transforms() + content_edits():
            for strength, op in steps:
                files.append(write_edit(op, base, os.path.join(tmp, str(len(files)))))
                keys.append((name, strength))
        rows = run_lines(tool, "measure", ref, *files)
    result = {}
    for (name, strength), row in zip(keys, rows):
        for algo, value in row.items():
            if algo != "file":
                result.setdefault(algo, {}).setdefault(name, []).append((strength, value))
    return result


def measure_robustness(tool, image):
    """measure_variants() for one image file."""
    base = Image.open(image).convert("RGB")
    with tempfile.TemporaryDirectory() as tmp:
        ref = os.path.join(tmp, "reference.ppm")
        base.save(ref)
        return measure_variants(tool, base, ref)

The measuring tool, linked against the library: it computes every algorithm's digest of the original, then of each copy, and compares the two by that algorithm's own metric.

tools/site/stages/measure.c
/* site_stages measure <reference> <variant>...: every algorithm's digest of the reference,
 * then one JSON line per variant with its comparison to the reference, per algorithm (the
 * similarity in [0, 1] for a bit hash, the peak correlation for Radial, the histogram
 * intersection for ColorHash, the L2 distance for ColorMoments). A `--load=<settings>`
 * between the images loads the ones after it so (take_load_settings()). */
int mode_measure(int argc, char **argv) {
    ph_digest_t ref[PH_ALGORITHM_COUNT], d[PH_ALGORITHM_COUNT];
    int have_ref[PH_ALGORITHM_COUNT], have[PH_ALGORITHM_COUNT];
    int status = 0, images = 0;
    for (int v = 0; v < argc && !status; v++) {
        if (take_load_settings(argv[v], &status)) {
            continue;
        }
        ph_context_t *ctx = NULL;
        if (load_image(&ctx, argv[v])) {
            return 1;
        }
        digest_all(ctx, images ? d : ref, images ? have : have_ref);
        ph_free(ctx);
        if (images++ == 0) {
            continue;
        }
        json_t j = json_begin(stdout);
        json_string(&j, "file", argv[v]);
        compare_all(&j, ref, have_ref, d, have);
        json_end(&j);
    }
    return status;
}
tools/site/stages/measure.c
/* One algorithm's comparison of two digests by its own metric; 0 when it does not apply. */
static int compare(const ph_digest_t *a, const ph_digest_t *b, double *out) {
    switch (a->kind) {
        case PH_DIGEST_KIND_BITS:
            *out = ph_similarity_digest(a, b);
            return *out >= 0.0;
        case PH_DIGEST_KIND_COEFFICIENTS:
            return ph_radial_similarity(a, b, out) == PH_SUCCESS;
        case PH_DIGEST_KIND_HISTOGRAM:
            return ph_histogram_intersection(a, b, out) == PH_SUCCESS;
        case PH_DIGEST_KIND_VECTOR16:
            *out = ph_l2_distance(a, b);
            return *out >= 0.0;
        default:
            return 0;
    }
}

The number on the chart:

tools/site/measure/metric.py
def bits_that_differ(similarity, bits_total):
    """The number on a bit hash's chart. ph_similarity_digest() is 1 - distance / bits,
    so this is the Hamming distance itself: how many of the hash's bits the edit flipped."""
    return (1.0 - similarity) * bits_total

To reproduce, make site builds the tool and redraws every figure. The tool can also be run by hand on any pair of images; it prints one JSON line per variant, with each algorithm's comparison:

cmake --preset release && cmake --build --preset release --target site_stages
build/release/site_stages measure reference.png variant.png …

Over two corpora

The same nine edits, applied to every image of two corpora: 200 public-domain photographs from Wikimedia Commons (the list), and the 24 generated images the library's tests measure (methodology), which are not photographs. Each line is the median over the corpus and the band around it holds the middle half of its images.

Bits that differ from the original under nine transforms, median and middle half over each corpus Bits that differ from the original under nine transforms, median and middle half over each corpus

On the photographs the picture is the single image's: rotation and cropping are the edits that matter, and they move pHash's median further than aHash's; everything else moves it by a few bits at most. The synthetic images are where pHash is weakest. Many of them are regular patterns, checks, stripes and rings, whose block holds a few large coefficients and many near zero, and there a degree of rotation, noise or heavy recompression moves many bits.

Copies and different images

Robustness alone proves little: a hash that never changes is perfectly robust. What makes a hash useful is that copies of an image land closer together than different images do. Here a copy is an original after one moderate edit (one strength of each of the nine, listed under "How this was measured"), and different images are every pair of distinct originals in the corpus.

Distribution of bits that differ, for copies and for pairs of different images, in both corpora Distribution of bits that differ, for copies and for pairs of different images, in both corpora

Copies pile up at a few bits, different images spread around 32, half of the 64, where two unrelated hashes are expected to fall. The dashed line is the distance that accepts 95 % of the copies, and its label says how many different pairs it would accept as well. d′ sums up the gap in one number, the distance between the two means in units of their spread.

On photographs this is pHash's strength. Its copies sit a little further from the original than aHash's, but different photographs land more tightly around 32 than with any of the other 64-bit hashes: the coarse layout of light and dark varies from photograph to photograph the way independent bits would. The gap between copies and different images is the widest of the four, and the threshold accepts the fewest pairs of different photographs. On the synthetic images, where many hashes are decided by a few coefficients, the gap is the narrowest of the four.

The numbers behind the charts
Corpus Copies Different pairs d′ Threshold Different pairs within it
synthetic corpus, 24 images; not photographs 216 276 3.41 17 10.5%
200 public-domain photographs from Wikimedia Commons 1800 19900 8.85 7 0 of 19,900

Median bits that differ over each corpus, with the 25th and 75th percentiles:

Transform Strength synthetic corpus, 24 images; not photographs 200 public-domain photographs from Wikimedia Commons
JPEG quality 95 0.5 (0–3) 0 (0–0)
JPEG quality 75 0.5 (0–3.2) 0 (0–0)
JPEG quality 50 2 (0–5) 0 (0–0)
JPEG quality 30 2.5 (0.8–8.2) 0 (0–0)
JPEG quality 15 5 (1.8–8) 0 (0–1)
JPEG quality 5 8.5 (5.2–14) 1 (0–2)
Downscale 0.75 0.5 (0–1) 0 (0–0)
Downscale 0.5 0 (0–2.2) 0 (0–0)
Downscale 0.25 2 (0–3) 0 (0–0)
Downscale 0.125 2 (0–4) 0 (0–0)
Rotation 1 10 (6.8–13.2) 2 (2–4)
Rotation 2 12 (6.8–16) 4 (3–6)
Rotation 5 16.5 (9.5–23.2) 10 (8–13)
Rotation 10 22 (15–24) 18 (14–21)
Rotation 20 22 (15.5–27.2) 26 (22–28)
Rotation 45 24 (18.8–29) 30 (27–32)
Brightness 0.5 0 (0–1.2) 0 (0–0)
Brightness 0.7 0 (0–1) 0 (0–0)
Brightness 0.85 0 (0–1.2) 0 (0–0)
Brightness 1.15 0 (0–3.2) 0 (0–2)
Brightness 1.3 1.5 (0–7) 2 (0–3)
Brightness 1.5 3.5 (0–8.8) 4 (2–6)
Contrast 0.5 0 (0–2) 0 (0–0)
Contrast 0.7 0 (0–2) 0 (0–0)
Contrast 0.85 0 (0–1) 0 (0–0)
Contrast 1.15 0 (0–2) 0 (0–1)
Contrast 1.3 0 (0–1.8) 1 (0–2)
Contrast 1.5 0 (0–5.5) 2 (0–3)
Gamma 0.5 1.5 (0–9.2) 3 (2–4)
Gamma 0.7 0 (0–7.2) 2 (1–3)
Gamma 0.85 0 (0–4) 1 (0–2)
Gamma 1.2 1 (0–4) 1 (0–2)
Gamma 1.5 2 (0–6) 2 (1–4)
Gamma 2 3.5 (0–8.2) 4 (3–6)
Gaussian blur 0.5 0 (0–0) 0 (0–0)
Gaussian blur 1 0 (0–0.8) 0 (0–0)
Gaussian blur 2 0 (0–1.2) 0 (0–0)
Gaussian blur 4 1.5 (0–4.2) 0 (0–0)
Gaussian blur 8 2.5 (0–5.2) 0 (0–1)
Noise 2 3 (1–5.2) 0 (0–0)
Noise 5 4 (2–9.2) 0 (0–0)
Noise 10 8.5 (5.8–13) 0 (0–0)
Noise 20 12 (9–15) 0 (0–1)
Noise 40 15 (11–17) 1 (0–2)
Crop 5 4 (1–8.2) 5 (4–7)
Crop 10 8.5 (0.8–15) 10 (8–12)
Crop 20 16.5 (5.2–23.2) 19 (16–22)
Crop 30 23.5 (2.5–28.5) 26 (23–29)
How this was measured

For each corpus, every image goes through the nine edits above, and each copy is compared with its original exactly as on the single photograph. Every pair of distinct originals is compared too, by site_stages pairs. Below is the code that ran.

The photographs are downloaded once and checked against the SHA-256 the manifest records for each:

tools/site/fetch_corpus.py
def download(row, unreachable):
    """The photograph's bytes as the manifest names them, from the copy of the corpus or
    else from Commons; (None, why) when neither has them. A source that cannot be reached
    is added to `unreachable` and not tried again."""
    why = "no source"
    for source in (MIRROR + row["sha256"] + extension(row), row["url"]):
        host = urllib.parse.urlsplit(source).netloc
        if host in unreachable:
            continue
        try:
            data = get(source)
        except urllib.error.HTTPError as e:
            why = f"{host}: {e}"
            continue
        except OSError as e:
            unreachable[host] = str(e)
            why = f"cannot reach {host} ({e})"
            continue
        finally:
            if source == row["url"]:
                time.sleep(0.2)  # Commons asks clients to pace their requests
        if hashlib.sha256(data).hexdigest() == row["sha256"]:
            return data, None
        why = f"{host}: the download differs from the manifest"
    return None, why


def unpack_archive(rows, d):
    """Writes the rows' photographs that the copy's archive holds, verified, into d.
    An archive that cannot be downloaded or read writes nothing; the files are then
    downloaded one by one."""
    wanted = {row["sha256"] + extension(row): row for row in rows}
    try:
        req = urllib.request.Request(MIRROR_ARCHIVE, headers={"User-Agent": USER_AGENT})
        with urllib.request.urlopen(req, timeout=300) as r, \
                tarfile.open(fileobj=r, mode="r|gz") as tar:
            for member in tar:
                row = wanted.get(os.path.basename(member.name))
                if row is None or not member.isfile():
                    continue
                data = tar.extractfile(member).read()
                if hashlib.sha256(data).hexdigest() != row["sha256"]:
                    continue
                path = os.path.join(d, local_name(row))
                with open(path + ".part", "wb") as f:
                    f.write(data)
                os.replace(path + ".part", path)
    except (OSError, tarfile.TarError) as e:
        print(f"warning: photo corpus: the archive of the copy: {e}", file=sys.stderr)


_fetched = {}


def fetch(cache=None):
    """The verified local paths of the manifest's photographs, downloading what is missing.
    Returns (paths, problems); a photograph that is unavailable is a problem, not an error.
    One process downloads once: a second call returns the first one's answer."""
    d = cache_dir(cache)
    if d in _fetched:
        return _fetched[d]
    os.makedirs(d, exist_ok=True)

    def cached(row):
        path = os.path.join(d, local_name(row))
        if not os.path.exists(path):
            return False
        with open(path, "rb") as f:
            return hashlib.sha256(f.read()).hexdigest() == row["sha256"]

    rows = read_manifest()
    missing = [row for row in rows if not cached(row)]
    if len(missing) > 10:
        unpack_archive(missing, d)
    paths, problems, unreachable = [], [], {}
    for row in rows:
        path = os.path.join(d, local_name(row))
        if row not in missing or cached(row):
            paths.append(path)
            continue
        data, why = download(row, unreachable)
        if data is None:
            problems.append(f"{row['file']}: {why}")
            continue
        with open(path + ".part", "wb") as f:
            f.write(data)
        os.replace(path + ".part", path)
        paths.append(path)
    _fetched[d] = (paths, problems)
    return paths, problems

Measuring a corpus, or taking the result from the cache when nothing that decides it has changed:

tools/site/measure/corpus.py
def measure_corpus(tool, corpus):
    """{"n", "label", "robust": {algo: {transform: [[strength, [value per image]]]}},
    "edits": {the same, for the edits that change the picture}, "different": {algo:
    [value per pair]}} for one corpus, from the cache when nothing that decides it has
    changed. Values are each algorithm's own metric, as `site_stages measure` and
    `site_stages pairs` print it; None where it does not apply."""
    paths = images(tool, corpus)

    def measure():
        with tempfile.TemporaryDirectory() as tmp:
            # The originals are decoded once, by Pillow, and written losslessly, so that
            # the reference an edit is compared with and the image that is edited are the
            # same pixels.
            refs = [os.path.join(tmp, f"{i:04d}.ppm") for i in range(len(paths))]
            with concurrent.futures.ProcessPoolExecutor() as pool:
                per_image = list(pool.map(_measure_one, [(tool, p, r) for p, r in
                                                         zip(paths, refs)]))
            rows = run_lines(tool, "pairs", *refs) if len(refs) >= 2 else []

        robust, edits = {}, {}
        content = {name for name, _, _ in content_edits()}
        for result in per_image:
            for algo, by_transform in result.items():
                for name, points in by_transform.items():
                    group = edits if name in content else robust
                    slot = group.setdefault(algo, {}).setdefault(
                        name, [[s, []] for s, _ in points])
                    for (strength, value), cell in zip(points, slot):
                        cell[1].append(value)
        different = {}
        for row in rows:
            for algo, value in row.items():
                if algo not in ("a", "b"):
                    different.setdefault(algo, []).append(value)
        return {"n": len(paths), "label": label(corpus, len(paths)), "robust": robust,
                "edits": edits, "different": different}

    return cached(corpus, cache_key(paths), measure, f"{corpus} corpus, {len(paths)} images")

Comparing every pair of originals:

tools/site/stages/measure.c
/* site_stages pairs <image>...: every image's digests, then one JSON line per pair of
 * distinct images (i < j, in the order given, as "a" and "b") with their comparison,
 * per algorithm. A `--load=<settings>` before the images loads them so. */
int mode_pairs(int argc, char **argv) {
    ph_digest_t(*dig)[PH_ALGORITHM_COUNT] = calloc((size_t)argc, sizeof(*dig));
    int (*have)[PH_ALGORITHM_COUNT] = calloc((size_t)argc, sizeof(*have));
    int bad = (!dig || !have) && fail("out of memory", NULL);
    int n = 0;
    for (int i = 0; i < argc && !bad; i++) {
        if (take_load_settings(argv[i], &bad)) {
            continue;
        }
        ph_context_t *ctx = NULL;
        bad = load_image(&ctx, argv[i]);
        if (!bad) {
            digest_all(ctx, dig[n], have[n]);
            n++;
            ph_free(ctx);
        }
    }
    for (int i = 0; i < n && !bad; i++) {
        for (int k = i + 1; k < n; k++) {
            json_t j = json_begin(stdout);
            json_int(&j, "a", i);
            json_int(&j, "b", k);
            compare_all(&j, dig[i], have[i], dig[k], have[k]);
            json_end(&j);
        }
    }
    free(dig);
    free(have);
    return bad;
}

The edits that make a copy, and the numbers on the second chart:

tools/site/measure/separability.py
# A "copy" is an original after one moderate edit: one strength of each transform, the
# kind of change a picture goes through between two places it is stored.
COPY_STRENGTHS = {
    "JPEG quality": 50,
    "Downscale": 0.5,
    "Rotation": 2,
    "Brightness": 1.15,
    "Contrast": 1.15,
    "Gamma": 1.2,
    "Gaussian blur": 1,
    "Noise": 5,
    "Crop": 5,
}
tools/site/measure/separability.py
def separability(copies, different, lower_is_closer, recall=RECALL):
    """d', the threshold that keeps `recall` of the copies (RECALL unless a page asks for
    another share), and the share of different pairs that threshold also accepts.

    d' is the gap between the two means in units of their pooled spread, as
    tests/src/test_hash_properties.c defines it (separability()), signed so that a
    positive d' means copies are closer than different images.
    """
    c, d = np.asarray(copies, float), np.asarray(different, float)
    sign = 1.0 if lower_is_closer else -1.0
    pooled = np.sqrt((c.std() ** 2 + d.std() ** 2) / 2.0)
    dprime = sign * (d.mean() - c.mean()) / pooled if pooled > 0 else float("inf")
    # The threshold: the closest value that still accepts `recall` of the copies.
    t = sign * np.quantile(sign * c, recall, method="inverted_cdf")
    accepted = (d <= t) if lower_is_closer else (d >= t)
    return dprime, t, accepted.mean()

The synthetic corpus is generated by make_base() in tests/src/synthetic_corpus.h, the same code the tests use. make site measures both corpora and draws every figure.

Edits that change the picture

The edits above are those a copy goes through. The ones here change what the picture shows: a patch of the image's own top-left corner pasted over its center, covering 1 to 16 % of the frame; the hue of every pixel turned, which recolors the picture; and a quarter turn, a half turn and a mirror image. Whether a hash should notice them depends on what it is for: a search for copies wants to ignore a recoloring, a search for retouched pictures wants to catch the patch.

The example photograph with a patch over 4 and 16 % of the frame, its hue turned by 90 and 180 degrees, turned by 90 and 180 degrees, and mirrored The example photograph with a patch over 4 and 16 % of the frame, its hue turned by 90 and 180 degrees, turned by 90 and 180 degrees, and mirrored

Each panel shows how far the edited images land from their originals over both corpora. The dashed line is the threshold of the chart above, the one that accepts 95 % of the copies, in each corpus's color: an edited image below it would be taken for a copy.

Bits that differ from the original for a patch, a hue rotation, and turns and a mirror, median and middle half over each corpus, against the threshold that accepts 95 % of copies Bits that differ from the original for a patch, a hue rotation, and turns and a mirror, median and middle half over each corpus, against the threshold that accepts 95 % of copies

  • A patch moves pHash further than the area hashes. Every DCT coefficient of the block is a sum over the whole 32×32 grid, so a change anywhere reaches all 64 of them; over 16 % of the frame few edited photographs pass for copies.
  • Turning the hue hardly moves the hash on photographs, whose muted colors keep their luminance under a hue rotation. On the flat, saturated colors of the synthetic images it moves the hash far.
  • A turn or a mirror image moves the hash as far as an unrelated image. A mirror reverses the sign of every cosine pattern that is odd from left to right, half the block, so it lands as far as a turn.
The numbers behind the chart

Median bits that differ, and the share of edited images within the threshold that accepts 95% of the copies; synthetic: synthetic corpus, 24 images; not photographs; photographs: 200 public-domain photographs from Wikimedia Commons.

Edit Synthetic: median (25th–75th) accepted as a copy Photographs: median (25th–75th) accepted as a copy
patch over 1% of the frame 7.5 (2.8–12.8) 88% 2 (1–4) 97%
patch over 4% of the frame 12.5 (10.8–16) 75% 6 (4–8.2) 62%
patch over 9% of the frame 16 (12.8–24) 54% 9 (6–12) 30%
patch over 16% of the frame 15.5 (14–20.5) 58% 13 (10–16) 10%
hue turned 30° 6.5 (0–19) 71% 3 (1–5) 90%
hue turned 90° 16 (5–35.8) 58% 2 (1–4) 96%
hue turned 180° 16 (6.5–41) 58% 4 (2–7) 76%
turned 90° 20 (16–31.5) 38% 32 (29–34) 0%
turned 180° 30 (7–31.2) 38% 32 (30–33) 0%
mirrored 20 (16–30.2) 42% 32 (30–32.2) 0%
How this was measured

The same measurement as for the copies, over the same images: each edit is applied alone to the original, the copy is saved losslessly, and the library compares it with the original. The threshold is the one of the chart above, computed from the copies. Below is the code of the edits.

tools/site/measure/transforms.py
def content_edits():
    """The edits that change the picture, in the shape of transforms(); a strength is a
    number, or a word where the edit has no scale.

    A patch replaces a square at the center, covering the given share of the frame, with
    the square of the same size at the top-left corner of the same image: a local edit
    with content the image itself supplies. A hue rotation turns every pixel's hue by the
    given angle and keeps its saturation and value, a recoloring. The turns and the mirror
    change only the orientation.
    """
    def patch(area):
        def f(im):
            side = area ** 0.5
            w, h = round(im.width * side), round(im.height * side)
            x0, y0 = (im.width - w) // 2, (im.height - h) // 2
            out = im.copy()
            out.paste(im.crop((0, 0, w, h)), (x0, y0))
            return out, ("ppm", None)
        return f

    def hue(degrees):
        def f(im):
            hsv = np.asarray(im.convert("HSV")).copy()
            hsv[..., 0] = (hsv[..., 0].astype(int) + round(degrees * 256 / 360)) % 256
            return Image.fromarray(hsv, "HSV").convert("RGB"), ("ppm", None)
        return f

    def turn(method):
        return lambda im: (im.transpose(method), ("ppm", None))

    return [
        ("Local patch", "share of the frame, %", [(a, patch(a / 100)) for a in (1, 4, 9, 16)]),
        ("Hue rotation", "degrees", [(d, hue(d)) for d in (30, 90, 180)]),
        ("Turn and mirror", "", [("90°", turn(Image.Transpose.ROTATE_90)),
                                 ("180°", turn(Image.Transpose.ROTATE_180)),
                                 ("mirror", turn(Image.Transpose.FLIP_LEFT_RIGHT))]),
    ]

What the DCT buys

pHash reads the same area grid as aHash, four times finer, and differs in what it thresholds: aHash the 64 cells, pHash the 64 coarsest of the grid's 1024 cosine patterns. Here are both, edit by edit, over both corpora, as the mean number of bits that differ from the original:

Mean bits that differ from the original for aHash and pHash under rotation, crop, gamma, noise and JPEG quality, over the photographs and the synthetic images Mean bits that differ from the original for aHash and pHash under rotation, crop, gamma, noise and JPEG quality, over the photographs and the synthetic images

  • Rotation and cropping move pHash further. A shift moves the shapes against every cosine pattern at once, where it carries only the cells near an edge across aHash's mean.
  • Gamma moves pHash a little more at the strongest settings: a tone curve changes the shape of the brightness, not only its scale, and the patterns follow.
  • Recompression and noise leave both alone on photographs. On the synthetic images they move pHash far more: their low frequencies hold little (see the margin), and noise reaches the coefficients that crowd the threshold.

So the DCT does not make copies closer: on every corpus they sit further from the original than aHash's, and the threshold that keeps 95 % of them is higher. What it buys is in the different images. On the photographs the gap between copies and different images is much wider than aHash's, and the threshold lets no pair of different photographs through; on the synthetic images the order is reversed:

Corpus Hash d′ Threshold Different pairs within it
photographs aHash 5.04 4 of 64 9 of 19,900
photographs pHash 8.85 7 of 64 0 of 19,900
synthetic images aHash 4.36 8 of 64 2.9%
synthetic images pHash 3.41 17 of 64 10.5%
The numbers behind the chart

Mean bits that differ from the original, over each corpus:

Transform Strength photographs, aHash photographs, pHash synthetic images, aHash synthetic images, pHash
JPEG quality 95 0.0 0.1 0.7 2.1
JPEG quality 75 0.1 0.2 4.0 2.6
JPEG quality 50 0.1 0.3 0.4 3.5
JPEG quality 30 0.2 0.3 0.6 4.8
JPEG quality 15 0.2 0.5 4.5 5.6
JPEG quality 5 0.7 1.4 4.6 9.7
Downscale 0.75 0.0 0.1 0.3 0.7
Downscale 0.5 0.1 0.2 0.7 1.1
Downscale 0.25 0.1 0.2 0.5 2.1
Downscale 0.125 0.1 0.3 2.8 2.1
Rotation 1 1.0 2.7 1.1 10.6
Rotation 2 1.9 4.6 5.1 11.9
Rotation 5 4.7 10.3 8.7 15.9
Rotation 10 8.8 17.6 13.6 19.3
Rotation 20 15.1 25.2 16.8 21.1
Rotation 45 23.5 29.7 23.1 23.8
Brightness 0.5 0.3 0.3 1.0 1.1
Brightness 0.7 0.3 0.3 1.0 0.8
Brightness 0.85 0.2 0.2 0.9 0.9
Brightness 1.15 0.5 1.0 1.0 2.9
Brightness 1.3 1.2 2.1 2.5 5.2
Brightness 1.5 2.7 4.0 3.4 6.0
Contrast 0.5 0.3 0.3 0.9 1.5
Contrast 0.7 0.4 0.3 0.9 1.2
Contrast 0.85 0.4 0.2 0.9 0.6
Contrast 1.15 0.3 0.7 0.6 1.6
Contrast 1.3 0.3 1.3 0.5 1.9
Contrast 1.5 0.7 1.9 1.1 2.8
Gamma 0.5 2.4 3.4 2.0 6.0
Gamma 0.7 1.4 2.1 1.3 4.3
Gamma 0.85 0.7 1.1 1.4 2.7
Gamma 1.2 0.8 1.3 2.0 2.5
Gamma 1.5 1.8 2.6 2.3 3.5
Gamma 2 3.5 4.7 5.2 5.1
Gaussian blur 0.5 0.0 0.1 0.3 0.3
Gaussian blur 1 0.3 0.2 0.8 0.9
Gaussian blur 2 0.1 0.2 1.9 1.1
Gaussian blur 4 0.2 0.3 3.0 2.5
Gaussian blur 8 0.3 0.5 2.9 3.4
Noise 2 0.1 0.2 0.7 5.9
Noise 5 0.1 0.2 0.8 7.7
Noise 10 0.1 0.3 1.7 10.4
Noise 20 0.2 0.5 2.7 12.6
Noise 40 0.3 0.9 4.3 15.5
Crop 5 2.9 5.2 5.5 5.5
Crop 10 5.7 10.1 8.5 9.5
Crop 20 11.4 19.0 14.7 14.6
Crop 30 16.4 25.9 18.1 17.7
How this was measured

The values are those of the corpus charts above, from the same cached measurement, for both algorithms; the figure and the table take their mean over the corpus instead of the median, since the median of a small edit is 0 for both.

How the nine algorithms compare is on choosing an algorithm.

Cost

pHash costs what aHash does, on a small image and on a large one (the times are in the table at the top of the page). The 32×32 grid comes from the same cached area pass over the grayscale image as aHash's, wHash's and BMH's, which is what grows with the image; the transform that follows works on 1024 values whatever the image's size, and computes only the 64 coefficients of the block, not all 1024.

How this was measured

Measured on Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz, 4 cores, Linux x86_64; compiled by 13.3.0, optimized, with libjpeg-turbo decoding the JPEGs; the minimum of the runs. Decoding, every hash at its default settings, and in bold this page's.

Case 400×400 20 Mpx
decode 0.53 ms 105 ms
ahash 0.21 ms 23 ms
dhash 0.23 ms 27 ms
phash 0.21 ms 23 ms
whash 0.21 ms 23 ms
bmh 0.21 ms 23 ms
mhash 1.8 ms 91 ms
radial 1.4 ms 120 ms
color_hash 0.24 ms 31 ms
color_moments 0.52 ms 66 ms
phash_dct_8 0.21 ms 23 ms
phash_dct_16 0.21 ms 23 ms
phash_dct_24 0.21 ms 23 ms

Each case runs once to warm up, then until it has run at least five times and for at least a second; the time is the minimum. The grayscale image and the area grid the library caches are dropped before every run, so each run is the first hash on a loaded image. The cases, and the timing loop:

tools/site/stages/timing.c
/* Seconds on a monotonic clock, as tests/src/bench_hash.c reads it. The calendar clock
 * (timespec_get() with TIME_UTC) would step back when the system corrects its time, and a
 * run spanning the step would come out negative and become the minimum. On Darwin,
 * CLOCK_MONOTONIC counts whole microseconds, 2 % of the cheapest hash; mach_absolute_time()
 * counts nanoseconds. */
static double seconds(void) {
#ifdef __APPLE__
    static mach_timebase_info_data_t tb;
    if (tb.denom == 0) {
        mach_timebase_info(&tb);
    }
    return (double)mach_absolute_time() * tb.numer / tb.denom * 1e-9;
#else
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
#endif
}

/* One timed case: `run` does the work once on a loaded image and returns nonzero on
 * failure; `arg` selects a variant of it. A case named after an algorithm, or starting
 * with its name and "_", is shown on that algorithm's page. */
typedef struct {
    const char *name;
    int (*run)(ph_context_t *ctx, const char *path, int arg);
    int arg;
} time_case_t;

static int time_decode(ph_context_t *ctx, const char *path, int arg) {
    (void)arg;
    return ph_load_from_file(ctx, path) != PH_SUCCESS;
}

/* The first hash computed on a loaded image, which is what a caller who loads an image
 * and hashes it pays: the grayscale conversion and the shared area grid are dropped
 * before every run, so each run computes them (the dropping is untimed). */
static int time_hash(ph_context_t *ctx, const char *path, int algo) {
    (void)path;
    ph_digest_t d;
    return ph_compute_digest(ctx, (ph_algorithm_t)algo, &d) != PH_SUCCESS;
}

/* The four 64-bit hashes in one ph_compute_multi() call, which computes the grayscale
 * image and the area grid once for all of them. */
static int time_multi(ph_context_t *ctx, const char *path, int arg) {
    (void)path;
    (void)arg;
    uint64_t h[PH_HASH_FLAGS_COUNT];
    return ph_compute_multi(ctx, PH_HASH_AHASH | PH_HASH_DHASH | PH_HASH_PHASH | PH_HASH_WHASH,
                            h) != PH_SUCCESS;
}

static int time_whash_full(ph_context_t *ctx, const char *path, int arg) {
    (void)path;
    (void)arg;
    uint64_t h;
    int bad = ph_context_set_whash_mode(ctx, PH_WHASH_FULL) != PH_SUCCESS ||
              ph_compute_whash(ctx, &h) != PH_SUCCESS;
    return ph_context_set_whash_mode(ctx, PH_WHASH_FAST) != PH_SUCCESS || bad;
}

static int time_mhash_size(ph_context_t *ctx, const char *path, int size) {
    (void)path;
    ph_digest_t d;
    int bad = ph_context_set_mhash_params(ctx, PH_MH_ALPHA, PH_MH_LEVEL, size) != PH_SUCCESS ||
              ph_compute_mhash(ctx, &d) != PH_SUCCESS;
    return ph_context_set_mhash_params(ctx, PH_MH_ALPHA, PH_MH_LEVEL, PH_MH_IMAGE_SIZE) !=
               PH_SUCCESS ||
           bad;
}

/* pHash with a dct_size other than the default, and the default block. */
static int time_phash_dct(ph_context_t *ctx, const char *path, int size) {
    (void)path;
    uint64_t h;
    int bad = ph_context_set_phash_params(ctx, size, PH_DCT_REDUCTION_SIZE) != PH_SUCCESS ||
              ph_compute_phash(ctx, &h) != PH_SUCCESS;
    return ph_context_set_phash_params(ctx, PH_DCT_SIZE, PH_DCT_REDUCTION_SIZE) != PH_SUCCESS ||
           bad;
}

/* Radial with the settings of radial_variants[variant]. */
static int time_radial(ph_context_t *ctx, const char *path, int variant) {
    (void)path;
    ph_digest_t d;
    int bad = !radial_settings(ctx, &radial_variants[variant]) ||
              ph_compute_radial_hash(ctx, &d) != PH_SUCCESS;
    return !radial_settings(ctx, &radial_variants[RADIAL_DEFAULT]) || bad;
}

/* Loading the image, then, unless `algo` is LOAD_ONLY, computing that algorithm: what a
 * caller pays for a load and a hash. `arg` packs the load -- a ph_decode_scale_t, plus
 * LOAD_GRAY for ph_context_set_load_grayscale() and LOAD_FRESH for a context created and
 * freed around the load, as a caller who creates one per image does -- and the algorithm
 * above it. Without LOAD_FRESH, a context of its own per kind of load, kept across runs,
 * so the cases after it hash the image as it was first loaded. */
enum {
    LOAD_GRAY = 4,
    LOAD_FRESH = 8,
    LOAD_ALGO = 16,
    LOAD_ONLY = -1,
};

static int time_load(ph_context_t *ctx, const char *path, int arg) {
    (void)ctx;
    static ph_context_t *kept[LOAD_FRESH];
    int kind = arg % LOAD_FRESH, fresh = arg & LOAD_FRESH, algo = arg / LOAD_ALGO - 1;
    ph_context_t *c = fresh ? NULL : kept[kind];
    if (!c && ph_create(&c) != PH_SUCCESS) {
        return 1;
    }
    ph_digest_t d;
    int bad = ph_context_set_decode_scale(c, (ph_decode_scale_t)(kind & 3)) != PH_SUCCESS ||
              ph_context_set_load_grayscale(c, (kind & LOAD_GRAY) != 0) != PH_SUCCESS ||
              ph_load_from_file(c, path) != PH_SUCCESS ||
              (algo != LOAD_ONLY && ph_compute_digest(c, (ph_algorithm_t)algo, &d) != PH_SUCCESS);
    if (fresh) {
        ph_free(c);
    } else {
        kept[kind] = c;
    }
    return bad;
}

#define LOADED(name, flags, algo) {name, time_load, (flags) + ((algo) + 1) * LOAD_ALGO}

static const time_case_t time_cases[] = {
    {"decode", time_decode, 0},
    {"ahash", time_hash, PH_ALGO_AHASH},
    {"dhash", time_hash, PH_ALGO_DHASH},
    {"phash", time_hash, PH_ALGO_PHASH},
    {"whash", time_hash, PH_ALGO_WHASH},
    {"whash_full", time_whash_full, 0},
    {"bmh", time_hash, PH_ALGO_BMH},
    {"mhash", time_hash, PH_ALGO_MHASH},
    {"radial", time_hash, PH_ALGO_RADIAL},
    {"color_hash", time_hash, PH_ALGO_COLOR_HASH},
    {"color_moments", time_hash, PH_ALGO_COLOR_MOMENTS},
    {"multi", time_multi, 0},
    {"mhash_size_62", time_mhash_size, 62},
    {"mhash_size_128", time_mhash_size, 128},
    {"mhash_size_256", time_mhash_size, 256},
    {"mhash_size_512", time_mhash_size, 512},
    {"mhash_size_1024", time_mhash_size, 1024},
    {"mhash_size_2048", time_mhash_size, 2048},
    {"mhash_size_4096", time_mhash_size, 4096},
    {"phash_dct_8", time_phash_dct, 8},
    {"phash_dct_16", time_phash_dct, 16},
    {"phash_dct_24", time_phash_dct, 24},
    {"radial_sigma_1", time_radial, RADIAL_SIGMA_1},
    {"radial_sigma_8", time_radial, RADIAL_SIGMA_8},
    {"radial_gamma_2", time_radial, RADIAL_GAMMA_2},
    {"radial_grid_40x32", time_radial, RADIAL_GRID_40X32},
    {"radial_grid_90x64", time_radial, RADIAL_GRID_90X64},
    {"radial_grid_360x256", time_radial, RADIAL_GRID_360X256},
    {"radial_grid_1440x1024", time_radial, RADIAL_GRID_1440X1024},
    {"radial_grid_4096x4096", time_radial, RADIAL_GRID_4096X4096},
    LOADED("scale_half_decode", PH_DECODE_SCALE_HALF, LOAD_ONLY),
    LOADED("scale_quarter_decode", PH_DECODE_SCALE_QUARTER, LOAD_ONLY),
    LOADED("scale_eighth_decode", PH_DECODE_SCALE_EIGHTH, LOAD_ONLY),
    LOADED("scale_full_phash", PH_DECODE_SCALE_FULL, PH_ALGO_PHASH),
    LOADED("scale_half_phash", PH_DECODE_SCALE_HALF, PH_ALGO_PHASH),
    LOADED("scale_quarter_phash", PH_DECODE_SCALE_QUARTER, PH_ALGO_PHASH),
    LOADED("scale_eighth_phash", PH_DECODE_SCALE_EIGHTH, PH_ALGO_PHASH),
    LOADED("scale_full_mhash", PH_DECODE_SCALE_FULL, PH_ALGO_MHASH),
    LOADED("scale_half_mhash", PH_DECODE_SCALE_HALF, PH_ALGO_MHASH),
    LOADED("scale_quarter_mhash", PH_DECODE_SCALE_QUARTER, PH_ALGO_MHASH),
    LOADED("scale_eighth_mhash", PH_DECODE_SCALE_EIGHTH, PH_ALGO_MHASH),
    LOADED("scale_full_radial", PH_DECODE_SCALE_FULL, PH_ALGO_RADIAL),
    LOADED("scale_half_radial", PH_DECODE_SCALE_HALF, PH_ALGO_RADIAL),
    LOADED("scale_quarter_radial", PH_DECODE_SCALE_QUARTER, PH_ALGO_RADIAL),
    LOADED("scale_eighth_radial", PH_DECODE_SCALE_EIGHTH, PH_ALGO_RADIAL),
    LOADED("gray_decode", LOAD_GRAY, LOAD_ONLY),
    LOADED("gray_phash", LOAD_GRAY, PH_ALGO_PHASH),
    LOADED("gray_mhash", LOAD_GRAY, PH_ALGO_MHASH),
    LOADED("gray_radial", LOAD_GRAY, PH_ALGO_RADIAL),
    LOADED("fresh_decode", LOAD_FRESH, LOAD_ONLY),
    LOADED("fresh_phash", LOAD_FRESH, PH_ALGO_PHASH),
};

static int cmp_double(const void *a, const void *b) {
    double x = *(const double *)a, y = *(const double *)b;
    return (x > y) - (x < y);
}

/* One warm-up run, then at least TIME_MIN_RUNS runs and TIME_MIN_SECONDS, at most
 * TIME_MAX_RUNS; the minimum is what the pages quote. */
enum {
    TIME_MIN_RUNS = 5,
    TIME_MAX_RUNS = 300,
};

#define TIME_MIN_SECONDS 1.0

/* Whether case `name` is among the `n` names of `only`; every case is when `n` is 0. */
static int time_chosen(const char *name, int n, char **only) {
    for (int i = 0; i < n; i++) {
        if (strcmp(name, only[i]) == 0) {
            return 1;
        }
    }
    return n == 0;
}

/* site_stages time <image> [case...]: every case of time_cases[] on the image, or the cases
 * named; the minimum and the median of its runs, in milliseconds, as one JSON object, with
 * the build that ran them. */
int mode_time(int argc, char **argv) {
    for (int i = 1; i < argc; i++) {
        int known = 0;
        for (size_t c = 0; c < COUNT(time_cases); c++) {
            known |= strcmp(argv[i], time_cases[c].name) == 0;
        }
        if (!known) {
            return fail("no such timed case", argv[i]);
        }
    }
    ph_context_t *ctx = NULL;
    if (load_image(&ctx, argv[0])) {
        return 1;
    }
    json_t j = json_begin(stdout);
    json_int(&j, "width", ctx->image.width);
    json_int(&j, "height", ctx->image.height);
    json_string(&j, "build_info", ph_get_build_info());
#ifdef __VERSION__
    json_string(&j, "compiler", __VERSION__);
#endif
    json_t cases = json_object(&j, "cases");
    static double ms[TIME_MAX_RUNS];
    for (size_t c = 0; c < COUNT(time_cases); c++) {
        const time_case_t *tc = &time_cases[c];
        if (!time_chosen(tc->name, argc - 1, argv + 1)) {
            continue;
        }
        int runs = 0;
        double total = 0.0;
        for (int k = -1; k < TIME_MAX_RUNS; k++) { /* k = -1: the warm-up */
            ph_drop_gray_cache(ctx);
            double t0 = seconds();
            if (tc->run(ctx, argv[0], tc->arg)) {
                ph_free(ctx);
                return fail("timed case failed", tc->name);
            }
            double t = seconds() - t0;
            if (k >= 0) {
                ms[runs++] = t * 1e3;
                total += t;
                if (runs >= TIME_MIN_RUNS && total >= TIME_MIN_SECONDS) {
                    break;
                }
            }
        }
        qsort(ms, (size_t)runs, sizeof(ms[0]), cmp_double);
        json_t o = json_object(&cases, tc->name);
        json_double(&o, "min_ms", ms[0]);
        json_double(&o, "median_ms", ms[runs / 2]);
        json_int(&o, "runs", runs);
        json_close_object(&o);
    }
    json_close_object(&cases);
    json_end(&j);
    ph_free(ctx);
    return 0;
}

The times are measured again only when the library, the tool or the machine changes:

tools/site/measure/timing.py
def measure_timing(tool):
    """{"machine": {...}, "small": {...}, "large": {...}, "scan": {...}}, each image's
    entry as `site_stages time` prints it (width, height, build_info, compiler, and per
    case the minimum and median in milliseconds) and the comparisons of a linear search as
    `site_stages scan` prints them (per case, nanoseconds per comparison), from the cache
    when nothing that decides them has changed, the machine included."""
    host = machine()
    paths = [os.path.join(ROOT, p) for p in IMAGES.values()]
    key = fingerprint(KEY_FILES, [tool] + paths, json.dumps(host, sort_keys=True))

    def measure():
        data = {"machine": host}
        for name, path in zip(IMAGES, paths):
            out = subprocess.run([tool, "time", path], check=True, capture_output=True,
                                 text=True).stdout
            data[name] = json.loads(out)
        data["scan"] = json.loads(subprocess.run([tool, "scan"], check=True,
                                                 capture_output=True, text=True).stdout)
        return data

    return cached("timing", key, measure, "timing")

Parameters

ph_context_set_phash_params() sets two sizes:

Parameter Range Default Meaning
dct_size reduction_size … 32 32 the side of the grid of step 2 and of the transform
reduction_size 4 … 8 8 the side of the block of step 4; the hash has reduction_size² bits

Hashes are comparable only when computed with the same parameters.

reduction_size

A smaller reduction_size keeps fewer patterns: the threshold is the median of that block's AC coefficients, and the bits go into the low reduction_size² bits of the hash, row by row, the rest staying 0. Here is the example at both ends of the range:

The 4×4 and the 8×8 block of the example's coefficients, with the bits each sets and the resulting hash The 4×4 and the 8×8 block of the example's coefficients, with the bits each sets and the resulting hash

The 4×4 block is the top-left corner of the 8×8 one, but its median is its own and its bits are packed four to a row, so the two hashes do not share a bit layout. Over both corpora, with the sizes 2 and 3 computed the same way, although the library refuses them:

d′ and the share of different pairs within the threshold against reduction_size from 2 to 8, for both corpora d′ and the share of different pairs within the threshold against reduction_size from 2 to 8, for both corpora

  • On the photographs every smaller block separates worse. Fewer patterns leave fewer ways for two photographs to differ: d′ falls at each step down, and at 4 the threshold that keeps 95 % of the copies lets through a share of different pairs that 8 does not.
  • Below 4 the hash is degenerate. A 2×2 block leaves three AC bits, and most photographs share one of a handful of hashes; a 3×3 block still lets many different photographs collide. That is why 4 is the lower bound.
  • On the synthetic images the size hardly matters. d′ stays level from 4 to 8; the 4×4 block lets the fewest different pairs through there, and the most on the photographs.
The numbers behind the chart
Corpus reduction_size Bits d′ Threshold Different pairs within it Distinct hashes of the originals
photographs 2 (below the range) 4 1.80 0 of 4 35.7% 3 of 200
photographs 3 (below the range) 9 3.32 1 of 9 2.2% 66 of 200
photographs 4 16 4.55 2 of 16 1.6% 193 of 200
photographs 5 25 5.82 2 of 25 5 of 19,900 200 of 200
photographs 6 36 6.88 4 of 36 1 of 19,900 200 of 200
photographs 7 49 7.98 5 of 49 0 of 19,900 200 of 200
photographs 8 64 8.85 7 of 64 0 of 19,900 200 of 200
synthetic images 2 (below the range) 4 1.48 1 of 4 52.5% 4 of 24
synthetic images 3 (below the range) 9 2.55 3 of 9 42.0% 16 of 24
synthetic images 4 16 3.32 3 of 16 5.4% 20 of 24
synthetic images 5 25 3.14 7 of 25 15.6% 21 of 24
synthetic images 6 36 3.31 9 of 36 8.7% 21 of 24
synthetic images 7 49 3.13 14 of 49 12.0% 21 of 24
synthetic images 8 64 3.41 17 of 64 10.5% 21 of 24
How this was measured

Every original of both corpora and its nine copies (those of the separability chart above) is hashed by site_stages phash-variants at every block size from 2 to 8 and at every dct_size of the next section. Each hash is computed from the block as in the steps above and, where the library accepts the parameters, checked against ph_compute_phash() with them. d′ and the threshold are computed as for the corpus charts, over reduction_size² bits.

tools/site/stages/phash.c
/* The DCT matrix of side n, by the library's formula (Zauner's definition 3.3, as
 * src/hashes/phash.c builds it for a dct_size other than 32). */
static void dct_matrix(float *matrix, int n) {
    float c = (float)sqrt(1.0 / n);
    for (int j = 0; j < n; j++) {
        matrix[j] = c;
    }
    c = (float)sqrt(2.0 / n);
    for (int i = 1; i < n; i++) {
        for (int j = 0; j < n; j++) {
            matrix[i * n + j] = (float)((double)c * cos(M_PI * i * (j + 0.5) / n));
        }
    }
}

/* pHash's block of r x r DCT coefficients of the image's d x d area grid, by the
 * library's own ph_dct2_partial(); its threshold, the median of the AC terms raised by
 * the margin; and the hash recomputed from them, checked against ph_compute_phash() with
 * the same parameters when the library accepts them (r from 4 to 8). Returns 0 on a
 * match. */
static int phash_block(ph_context_t *ctx, int d, int r, uint8_t grid[N * N], float block[64],
                       float *threshold, uint64_t *bits) {
    static float matrix[N * N];
    const float *mat = ph_get_dct_matrix_32();
    if (d != N) {
        dct_matrix(matrix, d);
        mat = matrix;
    }
    if (!ph_area_downscale(ctx, d, d, grid) ||
        ph_dct2_partial(mat, grid, d, r, block) != PH_SUCCESS) {
        return fail("cannot compute the DCT block", NULL);
    }
    float ac[63];
    const int m = r * r - 1;
    memcpy(ac, block + 1, (size_t)m * sizeof(float));
    sort_floats(ac, m); /* for the median and the range */
    float median = m % 2 ? ac[m / 2] : (ac[m / 2 - 1] + ac[m / 2]) * 0.5f;
    *threshold = median + PH_PHASH_MEDIAN_MARGIN * (ac[m - 1] - ac[0]);
    *bits = 0;
    for (int i = 0; i < r * r; i++) {
        if (block[i] > *threshold) {
            *bits |= 1ULL << i;
        }
    }
    if (r < MIN_R) {
        return 0; /* below the range ph_context_set_phash_params() accepts */
    }
    uint64_t lib = 0;
    int bad = ph_context_set_phash_params(ctx, d, r) != PH_SUCCESS ||
              ph_compute_phash(ctx, &lib) != PH_SUCCESS;
    if (ph_context_set_phash_params(ctx, N, MAX_R) != PH_SUCCESS || bad) {
        return fail("ph_compute_phash failed", NULL);
    }
    char what[48];
    snprintf(what, sizeof(what), "pHash (dct_size %d, block %d)", d, r);
    return check_hash64(what, *bits, lib);
}
tools/site/stages/phash.c
/* One image of `site_stages phash-variants`: its pHash at every block size from 2 to 8
 * ("r2" … "r8", dct_size 32; 2 and 3 are below what the library accepts, and computed
 * the same way) and at every dct_size of dct_sizes[] with the default block ("d8" …
 * "d32"), each that the library accepts checked against ph_compute_phash(). */
static int phash_variants_row(ph_context_t *ctx, json_t *row, const char *path) {
    (void)path;
    uint8_t grid[N * N];
    float block[64], threshold;
    uint64_t bits;
    char key[8];
    for (int r = 2; r <= MAX_R; r++) {
        if (phash_block(ctx, N, r, grid, block, &threshold, &bits)) {
            return 1;
        }
        snprintf(key, sizeof(key), "r%d", r);
        json_hex64(row, key, bits);
    }
    for (size_t k = 0; k < COUNT(dct_sizes); k++) {
        if (phash_block(ctx, dct_sizes[k], MAX_R, grid, block, &threshold, &bits)) {
            return 1;
        }
        snprintf(key, sizeof(key), "d%d", dct_sizes[k]);
        json_hex64(row, key, bits);
    }
    return 0;
}

/* site_stages phash-variants <image>...: one JSON line per image, as above. */
int mode_phash_variants(int argc, char **argv) {
    return for_each_image(argc, argv, phash_variants_row);
}
tools/site/measure/corpus.py
def write_copies(base, tmp, stem):
    """The copies of the separability measurement: one moderate strength of each of the
    nine edits (COPY_STRENGTHS), saved as measure/robustness.py saves them; their paths."""
    return [write_edit(dict(steps)[COPY_STRENGTHS[name]], base,
                       os.path.join(tmp, f"{stem}-{k}"))
            for k, (name, _, steps) in enumerate(transforms())]


def _variants_one(args):
    """One original and its copies, through `site_stages <mode>`: one row per file."""
    tool, mode, path, tmp, stem = args
    base = Image.open(path).convert("RGB")
    ref = os.path.join(tmp, f"{stem}.ppm")
    base.save(ref)
    files = [ref] + write_copies(base, tmp, stem)
    rows = run_lines(tool, mode, *files)
    for f in files:
        os.remove(f)
    return [{k: v for k, v in row.items() if k != "file"} for row in rows]


def measure_settings(tool, mode):
    """{corpus: {"n", "label", "images": [[original, copy…] rows per image]}}: every
    original of both corpora and its nine copies through `site_stages <mode>`, which
    prints one JSON row of digests per file, one digest per setting it tries. Cached like
    the corpora themselves, as <mode>-<corpus>."""
    result = {}
    for corpus in CORPORA:
        paths = images(tool, corpus)
        rows = []
        if paths:
            def measure():
                with tempfile.TemporaryDirectory() as tmp, \
                        concurrent.futures.ProcessPoolExecutor() as pool:
                    return list(pool.map(_variants_one, [(tool, mode, p, tmp, f"{i:04d}")
                                                         for i, p in enumerate(paths)]))
            rows = cached(f"{mode}-{corpus}", cache_key(paths), measure,
                          f"{mode}, {len(paths)} images and their copies")
        result[corpus] = {"n": len(paths), "label": label(corpus, len(paths)), "images": rows}
    return result
tools/site/measure/separability.py
def variant_distances(images, variant, to_bits):
    """(copies, different) for one setting a `site_stages <algo>-variants` mode hashes
    with: bits that differ between each original and its copies, and between every pair
    of distinct originals. `images` are the rows measure_settings() returns, `to_bits`
    turns one hexadecimal digest into its 0/1 bits."""
    b = np.array([[to_bits(row[variant]) for row in img] for img in images], np.uint8)
    copies = (b[:, 1:] != b[:, :1]).sum(axis=2).ravel()
    orig = b[:, 0].astype(np.int32)
    ones = orig.sum(axis=1)
    # Hamming distance of every pair i < j, from dot products of 0/1 vectors.
    same_ones = orig @ orig.T
    dist = ones[:, None] + ones[None, :] - 2 * same_ones
    iu = np.triu_indices(len(images), 1)
    return copies, dist[iu]

dct_size

dct_size is the side of the grid of step 2, and so of the transform; the block of step 4 is taken from whatever transform that is. At 32 the 8×8 block holds the lowest eighth of the frequencies the grid can hold, and the detail finer than them lies in the coefficients outside it. At 8 the block is the whole transform of an 8×8 grid, the grid aHash reads, and whatever an edit changes in that grid reaches the hash. Here is the example at four sizes:

The example photograph's grid at dct_size 8, 16, 24 and 32, with the bits of the default block at each, the bits that differ from dct_size 32 outlined The example photograph's grid at dct_size 8, 16, 24 and 32, with the bits of the default block at each, the bits that differ from dct_size 32 outlined

On the example only the coarsest grid changes the hash. Over both corpora, with the mean bits that the copies of three edits of fine detail move:

Corpus dct_size d′ Threshold Different pairs within it Copies, JPEG 50 Copies, blur 1 Copies, noise 5
photographs 8 8.64 7 of 64 0 of 19,900 0.6 0.9 0.6
photographs 16 8.71 7 of 64 0 of 19,900 0.3 0.4 0.3
photographs 24 8.83 7 of 64 0 of 19,900 0.3 0.3 0.2
photographs 32 8.85 7 of 64 0 of 19,900 0.3 0.2 0.2
synthetic images 8 2.93 17 of 64 14.1% 4.1 3.0 8.2
synthetic images 16 3.13 21 of 64 17.0% 4.6 1.8 9.5
synthetic images 24 3.38 17 of 64 10.9% 3.7 0.8 8.4
synthetic images 32 3.41 17 of 64 10.5% 3.5 0.9 7.7
  • A smaller grid separates a little worse, most on the synthetic images, and at 8 the copies made by recompression, blur and noise move further: the block then takes the fine detail in.
  • It costs the same. The time goes into the area pass over the image, which reads every pixel whatever the grid; the transform of at most 32×32 values is a small part of it. The times are in the table under Cost.

The default, 32, is therefore also the best of the range.

Settings that affect it

Five context settings change the pixels pHash reads.

Setting Effect on pHash
ph_context_set_gray_weights() the grayscale formula of step 1
ph_context_set_load_grayscale() a JPEG decoder converts to grayscale itself (measured), which can move a value by one level
ph_context_set_auto_orient() on by default: the hash describes the image as displayed, after its EXIF rotation
ph_context_set_alpha_mode() how transparent pixels are composited before grayscale (which background)
ph_context_set_decode_scale() a JPEG read by libjpeg-turbo is decoded at ½, ¼ or ⅛ of its size (measured); the 32×32 grid averages the smaller image into the same cells, and every hash stays within the threshold for copies at every scale

A level here and there in the 32×32 grid moves every coefficient a little, and can tip only one that lies near the threshold.

The example photograph hashes to 7eb66192bc394247 both ways.

In code

Loading an image and printing its pHash, with the decoder's grayscale, since pHash reads nothing else:

examples/basic_hash.c
/* Loads an image and prints its pHash. Build/run:
 *   cc basic_hash.c -o basic_hash $(pkg-config --cflags --libs libphash)
 *   ./basic_hash path/to/image.jpg
 */
#include <libphash.h>
#include <stdio.h>

int main(int argc, char **argv) {
    if (argc != 2) {
        fprintf(stderr, "usage: %s <image>\n", argv[0]);
        return 1;
    }

    ph_context_t *ctx = NULL;
    if (ph_create(&ctx) != PH_SUCCESS) {
        fprintf(stderr, "ph_create failed\n");
        return 1;
    }

    /* Fast grayscale loading: skips the RGB-to-gray conversion pass. Fine here since
     * pHash only ever operates on grayscale; skip this call to load in color if you
     * also want a color hash (ph_compute_color_hash(), ph_compute_color_moments_hash())
     * from the same load. */
    ph_context_set_load_grayscale(ctx, 1);

    ph_error_t err = ph_load_from_file(ctx, argv[1]);
    if (err != PH_SUCCESS) {
        fprintf(stderr, "failed to load %s: %s (%s)\n", argv[1], ph_get_error_string(err),
                ph_get_last_error_message(ctx));
        ph_free(ctx);
        return 1;
    }

    uint64_t hash = 0;
    err = ph_compute_phash(ctx, &hash);
    if (err != PH_SUCCESS) {
        fprintf(stderr, "ph_compute_phash failed: %s\n", ph_get_error_string(err));
        ph_free(ctx);
        return 1;
    }

    char hex[17];
    if (ph_hash_to_hex(hash, hex, sizeof(hex)) != PH_SUCCESS) {
        fprintf(stderr, "ph_hash_to_hex failed\n");
        ph_free(ctx);
        return 1;
    }
    printf("pHash: %s\n", hex);

    ph_free(ctx);
    return 0;
}

Where it comes from

pHash has no paper. It is what the pHash library's ph_dct_imagehash() computes, written by Evan Klinger and David Starkweather; the two careful descriptions of it, Christoph Zauner's thesis (2010) and Neal Krawetz's "Looks Like It" (2011), are third parties describing that code. Zauner traces the choice of 64 low-frequency coefficients to a DCT video hash by Coskun and Sankur (2004). This implementation follows the code where it is unambiguous: the 32×32 reduction, the type-II DCT with Zauner's matrix, the 8×8 block at DCT(0,0), the median taken over the 63 AC coefficients only, and the strict >.

Both written descriptions say the block leaves out DC, starting at DCT(1,1). The code does not: DC keeps its bit and loses only its vote on the median, and since it is always above that median, its bit is always set, so the hash is effectively 63 bits wide. Moving the block to (1,1) would give all 64 bits information; measured, it trades the dead bit for coefficients that separate copies from different images worse, and the block stays where pHash's code has it.

Three things differ from ph_dct_imagehash(). pHash smooths the image with a 7×7 mean filter before resizing; the exact area average here is a low-pass step of the same kind. The margin of step 5 is this library's own, so the hash values are not pHash's. And the bit order, which no source specifies, is this library's choice. The comparison, with the numbers behind each choice, is in provenance § 3.


  1. Measured when this site was built, on Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz, 4 cores, Linux x86_64; compiled by 13.3.0, optimized, with libjpeg-turbo decoding the JPEGs: the minimum of at least five runs after a warm-up. What each time covers, and the code, are under “How this was measured” beside it. ↩