Skip to content

Radial — Radial Variance Hash

Draw lines through the center of the image, one per degree, and measure how much the brightness varies along each. The 180 variances form a profile of the image by angle: high where a line crosses strong contrast, low where it runs along it. The digest is the shape of that profile, compressed into 40 numbers. It describes how the picture is organized around its center, not where its light and dark parts lie.

Call ph_compute_radial_hash(), or PH_ALGO_RADIAL in ph_compute_digest()
Output digest of 40 bytes, compared with ph_radial_similarity(), never bit by bit
Source Christophe De Roover, Christophe De Vleeschouwer, Frédéric Lefèbvre and Benoît Macq, "Robust image hashing based on radial variance of pixels", 2005, as pHash implements it (provenance)
Cost 1.4 ms on a 400×400 image, 120 ms on 20 Mpx, after decoding1

The steps

The steps below are those of the default settings: 180 lines of 128 points each, a blur of σ 3.5 and a gamma of 1; ph_context_set_radial_params() and ph_context_set_gamma() set others, below. Every picture on this page is computed by the library from the same photograph, and the digest at the end is what ph_compute_radial_hash() returns for it.

The stages of Radial on the example photograph: decoded, grayscale, blurred, and the lines through the center The stages of Radial on the example photograph: decoded, grayscale, blurred, and the lines through the center

1. Grayscale. Each pixel becomes one luminance value,

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

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

2. Blur. The grayscale image is blurred at full resolution by a Gaussian of standard deviation σ = 3.5 pixels, applied along the rows and then the columns, with a kernel reaching \(\lceil 3\sigma \rceil = 11\) pixels each side; edge pixels are repeated past the border, and the result is rounded to whole gray levels. The blur takes out the pixel-level detail that a resampled or recompressed copy would not keep.

3. Gamma. Each value \(v\) becomes \(\left(v / v_{\max}\right)^{\gamma} \cdot v_{\max}\), where \(v_{\max}\) is the brightest value of the blurred image, truncated to a whole level. At the default \(\gamma = 1\) this is the identity, and the library skips it.

4. Lines through the center. For each angle \(\theta_i = i \cdot 180° / 180\), \(i = 0 \dots 179\), a line runs through the center \((w/2,\, h/2)\) of the image, and 128 points are read on it, spaced \(r / 64\) apart from \(-r\) to \(r - r/64\), where \(r = \min(w, h) / 2\). A point between pixels takes the bilinear blend of its four neighbors; a point outside the image is skipped. The lines all stay inside the disc of radius \(r\), so the corners of a square image, and the ends of a long one, are never read.

The blurred photograph with every tenth line, two of them highlighted, and the gray levels along those two lines The blurred photograph with every tenth line, two of them highlighted, and the gray levels along those two lines

The highlighted lines are the ones with the largest and the smallest variance, and the gray levels along them are on the right. The first runs from the dark soil to the bright background, the whole range of the image; the second meets less contrast end to end.

5. Variance along each line. Over the \(n\) points \(p_1 \dots p_n\) read on line \(i\),

\[ R_i = \frac{1}{n} \sum_k p_k^2 - \left( \frac{1}{n} \sum_k p_k \right)^2 , \]

the definition of the source. The 180 variances are the profile:

The variance along each of the 180 lines, against its angle, with its mean dashed The variance along each of the 180 lines, against its angle, with its mean dashed

A line at \(\theta\) and one at \(\theta + 180°\) are the same line, so 180 angles cover every direction once.

6. Standardize. With \(\mu\) and \(s\) the mean and the standard deviation of the 180 variances, each becomes

\[ x_i = \frac{R_i - \mu}{s} . \]

This drops the overall level of the variances, which is the image's contrast, and keeps their shape. If \(\mu \le 10^{-6}\), or if \(s\) is no more than 1 % of \(\mu\) (\(s^2 \le 10^{-4} \mu^2\)), the image has no angular structure, and the digest is 40 zero bytes (below).

7. Transform. A one-dimensional DCT-II, orthonormally scaled, gives the first 40 coefficients of the profile:

\[ X_k = c_k \sum_{i=0}^{179} x_i \cos\frac{\pi (2i + 1) k}{360}, \qquad c_0 = \sqrt{1/180},\; c_k = \sqrt{2/180}, \qquad k = 0 \dots 39 . \]

Coefficient \(k\) measures how much of the profile is a wave of \(k/2\) cycles over the 180 degrees. \(X_0\) is the profile's sum, zero after step 6. Forty coefficients keep the waves up to 19.5 cycles, which is the outline of the profile without its finest wiggles:

The standardized profile with the profile its 40 coefficients describe, and the 40 coefficients The standardized profile with the profile its 40 coefficients describe, and the 40 coefficients

8. Quantize. The coefficients are mapped onto bytes by their own range: with \(X_{\min}\) and \(X_{\max}\) the smallest and the largest of the 40,

\[ \text{byte}_k = \left\lfloor 255 \cdot \frac{X_k - X_{\min}}{X_{\max} - X_{\min}} \right\rfloor . \]

The largest coefficient always becomes 255 and the smallest 0. Multiplying the whole profile by a constant changes no byte.

On the example photograph, 400×400, the lines run 200 pixels each side of the center; the variance ranges from 1,923 to 4,990 gray levels², the largest coefficient is number 2 and the smallest number 6, and the zero of coefficient 0 lands at byte value 92.

No angular structure

A profile that hardly varies with the angle has no shape to describe. Standardizing it would divide by a spread that is mostly rounding, and the digest would describe the rounding: in the library's own measurement, such a digest correlates with that of the same image plus a little noise at 0.4 to 0.7, where the digest of an image with structure correlates at 0.94 or more. So below a spread of 1 % of the mean the library returns 40 zero bytes, and ph_radial_similarity() refuses to compare it with anything, with PH_ERR_NO_STRUCTURE.

Two synthetic images of concentric rings and the example photograph, each with its profile divided by its mean, against a band of 1 % around the mean Two synthetic images of concentric rings and the example photograph, each with its profile divided by its mean, against a band of 1 % around the mean

Concentric rings are the typical case: every line through their center crosses the same rings, and the variance is the same at every angle. A blank image is the other: it has no variance at all. The bound is relative, so a faint pattern of a gray level or two still gets a digest of its own, and so does an image with a little angular structure, like the disc beside the rings, off the center.

2 of the 24 synthetic images, both sets of concentric rings, have no structure by this measure; the smallest spread among the others is 2.1% of the mean, and the smallest among the photographs 10.3%.

The bound and its measurement are in provenance § 7; what a refused comparison means for a search is the caller's decision.

Digest layout

Byte \(k\) is coefficient \(k\): byte 0 is the coefficient of the profile's mean, zero, and lands wherever zero falls between \(X_{\min}\) and \(X_{\max}\); bytes 1 and 2 are the waves of half a cycle and one cycle over the 180 degrees, and so on up to byte 39. In hexadecimal (ph_digest_to_hex() writes byte 0 first) each pair of digits is one coefficient.

The 40 coefficients with their smallest and largest marked, and the 40 bytes they become, each with its value in hexadecimal The 40 coefficients with their smallest and largest marked, and the 40 bytes they become, each with its value in hexadecimal

The bytes are numbers, not bits. Two digests are compared by ph_radial_similarity(): the Pearson correlation of the two byte sequences, computed with the second shifted cyclically by each of 0 to 39 places, and the largest of the 40. It runs from −1 to 1; 1 means the same shape up to a shift. PH_RADIAL_PCC_THRESHOLD, 0.9, is the cut the source uses for "the same image". Hamming distance would count bits of numbers and mean nothing, and the bit-vector functions refuse a digest of this kind.

What changes the hash

The digest describes how contrast is spread over the directions through the center. Anything that keeps that spread keeps the digest; anything that turns the image or changes what lies near the center moves it.

Peak correlation with the original under nine transforms, on the example photograph, with PH_RADIAL_PCC_THRESHOLD dashed Peak correlation with the original under nine transforms, on the example photograph, with PH_RADIAL_PCC_THRESHOLD dashed

  • Brightness, contrast and gamma mostly scale the variances, and step 6 takes the scale out. What is left is a change in the profile's shape: where a brightening clips the light parts to white, or a strong gamma bends dark and light differently, the score approaches 0.9.
  • Recompression, noise and blur change single pixels, which the blur of step 2 and the 128 points of each line average away.
  • Downscaling and cropping change the scale of the disc the lines read, a little: the same lines cross the same structures, nearer to or further from the ends.
  • Rotation turns every line onto another angle, and the score falls by a few hundredths per degree, below 0.9 within a few degrees. The section Turning the image explains why.

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

The numbers behind the chart
Transform Strength Peak correlation
JPEG quality 95 1.000
JPEG quality 75 1.000
JPEG quality 50 1.000
JPEG quality 30 1.000
JPEG quality 15 1.000
JPEG quality 5 0.997
Downscale 0.75 0.999
Downscale 0.5 0.992
Downscale 0.25 0.972
Downscale 0.125 0.876
Rotation 1 0.995
Rotation 2 0.980
Rotation 5 0.893
Rotation 10 0.712
Rotation 20 0.498
Rotation 45 0.264
Brightness 0.5 1.000
Brightness 0.7 1.000
Brightness 0.85 1.000
Brightness 1.15 0.999
Brightness 1.3 0.985
Brightness 1.5 0.865
Contrast 0.5 1.000
Contrast 0.7 1.000
Contrast 0.85 1.000
Contrast 1.15 0.997
Contrast 1.3 0.983
Contrast 1.5 0.968
Gamma 0.5 0.876
Gamma 0.7 0.965
Gamma 0.85 0.993
Gamma 1.2 0.994
Gamma 1.5 0.976
Gamma 2 0.951
Gaussian blur 0.5 1.000
Gaussian blur 1 1.000
Gaussian blur 2 1.000
Gaussian blur 4 0.997
Gaussian blur 8 0.987
Noise 2 1.000
Noise 5 1.000
Noise 10 1.000
Noise 20 0.999
Noise 40 0.997
Crop 5 0.995
Crop 10 0.979
Crop 20 0.882
Crop 30 0.787
How this was measured

Each transform is applied alone to the photograph above, and the edited copy is saved losslessly, as PPM (as a JPEG of the given quality for that panel). The library hashes the original and every copy, and compares each copy with the original by ph_radial_similarity(), whose score is the number on the chart. Below is the code that ran, not a copy of it.

The edits, in Python with Pillow:

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

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

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

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

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

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

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

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

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

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

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

Writing the copies and handing them to the measuring tool:

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

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


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

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

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

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

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

Over two corpora

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

Peak correlation with the original under nine transforms, median and middle half over each corpus Peak correlation with the original under nine transforms, median and middle half over each corpus

The corpora repeat the single photograph: rotation is the one edit that takes the median below 0.9 at a moderate strength, at a few degrees, and the strongest downscale and crop come next. On the synthetic images the strongest downscale goes much further, and strong noise and blur move a quarter of them: their narrow stripes and small checks are the structure those edits erase. Two of them, the concentric rings, have no angular structure and take no part (the table counts them).

Copies and different images

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

Distribution of the peak correlation, for copies and for pairs of different images, in both corpora Distribution of the peak correlation, for copies and for pairs of different images, in both corpora

Copies pile up near 1. Different images do not spread around 0, as two unrelated correlations would: the score is the best of 40 shifts, and the shape of most profiles, a few broad waves, lets some shift line two of them up. The dashed line is the score that accepts 95 % of the copies, and its label says how many different pairs it would accept as well. d′ sums up the gap in one number, the distance between the two means in units of their spread.

On the photographs the copies that set the threshold are mostly the rotated ones: a turn of 2 degrees is the moderate edit that moves the digest furthest. Different synthetic images often score high against each other: their simple patterns give a few profiles of the same broad shape, and the threshold lets a large share of their pairs through.

The numbers behind the charts
Corpus Copies Different pairs d′ Threshold Different pairs within it
synthetic corpus, 24 images; not photographs 195 231 2.80 0.751 14.7%
200 public-domain photographs from Wikimedia Commons 1800 19900 5.52 0.956 4 of 19,900

Left out, because the comparison refuses an image with no angular structure: synthetic corpus, 24 images; not photographs: 109 edited images and 45 pairs of different images.

Median peak correlation over each corpus, with the 25th and 75th percentiles:

Transform Strength synthetic corpus, 24 images; not photographs 200 public-domain photographs from Wikimedia Commons
JPEG quality 95 1.000 (0.999–1.000) 1.000 (1.000–1.000)
JPEG quality 75 1.000 (0.999–1.000) 1.000 (1.000–1.000)
JPEG quality 50 1.000 (0.995–1.000) 1.000 (1.000–1.000)
JPEG quality 30 0.999 (0.997–1.000) 1.000 (1.000–1.000)
JPEG quality 15 0.999 (0.966–1.000) 1.000 (1.000–1.000)
JPEG quality 5 0.995 (0.881–0.998) 0.998 (0.996–0.999)
Downscale 0.75 0.997 (0.978–1.000) 0.999 (0.998–0.999)
Downscale 0.5 0.981 (0.880–0.998) 0.994 (0.984–0.997)
Downscale 0.25 0.949 (0.531–0.977) 0.965 (0.918–0.981)
Downscale 0.125 0.594 (0.477–0.953) 0.889 (0.783–0.949)
Rotation 1 0.993 (0.980–0.998) 0.991 (0.986–0.994)
Rotation 2 0.980 (0.921–0.994) 0.965 (0.946–0.977)
Rotation 5 0.901 (0.637–0.964) 0.832 (0.738–0.891)
Rotation 10 0.773 (0.368–0.878) 0.638 (0.500–0.746)
Rotation 20 0.616 (0.326–0.749) 0.464 (0.388–0.570)
Rotation 45 0.484 (0.375–0.658) 0.401 (0.326–0.482)
Brightness 0.5 1.000 (0.999–1.000) 1.000 (1.000–1.000)
Brightness 0.7 1.000 (1.000–1.000) 1.000 (1.000–1.000)
Brightness 0.85 1.000 (1.000–1.000) 1.000 (1.000–1.000)
Brightness 1.15 1.000 (1.000–1.000) 0.998 (0.995–1.000)
Brightness 1.3 0.999 (0.984–1.000) 0.986 (0.969–0.997)
Brightness 1.5 0.998 (0.976–0.999) 0.955 (0.914–0.980)
Contrast 0.5 1.000 (0.999–1.000) 1.000 (1.000–1.000)
Contrast 0.7 1.000 (1.000–1.000) 1.000 (1.000–1.000)
Contrast 0.85 1.000 (1.000–1.000) 1.000 (1.000–1.000)
Contrast 1.15 1.000 (1.000–1.000) 0.999 (0.998–1.000)
Contrast 1.3 1.000 (0.999–1.000) 0.996 (0.991–0.999)
Contrast 1.5 0.998 (0.996–1.000) 0.988 (0.975–0.995)
Gamma 0.5 0.998 (0.985–0.999) 0.970 (0.929–0.986)
Gamma 0.7 0.999 (0.992–1.000) 0.990 (0.977–0.995)
Gamma 0.85 1.000 (0.998–1.000) 0.998 (0.994–0.999)
Gamma 1.2 1.000 (0.999–1.000) 0.997 (0.993–0.999)
Gamma 1.5 0.999 (0.998–1.000) 0.983 (0.962–0.993)
Gamma 2 0.998 (0.991–1.000) 0.944 (0.887–0.978)
Gaussian blur 0.5 1.000 (1.000–1.000) 1.000 (1.000–1.000)
Gaussian blur 1 1.000 (0.999–1.000) 1.000 (1.000–1.000)
Gaussian blur 2 0.999 (0.995–1.000) 1.000 (0.999–1.000)
Gaussian blur 4 0.994 (0.965–1.000) 0.998 (0.995–0.999)
Gaussian blur 8 0.976 (0.770–0.998) 0.987 (0.971–0.993)
Noise 2 1.000 (0.998–1.000) 1.000 (1.000–1.000)
Noise 5 1.000 (0.983–1.000) 1.000 (1.000–1.000)
Noise 10 0.999 (0.944–1.000) 1.000 (1.000–1.000)
Noise 20 0.998 (0.841–1.000) 1.000 (0.999–1.000)
Noise 40 0.992 (0.610–0.998) 0.999 (0.997–0.999)
Crop 5 0.997 (0.993–1.000) 0.995 (0.989–0.997)
Crop 10 0.987 (0.974–1.000) 0.981 (0.960–0.990)
Crop 20 0.965 (0.897–0.999) 0.930 (0.857–0.964)
Crop 30 0.899 (0.857–0.998) 0.842 (0.721–0.922)
How this was measured

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

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

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


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


_fetched = {}


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

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

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

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

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

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

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

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

Comparing every pair of originals:

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

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

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

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

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

Edits that change the picture

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

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

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

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

  • A patch at the center is crossed by every one of the 180 lines, and the points of each line lie closest together there. Radial notices it more than any part of the frame of the same size: on the photographs a patch over a few percent of the frame already takes most edited images past the threshold.
  • Turning the hue hardly moves the digest: the variances change little when the luminance does.
  • A half turn leaves the digest where it was: every line maps onto itself. A quarter turn moves it as far as an unrelated image. A mirror image runs the profile backwards, which moves a photograph's digest far and leaves the symmetric patterns of the synthetic images in place; the next section has the reason.
The numbers behind the chart

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

Edit Synthetic: median (25th–75th) accepted as a copy Photographs: median (25th–75th) accepted as a copy
patch over 1% of the frame 0.999 (0.953–1.000) 86% 0.957 (0.897–0.988) 50%
patch over 4% of the frame 0.977 (0.782–0.999) 73% 0.817 (0.599–0.933) 16%
patch over 9% of the frame 0.968 (0.695–1.000) 73% 0.677 (0.489–0.850) 5%
patch over 16% of the frame 0.767 (0.518–0.990) 50% 0.583 (0.429–0.721) 2%
hue turned 30° 0.971 (0.848–1.000) 86% 0.987 (0.967–0.996) 80%
hue turned 90° 0.991 (0.754–1.000) 73% 0.995 (0.981–0.999) 90%
hue turned 180° 0.999 (0.878–1.000) 76% 0.968 (0.904–0.995) 57%
turned 90° 0.254 (0.164–0.511) 18% 0.318 (0.257–0.397) 0%
turned 180° 0.999 (0.994–1.000) 100% 0.999 (0.998–0.999) 100%
mirrored 0.986 (0.536–0.999) 73% 0.445 (0.342–0.633) 2%
How this was measured

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

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

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

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

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

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

Turning the image

The source credits the radial variance with robustness to rotation, and the profile has it. Turning the image turns every line with it: the line that was at \(\theta\) is now at \(\theta + \alpha\), so the profile of the turned image is the original profile moved along by \(\alpha\), wrapping around at 180°.

The variance profile of the example photograph and of the photograph turned 15 degrees, and the second moved back into place The variance profile of the example photograph and of the photograph turned 15 degrees, and the second moved back into place

The digest does not keep it. A shift of the profile is not a shift of its DCT coefficients: moving a wave along changes how much of it each coefficient's cosine catches, coefficient by coefficient. ph_radial_similarity() tries every cyclic shift of the 40 bytes, as the source does, but shifting coefficient \(k\) into the place of coefficient \(k + 1\) does not undo a turn. What remains is the tolerance of the coefficients to a small change in the profile:

Peak correlation of the rotated photograph with the original, for every whole degree from 0 to 180: computed on the digests, it falls below 0.9 within a few degrees and rises back at 180; computed on the profiles, it stays near 1 Peak correlation of the rotated photograph with the original, for every whole degree from 0 to 180: computed on the digests, it falls below 0.9 within a few degrees and rises back at 180; computed on the profiles, it stays near 1

On this photograph the digests stay at or above 0.9 up to 4°; at a quarter turn they score 0.360, and at a half turn 0.998. The profiles never fall below 0.998.

  • A few degrees match. That is the turn a scan or a straightened photograph comes with, and the threshold holds for it.
  • A half turn matches exactly: each line maps onto itself, and the profile does not move at all.
  • Anything between scores as an unrelated image would. Recognizing a picture turned by an arbitrary angle would take a comparison of the profiles, which the digest does not store.

A mirror image is the same story: the line at \(\theta\) becomes the line at \(180° - \theta\), so the profile runs backwards, and a profile read backwards has the same even coefficients and the odd ones with their signs turned. A pattern symmetric about a vertical axis has odd coefficients near zero, and its mirror image keeps the digest. On the example photograph the mirrored image's profile correlates with the original's read backwards at 0.999; its odd coefficients correlate with the original's turned in sign at 0.99, its even ones with the original's unchanged at 1.00, and ph_radial_similarity() scores the two digests 0.693.

The numbers behind the chart
Rotation Digests: ph_radial_similarity() Profiles: best shift Shift found
1° 0.995 1.000 1
2° 0.980 1.000 2
3° 0.956 1.000 3
5° 0.893 1.000 5
10° 0.712 1.000 10
15° 0.527 1.000 15
30° 0.456 1.000 30
45° 0.264 0.999 45
90° 0.360 0.999 90
135° 0.639 0.999 135
180° 0.998 0.998 0
How this was measured

The photograph is turned by each whole degree from 1 to 180 with the rotation of the charts above; the tool computes the profile and the digest of each turned image, checks the digest against ph_compute_radial_hash(), and scores it against the original's with ph_radial_similarity(). The score on the profiles is the same formula applied to the 180 variances instead of the 40 bytes, computed in Python.

tools/site/pages/radial.py
def _rotate(im, degrees):
    """The rotation of transforms.py: about the center, bicubic, the corners filled with
    the image's mean color (the lines never read them: they stay inside the disc)."""
    fill = tuple(int(v) for v in np.asarray(im).reshape(-1, 3).mean(axis=0))
    return im.rotate(degrees, resample=Image.BICUBIC, fillcolor=fill)


def rotation_sweep(tool, base, angles):
    """For each angle, the library's score for the rotated image's digest against the
    original's, and the same score computed on the two variance profiles instead, with
    the shift it is found at."""
    rows = _profiles(tool, base, [_rotate(base, a) for a in angles])
    ref = np.array(rows[0]["radial"]["variance"])
    out = []
    for a, row in zip(angles, rows[1:]):
        p, shift = peak_profile_correlation(ref, np.array(row["radial"]["variance"]))
        out.append({"angle": a, "digest": row["similarity"], "profile": p, "shift": shift,
                    "variance": row["radial"]["variance"]})
    return rows[0], out
tools/site/pages/radial.py
def similarity(a, b):
    """ph_radial_similarity() for rows of digests: a[i] against b[i], the Pearson
    correlation at every cyclic shift of b[i], the largest of them; NaN where either
    digest has all its bytes equal, the pair the library refuses."""
    a, b = np.atleast_2d(a).astype(float), np.atleast_2d(b).astype(float)
    ra = a - a.mean(axis=1, keepdims=True)
    rb = b - b.mean(axis=1, keepdims=True)
    denom = np.sqrt((ra ** 2).sum(axis=1) * (rb ** 2).sum(axis=1))
    # np.roll(rb, d)[i] is rb[(i - d) % n], the term ph_radial_similarity() pairs with ra[i].
    peak = np.max([(ra * np.roll(rb, d, axis=1)).sum(axis=1)
                   for d in range(a.shape[1])], axis=0)
    with np.errstate(invalid="ignore", divide="ignore"):
        return np.where(denom > 0, np.clip(peak / np.where(denom > 0, denom, 1), -1, 1), np.nan)


def peak_profile_correlation(p, q):
    """The same score on two variance profiles instead of two digests: the Pearson
    correlation at every cyclic shift of q, the largest, and the shift it is found at."""
    p = (p - p.mean()) / p.std()
    q = (q - q.mean()) / q.std()
    scores = [np.mean(p * np.roll(q, d)) for d in range(len(p))]
    return max(scores), int(np.argmax(scores))
tools/site/stages/radial.c
/* site_stages radial-profiles <reference> <image>...: for every image, the default
 * Radial's variance profile and digest, recomputed from its stages and checked against
 * ph_compute_radial_hash(), and the digest compared with the reference's by
 * ph_radial_similarity() (null where the library refuses, an image with no angular
 * structure): one JSON line per image, the reference first. */
int mode_radial_profiles(int argc, char **argv) {
    ph_digest_t ref = {0};
    for (int i = 0; i < argc; i++) {
        ph_context_t *ctx = NULL;
        if (load_image(&ctx, argv[i])) {
            return 1;
        }
        radial_stages_t s;
        ph_digest_t d;
        int bad = radial_run(ctx, &radial_variants[RADIAL_DEFAULT], &s) ||
                  ph_compute_radial_hash(ctx, &d) != PH_SUCCESS;
        ph_free(ctx);
        if (bad) {
            radial_free(&s);
            return 1;
        }
        if (i == 0) {
            ref = d;
        }
        json_t j = json_begin(stdout);
        json_string(&j, "file", argv[i]);
        radial_json(&j, "radial", &s, 1);
        double pcc = 0.0;
        if (ph_radial_similarity(&ref, &d, &pcc) == PH_SUCCESS) {
            json_double(&j, "similarity", pcc);
        } else {
            json_null(&j, "similarity");
        }
        json_end(&j);
        radial_free(&s);
    }
    return 0;
}

The profile and the digest are computed from the stages as in the steps above, and each digest is checked against ph_compute_radial_hash():

tools/site/stages/radial.c
/* One Radial computation with the given settings, recomputed from its stages and checked
 * against ph_compute_radial_hash() with the same settings: the grayscale image blurred at
 * `sigma` and put through `gamma`; the variance along each of `projections` lines through
 * the center, `samples` points on each; the profile's mean and spread; the profile
 * standardized; its first PH_RADIAL_COEFFS DCT coefficients; and the digest, the
 * coefficients mapped onto 0..255 by their own minimum and maximum (all zero when the
 * profile has no spread worth the name). The context is left at the default settings. */
typedef struct {
    radial_variant_t settings;
    double center_x, center_y, radius;
    double *variance, *standardized; /* projections each */
    double mean, spread_sq;
    int structure;
    double coefficients[PH_RADIAL_COEFFS];
    uint8_t digest[PH_RADIAL_COEFFS];
    uint8_t *blurred; /* width x height */
} radial_stages_t;

static void radial_free(radial_stages_t *s) {
    free(s->variance);
    free(s->standardized);
    free(s->blurred);
    s->variance = s->standardized = NULL;
    s->blurred = NULL;
}

static int radial_run(ph_context_t *ctx, const radial_variant_t *v, radial_stages_t *s) {
    const int w = ctx->image.width, h = ctx->image.height, projections = v->projections;
    const size_t npix = (size_t)w * (size_t)h;
    memset(s, 0, sizeof(*s));
    s->settings = *v;
    s->variance = malloc((size_t)projections * sizeof(double));
    s->standardized = calloc((size_t)projections, sizeof(double));
    s->blurred = malloc(npix);
    float *scratch = malloc(npix * sizeof(float));
    const uint8_t *gray = ph_get_gray(ctx);
    if (!s->variance || !s->standardized || !s->blurred || !scratch || !gray ||
        !radial_settings(ctx, v)) {
        free(scratch);
        return fail("cannot set up Radial", NULL);
    }
    ph_gaussian_blur_sigma(gray, w, h, v->sigma, scratch, s->blurred);
    free(scratch);
    ph_apply_gamma(ctx, s->blurred, w, h);

    s->center_x = w / 2.0;
    s->center_y = h / 2.0;
    s->radius = (w < h ? w : h) / 2.0;
    double sum = 0.0, sum_sq = 0.0;
    for (int i = 0; i < projections; i++) {
        double theta = i * M_PI / projections;
        s->variance[i] =
            ph_projection_variance(s->blurred, w, h, s->center_x, s->center_y, s->radius,
                                   (float)cos(theta), (float)sin(theta), v->samples);
        sum += s->variance[i];
        sum_sq += s->variance[i] * s->variance[i];
    }
    s->mean = sum / projections;
    s->spread_sq = sum_sq / projections - s->mean * s->mean;
    s->structure = s->mean > PH_RADIAL_MIN_MEAN_VARIANCE &&
                   s->spread_sq > PH_RADIAL_MIN_RELATIVE_SPREAD * s->mean * s->mean;
    if (s->structure) {
        for (int i = 0; i < projections; i++) {
            s->standardized[i] = (s->variance[i] - s->mean) / sqrt(s->spread_sq);
        }
        if (ph_dct1d_partial(s->standardized, projections, PH_RADIAL_COEFFS, s->coefficients) !=
            PH_SUCCESS) {
            return fail("ph_dct1d_partial failed", NULL);
        }
        double lo = s->coefficients[0], hi = s->coefficients[0];
        for (int k = 1; k < PH_RADIAL_COEFFS; k++) {
            lo = s->coefficients[k] < lo ? s->coefficients[k] : lo;
            hi = s->coefficients[k] > hi ? s->coefficients[k] : hi;
        }
        for (int k = 0; k < PH_RADIAL_COEFFS; k++) {
            s->digest[k] = (uint8_t)(255.0 * (s->coefficients[k] - lo) / (hi - lo));
        }
    }

    ph_digest_t lib;
    int bad = ph_compute_radial_hash(ctx, &lib) != PH_SUCCESS;
    if (!radial_settings(ctx, &radial_variants[RADIAL_DEFAULT]) || bad) {
        return fail("ph_compute_radial_hash failed", NULL);
    }
    char what[64];
    snprintf(what, sizeof(what), "Radial (%d x %d, sigma %g, gamma %g)", projections, v->samples,
             (double)v->sigma, (double)v->gamma);
    return check_digest(what, s->digest, PH_RADIAL_COEFFS, &lib);
}

How the nine algorithms compare is on choosing an algorithm.

Cost

Radial reads every pixel twice before it reads a line: the grayscale conversion, which is cached and shared with the other grayscale hashes, and the blur, which is its own. The blur is most of the time at every image size. Its kernel has \(2\lceil 3\sigma \rceil + 1\) taps, 23 at the default, along each axis, so its cost grows with the image's pixel count and with σ, and on a large photograph Radial costs about as much as decoding the JPEG. The lines are \(180 \times 128\) bilinear reads whatever the image's size, a fraction of a millisecond, and the transform is 40 sums over 180 numbers. The table under projections and samples and the cases radial_sigma_* below separate the two parts.

How this was measured

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

Case 400×400 20 Mpx
decode 0.53 ms 105 ms
ahash 0.21 ms 23 ms
dhash 0.23 ms 27 ms
phash 0.21 ms 23 ms
whash 0.21 ms 23 ms
bmh 0.21 ms 23 ms
mhash 1.8 ms 91 ms
radial 1.4 ms 120 ms
color_hash 0.24 ms 31 ms
color_moments 0.52 ms 66 ms
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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

#define TIME_MIN_SECONDS 1.0

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

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

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

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

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

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

Parameters

ph_context_set_radial_params() sets the lines and the blur, and ph_context_set_gamma() the gamma, which no other hash reads.

Setting Values Default Meaning
projections 40 … 4096 180 the number of lines, spread over 180°
samples 2 … 4096 128 the points read on each line
sigma above 0, up to 64/3 3.5 the blur of step 2
gamma above 0.001, up to 1000 1 the gamma of step 3

Digests are comparable only when computed with the same settings. All of them give the same 40 bytes, so a comparison cannot tell that two digests were computed differently.

The bounds come from the steps. Fewer than 40 lines would give fewer than 40 coefficients. One point on a line has no variance, so every image would have no structure; two is the least that can vary. At σ above 64/3 the blur's kernel would need more than the 64 taps a side it holds. The upper bounds on lines and points stop a setting taken from an untrusted source from costing seconds per call, past the point where the digest stops changing (below).

How each setting separates copies from different images is measured over both corpora, with the copies of the chart above.

sigma

The blur sets the finest structure the lines see:

The example photograph blurred at sigma 1, 3.5 and 8, and the three standardized profiles The example photograph blurred at sigma 1, 3.5 and 8, and the three standardized profiles

The profile keeps its broad shape at every σ: the lines cross the same large structures, and step 6 takes out the overall drop in variance a wider blur brings. Its details move, and with them the digest. Over the corpora:

Corpus Blur d′ Threshold Different pairs within it
photographs σ 1 5.54 0.955 3 of 19,900
photographs σ 3.5 (default) 5.52 0.956 4 of 19,900
photographs σ 8 5.35 0.952 5 of 19,900
synthetic images σ 1 3.00 0.928 5.2%
synthetic images σ 3.5 (default) 2.80 0.751 14.7%
synthetic images σ 8 2.55 0.777 16.5%

On the photographs the three blurs separate copies from different images about equally. On the synthetic images a lighter blur separates better: their narrow stripes and small checks are structure the default blur erases. A wider blur costs more, in proportion to its kernel, and separates no better on either corpus.

projections and samples

More lines and more points per line estimate the same profile more finely. The digest settles quickly; the time grows with the product of the two:

Time against the number of lines and points, on a 400×400 and a 20 Mpx image, and how close the example's digest comes to that of the finest grid Time against the number of lines and points, on a 400×400 and a 20 Mpx image, and how close the example's digest comes to that of the finest grid

The dashed line is the default. Below it the digest is a coarser estimate of the same profile; above it the digest hardly changes, and past a few hundred lines the time grows with the grid, on a small image first. Over the corpora, the default separates the photographs as well as any finer grid, and a finer grid lets a little fewer of the different synthetic pairs through:

Lines × points 400×400 20 Mpx Peak correlation with 4096 × 4096
40 × 32 1.2 ms 119 ms 0.972
90 × 64 1.2 ms 120 ms 0.994
180 × 128 1.4 ms 120 ms 0.999
360 × 256 2.2 ms 123 ms 1.000
1440 × 1024 16 ms 139 ms 1.000
4096 × 4096 164 ms 290 ms 1.000
Corpus Lines × points d′ Threshold Different pairs within it
photographs 40 × 32 5.57 0.889 19 of 19,900
photographs 90 × 64 5.53 0.953 4 of 19,900
photographs 180 × 128 (default) 5.52 0.956 4 of 19,900
photographs 360 × 256 5.51 0.955 4 of 19,900
photographs 1440 × 1024 5.51 0.955 4 of 19,900
photographs 4096 × 4096 5.51 0.956 4 of 19,900
synthetic images 40 × 32 2.89 0.646 19.4%
synthetic images 90 × 64 2.78 0.691 19.9%
synthetic images 180 × 128 (default) 2.80 0.751 14.7%
synthetic images 360 × 256 2.74 0.760 13.4%
synthetic images 1440 × 1024 2.74 0.762 13.4%
synthetic images 4096 × 4096 2.74 0.763 13.4%

gamma

Gamma bends the gray levels before the lines read them: below 1 it lifts the dark parts, above 1 it deepens them.

The example photograph after the blur, with gamma 0.5, 1 and 2, and the three standardized profiles The example photograph after the blur, with gamma 0.5, 1 and 2, and the three standardized profiles

Corpus Gamma d′ Threshold Different pairs within it
photographs γ 0.5 5.64 0.953 1 of 19,900
photographs γ 1 (default) 5.52 0.956 4 of 19,900
photographs γ 2 5.51 0.952 4 of 19,900
synthetic images γ 0.5 2.83 0.754 12.3%
synthetic images γ 1 (default) 2.80 0.751 14.7%
synthetic images γ 2 2.75 0.735 14.7%

Neither gamma separates differently from the default on either corpus. The default, 1, is pHash's, and the library skips the step at it. The formula of step 3 is pHash's too, so a gamma taken from a pHash configuration means the same here. Gamma is Radial's alone: none of the implementations the library follows applies it to another hash, and no other hash reads the setting.

How the settings were measured

Each original and its nine copies are hashed under every setting by site_stages radial-variants, through ph_compute_radial_hash(). The scores are computed from the digests by the same formula as ph_radial_similarity(), in Python (above, under Turning the image); the default's d′ is checked against the corpus charts, which take the library's own scores. d′, the threshold and the share of different pairs are computed as for the corpus charts.

tools/site/measure/corpus.py
def write_copies(base, tmp, stem):
    """The copies of the separability measurement: one moderate strength of each of the
    nine edits (COPY_STRENGTHS), saved as measure/robustness.py saves them; their paths."""
    return [write_edit(dict(steps)[COPY_STRENGTHS[name]], base,
                       os.path.join(tmp, f"{stem}-{k}"))
            for k, (name, _, steps) in enumerate(transforms())]


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


def measure_settings(tool, mode):
    """{corpus: {"n", "label", "images": [[original, copy…] rows per image]}}: every
    original of both corpora and its nine copies through `site_stages <mode>`, which
    prints one JSON row of digests per file, one digest per setting it tries. Cached like
    the corpora themselves, as <mode>-<corpus>."""
    result = {}
    for corpus in CORPORA:
        paths = images(tool, corpus)
        rows = []
        if paths:
            def measure():
                with tempfile.TemporaryDirectory() as tmp, \
                        concurrent.futures.ProcessPoolExecutor() as pool:
                    return list(pool.map(_variants_one, [(tool, mode, p, tmp, f"{i:04d}")
                                                         for i, p in enumerate(paths)]))
            rows = cached(f"{mode}-{corpus}", cache_key(paths), measure,
                          f"{mode}, {len(paths)} images and their copies")
        result[corpus] = {"n": len(paths), "label": label(corpus, len(paths)), "images": rows}
    return result
tools/site/stages/radial.c
const radial_variant_t radial_variants[RADIAL_VARIANTS] = {
    [RADIAL_DEFAULT] = {"default", PH_RADIAL_PROJECTIONS, PH_RADIAL_SAMPLES,
                        PH_RADIAL_DEFAULT_SIGMA, 1.0f},
    [RADIAL_SIGMA_1] = {"sigma_1", PH_RADIAL_PROJECTIONS, PH_RADIAL_SAMPLES, 1.0f, 1.0f},
    [RADIAL_SIGMA_8] = {"sigma_8", PH_RADIAL_PROJECTIONS, PH_RADIAL_SAMPLES, 8.0f, 1.0f},
    [RADIAL_GAMMA_05] = {"gamma_0.5", PH_RADIAL_PROJECTIONS, PH_RADIAL_SAMPLES,
                         PH_RADIAL_DEFAULT_SIGMA, 0.5f},
    [RADIAL_GAMMA_2] = {"gamma_2", PH_RADIAL_PROJECTIONS, PH_RADIAL_SAMPLES,
                        PH_RADIAL_DEFAULT_SIGMA, 2.0f},
    [RADIAL_GRID_40X32] = {"grid_40x32", 40, 32, PH_RADIAL_DEFAULT_SIGMA, 1.0f},
    [RADIAL_GRID_90X64] = {"grid_90x64", 90, 64, PH_RADIAL_DEFAULT_SIGMA, 1.0f},
    [RADIAL_GRID_360X256] = {"grid_360x256", 360, 256, PH_RADIAL_DEFAULT_SIGMA, 1.0f},
    [RADIAL_GRID_1440X1024] = {"grid_1440x1024", 1440, 1024, PH_RADIAL_DEFAULT_SIGMA, 1.0f},
    [RADIAL_GRID_4096X4096] = {"grid_4096x4096", PH_RADIAL_MAX_PROJECTIONS, PH_RADIAL_MAX_SAMPLES,
                               PH_RADIAL_DEFAULT_SIGMA, 1.0f},
};
tools/site/stages/radial.c
/* One image of `site_stages radial-variants`: its Radial digest under each setting of
 * radial_variants[], as ph_compute_radial_hash() computes it. */
static int radial_variants_row(ph_context_t *ctx, json_t *row, const char *path) {
    (void)path;
    for (int k = 0; k < RADIAL_VARIANTS; k++) {
        ph_digest_t d;
        if (!radial_settings(ctx, &radial_variants[k]) ||
            ph_compute_radial_hash(ctx, &d) != PH_SUCCESS) {
            return fail("ph_compute_radial_hash failed", radial_variants[k].name);
        }
        json_hexbytes(row, radial_variants[k].name, d.data, d.size);
    }
    return 0;
}

/* site_stages radial-variants <image>...: one JSON line per image, as above. */
int mode_radial_variants(int argc, char **argv) {
    return for_each_image(argc, argv, radial_variants_row);
}

Settings that affect it

Five context settings change the pixels Radial reads, besides its own parameters.

Setting Effect on Radial
ph_context_set_gray_weights() the grayscale formula of step 1
ph_context_set_load_grayscale() a JPEG decoder converts to grayscale itself (measured), which can move a value by one level
ph_context_set_auto_orient() on by default: the digest describes the image as displayed, after its EXIF rotation; a photograph stored on its side and read without it scores as an unrelated image
ph_context_set_alpha_mode() how transparent pixels are composited before grayscale (which background)
ph_context_set_decode_scale() a JPEG read by libjpeg-turbo is decoded at ½, ¼ or ⅛ of its size (measured); the blur is fixed in pixels, so a smaller decode changes what the lines sample; Radial moves the most of the nine hashes, and at an eighth of a 1280-pixel photograph most digests fall outside the threshold for copies

A level here and there is averaged away by the blur and the 128 points of a line.

On the example photograph the two digests differ in 11 of 40 bytes, by at most 1, and ph_radial_similarity() scores them 1.000.

In code

Radial returns a digest, like mHash, BMH and the two color hashes. This example computes all five for two images and compares each pair with the function its kind calls for, Radial's through ph_radial_similarity(), which reports an image with no angular structure instead of a score, and shows that a bit-vector comparison refuses a Radial digest:

examples/digest_and_metrics.c
/* Hashes two images with every digest algorithm, compares each pair with the function
 * that digest's kind calls for, shows what a mismatched comparison returns, and stores a
 * digest as text and reads it back. Build/run:
 *   cc digest_and_metrics.c -o digest_and_metrics $(pkg-config --cflags --libs libphash)
 *   ./digest_and_metrics a.jpg b.jpg
 */
#include <libphash.h>
#include <stdio.h>
#include <string.h>

/* Loads `path` into `ctx` in color, so the color hashes can run on it too. */
static int load(ph_context_t *ctx, const char *path) {
    ph_error_t err = ph_load_from_file(ctx, path);
    if (err != PH_SUCCESS) {
        fprintf(stderr, "failed to load %s: %s (%s)\n", path, ph_get_error_string(err),
                ph_get_last_error_message(ctx));
        return 0;
    }
    return 1;
}

/* Compares two digests of one algorithm with the function its kind calls for. */
static void compare(const char *name, const ph_digest_t *a, const ph_digest_t *b) {
    double score = 0.0;
    switch ((ph_digest_kind_t)a->kind) {
        case PH_DIGEST_KIND_BITS:
            printf("%-13s %3d of %d bits differ, similarity %.3f\n", name,
                   ph_hamming_distance_digest(a, b), a->size * 8, ph_similarity_digest(a, b));
            break;
        case PH_DIGEST_KIND_COEFFICIENTS:
            if (ph_radial_similarity(a, b, &score) == PH_SUCCESS) {
                printf("%-13s peak correlation %.3f (same image at %.1f and above)\n", name, score,
                       PH_RADIAL_PCC_THRESHOLD);
            } else {
                printf("%-13s no angular structure to compare\n", name);
            }
            break;
        case PH_DIGEST_KIND_HISTOGRAM:
            if (ph_histogram_intersection(a, b, &score) == PH_SUCCESS) {
                printf("%-13s histogram intersection %.3f\n", name, score);
            }
            break;
        case PH_DIGEST_KIND_VECTOR16:
        case PH_DIGEST_KIND_VECTOR:
            printf("%-13s L2 distance %.2f\n", name, ph_l2_distance(a, b));
            break;
        default:
            printf("%-13s kind %d: no comparison\n", name, a->kind);
            break;
    }
}


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

    static const ph_algorithm_t algos[] = {PH_ALGO_MHASH, PH_ALGO_BMH, PH_ALGO_RADIAL,
                                           PH_ALGO_COLOR_HASH, PH_ALGO_COLOR_MOMENTS};

    enum {
        N_ALGOS = sizeof(algos) / sizeof(algos[0]),
    };

    ph_digest_t da[N_ALGOS], db[N_ALGOS];

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

    /* One context, reused: each load replaces the previous image and its cached work. */
    for (int img = 0; img < 2; img++) {
        if (!load(ctx, argv[1 + img])) {
            ph_free(ctx);
            return 1;
        }
        for (int i = 0; i < N_ALGOS; i++) {
            ph_error_t err = ph_compute_digest(ctx, algos[i], img == 0 ? &da[i] : &db[i]);
            if (err != PH_SUCCESS) {
                fprintf(stderr, "%s failed: %s\n", ph_algorithm_name(algos[i]),
                        ph_get_error_string(err));
                ph_free(ctx);
                return 1;
            }
        }
    }

    for (int i = 0; i < N_ALGOS; i++) {
        compare(ph_algorithm_name(algos[i]), &da[i], &db[i]);
    }

    /* The wrong metric is refused, not answered with a meaningless number: a radial
     * digest is quantized coefficients, not a bit vector. */
    printf("hamming distance of two radial digests: %d (refused)\n",
           ph_hamming_distance_digest(&da[2], &db[2]));

    /* Store a digest as text and read it back; the text carries the kind. */
    char hex[PH_DIGEST_HEX_BUFFER_SIZE];
    ph_digest_t back;
    if (ph_digest_to_hex(&da[1], hex, sizeof(hex)) != PH_SUCCESS ||
        ph_digest_from_hex(hex, &back) != PH_SUCCESS) {
        fprintf(stderr, "hex round trip failed\n");
        ph_free(ctx);
        return 1;
    }
    printf("BMH as text: %s\n", hex);
    printf("read back: %s\n", (back.size == da[1].size && back.kind == da[1].kind &&
                               memcmp(back.data, da[1].data, back.size) == 0)
                                  ? "identical"
                                  : "DIFFERENT");

    ph_free(ctx);
    return 0;
}

Where it comes from

The radial variance hash is the algorithm of "Robust image hashing based on radial variance of pixels" by Christophe De Roover, Christophe De Vleeschouwer, Frédéric Lefèbvre and Benoît Macq (ICIP 2005), which improved the authors' earlier RASH. The paper is paywalled; its steps are known from Christoph Zauner's thesis (2010), which restates the method as pHash implements it: the variance along 180 lines, a DCT of that profile, the first 40 coefficients, and comparison by the peak of the cross-correlation with a threshold of 0.9. pHash is the reference implementation, and the library follows it for what the paper leaves open: the blur's σ of 3.5 and the gamma of 1 (the thesis reports the authors suggesting 1 for both), the standardization before the transform, and the quantization by the coefficients' own range.

The library differs in three places, each recorded in provenance § 7. Each line is read at a fixed number of points with bilinear interpolation, where the source sums the pixels of a one-pixel strip, whose length depends on the angle and the image's size. The lines stop at the inscribed disc, so they stay inside the image at every angle. And the peak correlation is symmetric in its two digests, where pHash divides by the first digest's variance alone. The bound on angular structure is the library's own: the source does not discuss images without it.


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