Skip to content

Choosing an algorithm

The nine algorithms answer the same question, "is this the same picture?", from different descriptions of an image, and each description survives some edits and not others. This page puts them side by side on the measurements their own pages make: what each edit does to each hash, how far apart copies and different images land, what each costs, and how long its digest is. Every number comes from the two corpora the algorithm pages use, 200 photographs and 24 synthetic images, and from the machine that built this site.

Four questions

flowchart LR
    A{Must a change of<br/>color count?} -->|yes| CH[ColorHash,<br/>beside a layout hash]
    A -->|no| B{Are copies turned<br/>or mirrored?}
    B -->|half a turn| R[Radial]
    B -->|a quarter turn<br/>or a mirror| N[No layout hash;<br/>ColorHash ignores it]
    B -->|no| C{Are copies<br/>cropped?}
    C -->|yes| R
    C -->|no| D{Are the images<br/>photographs?}
    D -->|yes| P[pHash]
    D -->|flat shapes<br/>and patterns| BMH[BMH]

Each answer rests on a chart below:

  • Color. Seven of the nine hash a gray image, and a hue turned by 30° barely moves them; it takes ColorHash about half of the way to an unrelated image (edits that change the picture). ColorHash separates copies from different images less well than every layout hash but mHash on the photographs (separation), and it cannot tell two arrangements of the same colors apart, so it goes beside a layout hash, not instead of one.
  • Turns and mirrors. A quarter turn or a mirror image takes every layout hash about as far as an unrelated image. A half turn leaves Radial's digest unchanged, since its correlation looks for the profile at every shift. The two color hashes do not see the turn at all, and they are the only ones that survive a quarter turn or a mirror.
  • Crops. A 5 % crop moves Radial least of all, and on the photographs the threshold that accepts 95 % of all copies keeps nearly every cropped one; the layout hashes lose between one in seven and two in five of them (Comparing hashes).
  • Photographs or patterns. For copies that are neither turned nor cropped, pHash separates copies from different photographs best, with no pair of different photographs inside its threshold, at the cost of aHash. On the synthetic images, which are checkerboards, stripes, rings and discs, pHash is among the worst and BMH is the best. The synthetic set has 24 images, and its numbers are the coarser of the two.

Time does not enter the choice above: aHash, pHash, wHash and BMH share one pass over the image and cost the same, and they are the cheapest four. Radial, the answer to two of the questions, is the most expensive on a large image (cost).

What each edit does

Every algorithm measures closeness on its own scale: bits that differ, a correlation, an intersection, a distance. To set them side by side, each value below is the distance of a copy from the original's own hash in units of the distance between two typical different images: 0 is the original's hash, 1 is as far as the median pair of different images of the corpus. A cell is the median over the corpus. The copies are the ones every algorithm page charts, one moderate strength of each of nine edits.

A matrix of nine algorithms by nine edits on the photographs, each cell the median distance of a copy from the original in units of the median distance between different photographs: near 0 nearly everywhere, mHash most of the way to 1 for a rotation and a crop, the two color hashes a little above 0 for brightness, contrast and gamma A matrix of nine algorithms by nine edits on the photographs, each cell the median distance of a copy from the original in units of the median distance between different photographs: near 0 nearly everywhere, mHash most of the way to 1 for a rotation and a crop, the two color hashes a little above 0 for brightness, contrast and gamma

  • Recompression, scaling, blur and noise leave nearly every hash where it was. They change pixels, not the layout of light and dark or the mix of colors.
  • Rotation and crop are the edits the layout hashes feel: they move content between the cells of the grid. mHash, which records where fine detail lies to within a few pixels, goes most of the way to an unrelated image under either. Radial barely moves under a crop.
  • Brightness, contrast and gamma are the edits the color hashes feel: they move colors between bins and shift the moments. The layout hashes compare gray levels with each other, with a mean, a median or a neighbor, and these edits keep their order.

A median says where the middle copy lands, not how many copies fall outside a threshold. Radial's median for a 2° rotation is as small as aHash's, yet its threshold misses a third of the rotated photographs: on this scale, the threshold that accepts 95 % of Radial's copies lies closer to 0 than any other algorithm's, and a small move crosses it. Comparing hashes counts the copies of each edit that a threshold keeps.

The synthetic images

The same matrix on the 24 synthetic images: near 0 for most cells, mHash further from 0 for JPEG, scaling, rotation, noise and crop, ColorMoments further from 0 for brightness, contrast and gamma The same matrix on the 24 synthetic images: near 0 for most cells, mHash further from 0 for JPEG, scaling, rotation, noise and crop, ColorMoments further from 0 for brightness, contrast and gamma

The sharp edges of the synthetic images make mHash feel recompression, scaling and noise as well, and pHash feel rotation more; the rest of the pattern is the photographs'.

The numbers behind the chart

200 public-domain photographs from Wikimedia Commons

Algorithm JPEG 50 Downscale ½ Rotation 2° Brightness ×1.15 Contrast ×1.15 Gamma 1.2 Blur σ 1 Noise σ 5 Crop 5 %
aHash 0.00 0.00 0.06 0.00 0.00 0.02 0.00 0.00 0.09
dHash 0.00 0.00 0.16 0.03 0.03 0.03 0.03 0.00 0.12
pHash 0.00 0.00 0.12 0.00 0.00 0.03 0.00 0.00 0.16
wHash 0.00 0.00 0.06 0.00 0.00 0.00 0.00 0.00 0.12
mHash 0.04 0.06 0.74 0.03 0.03 0.02 0.02 0.04 0.86
BMH 0.00 0.00 0.11 0.01 0.01 0.02 0.01 0.00 0.14
Radial 0.00 0.01 0.06 0.00 0.00 0.01 0.00 0.00 0.01
ColorHash 0.03 0.02 0.03 0.20 0.11 0.16 0.04 0.12 0.03
ColorMoments 0.01 0.03 0.01 0.24 0.13 0.20 0.06 0.00 0.03

Synthetic corpus, 24 images; not photographs

Algorithm JPEG 50 Downscale ½ Rotation 2° Brightness ×1.15 Contrast ×1.15 Gamma 1.2 Blur σ 1 Noise σ 5 Crop 5 %
aHash 0.00 0.00 0.06 0.00 0.00 0.00 0.00 0.00 0.00
dHash 0.02 0.00 0.13 0.00 0.00 0.03 0.00 0.05 0.18
pHash 0.07 0.00 0.41 0.00 0.00 0.03 0.00 0.14 0.14
wHash 0.00 0.00 0.03 0.00 0.00 0.00 0.00 0.00 0.03
mHash 0.19 0.24 0.39 0.02 0.03 0.01 0.09 0.46 0.60
BMH 0.00 0.00 0.06 0.00 0.00 0.00 0.00 0.00 0.06
Radial 0.00 0.04 0.04 0.00 0.00 0.00 0.00 0.00 0.01
ColorHash 0.05 0.02 0.03 0.12 0.06 0.04 0.03 0.02 0.02
ColorMoments 0.09 0.04 0.05 0.40 0.24 0.29 0.05 0.02 0.05
How this was measured

The values are the ones the algorithm pages chart, read from the cache; nothing is measured for this page. Each copy's value is put on the common scale, and the cell is the median over the corpus. Below is the code that ran, not a copy of it.

tools/site/pages/choosing.py
def moved(values, different, lower_is_closer):
    """How far an edit moves a hash toward an unrelated image: the median over the corpus
    of each copy's distance from identical, in units of the median distance between two
    different images. 0 is the original's own hash, 1 is as far as a typical pair of
    different images. Identical is 0 for a distance and 1 for a similarity (Radial's peak
    correlation, ColorHash's intersection)."""
    identical = 0.0 if lower_is_closer else 1.0
    typical = np.median(different) - identical
    return float(np.median((np.asarray(values, float) - identical) / typical))
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/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")
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;
    }
}
make site

Edits that change the picture

Whether these edits should be noticed depends on the collection: a patch over a corner may be a watermark or a different photograph, a turned hue may be the same product in another color or a fake. The same scale, every strength of each edit:

A matrix of nine algorithms by ten edits on the photographs: a patch over 1 to 16 % of the frame, a hue turned by 30, 90 and 180 degrees, a quarter and a half turn and a mirror image. Turns and mirrors put the layout hashes near or above 1, except Radial at 0 for a half turn; the color hashes stay at 0 for every turn and mirror and go up to about 1 for a turned hue A matrix of nine algorithms by ten edits on the photographs: a patch over 1 to 16 % of the frame, a hue turned by 30, 90 and 180 degrees, a quarter and a half turn and a mirror image. Turns and mirrors put the layout hashes near or above 1, except Radial at 0 for a half turn; the color hashes stay at 0 for every turn and mirror and go up to about 1 for a turned hue

  • A turned hue is seen by ColorHash, half of the way to an unrelated image at 30°. ColorMoments, which is dominated by tone, moves far less. The gray hashes barely move on the photographs; on the synthetic images, whose pure colors turn into colors of another lightness, they do.
  • A turn or a mirror takes the layout hashes to about 1, and a half turn some of them beyond it: a half turn moves every cell of the grid to the opposite one, and the hash of that layout can be further from the original than an unrelated photograph's. Radial is the exception for the half turn and not for the quarter; the color hashes are unchanged by all three.
  • A patch moves every hash a little more as it grows. Radial moves the most, then pHash; the color hashes, which see only how much of each color there is, the least.
The synthetic images

The same matrix on the 24 synthetic images: turned hues move every gray hash, up to beyond 1 for a half turn of the hue; turns and mirrors put most layout hashes near 1, Radial at 0 for a half turn and a mirror The same matrix on the 24 synthetic images: turned hues move every gray hash, up to beyond 1 for a half turn of the hue; turns and mirrors put most layout hashes near 1, Radial at 0 for a half turn and a mirror

The numbers behind the chart

200 public-domain photographs from Wikimedia Commons

Algorithm patch over 1% of the frame patch over 4% of the frame patch over 9% of the frame patch over 16% of the frame hue turned 30° hue turned 90° hue turned 180° turned 90° turned 180° mirrored
aHash 0.00 0.06 0.12 0.22 0.06 0.03 0.06 0.94 1.12 0.50
dHash 0.03 0.09 0.16 0.25 0.06 0.06 0.12 0.97 0.94 0.88
pHash 0.06 0.19 0.28 0.41 0.09 0.06 0.12 1.00 1.00 1.00
wHash 0.00 0.06 0.16 0.25 0.06 0.06 0.11 1.00 1.12 0.62
mHash 0.02 0.07 0.13 0.20 0.11 0.09 0.17 0.99 1.00 0.97
BMH 0.01 0.06 0.15 0.25 0.06 0.04 0.10 0.98 1.10 0.58
Radial 0.07 0.32 0.56 0.72 0.02 0.01 0.05 1.18 0.00 0.96
ColorHash 0.01 0.05 0.09 0.13 0.57 1.13 1.05 0.00 0.00 0.00
ColorMoments 0.01 0.04 0.09 0.14 0.10 0.27 0.39 0.00 0.00 0.00

Synthetic corpus, 24 images; not photographs

Algorithm patch over 1% of the frame patch over 4% of the frame patch over 9% of the frame patch over 16% of the frame hue turned 30° hue turned 90° hue turned 180° turned 90° turned 180° mirrored
aHash 0.00 0.05 0.06 0.12 0.28 0.94 1.38 0.97 1.00 0.62
dHash 0.05 0.10 0.18 0.32 0.24 0.77 0.94 0.71 0.77 0.79
pHash 0.26 0.43 0.55 0.53 0.22 0.55 0.55 0.69 1.03 0.69
wHash 0.00 0.06 0.06 0.17 0.22 0.81 1.12 1.00 0.88 0.62
mHash 0.03 0.07 0.16 0.21 0.23 0.93 1.03 0.99 0.76 0.95
BMH 0.01 0.05 0.09 0.18 0.22 0.83 1.19 1.00 1.09 0.98
Radial 0.00 0.04 0.06 0.43 0.05 0.02 0.00 1.39 0.00 0.03
ColorHash 0.01 0.02 0.05 0.06 0.65 0.92 1.08 0.00 0.00 0.00
ColorMoments 0.04 0.13 0.25 0.29 0.53 0.86 1.20 0.00 0.00 0.00

Copies against different images

How far an edit moves a hash matters only against how far apart different images are. d′ is the gap between the mean of the copies and the mean of the different pairs, in units of their spread; the second panel is the share of pairs of different images that the threshold accepting 95 % of the copies lets through as well (Comparing hashes has the curves behind it).

Two panels of bars, two bars per algorithm, one per corpus: d′ on the left, highest for pHash on the photographs and for BMH on the synthetic images; on the right, on a logarithmic axis, the share of different pairs within the 95 % threshold, from none for pHash on the photographs to above 10 % for pHash, Radial and ColorMoments on the synthetic images Two panels of bars, two bars per algorithm, one per corpus: d′ on the left, highest for pHash on the photographs and for BMH on the synthetic images; on the right, on a logarithmic axis, the share of different pairs within the 95 % threshold, from none for pHash on the photographs to above 10 % for pHash, Radial and ColorMoments on the synthetic images

  • On the photographs, pHash separates best, then dHash; aHash, wHash, BMH and Radial follow close together.
  • On the synthetic images the order changes. BMH leads, and pHash and Radial let through more than a tenth of the different pairs. Which hash separates best is a property of the collection as much as of the hash, and a collection of drawings or screenshots is worth measuring before the choice (on your own collection).
  • On the photographs, the color hashes and mHash separate least: the color hashes ignore the layout, so different photographs of similar colors land close, and mHash's copies move far.
The numbers behind the chart
Algorithm Photographs: d′ different pairs within Synthetic images: d′ different pairs within
aHash 5.04 9 of 19,900 4.36 2.9%
dHash 6.94 4 of 19,900 3.73 4.7%
pHash 8.85 0 of 19,900 3.41 10.5%
wHash 5.12 4 of 19,900 4.34 3.6%
mHash 3.46 0.7% 2.94 6.9%
BMH 5.72 0.2% 6.26 2.2%
Radial 5.52 4 of 19,900 2.80 14.7%
ColorHash 4.02 1.0% 3.77 6.2%
ColorMoments 2.68 4.1% 2.29 14.1%

200 public-domain photographs from Wikimedia Commons: 19,900 pairs of different images; Synthetic corpus, 24 images; not photographs: 276 pairs of different images.

How this was measured

Copies and different pairs as on every algorithm page, from the cache: the copies are one moderate strength of each edit, the different pairs every pair of distinct images of a corpus. Below is the code that ran, not a copy of it.

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()
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")
make site

Cost

The time of a hash grows with the number of pixels in the image, not with the size of the hash: the grayscale conversion and the reduction to the working size read every pixel. The chart shows the first hash on a freshly loaded image, so that pass is included, with the time to decode the same JPEG marked for scale, as measured on the machine that built this site.1

Two panels of bars, one per image size, the time of each algorithm on a logarithmic axis with the decoding time dashed: aHash, pHash, wHash and BMH equal and cheapest at both sizes, dHash a little above them on the large image, ColorHash, ColorMoments, mHash and Radial several times above them, Radial the highest on the large image Two panels of bars, one per image size, the time of each algorithm on a logarithmic axis with the decoding time dashed: aHash, pHash, wHash and BMH equal and cheapest at both sizes, dHash a little above them on the large image, ColorHash, ColorMoments, mHash and Radial several times above them, Radial the highest on the large image

  • aHash, pHash, wHash and BMH cost the same, because the work that grows with the image is one area-average pass over the grayscale image, which they share and the library caches. What each does on its small grid afterwards takes almost no time.
  • dHash resamples on its own with a Mitchell filter, which on a large image costs about two and a half times the shared pass.
  • The color hashes, mHash and Radial read every pixel in color, blur the image or build a profile around its center: from a few times the cheap four on the small image to twenty times on the large one. On the large image Radial and mHash take most of the time that decoding the JPEG does.

Computed together, through ph_compute_multi() or one after another on the same context, aHash, pHash, wHash and BMH share the pass, and the second of them costs almost nothing. On a large JPEG, ph_context_set_decode_scale() shrinks the decoding and the hash together (measured).

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.

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
whash_full 0.27 ms 32 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
multi 0.31 ms 32 ms
mhash_size_62 0.85 ms 92 ms
mhash_size_128 0.91 ms 94 ms
mhash_size_256 1.1 ms 93 ms
mhash_size_512 1.8 ms 92 ms
mhash_size_1024 4.7 ms 97 ms
mhash_size_2048 17 ms 109 ms
mhash_size_4096 66 ms 159 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
radial_sigma_1 0.73 ms 83 ms
radial_sigma_8 3.3 ms 212 ms
radial_gamma_2 1.5 ms 129 ms
radial_grid_40x32 1.2 ms 119 ms
radial_grid_90x64 1.2 ms 120 ms
radial_grid_360x256 2.2 ms 123 ms
radial_grid_1440x1024 16 ms 139 ms
radial_grid_4096x4096 164 ms 290 ms
scale_half_decode 0.39 ms 84 ms
scale_quarter_decode 0.33 ms 77 ms
scale_eighth_decode 0.26 ms 62 ms
scale_full_phash 0.78 ms 128 ms
scale_half_phash 0.46 ms 90 ms
scale_quarter_phash 0.36 ms 79 ms
scale_eighth_phash 0.28 ms 62 ms
scale_full_mhash 2.4 ms 199 ms
scale_half_mhash 1.8 ms 104 ms
scale_quarter_mhash 1.6 ms 83 ms
scale_eighth_mhash 1.5 ms 64 ms
scale_full_radial 2.1 ms 225 ms
scale_half_radial 1.1 ms 112 ms
scale_quarter_radial 0.77 ms 85 ms
scale_eighth_radial 0.64 ms 64 ms
gray_decode 0.36 ms 84 ms
gray_phash 0.44 ms 89 ms
gray_mhash 2.1 ms 157 ms
gray_radial 1.7 ms 187 ms
fresh_decode 0.53 ms 105 ms
fresh_phash 0.76 ms 128 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")

Cost against separation

A scatter of nine labeled points: time on the large image on a logarithmic axis against d′ on the photographs. pHash sits at the top left, cheapest and best separated; aHash, wHash and BMH beside it lower down; dHash a little to the right; Radial far right at middle height; ColorHash, mHash and ColorMoments to the right and lowest A scatter of nine labeled points: time on the large image on a logarithmic axis against d′ on the photographs. pHash sits at the top left, cheapest and best separated; aHash, wHash and BMH beside it lower down; dHash a little to the right; Radial far right at middle height; ColorHash, mHash and ColorMoments to the right and lowest

On the photographs, the cheapest hash is also the best separated: pHash is at the top left, and nothing to the right of it separates better. An algorithm further right is worth its time only for an edit the cheap four do not survive, a crop or a half turn for Radial, a change of color for ColorHash. On the synthetic images the top left belongs to BMH, at the same cost.

Summary

Algorithm Digest d′ Different pairs within Time, 20 Mpx When to take it
aHash 64 bits 5.04 / 4.36 9 of 19,900 / 2.9% 23 ms The cheapest; finds copies well, but pHash finds them better at the same cost.
dHash 64 bits 6.94 / 3.73 4 of 19,900 / 4.7% 27 ms Costs more than aHash on a large image and separates better on photographs, worse on the synthetic images.
pHash 64 bits 8.85 / 3.41 0 of 19,900 / 10.5% 23 ms Copies of photographs: the widest gap between copies and different images, at aHash's cost; among the narrowest on the synthetic images.
wHash 64 bits 5.12 / 4.34 4 of 19,900 / 3.6% 23 ms aHash's 8×8 grid with a median threshold, at the same cost; little reason to prefer it over aHash.
mHash 576 bits 3.46 / 2.94 0.7% / 6.9% 91 ms Fine detail in 576 bits; a 2° rotation or a 5 % crop takes it most of the way to an unrelated image.
BMH 256 bits 5.72 / 6.26 0.2% / 2.2% 23 ms A 16×16 grid in 256 bits, at aHash's cost; the best separation on the synthetic images.
Radial 40 bytes 5.52 / 2.80 4 of 19,900 / 14.7% 120 ms Copies that are cropped or turned by half a turn; the most different pairs within its threshold on the synthetic images.
ColorHash 108 bytes 4.02 / 3.77 1.0% / 6.2% 31 ms When a change of color must count; alongside a layout hash, since it ignores layout.
ColorMoments 18 bytes 2.68 / 2.29 4.1% / 14.1% 66 ms Moves more with tone than with hue; the weakest separation of the nine.

d′ and the different pairs within the threshold that accepts 95 % of the copies are given as photographs / synthetic images.

The digest's size is what the algorithm returns: the four 64-bit hashes also come as a uint64_t, which fits an integer column of a database and an index of Hamming distances (Comparing hashes for the text form). mHash and ColorMoments are not the answer to any of the four questions above on these corpora; their pages show what each describes and where it stands.

How the sizes were read

Each digest's size and kind are read from the library, from every algorithm's digest of the example photograph:

tools/site/stages/measure.c
/* site_stages sizes <image>: one JSON line per algorithm, its digest's size in bytes, its
 * kind as the text form names it, and the length of that text, from that algorithm's
 * digest of the image. */
int mode_sizes(int argc, char **argv) {
    (void)argc;
    ph_context_t *ctx = NULL;
    if (load_image(&ctx, argv[0])) {
        return 1;
    }
    ph_digest_t d[PH_ALGORITHM_COUNT];
    int have[PH_ALGORITHM_COUNT];
    digest_all(ctx, d, have);
    ph_free(ctx);
    int status = 0;
    for (int k = 0; k < PH_ALGORITHM_COUNT; k++) {
        char hex[PH_DIGEST_HEX_BUFFER_SIZE];
        if (!have[k] || ph_digest_to_hex(&d[k], hex, sizeof(hex)) != PH_SUCCESS) {
            status = fail("no digest of", ph_algorithm_name((ph_algorithm_t)k));
            continue;
        }
        size_t text = strlen(hex);
        *strchr(hex, ':') = '\0';
        json_t j = json_begin(stdout);
        json_string(&j, "algorithm", ph_algorithm_name((ph_algorithm_t)k));
        json_int(&j, "size", d[k].size);
        json_string(&j, "kind", hex);
        json_int(&j, "text", (long long)text);
        json_end(&j);
    }
    return status;
}

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