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.
1. Grayscale. Each pixel becomes one luminance value,
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 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\),
the definition of the source. The 180 variances are the profile:
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
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:
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:
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,
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.
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 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.
- 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:
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:
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.
/* 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;
}
/* 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:
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.
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.
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:
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:
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:
/* 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:
# 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,
}
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.
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.
- 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.
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 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:
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.
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
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))
/* 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():
/* 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:
/* 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:
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 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:
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.
| 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.
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
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},
};
/* 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:
/* 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.
-
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. ↩