Skip to content

aHash — Average Hash

Shrink the image to 8×8, and record for each of the 64 cells whether it is brighter or darker than the average. The simplest of the nine algorithms, and the cheapest: it shows the whole idea of perceptual hashing in two lines of arithmetic.

Call ph_compute_ahash(), or PH_HASH_AHASH in ph_compute_multi()
Output 64-bit hash, compared with ph_hamming_distance()
Source Neal Krawetz, "Looks Like It", 2011 (provenance)
Cost 0.21 ms on a 400×400 image, 23 ms on 20 Mpx, after decoding1

The steps

aHash has no parameters. Every picture on this page is computed by the library from the same photograph, and the hash at the end is what ph_compute_ahash() returns for it.

The four stages of aHash on the example photograph The four stages of aHash on the example photograph

1. Grayscale. Each pixel becomes one luminance value,

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

an integer approximation of the ITU-R BT.601 weights. ph_context_set_gray_weights() changes them; image preparation has why these weights.

2. Reduce to 8×8. The image is divided into an 8×8 grid of equal areas, regardless of its aspect ratio, and each cell becomes the mean of the pixels it covers, computed exactly and rounded to the nearest integer. Nothing is sampled or skipped: every pixel of the image counts towards exactly the cells it overlaps, in proportion to the overlap.

The 8×8 grid with its values The 8×8 grid with its values

3. Compare with the mean. With the 64 values \(p_0 \dots p_{63}\) read left to right and top to bottom, and their mean \(\bar p\), each cell contributes one bit:

\[ b_i = \begin{cases} 1 & p_i \ge \bar p \\ 0 & p_i < \bar p \end{cases} \]

The comparison is exact — \(64\,p_i \ge \sum_j p_j\) in integers — so a cell only ties with the mean when it is equal to it, and a tie sets the bit.

The 64 bits of aHash on the example photograph as an 8×8 grid, a cell filled where its bit is set: where the cell is at or above the mean The 64 bits of aHash on the example photograph as an 8×8 grid, a cell filled where its bit is set: where the cell is at or above the mean

4. Pack the bits. The first cell goes into the most significant bit:

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

The mean and ties

The mean of 64 integers is seldom an integer itself, and comparing with it rounded would not be the same hash: every cell equal to the rounded-down mean lies below the true one, and would set its bit. On an image with little contrast, the cells take only a value or two and that is most of the grid. Here is the image of the two corpora whose hash a rounded-down mean would change the most:

A synthetic image of green and magenta stripes, its 8×8 grid of 117s and 118s with the bits the exact mean sets, and the same grid with every bit set by the mean rounded down A synthetic image of green and magenta stripes, its 8×8 grid of 117s and 118s with the bits the exact mean sets, and the same grid with every bit set by the mean rounded down

The green and the magenta have almost the same luminance, and each cell averages several stripes into one gray, 117 or 118. The exact mean lies between the two values and splits them; rounded down, it would equal the lower one and set every bit.

The same happens without any rounding when an image is flat. Every cell then equals the mean, every comparison is a tie, and the hash is ffffffffffffffff, whatever the image's color. A detail finer than a cell averages out the same way, so a fine pattern hashes like a flat gray: the synthetic corpus has two fine checkerboards that do, and they get the same hash.

A mean does not split the bits in half either, as a median does: a dark image with a bright corner sets few of them. Over the two corpora:

Corpus Bits set: range; middle 80 % Hashes with all 64 Hashes a rounded-down mean changes; most bits Different pairs within the threshold: exact mean rounded-down mean
photographs (200) 16–48; 25–37 0 87; 8 9 of 19,900 10 of 19,900
synthetic images (24) 32–64; 32–54 2 5; 24 2.9% 8.7%

On the photographs a rounded-down mean would change a few bits of many hashes and separate copies from different images about as well; on the synthetic images, where nearly flat grids are common, it would let many more different pairs through. wHash thresholds the same grid at its median instead, which sets exactly half of the bits unless values tie.

How this was measured

Every original of both corpora and its nine copies (those of the separability chart below) is hashed by site_stages ahash-variants, which computes the hash from the grid as in the steps above, checks it against ph_compute_ahash(), and thresholds the same grid at its mean rounded down. d′ and the threshold are computed as for the corpus charts. The image in the figure is the one whose two hashes differ in the most bits.

tools/site/stages/ahash.c
/* The bits of an 8x8 grid: set where the cell is at or above the mean of the 64, compared
 * exactly as cell * 64 >= sum. With `floor_mean`, against the mean rounded down to an
 * integer instead, which the library does not do. */
static uint64_t ahash_bits(const uint8_t grid[N * N], int floor_mean) {
    uint64_t sum = 0;
    for (int i = 0; i < N * N; i++) {
        sum += grid[i];
    }
    uint64_t bits = 0;
    for (int i = 0; i < N * N; i++) {
        int set = floor_mean ? grid[i] >= sum / (N * N) : (uint64_t)grid[i] * (N * N) >= sum;
        if (set) {
            bits |= 1ULL << (63 - i);
        }
    }
    return bits;
}

/* aHash of a loaded image from its area grid, checked against ph_compute_ahash().
 * Returns 0 on a match. */
static int ahash_run(ph_context_t *ctx, uint8_t grid[N * N], uint64_t *bits) {
    if (!ahash_grid(ctx, GRID_AREA, grid)) {
        return fail("cannot reduce the image to 8x8", NULL);
    }
    *bits = ahash_bits(grid, 0);
    uint64_t lib = 0;
    if (ph_compute_ahash(ctx, &lib) != PH_SUCCESS) {
        return fail("ph_compute_ahash failed", NULL);
    }
    return check_hash64("aHash", *bits, lib);
}
tools/site/stages/ahash.c
/* One image of `site_stages ahash-variants`: its aHash as ph_compute_ahash() computes it
 * (ahash_run() checks), the hash of the same grid against its mean rounded down, and the
 * hashes of the grids the other two reductions give. */
static int ahash_variants_row(ph_context_t *ctx, json_t *row, const char *path) {
    (void)path;
    uint8_t grid[N * N];
    uint64_t bits = 0;
    if (ahash_run(ctx, grid, &bits)) {
        return 1;
    }
    json_hex64(row, "area", bits);
    json_hex64(row, "floor_mean", ahash_bits(grid, 1));
    for (int k = GRID_AREA + 1; k < GRIDS; k++) {
        if (!ahash_grid(ctx, k, grid)) {
            return fail("cannot reduce the image to 8x8", NULL);
        }
        json_hex64(row, grid_names[k], ahash_bits(grid, 0));
    }
    return 0;
}

/* site_stages ahash-variants <image>...: one JSON line per image, as above. */
int mode_ahash_variants(int argc, char **argv) {
    return for_each_image(argc, argv, ahash_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]

Why an exact area average

The post says only "reduce size", and how the image is reduced decides what a cell is. The library averages every pixel a cell covers, each weighted by how much of it the cell covers. Here it is beside two other ways to reach 8×8 from the same grayscale image: the Mitchell filter, which dHash reduces with and which weighs pixels by a smooth curve that reaches past the cell's edges, and the nearest pixel, which keeps the one pixel at each cell's center and drops the rest.

The example photograph's 8×8 grid by an exact area average, by the Mitchell filter and by the nearest pixel, with the bits each sets and the bits that differ from the area average outlined The example photograph's 8×8 grid by an exact area average, by the Mitchell filter and by the nearest pixel, with the bits each sets and the bits that differ from the area average outlined

The three grids agree in their broad layout and part on the cells that lie near the mean. Over the two corpora, with the copies and the different pairs of the separability chart below:

Corpus Reduction d′ Threshold Different pairs within it Different pairs with the same hash
photographs exact area average 5.04 4 of 64 9 of 19,900 0
photographs Mitchell filter 4.69 4 of 64 18 of 19,900 1
photographs nearest pixel 4.64 16 of 64 1.2% 0
synthetic images exact area average 4.36 8 of 64 2.9% 5
synthetic images Mitchell filter 3.40 23 of 64 9.4% 4
synthetic images nearest pixel 3.67 20 of 64 8.0% 4
  • The nearest pixel lets 64 pixels decide the hash. A rotation, a crop, a downscale or a blur changes which pixels those are, and its copies spread so far that the threshold that keeps 95 % of them takes in many different pairs.
  • The Mitchell filter moves copies of a photograph about as little as the area average, and separates different photographs a little less well. On the synthetic images it keeps part of the fine patterns that the area average cancels within a cell, and noise, blur and JPEG move what it keeps.
  • The exact area average separates best on both corpora. It is also shared: pHash, wHash and BMH reduce from the same cached pass over the image.
The numbers behind the table

Mean bits that differ between an original and its copy, for each edit and reduction:

Edit photographs, exact area average photographs, Mitchell filter photographs, nearest pixel synthetic images, exact area average synthetic images, Mitchell filter synthetic images, nearest pixel
JPEG quality 50 0.1 0.2 1.8 0.4 2.1 4.5
Downscale 0.5 0.1 0.1 3.5 0.7 2.3 2.3
Rotation 2 1.9 1.9 11.2 5.1 6.0 6.3
Brightness 1.15 0.5 0.6 0.4 1.0 1.0 1.1
Contrast 1.15 0.3 0.4 0.3 0.6 1.4 0.2
Gamma 1.2 0.8 0.8 1.0 2.0 3.2 2.0
Gaussian blur 1 0.3 0.4 3.1 0.8 2.9 0.9
Noise 5 0.1 0.1 1.2 0.8 5.1 5.4
Crop 5 2.9 2.6 12.5 5.5 6.4 10.2
How this was measured

The same measurement as for the ties above, by site_stages ahash-variants: each original and its copies reduced the three ways, each grid thresholded at its own mean. The area grid is the library's (its hash is checked against ph_compute_ahash()); the Mitchell grid is the library's own Mitchell resampling, the one dHash uses, and the nearest pixel is taken by the tool. The figure is site_stages ahash on the example photograph.

tools/site/stages/ahash.c
/* The 8x8 grids `site_stages ahash` draws side by side and `ahash-variants` measures:
 * the library's exact area average first, then two reductions it does not use. */
enum {
    GRID_AREA,     /* ph_area_downscale(): every pixel, weighted by its overlap */
    GRID_MITCHELL, /* ph_resize_mitchell(), the filter dHash reduces with */
    GRID_NEAREST,  /* one pixel per cell, the one nearest its center */
    GRIDS,
};

static const char *const grid_names[GRIDS] = {"area", "mitchell", "nearest"};

/* The 8x8 grid of a loaded image by one of the reductions above. */
static int ahash_grid(ph_context_t *ctx, int which, uint8_t grid[N * N]) {
    if (which == GRID_AREA) {
        return ph_area_downscale(ctx, N, N, grid);
    }
    const uint8_t *gray = ph_get_gray(ctx);
    const int w = ctx->image.width, h = ctx->image.height;
    if (!gray) {
        return 0;
    }
    if (which == GRID_MITCHELL) {
        return ph_resize_mitchell(gray, w, h, grid, N, N);
    }
    for (int r = 0; r < N; r++) {
        for (int c = 0; c < N; c++) {
            grid[r * N + c] = gray[(size_t)((2 * r + 1) * h / (2 * N)) * (size_t)w +
                                   (size_t)((2 * c + 1) * w / (2 * N))];
        }
    }
    return 1;
}

Bit layout

Reading the hash in hexadecimal reads the grid row by row: the first two hex digits are the top row, the last two the bottom row.

Which bit each cell sets Which bit each cell sets

What changes the hash

The bits record which side of the mean each cell is on, so an edit that keeps that order keeps the hash, and an edit that moves content between cells does not.

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, noise and blur leave the 8×8 averages where they were: the hash does not move until the image is blurred to a smear.
  • Brightness and contrast scale every cell and the mean together. The order is kept, and the hash with it, until values start to clip at white.
  • Gamma bends the values unevenly. The order of the cells is kept almost intact, but the mean moves against them, and cells that lie near it cross. Over the photographs below, the strongest gamma moves aHash about as much as a rotation of a few degrees, and more than it moves wHash, whose median depends only on the order.
  • Rotation and cropping move content from one cell to another, and the hash follows them: a 5° turn or a 10 % crop already moves a handful of bits. aHash 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 0
Downscale 0.75 0
Downscale 0.5 0
Downscale 0.25 0
Downscale 0.125 0
Rotation 1 0
Rotation 2 1
Rotation 5 5
Rotation 10 7
Rotation 20 12
Rotation 45 19
Brightness 0.5 0
Brightness 0.7 0
Brightness 0.85 0
Brightness 1.15 0
Brightness 1.3 0
Brightness 1.5 0
Contrast 0.5 0
Contrast 0.7 0
Contrast 0.85 0
Contrast 1.15 0
Contrast 1.3 0
Contrast 1.5 1
Gamma 0.5 0
Gamma 0.7 0
Gamma 0.85 0
Gamma 1.2 0
Gamma 1.5 0
Gamma 2 2
Gaussian blur 0.5 0
Gaussian blur 1 0
Gaussian blur 2 0
Gaussian blur 4 0
Gaussian blur 8 1
Noise 2 0
Noise 5 0
Noise 10 0
Noise 20 0
Noise 40 0
Crop 5 3
Crop 10 7
Crop 20 17
Crop 30 24
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

The photographs say what the single example says: rotation and cropping are the edits that move aHash, and everything else moves its median by a few bits at most. The synthetic images spread wider, as their bands show: they are made of hard edges and fine patterns, where a small shift moves a whole stripe from one cell into the next.

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: that is the price of the threshold. d′ sums up the gap in one number, the distance between the two means in units of their spread: above about 1 the distributions are usefully apart. On the photographs the two barely touch; the synthetic images, built to be hard, overlap more.

The numbers behind the charts
Corpus Copies Different pairs d′ Threshold Different pairs within it
synthetic corpus, 24 images; not photographs 216 276 4.36 8 2.9%
200 public-domain photographs from Wikimedia Commons 1800 19900 5.04 4 9 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 (0–0) 0 (0–0)
JPEG quality 75 0 (0–0) 0 (0–0)
JPEG quality 50 0 (0–0) 0 (0–0)
JPEG quality 30 0 (0–0.2) 0 (0–0)
JPEG quality 15 0 (0–1.2) 0 (0–0)
JPEG quality 5 0 (0–4.2) 0 (0–1)
Downscale 0.75 0 (0–0) 0 (0–0)
Downscale 0.5 0 (0–0) 0 (0–0)
Downscale 0.25 0 (0–0) 0 (0–0)
Downscale 0.125 0 (0–1.2) 0 (0–0)
Rotation 1 0 (0–2) 1 (0–1)
Rotation 2 2 (0–4.5) 2 (1–3)
Rotation 5 3 (0–11) 4 (3–6)
Rotation 10 8.5 (3–27.2) 8 (6–11)
Rotation 20 11.5 (6–29) 15 (11–19)
Rotation 45 20 (14–33) 24 (18–29)
Brightness 0.5 0 (0–0) 0 (0–1)
Brightness 0.7 0 (0–0) 0 (0–0)
Brightness 0.85 0 (0–0) 0 (0–0)
Brightness 1.15 0 (0–1) 0 (0–1)
Brightness 1.3 0 (0–3.5) 1 (0–2)
Brightness 1.5 0 (0–8) 2 (0–4)
Contrast 0.5 0 (0–0) 0 (0–0)
Contrast 0.7 0 (0–0) 0 (0–1)
Contrast 0.85 0 (0–0) 0 (0–1)
Contrast 1.15 0 (0–0.2) 0 (0–0)
Contrast 1.3 0 (0–0) 0 (0–1)
Contrast 1.5 0 (0–0) 0 (0–1)
Gamma 0.5 0 (0–2.5) 2 (1–4)
Gamma 0.7 0 (0–0.5) 1 (0–2)
Gamma 0.85 0 (0–2) 0 (0–1)
Gamma 1.2 0 (0–2) 0.5 (0–1)
Gamma 1.5 0 (0–2.5) 1 (0.8–3)
Gamma 2 4.5 (0–8.2) 3 (1–5)
Gaussian blur 0.5 0 (0–0) 0 (0–0)
Gaussian blur 1 0 (0–0) 0 (0–0)
Gaussian blur 2 0 (0–0) 0 (0–0)
Gaussian blur 4 0 (0–0) 0 (0–0)
Gaussian blur 8 0 (0–2) 0 (0–1)
Noise 2 0 (0–0) 0 (0–0)
Noise 5 0 (0–0) 0 (0–0)
Noise 10 0 (0–0.2) 0 (0–0)
Noise 20 0 (0–1) 0 (0–0)
Noise 40 1 (0–3.2) 0 (0–1)
Crop 5 0 (0–8) 3 (1–4)
Crop 10 0 (0–17) 5.5 (3–8)
Crop 20 8 (1.5–30.2) 11 (7.8–14)
Crop 30 16 (3.8–32) 16 (11.8–21)
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, from a copy of the corpus at a fixed commit, 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 changes only the cells it covers, and moves the hash in proportion to its area: over 4 % of the frame most edited photographs still pass for copies, over 16 % most do not.
  • Turning the hue hardly moves the hash on photographs. A hue rotation keeps each pixel's saturation and its brightest channel, and on the muted colors of most photographs the luminance barely changes. The synthetic images are flat, saturated colors, whose luminance a hue rotation changes a great deal, and there the hash moves as far as for an unrelated image.
  • A quarter or half turn moves the hash as far as an unrelated image. A mirror image moves it about half as far: it reverses the layout from left to right and keeps it from top to bottom.
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 0 (0–1.2) 100% 0 (0–1) 100%
patch over 4% of the frame 1.5 (0–2) 96% 2 (1–4) 83%
patch over 9% of the frame 2 (1–3.2) 92% 4 (3–6) 52%
patch over 16% of the frame 4 (1–10.2) 71% 7 (5–10) 16%
hue turned 30° 9 (0–16.2) 50% 2 (1–4) 76%
hue turned 90° 30 (12–57) 25% 1 (0–2) 90%
hue turned 180° 44 (25.5–58) 21% 2 (0.8–6) 69%
turned 90° 31 (13.5–32) 21% 30 (26–32) 1%
turned 180° 32 (14–64) 25% 36 (24–44) 2%
mirrored 20 (0–39) 38% 16 (10–26) 12%
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))]),
    ]

Two hashes are built like aHash and measure themselves against it on their pages: wHash thresholds the same 8×8 grid at its median (wHash and aHash), BMH a 16×16 grid at its median (block_size). How the nine algorithms compare is on choosing an algorithm.

Cost

aHash costs a grayscale conversion and one area-average pass over the image, which read every pixel and so grow with it; the 64 comparisons that follow do not (the times are in the table at the top of the page). The area pass is cached: pHash, wHash and BMH reduce from the same grid.

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

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

Settings that affect it

aHash has no parameters of its own: the grid is always 8×8. Five context settings change the pixels it reads.

Setting Effect on aHash
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 8×8 grid averages the smaller image into the same cells, and nearly every hash stays within the threshold for copies, down to images a few times the grid

A level here and there moves a cell by a fraction of a level, and can tip only a cell that lies next to the mean.

The example photograph hashes to 000c6461c1c3ffff both ways.

In code

Hashing two images and counting the bits their hashes differ in, for aHash and the other three 64-bit algorithms at once:

examples/hash_distance.c
/* Hashes two images with the four 64-bit algorithms and prints how many bits each pair
 * of hashes differs in. Build/run:
 *   cc hash_distance.c -o hash_distance $(pkg-config --cflags --libs libphash)
 *   ./hash_distance original.jpg edited.jpg
 */
#include <libphash.h>
#include <stdio.h>

#define ALL_FOUR (PH_HASH_AHASH | PH_HASH_DHASH | PH_HASH_PHASH | PH_HASH_WHASH)

/* aHash, dHash, pHash and wHash of one file, in that order: ph_compute_multi() writes one
 * slot per flag, in ascending bit order, from a single grayscale conversion. */
static int hash_file(const char *path, uint64_t out[PH_HASH_FLAGS_COUNT]) {
    ph_context_t *ctx = NULL;
    if (ph_create(&ctx) != PH_SUCCESS) {
        fprintf(stderr, "ph_create failed\n");
        return 1;
    }
    ph_error_t err = ph_load_from_file(ctx, path);
    if (err == PH_SUCCESS) {
        err = ph_compute_multi(ctx, ALL_FOUR, out);
    }
    if (err != PH_SUCCESS) {
        fprintf(stderr, "%s: %s (%s)\n", path, ph_get_error_string(err),
                ph_get_last_error_message(ctx));
    }
    ph_free(ctx);
    return err != PH_SUCCESS;
}

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

    static const char *const names[PH_HASH_FLAGS_COUNT] = {"aHash", "dHash", "pHash", "wHash"};
    for (int i = 0; i < PH_HASH_FLAGS_COUNT; i++) {
        printf("%s  %016llx  %016llx  %2d bits differ\n", names[i], (unsigned long long)a[i],
               (unsigned long long)b[i], ph_hamming_distance(a[i], b[i]));
    }
    return 0;
}

Where it comes from

aHash was described by Neal Krawetz in a blog post in 2011; there is no paper. The post fixes the 8×8 reduction, the grayscale step, the mean and the bit order, and leaves three things open, which this implementation pins: the resampling filter (an exact area average), the grayscale weights (BT.601) and the rule for a cell equal to the mean (the bit is set, compared exactly). With those, the implementation follows the post exactly, bit order included. The comparison, with the numbers behind each choice, is in provenance § 1.


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