Skip to content

Image preparation

Every hash in libphash reads an image that the library has already prepared. The file is decoded, its transparency resolved and the picture turned upright, and for seven of the nine algorithms it is then converted to grayscale and reduced. This page describes those steps once, with what each does to a hash and the settings that change it. The algorithm pages start where it ends.

flowchart LR
    A[File] --> B[Decoder]
    B --> C[Transparency]
    C --> D[Orientation]
    D --> E[Grayscale]
    E --> F[Reduction]
    D --> G[ColorHash,<br/>ColorMoments]

The first three steps happen once, when the image is loaded by ph_load_from_file() or ph_load_from_memory(), which share one decode path. ph_load_from_pixels() takes pixels that are already decoded: it resolves their transparency and carries no orientation. The last two happen inside each hash, and the grayscale image is kept for the next hash of the same image.

Decoding

The first bytes of the file choose the decoder. Each native decoder claims its own format, and stb_image takes whatever none of them claimed.

flowchart TD
    M{First bytes} -->|FF D8| J[libjpeg-turbo]
    M -->|PNG signature| P[libpng]
    M -->|RIFF … WEBP| W[libwebp]
    M -->|anything else| S[stb_image]
    W -.->|not compiled in| U[Refused: no decoder]

stb_image is always compiled in. It reads BMP, GIF (the first frame of an animation), TGA, PSD, HDR, PIC and PNM, and JPEG and PNG in a build without their native decoder; ph_get_build_info() says which decoders a build has. It has no WebP decoder, so WebP without libwebp is refused as PH_ERR_DECODER_UNAVAILABLE rather than as an unknown format. TIFF is not read by any of them. Two JPEG decoders do not agree to the last level on every pixel: the example photograph's 64-bit hashes are the same from libjpeg-turbo and from stb_image, but its mHash, Radial, ColorHash and ColorMoments digests are not, and the library's tests keep one set of reference hashes per JPEG decoder for that reason.

A decoded file must reach its own end: the end-of-image marker of a JPEG, the IEND chunk of a PNG, the size a WebP's RIFF header states. A file cut short is refused as PH_ERR_CORRUPT_DATA, by every decoder, rather than hashed as the part of a picture that arrived.

Size limits

The dimensions a file's header declares are checked before any pixel buffer is allocated, so a small file that claims an enormous image costs nothing:

Limit Value
Pixels, width × height ph_context_set_max_pixels(), 256 × 1024 × 1024 by default; 0 leaves only the ceiling below
Pixels, always 2 147 483 647 (INT_MAX): the library indexes pixels in int
Width or height, always 1 000 000, so that an image of one enormous row cannot pass the area limit
Encoded file 2 GiB − 1 byte

An image over a limit is refused as PH_ERR_IMAGE_TOO_LARGE. A JPEG decoded at a reduced scale is judged by its full dimensions: a scale request does not make a decompression bomb safe. ph_load_from_pixels() is held to the pixel limits only, as it has no decoder to protect.

Transparency

A transparent pixel looks like whatever is behind it, and the color a file stores under it is invisible and arbitrary: one encoder writes black there, another white. Right after decoding, before anything else, the library composites every image that has an alpha channel (or a PNG tRNS chunk) onto a background, per channel,

\[ c' = \left\lfloor \frac{c \cdot a + b \cdot (255 - a) + 127}{255} \right\rfloor , \]

with \(b\) = 128 by default, 255 or 0 if ph_context_set_alpha_mode() chooses white or black. PH_ALPHA_IGNORE drops the alpha channel and keeps the stored color, as ImageHash does. A loaded image never has an alpha channel.

A photograph cut out in an ellipse, stored once with black and once with white under its transparent pixels: the two files as stored, and either of them on gray, white and black A photograph cut out in an ellipse, stored once with black and once with white under its transparent pixels: the two files as stored, and either of them on gray, white and black

The two files look the same in any viewer. With alpha ignored the library sees two different pictures; composited, it loads the same pixels from both.

The color under transparent pixels

Here every image of both corpora is cut out the same way, in an ellipse of its own place and size, and stored twice, with black and with white under the transparent part. The two files of each image are compared:

Median (25th–75th percentile) over the cut-outs, alpha ignored:

Algorithm Measured in synthetic corpus, 24 images; not photographs 200 public-domain photographs from Wikimedia Commons
aHash bits of 64 63 (62–64) 61 (59–62.2)
dHash bits of 64 29.5 (27.8–34.2) 27.5 (25–32)
pHash bits of 64 50.5 (41.8–54) 40 (32–44)
wHash bits of 64 26 (21.8–29.2) 28.5 (24–60)
mHash bits of 576 117 (93.5–138.7) 112.5 (99–127)
BMH bits of 256 87.5 (65.7–100.2) 93.5 (79–118.3)
Radial peak correlation 0.693 (0.425–0.897) 0.462 (0.340–0.608)
ColorHash histogram intersection 0.275 (0.198–0.312) 0.296 (0.239–0.387)
ColorMoments L2 distance, channel levels 428.4 (401.6–436.2) 412.8 (371.9–434.9)
  • With alpha ignored, the two files of one image land as far apart as unrelated images, or farther: for aHash the background turns from below the mean to above it, and nearly every bit flips.
  • Composited, the two files hash identically, for every algorithm: they load the same pixels. The build checks it for every image.

Which background

The background is a picture of its own, and with much of the frame transparent it decides much of the hash. Over the same cut-outs, here is how well each background lets copies be told from different images (d′ and the threshold as in the corpus charts of the algorithm pages, with the copies cut out after their edit):

Each cell: d′, then the share of pairs of different images within the threshold that accepts 95% of the copies.

Synthetic images (synthetic corpus, 24 images; not photographs, each cut out):

Algorithm On gray (default) On white On black
aHash 1.57 · 81.9% 3.59 · 0 of 276 3.32 · 0.4%
dHash 4.31 · 0.4% 4.03 · 0 of 276 4.31 · 0 of 276
pHash 4.44 · 0 of 276 7.47 · 0 of 276 7.50 · 0 of 276
wHash 1.88 · 22.5% 1.04 · 62.0% 3.48 · 0 of 276
mHash 4.59 · 0 of 276 4.71 · 0 of 276 5.95 · 0 of 276
BMH 3.25 · 7.2% 3.65 · 0 of 276 0.62 · 83.7%
Radial 3.01 · 14.5% 3.24 · 2.2% 3.06 · 1.8%
ColorHash 3.21 · 5.4% 3.31 · 5.1% 3.31 · 5.1%
ColorMoments 2.12 · 62.7% 2.72 · 2.5% 2.70 · 7.2%

Photographs (200 public-domain photographs from Wikimedia Commons, each cut out):

Algorithm On gray (default) On white On black
aHash 2.67 · 0.4% 3.53 · 12 of 19,900 3.56 · 13 of 19,900
dHash 5.99 · 0 of 19,900 4.09 · 5 of 19,900 4.37 · 6 of 19,900
pHash 7.63 · 0 of 19,900 7.67 · 1 of 19,900 7.84 · 0 of 19,900
wHash 2.55 · 6.3% 1.71 · 35.3% 3.41 · 0.2%
mHash 4.83 · 0.2% 5.60 · 3 of 19,900 5.76 · 5 of 19,900
BMH 3.71 · 0.2% 3.67 · 0 of 19,900 1.02 · 64.7%
Radial 5.19 · 6 of 19,900 4.43 · 4 of 19,900 4.38 · 3 of 19,900
ColorHash 3.25 · 0.6% 3.34 · 0.6% 3.17 · 0.9%
ColorMoments 2.24 · 28.4% 2.21 · 2.9% 2.43 · 8.6%

No background is best for every algorithm, and the reason is in each algorithm's threshold. A flat background is a block of equal cells, and when it covers more than half of the frame its value is the median of the grid:

  • On white, wHash sets no bit at all, because it sets a bit only above the median and no cell is brighter than white: every image with more than half of its cells transparent gets the hash 0000000000000000.
  • On black, BMH sets every bit, because it sets a bit at or above the median and every block is at least black.
  • On gray, aHash loses the images whose visible part averages near mid-gray. The background cells equal 128, the mean of the grid lies close to it, and a small edit that moves the mean across 128 flips every background bit at once. ColorMoments, whose digest is the mean, spread and skew of each channel over every pixel, also separates the cut-outs worst on gray, on both corpora.
  • pHash, dHash, mHash and ColorHash let almost no different pairs through on any of the three. Radial does the same on the photographs, and on the synthetic images it too does worst on gray.

Gray is the default because it is the one background on which neither median hash collapses, though wHash does better on black. Its price is aHash and ColorMoments, and Radial on the synthetic images, when the visible part is close to mid-gray. A collection of such images hashed with those is better composited on white or black. The cut-outs here are photographs and synthetic images inside an ellipse: artwork drawn on a transparent canvas has other shapes, and the shape is part of what every hash sees.

How this was measured

Each image of both corpora is cut out in an ellipse whose center and radii are drawn from a generator seeded with the image's number, so an image and its copies share it. For the color under transparent pixels, the two files of each image are compared by site_stages measure, once with --load=alpha=ignore and once with the default. For the backgrounds, each original is cut out, and each of its nine copies (the separability chart's) is edited first and cut out after; the copies are compared with their original and every pair of originals is compared, loaded with each background. --load= sets the context before the files after it are loaded.

tools/site/pages/preparation.py
BLACK, WHITE = (0, 0, 0), (255, 255, 255)
BACKGROUNDS = ("grey", "white", "black")


def ellipse_alpha(w, h, cx, cy, rx, ry):
    """Opaque inside an ellipse (center and radii as fractions of the frame), fully
    transparent outside: the alpha of a cut-out."""
    y, x = np.mgrid[0:h, 0:w]
    inside = ((x + 0.5) / w - cx) ** 2 / rx ** 2 + ((y + 0.5) / h - cy) ** 2 / ry ** 2 <= 1
    return np.where(inside, 255, 0).astype(np.uint8)


def ellipse_of(seed):
    """The ellipse of image number `seed`: a different place and size for every image, the
    same for an image and its copies."""
    rng = np.random.default_rng(seed)
    return (*rng.uniform(0.3, 0.7, 2), *rng.uniform(0.2, 0.45, 2))


def cutout(image, ellipse, under):
    """An RGBA image: `image` inside the ellipse, and the color `under` stored beneath the
    transparent pixels outside it, as an encoder that writes black, or white, there would."""
    rgb = np.asarray(image.convert("RGB")).copy()
    alpha = ellipse_alpha(image.width, image.height, *ellipse)
    rgb[alpha == 0] = under
    return Image.fromarray(np.dstack([rgb, alpha]), "RGBA")
tools/site/pages/preparation.py
def cutout_copies(base, seed, tmp, stem):
    """An original cut out, then its nine copies (the separability chart's), each edited
    first and cut out after with the original's ellipse: black under alpha 0."""
    ellipse = ellipse_of(seed)
    files = [_save_png(cutout(base, ellipse, BLACK), os.path.join(tmp, f"{stem}.png"))]
    for k, (name, _, steps) in enumerate(transforms()):
        im, (ext, quality) = dict(steps)[COPY_STRENGTHS[name]](base)
        if ext == "jpg":
            im = _jpeg_roundtrip(im, quality)
        files.append(_save_png(cutout(im, ellipse, BLACK), os.path.join(tmp, f"{stem}-{k}.png")))
    return files


def plain_copies(base, seed, tmp, stem):
    """An original and its nine copies, as the corpus charts make them."""
    ref = os.path.join(tmp, f"{stem}.ppm")
    base.save(ref)
    return [ref] + corpus.write_copies(base, tmp, stem)
tools/site/pages/preparation.py
def over_corpus(tool, which, name, make, loads):
    """{"n", "label", load: {"copies": {algo: [value]}, "different": {algo: [value]}}} for
    one corpus: every original and its copies made by `make`, each compared with its
    original, and every pair of originals, loaded with each of `loads` (a `--load=`
    setting); cached as preparation-<name>-<corpus>."""
    paths = corpus.images(tool, which)
    out = {"n": len(paths), "label": corpus.label(which, len(paths))}
    if not paths:
        return out

    def measure():
        with tempfile.TemporaryDirectory() as tmp, \
                concurrent.futures.ProcessPoolExecutor() as pool:
            done = list(pool.map(_copies_one, [(tool, make, loads, p, i, tmp)
                                               for i, p in enumerate(paths)]))
            originals = [f for f, _ in done]
            result = {}
            for load in loads:
                copies = {}
                for _, rows in done:
                    for row in rows[load]:
                        for algo, v in row.items():
                            if algo != "file":
                                copies.setdefault(algo, []).append(v)
                different = {}
                for row in run_lines(tool, "pairs", f"--load={load}", *originals):
                    for algo, v in row.items():
                        if algo not in ("a", "b"):
                            different.setdefault(algo, []).append(v)
                result[load] = {"copies": copies, "different": different}
            return result

    key = corpus.cache_key(paths, f"{name} {loads}", also=[MODULE])
    out.update(cached(f"{NAME}-{name}-{which}", key, measure,
                      f"{name}, {len(paths)} images and their copies"))
    return out
tools/site/stages/util.c
/* How load_image() loads: the library's defaults, unless a mode that takes a list of
 * images met `--load=<settings>` before an image (take_load_settings()). */
typedef struct {
    int alpha;      /* a ph_alpha_mode_t, or -1 for the default */
    int scale;      /* a ph_decode_scale_t */
    int orient;     /* ph_context_set_auto_orient() */
    int gray;       /* ph_context_set_load_grayscale(): the decoder converts to grayscale */
    int weights[4]; /* r, g, b and a shift: grayscale computed here, (r R + g G + b B) >> shift,
                     * and loaded as a one-channel image; a shift of 0 leaves it to the library */
} load_settings_t;

static const load_settings_t defaults = {-1, PH_DECODE_SCALE_FULL, 1, 0, {0, 0, 0, 0}};
static load_settings_t settings = {-1, PH_DECODE_SCALE_FULL, 1, 0, {0, 0, 0, 0}};

/* One `name=value` of a --load option; 0 when it is not one this tool knows. */
static int set_one(load_settings_t *s, const char *item) {
    static const char *const alphas[] = {"grey", "white", "black", "ignore"};
    static const char *const scales[] = {"full", "half", "quarter", "eighth"};
    for (int k = 0; k < 4; k++) {
        char want[32];
        snprintf(want, sizeof(want), "alpha=%s", alphas[k]);
        if (strcmp(item, want) == 0) {
            s->alpha = k; /* PH_ALPHA_BLEND_GREY … PH_ALPHA_IGNORE, in that order */
            return 1;
        }
        snprintf(want, sizeof(want), "scale=%s", scales[k]);
        if (strcmp(item, want) == 0) {
            s->scale = k; /* PH_DECODE_SCALE_FULL … PH_DECODE_SCALE_EIGHTH */
            return 1;
        }
    }
    if (strcmp(item, "orient=off") == 0) {
        s->orient = 0;
        return 1;
    }
    if (strcmp(item, "gray=decoder") == 0) {
        s->gray = 1;
        return 1;
    }
    int *w = s->weights;
    return sscanf(item, "weights=%d/%d/%d/%d", &w[0], &w[1], &w[2], &w[3]) == 4 && w[0] >= 0 &&
           w[1] >= 0 && w[2] >= 0 && w[3] > 0 && w[3] < 16 && w[0] + w[1] + w[2] <= 1 << w[3];
}

int take_load_settings(const char *arg, int *status) {
    static const char prefix[] = "--load=";
    if (strncmp(arg, prefix, sizeof(prefix) - 1) != 0) {
        return 0;
    }
    load_settings_t s = defaults;
    char list[256];
    snprintf(list, sizeof(list), "%s", arg + sizeof(prefix) - 1);
    for (char *item = strtok(list, ","); item; item = strtok(NULL, ",")) {
        if (strcmp(item, "default") != 0 && !set_one(&s, item)) {
            *status = fail("unknown load setting", item);
            return 1;
        }
    }
    settings = s;
    return 1;
}

/* The settings' own grayscale, (r R + g G + b B) >> shift per pixel, replaces the loaded
 * color image. */
static ph_error_t own_grayscale(ph_context_t *ctx) {
    const int *wt = settings.weights;
    size_t n = (size_t)ctx->image.width * (size_t)ctx->image.height;
    uint8_t *gray = malloc(n ? n : 1);
    if (!gray) {
        return PH_ERR_ALLOCATION_FAILED;
    }
    const uint8_t *p = ctx->image.raw_rgb;
    for (size_t i = 0; i < n; i++, p += 3) {
        gray[i] = (uint8_t)((wt[0] * p[0] + wt[1] * p[1] + wt[2] * p[2]) >> wt[3]);
    }
    ph_error_t err =
        ph_load_from_pixels(ctx, gray, ctx->image.width, ctx->image.height, 1, ctx->image.width);
    free(gray);
    return err;
}

static int load_with(ph_context_t **ctx, const char *path, int decoder_gray) {
    if (ph_create(ctx) != PH_SUCCESS) {
        return fail("ph_create failed", NULL);
    }
    decoder_gray |= settings.gray;
    ph_error_t err = ph_context_set_load_grayscale(*ctx, decoder_gray);
    if (err == PH_SUCCESS && settings.alpha >= 0) {
        err = ph_context_set_alpha_mode(*ctx, (ph_alpha_mode_t)settings.alpha);
    }
    if (err == PH_SUCCESS) {
        err = ph_context_set_decode_scale(*ctx, (ph_decode_scale_t)settings.scale);
    }
    if (err == PH_SUCCESS) {
        err = ph_context_set_auto_orient(*ctx, settings.orient);
    }
    if (err == PH_SUCCESS) {
        err = ph_load_from_file(*ctx, path);
    }
    if (err == PH_SUCCESS && settings.weights[3] && (*ctx)->image.channels == 3) {
        err = own_grayscale(*ctx);
    }
    if (err != PH_SUCCESS) {
        fprintf(stderr, "site_stages: cannot load %s%s: %s (%s)\n", path,
                decoder_gray ? " in grayscale" : "", ph_get_error_string(err),
                ph_get_last_error_message(*ctx));
        ph_free(*ctx);
        *ctx = NULL;
        return 1;
    }
    return 0;
}

Orientation

A camera stores a picture the way its sensor read it, and an EXIF Orientation tag (TIFF tag 0x0112, values 1 to 8) tells a viewer how to turn or mirror it for display. The library reads the tag from a JPEG's APP1 Exif segment, a WebP's EXIF chunk or a PNG's eXIf chunk, and applies it after the transparency and before any hash, so that a hash describes the picture as a viewer shows it. Values 5 to 8 swap the width and the height.

The example photograph stored in each of the eight orientations, with the tag that shows it upright, and the eight images the library loads from them, all upright The example photograph stored in each of the eight orientations, with the tag that shows it upright, and the eight images the library loads from them, all upright

Each of the eight files loads as the same upright picture, pixel for pixel; the build fails if one does not. With ph_context_set_auto_orient() off, the library hashes the pixels as stored, and the same eight files hash as eight pictures:

Orientation The viewer shows it pHash bits from the upright picture, tag read tag ignored
1 as stored 0 0
2 mirrored 0 35
3 turned 180° 0 39
4 flipped 0 33
5 transposed 0 26
6 turned 90° right 0 33
7 transversed 0 31
8 turned 90° left 0 33

That is as far as a turned or mirrored copy goes on the algorithm pages, where the turns and the mirror are among the edits that change the picture: far enough to be taken for a different image.

A missing or malformed tag reads as 1, no transform, and never fails a load. Applying a real orientation takes a second buffer the size of the image; if it cannot be allocated, the load fails as a whole rather than hashing the picture on its side.

How this was measured

The example photograph, cropped to a landscape frame, is stored eight times as PNG, each turned or mirrored the way a camera with that tag would store it, with the tag in the file's eXIf chunk. Pillow's own reading of each tag is checked first. The library loads every file with the tag read and with it ignored (site_stages loaded), and the tool compares the pixels with the upright picture and with the stored one.

tools/site/pages/preparation.py
T = Image.Transpose
# What a viewer does to the stored pixels for each value of the EXIF Orientation tag, as
# Pillow's ImageOps.exif_transpose() does it, and the transform that undoes it: a camera
# that stores the picture with value k stores UNDO[k] of what it shows.
DISPLAY = {2: T.FLIP_LEFT_RIGHT, 3: T.ROTATE_180, 4: T.FLIP_TOP_BOTTOM, 5: T.TRANSPOSE,
           6: T.ROTATE_270, 7: T.TRANSVERSE, 8: T.ROTATE_90}
UNDO = {**DISPLAY, 6: T.ROTATE_90, 8: T.ROTATE_270}
ORIENTATION_NAMES = {1: "as stored", 2: "mirrored", 3: "turned 180°", 4: "flipped",
                     5: "transposed", 6: "turned 90° right", 7: "transversed",
                     8: "turned 90° left"}


def oriented_files(base, tmp):
    """The picture stored the eight ways a camera may store it, each as a PNG whose eXIf
    chunk carries the Orientation that shows it upright; checked with Pillow's own reading
    of the tag. Lossless, so the library's result can be compared pixel for pixel."""
    files = []
    for k in range(1, 9):
        stored = base.transpose(UNDO[k]) if k > 1 else base.copy()
        exif = Image.Exif()
        exif[0x0112] = k
        files.append(os.path.join(tmp, f"orientation-{k}.png"))
        stored.save(files[-1], exif=exif)
        assert np.array_equal(np.asarray(ImageOps.exif_transpose(Image.open(files[-1]))),
                              np.asarray(base)), k
    return files


def orientation(tool, image):
    """The stored and the loaded pixels of the eight files, and pHash's bits between each
    file and the upright picture with the tag read and with it ignored. Fails the build
    unless the library loads every file as the upright picture, exactly."""
    base = Image.open(image).convert("RGB")
    base = base.crop((0, base.height // 8, base.width, base.height - base.height // 8))
    with tempfile.TemporaryDirectory() as tmp:
        files = oriented_files(base, tmp)
        ref = os.path.join(tmp, "upright.ppm")
        base.save(ref)
        run_lines(tool, "loaded", tmp, *files, "--load=orient=off", *files)
        loaded = [read_pnm(os.path.join(tmp, f"{k:02d}.ppm")) for k in range(16)]
        on = run_lines(tool, "measure", ref, *files)
        off = run_lines(tool, "measure", ref, "--load=orient=off", *files)
    for k in range(8):
        stored = base.transpose(UNDO[k + 1]) if k else base
        if not np.array_equal(loaded[k], np.asarray(base)):
            raise SystemExit(f"preparation: orientation {k + 1} did not load upright")
        if not np.array_equal(loaded[8 + k], np.asarray(stored)):
            raise SystemExit(f"preparation: orientation {k + 1} did not load as stored")
    return loaded[8:], loaded[:8], on, off
tools/site/stages/prepare.c
/* site_stages loaded <outdir> <image>...: each image as the library loaded it, written as
 * NN.ppm (NN.pgm when it holds one channel), and its grayscale as NN-gray.pgm, NN counting
 * the images from 00; one JSON line per image with its size and channels. A
 * `--load=<settings>` between the images loads the ones after it so. */
int mode_loaded(int argc, char **argv) {
    int status = 0, n = 0;
    for (int i = 1; i < argc && !status; i++) {
        if (take_load_settings(argv[i], &status)) {
            continue;
        }
        ph_context_t *ctx = NULL;
        if (load_image(&ctx, argv[i])) {
            return 1;
        }
        int w = ctx->image.width, h = ctx->image.height, ch = ctx->image.channels;
        char name[32];
        snprintf(name, sizeof(name), "%02d.%s", n, ch == 1 ? "pgm" : "ppm");
        status |= write_pnm(argv[0], name, ctx->image.raw_rgb, w, h, ch);
        const uint8_t *gray = ph_get_gray(ctx);
        snprintf(name, sizeof(name), "%02d-gray.pgm", n);
        status |= gray ? write_pnm(argv[0], name, gray, w, h, 1) : fail("no grayscale", argv[i]);
        json_t j = json_begin(stdout);
        json_string(&j, "file", argv[i]);
        json_int(&j, "index", n++);
        json_int(&j, "width", w);
        json_int(&j, "height", h);
        json_int(&j, "channels", ch);
        json_end(&j);
        ph_free(ctx);
    }
    return status;
}

Grayscale

aHash, dHash, pHash, wHash, mHash, BMH and Radial read one luminance value per pixel:

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

an integer approximation of the ITU-R BT.601 weights (0.299, 0.587, 0.114). The sum is shifted down by seven bits, so the result is truncated, not rounded. ph_context_set_gray_weights() takes other weights and scales them to sum to 128, the red and green shares rounded down and blue taking the rest; ph_context_get_gray_weights() reads back what is used.

The grayscale image is computed by the first hash that needs it and kept until the next load, so the seven algorithms on one image pay for it once. An image that is already gray is used as it is. ColorHash and ColorMoments read the color channels instead, and refuse a one-channel image with PH_ERR_REQUIRES_COLOR.

Why 38, 75 and 15

The closest 8-bit approximation of the same weights would be 77, 150 and 29 over 256, nearer to BT.601 on every channel. Over both corpora, with the copies and different pairs of the corpus charts:

Each cell: d′, then the share of pairs of different images within the threshold that accepts 95% of the copies.

Synthetic images (synthetic corpus, 24 images; not photographs):

Algorithm 38/75/15 over 128 (the library) 77/150/29 over 256
aHash 4.36 · 2.9% 4.08 · 3.3%
dHash 3.73 · 4.7% 3.77 · 4.7%
pHash 3.41 · 10.5% 3.39 · 10.5%
wHash 4.34 · 3.6% 4.20 · 3.6%
mHash 2.94 · 6.9% 2.92 · 11.2%
BMH 6.26 · 2.2% 5.92 · 2.2%
Radial 2.80 · 14.7% 2.91 · 13.4%

Photographs (200 public-domain photographs from Wikimedia Commons):

Algorithm 38/75/15 over 128 (the library) 77/150/29 over 256
aHash 5.04 · 9 of 19,900 5.05 · 9 of 19,900
dHash 6.94 · 4 of 19,900 6.97 · 2 of 19,900
pHash 8.85 · 0 of 19,900 8.87 · 0 of 19,900
wHash 5.12 · 4 of 19,900 5.13 · 4 of 19,900
mHash 3.46 · 0.7% 3.47 · 0.7%
BMH 5.72 · 0.2% 5.72 · 0.2%
Radial 5.52 · 4 of 19,900 5.52 · 4 of 19,900

On the photographs the two give the same separability. On the synthetic images, 38/75/15 separates a little better for aHash, wHash, BMH and mHash, and a little worse for Radial. Being closer to the standard does not make a hash better at telling images apart.

How this was measured

The 77/150/29 grayscale is computed by the tool from the decoded image and loaded as a one-channel image (--load=weights=77/150/29/8); the same path with 38/75/15 over 128 gives exactly the library's hashes, which the build checks on the example photograph. The library's own figures are those of the corpus charts.

tools/site/pages/preparation.py
def over_corpus(tool, which, name, make, loads):
    """{"n", "label", load: {"copies": {algo: [value]}, "different": {algo: [value]}}} for
    one corpus: every original and its copies made by `make`, each compared with its
    original, and every pair of originals, loaded with each of `loads` (a `--load=`
    setting); cached as preparation-<name>-<corpus>."""
    paths = corpus.images(tool, which)
    out = {"n": len(paths), "label": corpus.label(which, len(paths))}
    if not paths:
        return out

    def measure():
        with tempfile.TemporaryDirectory() as tmp, \
                concurrent.futures.ProcessPoolExecutor() as pool:
            done = list(pool.map(_copies_one, [(tool, make, loads, p, i, tmp)
                                               for i, p in enumerate(paths)]))
            originals = [f for f, _ in done]
            result = {}
            for load in loads:
                copies = {}
                for _, rows in done:
                    for row in rows[load]:
                        for algo, v in row.items():
                            if algo != "file":
                                copies.setdefault(algo, []).append(v)
                different = {}
                for row in run_lines(tool, "pairs", f"--load={load}", *originals):
                    for algo, v in row.items():
                        if algo not in ("a", "b"):
                            different.setdefault(algo, []).append(v)
                result[load] = {"copies": copies, "different": different}
            return result

    key = corpus.cache_key(paths, f"{name} {loads}", also=[MODULE])
    out.update(cached(f"{NAME}-{name}-{which}", key, measure,
                      f"{name}, {len(paths)} images and their copies"))
    return out

The decoder's grayscale

ph_context_set_load_grayscale() asks the decoder for one channel instead of three, which saves the library's conversion. What the decoder returns depends on the format:

  • JPEG: libjpeg-turbo returns the luma the file stores, without computing color at all.
  • PNG, WebP and the formats stb_image reads: the decoder converts with the library's default weights, 38/75/15; weights set with ph_context_set_gray_weights() do not reach it. libwebp has no grayscale output and libpng's is not these weights, so their backends decode in color and convert. An alpha channel is kept through the conversion and resolved on the gray.

So the setting changes the pixels a hash reads only for JPEG. Over the photographs:

Over 200 public-domain photographs from Wikimedia Commons, read as the JPEG files they are. The decoder's gray level minus the library's, over every pixel (at most 32 levels either way):

Difference Share of pixels
−3 or less 0.22%
−2 0.20%
−1 0.43%
0 50.66%
+1 48.02%
+2 0.21%
+3 or more 0.25%

The hash of each photograph loaded with the decoder's grayscale against the library's:

Algorithm Measured in Unchanged Median (25th–75th) Farthest
aHash bits of 64 77% 0 (0–0) 2
dHash bits of 64 50% 0 (0–1) 5
pHash bits of 64 80% 0 (0–0) 2
wHash bits of 64 86% 0 (0–0) 4
mHash bits of 576 8% 5 (2–13.2) 149
BMH bits of 256 42% 1 (0–2) 16
Radial peak correlation 2% 1.000 (1.000–1.000) 1.000

The stored luma is rounded where the library's formula truncates, so about half of the pixels come out one level brighter; a few differ by more. A level here and there tips only the values that lie next to a threshold. The 64-bit hashes stay the same or move by a bit or two; mHash and BMH, with many more bits, move on most photographs, by a few. With the setting on, ColorHash and ColorMoments refuse every image.

How this was measured

Every photograph, as the JPEG file it is, is loaded with the library's grayscale and with the decoder's (--load=gray=decoder); site_stages loaded writes both grayscale images, and site_stages measure compares the hashes.

tools/site/stages/prepare.c
/* site_stages loaded <outdir> <image>...: each image as the library loaded it, written as
 * NN.ppm (NN.pgm when it holds one channel), and its grayscale as NN-gray.pgm, NN counting
 * the images from 00; one JSON line per image with its size and channels. A
 * `--load=<settings>` between the images loads the ones after it so. */
int mode_loaded(int argc, char **argv) {
    int status = 0, n = 0;
    for (int i = 1; i < argc && !status; i++) {
        if (take_load_settings(argv[i], &status)) {
            continue;
        }
        ph_context_t *ctx = NULL;
        if (load_image(&ctx, argv[i])) {
            return 1;
        }
        int w = ctx->image.width, h = ctx->image.height, ch = ctx->image.channels;
        char name[32];
        snprintf(name, sizeof(name), "%02d.%s", n, ch == 1 ? "pgm" : "ppm");
        status |= write_pnm(argv[0], name, ctx->image.raw_rgb, w, h, ch);
        const uint8_t *gray = ph_get_gray(ctx);
        snprintf(name, sizeof(name), "%02d-gray.pgm", n);
        status |= gray ? write_pnm(argv[0], name, gray, w, h, 1) : fail("no grayscale", argv[i]);
        json_t j = json_begin(stdout);
        json_string(&j, "file", argv[i]);
        json_int(&j, "index", n++);
        json_int(&j, "width", w);
        json_int(&j, "height", h);
        json_int(&j, "channels", ch);
        json_end(&j);
        ph_free(ctx);
    }
    return status;
}

Reduction

Each grayscale hash then brings the image down to what it describes. Four of them share one method, the exact area average; the others filter.

Algorithm What it reduces the grayscale image to
aHash an 8×8 area average
dHash 9×8 by the Mitchell filter
pHash a 32×32 area average (the dct_size)
wHash a 16×16 area average; in the full mode, a box filter to the largest power of two that fits the shorter side
BMH a 16×16 area average (the block_size)
mHash a Gaussian blur of σ = 1 at full size, then 512×512 by the Mitchell filter
Radial a Gaussian blur of σ = 3.5 at full size; its lines are sampled from the whole image

ColorHash and ColorMoments reduce nothing: they count every pixel of the color image.

The area average

The grid divides the image into equal areas, whatever its size and aspect ratio. Each cell is the mean of every pixel it covers, a pixel the cell only partly covers counting by the part covered, rounded half up. The arithmetic is in integers, so the result is exact and the same on every platform.

A 10 by 7 pixel image under a 3 by 2 grid, with the share of each pixel that the top middle cell covers, and the 3 by 2 grid of means the library computes A 10 by 7 pixel image under a 3 by 2 grid, with the share of each pixel that the top middle cell covers, and the 3 by 2 grid of means the library computes

The middle column of cells spans pixels 3⅓ to 6⅔, and the top row rows 0 to 3½: the pixels on the outlined cell's edges count by two thirds or by a half, and those in its lower corners by a third. The outlined cell covers 35/3 pixels; its pixels weighted by coverage sum to 789.83, and the mean, 67.70, rounds to 68.

The build recomputes every cell from these weights and fails if one differs from the library's.

The library keeps the area sums of the image on a 32×32 grid. Any grid whose side divides 32 is assembled from those sums, and comes out bit for bit as a pass of its own would, so aHash (8×8), wHash (16×16), BMH (16×16) and pHash (32×32) on one image read its pixels once. An image smaller than 32 pixels on a side, or a grid that does not divide 32, takes a pass of its own.

Why an area average rather than a filter is measured on the aHash page: it separates copies from different images best on both corpora. dHash compares neighboring cells, and an area average flattens a pattern finer than a cell into equal neighbors; why it uses the Mitchell filter is on its page.

How this was measured

A 10×7 grayscale image, the center of the example photograph reduced by Pillow, is reduced to 3×2 by the library (site_stages area), and every cell is recomputed from the coverage of each pixel in exact fractions.

tools/site/pages/preparation.py
def area_weights(sw, sh, dw, dh, col, row):
    """How much of each source pixel the cell (col, row) of a dw×dh grid covers, exactly:
    the grid divides the image into equal areas whatever its size."""
    def axis(n, m, k):
        lo, hi = Fraction(k * n, m), Fraction((k + 1) * n, m)
        return [max(Fraction(0), min(hi, i + 1) - max(lo, Fraction(i))) for i in range(n)]
    return np.outer(axis(sh, dh, row), axis(sw, dw, col))


def area_mean(pixels, weights):
    """The cell's value: the covered pixels averaged by their coverage, rounded half up."""
    total = sum(w * int(p) for w, p in zip(weights.ravel(), pixels.ravel()))
    return int(total / weights.sum() + Fraction(1, 2))
tools/site/stages/prepare.c
/* site_stages area <width> <height> <image>: the image's grayscale reduced to
 * width x height by the library's area average (ph_area_downscale(), what aHash, pHash,
 * wHash and BMH reduce with), as one JSON object. */
int mode_area(int argc, char **argv) {
    (void)argc;
    int dw = atoi(argv[0]), dh = atoi(argv[1]);
    if (dw <= 0 || dh <= 0 || dw > 64 || dh > 64) {
        return fail("the grid must be 1 to 64 cells on a side", NULL);
    }
    ph_context_t *ctx = NULL;
    if (load_image(&ctx, argv[2])) {
        return 1;
    }
    uint8_t grid[64 * 64];
    int ok = ph_area_downscale(ctx, dw, dh, grid);
    json_t j = json_begin(stdout);
    json_int(&j, "width", ctx->image.width);
    json_int(&j, "height", ctx->image.height);
    json_u8s(&j, "grid", grid, ok ? dw * dh : 0);
    json_end(&j);
    ph_free(ctx);
    return ok ? 0 : fail("ph_area_downscale failed", argv[2]);
}

Blur and gamma

mHash and Radial blur the grayscale image at full size before they reduce or sample it, so that what they read are shapes rather than pixels; the mHash and Radial pages show what the blur does. Radial alone then applies a gamma, ph_context_set_gamma(), which is 1, no change, by default; it lives on the context but no other algorithm reads it.

Decoding at a reduced scale

A JPEG can be decoded straight to ½, ¼ or ⅛ of its width and height, by ph_context_set_decode_scale(): libjpeg-turbo scales the blocks of the compressed image as it decodes them, and a side that does not divide evenly is rounded up. Only JPEG through libjpeg-turbo is decoded this way; a PNG, a WebP, or a JPEG in a build whose JPEG decoder is stb_image is decoded in full whatever the setting.

On a 20-megapixel photograph:

A 5472×3648 JPEG; the minimum of the runs, on the machine named at the foot of the page:

Scale Decode Decode + pHash Decode + mHash Decode + Radial
1 (full) 105 ms 128 ms 199 ms 225 ms
½ 84.1 ms 90.1 ms 104 ms 112 ms
¼ 77.1 ms 78.8 ms 83.2 ms 84.9 ms
⅛ 61.8 ms 62.4 ms 64.4 ms 64.2 ms

The decode itself gets only somewhat cheaper, because every coefficient of the file is still read. Everything after it reads every decoded pixel, and shrinks with their number: four times fewer at each step. The saving is largest for the hashes that cost the most per pixel, mHash and Radial.1

What it costs is the hash: the image a hash reads is smaller. Here every image of both corpora, as a JPEG, is decoded at each scale and compared with the same file decoded in full, against the threshold that accepts 95 % of the copies on each algorithm's page:

For each of the nine algorithms, how far the hash of a JPEG decoded at a half, a quarter and an eighth of its size lands from the hash of the full decode, median and middle half over each corpus, against the threshold that accepts 95 % of copies For each of the nine algorithms, how far the hash of a JPEG decoded at a half, a quarter and an eighth of its size lands from the hash of the full decode, median and middle half over each corpus, against the threshold that accepts 95 % of copies

  • What matters is the size decoded, not the scale. The photographs are 1280 pixels wide, so an eighth is 160; the synthetic images are 160, so an eighth is 20. On the 20-megapixel photograph, whose eighth is still 684 pixels wide, no hash moves more than a few bits at any scale:

    One 5472×3648 photograph, each scale against the full decode:

    Algorithm Measured in ½ ¼ ⅛
    aHash bits of 64 0 0 0
    dHash bits of 64 0 0 1
    pHash bits of 64 0 0 0
    wHash bits of 64 0 0 0
    mHash bits of 576 3 4 5
    BMH bits of 256 0 0 0
    Radial peak correlation 1.000 1.000 0.999
    ColorHash histogram intersection 0.993 0.990 0.991
    ColorMoments L2 distance, channel levels 1.2 1.5 1.5
  • The 64-bit hashes and BMH average the image into a few cells, and keep nearly every hash at every scale, down to images a few times their grid.

  • mHash blurs by a σ fixed in pixels and then resamples to 512×512, so a smaller decode changes what it reads: it moves at every scale, toward its copy threshold.
  • Radial samples its lines from the blurred image at full size, with a blur fixed in pixels, and it moves the most: at an eighth of a 1280-pixel photograph, most digests fall outside the threshold for copies.
  • ColorHash and ColorMoments count colors, and a smaller decode keeps their proportions: they stay within their thresholds for nearly every image.
The numbers behind the chart

Each cell: the median (25th–75th percentile) against the full decode, then the share of images within the threshold that accepts 95% of the copies.

Synthetic images (synthetic corpus, 24 images; not photographs, encoded as JPEG at quality 95):

Algorithm Measured in ½ ¼ ⅛
aHash bits of 64 0 (0–0), 100% 0 (0–0), 100% 0 (0–0.2), 83%
dHash bits of 64 0 (0–0), 100% 0 (0–0.2), 100% 1 (0–5), 92%
pHash bits of 64 1 (0–2.2), 100% 2 (0–3.2), 100% 3 (0–4.5), 100%
wHash bits of 64 0 (0–0), 100% 0 (0–0), 96% 0 (0–3.2), 88%
mHash bits of 576 71 (36.2–137.2), 100% 160 (61.5–237.7), 88% 186.5 (145.2–277.5), 67%
BMH bits of 256 0 (0–0), 100% 0 (0–3.3), 96% 3.5 (0–9.2), 92%
Radial peak correlation 0.981 (0.895–0.998), 86% 0.947 (0.514–0.978), 65% 0.494 (0.356–0.954), 48%
ColorHash histogram intersection 0.982 (0.899–0.988), 92% 0.969 (0.911–0.986), 96% 0.906 (0.745–0.970), 88%
ColorMoments L2 distance, channel levels 3.0 (1.0–12.2), 100% 4.2 (0.8–13.2), 100% 14.7 (4.5–45.0), 88%

Photographs (200 public-domain photographs from Wikimedia Commons):

Algorithm Measured in ½ ¼ ⅛
aHash bits of 64 0 (0–0), 100% 0 (0–0), 100% 0 (0–1), 100%
dHash bits of 64 0 (0–1), 100% 0 (0–1), 100% 1 (0–2), 98%
pHash bits of 64 0 (0–0), 100% 0 (0–1), 100% 0 (0–1), 100%
wHash bits of 64 0 (0–0), 100% 0 (0–0), 100% 0 (0–0), 100%
mHash bits of 576 19 (14–28), 100% 54.5 (42.8–70), 100% 108 (91–124), 100%
BMH bits of 256 0 (0–1), 100% 1 (0–2), 100% 2 (1–4), 100%
Radial peak correlation 0.994 (0.984–0.997), 92% 0.963 (0.918–0.981), 58% 0.888 (0.783–0.948), 20%
ColorHash histogram intersection 0.987 (0.974–0.993), 100% 0.974 (0.953–0.986), 100% 0.957 (0.925–0.977), 100%
ColorMoments L2 distance, channel levels 4.1 (2.3–8.0), 97% 8.4 (5.0–16.1), 93% 13.1 (8.1–23.4), 90%
How this was measured

Every photograph is read as the JPEG file it is; every synthetic image is encoded as a JPEG at quality 95 first. Each file is compared by site_stages measure with itself, decoded at each scale (--load=scale=half and so on). The thresholds are those of the corpus charts of each algorithm's page. The times are cases of site_stages time, each a load and, where named, a hash.

tools/site/pages/preparation.py
def decode_scales(tool, which):
    """Every image of a corpus as a JPEG (the synthetic images encoded at quality 95),
    decoded at each reduced scale and compared with the same file decoded in full: one
    row per scale, per image."""
    paths = corpus.images(tool, which)
    if not paths:
        return {"n": 0, "label": corpus.label(which, 0), "rows": []}

    def measure():
        with tempfile.TemporaryDirectory() as tmp, \
                concurrent.futures.ProcessPoolExecutor() as pool:
            return list(pool.map(_scale_one, [(tool, p, tmp, i) for i, p in enumerate(paths)]))

    rows = cached(f"{NAME}-scale-{which}", corpus.cache_key(paths, "scale", also=[MODULE]),
                  measure, f"decode scales, {len(paths)} images")
    return {"n": len(paths), "label": corpus.label(which, len(paths)), "rows": rows}
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;
}

Settings that change it

Setting Step Default
ph_context_set_max_pixels() size limits 256 × 1024 × 1024 pixels
ph_context_set_decode_scale() decoding, JPEG only full size
ph_context_set_alpha_mode() transparency composited on gray
ph_context_set_auto_orient() orientation on
ph_context_set_load_grayscale() the decoder's grayscale off
ph_context_set_gray_weights() grayscale 38, 75, 15
ph_context_set_gamma() Radial only 1

The first five are read when an image is loaded, so they apply to the next load; the last two are read by the hash. Hashes are comparable only when computed with the same settings.


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