dHash — Difference Hash¶
Shrink the image to 9×8, and record for each pair of horizontal neighbors whether the left one is darker than the right. Where aHash compares every cell with one number for the whole image, dHash compares each cell with the one beside it: the hash describes which way the brightness goes, not where it is high.
| Call | ph_compute_dhash(), or PH_HASH_DHASH in ph_compute_multi() |
| Output | 64-bit hash, compared with ph_hamming_distance() |
| Source | David Oftedal and Neal Krawetz, "Kind of Like That", 2013 (provenance) |
| Cost | 0.23 ms on a 400×400 image, 27 ms on 20 Mpx, after decoding1 |
The steps¶
dHash has no parameters. Every picture on this page is computed by the library from the
same photograph, and the hash at the end is what ph_compute_dhash() 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. Reduce to 9×8. The image is resampled to 9 columns and 8 rows, regardless of its aspect ratio, with the Mitchell–Netravali cubic filter (\(B = C = \tfrac13\)):
The kernel is stretched to the reduction, so each output cell is a weighted mean of the source pixels within two cells' width of its center, rows first, then columns. Pixels beyond the border repeat the edge, and the result is rounded and clamped to 0–255. Unlike an area average, neighboring cells share source pixels, and the weights fall off smoothly from the center: what that changes for dHash is in Why Mitchell, not an area average.
3. Compare neighbors. With \(q_{r,c}\) the cell in row \(r\) and column \(c\), each of the 8 pairs of neighbors in each of the 8 rows gives one bit:
A set bit means the brightness rises to the right. Two equal neighbors give 0, so in a flat area every bit is clear.
4. Pack the bits. The first pair of the top row goes into the most significant bit:
Why nine columns¶
Eight comparisons need nine values: the ninth column exists only to be the right-hand neighbor of the eighth. Here is the top row of the grid above, and the eight bits it gives.
Why Mitchell, not an area average¶
The post says only "shrink", and dHash is the one 64-bit hash here that does not reduce with the exact area average aHash, pHash, wHash and BMH share. Here is the example's 9×8 grid both ways:
The values differ by a few levels, and the bits that part are those of nearly equal neighbors. Over the two corpora, with the copies and the different pairs of the separability chart below:
| Corpus | Grid | d′ | Threshold | Different pairs within it | Different pairs with the same hash | Hashes with no bit set |
|---|---|---|---|---|---|---|
| photographs | Mitchell filter | 6.94 | 7 of 64 | 4 of 19,900 | 0 | 0 |
| photographs | exact area average | 7.34 | 7 of 64 | 1 of 19,900 | 0 | 0 |
| synthetic images | Mitchell filter | 3.73 | 15 of 64 | 4.7% | 2 | 0 |
| synthetic images | exact area average | 3.39 | 13 of 64 | 12.3% | 4 | 3 |
- On the synthetic images the Mitchell filter separates better. An area average
cancels a pattern finer than a cell: a fine checkerboard or fine stripes become one
flat gray, every pair of neighbors is equal, and every bit is clear. Such images
collide at
0000000000000000, and the threshold takes in many more different pairs. The Mitchell filter's weights reach past the cell and fall off smoothly, so a ripple of the pattern survives, and the comparisons have something to compare. - On the photographs the two are close, with the area average a little ahead on this corpus: patterns that cancel within a cell are rare in photographs, and the copies of both grids move about as far (the table under this list has each edit).
- The Mitchell filter costs more: on a large image the resampling pass is most of dHash's time, and it is not shared with the other hashes (Cost).
The numbers behind the table
Mean bits that differ between an original and its copy, for each edit and grid:
| Edit | photographs, Mitchell filter | photographs, exact area average | synthetic images, Mitchell filter | synthetic images, exact area average |
|---|---|---|---|---|
| JPEG quality 50 | 0.6 | 0.7 | 2.5 | 2.3 |
| Downscale 0.5 | 0.4 | 0.1 | 2.1 | 0.7 |
| Rotation 2 | 4.9 | 5.2 | 7.2 | 6.2 |
| Brightness 1.15 | 1.2 | 1.2 | 1.5 | 1.2 |
| Contrast 1.15 | 0.9 | 0.9 | 2.8 | 1.3 |
| Gamma 1.2 | 1.0 | 1.1 | 2.9 | 1.2 |
| Gaussian blur 1 | 0.8 | 0.7 | 3.2 | 1.8 |
| Noise 5 | 0.8 | 0.6 | 3.0 | 2.7 |
| Crop 5 | 4.5 | 5.6 | 7.2 | 5.6 |
How this was measured
Every original of both corpora and its nine copies (those of the separability chart
below) is hashed by site_stages dhash-variants, which computes the hash from the
Mitchell grid as in the steps above, checks it against
ph_compute_dhash(), and makes the same
comparisons on the library's exact area average reduced to 9×8. d′ and the
threshold are computed as for the corpus charts. The figure is site_stages dhash on
the example photograph.
/* The bits of a 9x8 grid: set where a cell is darker than its right-hand neighbor. */
static uint64_t dhash_bits(const uint8_t grid[W * H]) {
uint64_t bits = 0;
for (int row = 0; row < H; row++) {
for (int col = 0; col < W - 1; col++) {
if (grid[row * W + col] < grid[row * W + col + 1]) {
bits |= 1ULL << (63 - (row * (W - 1) + col));
}
}
}
return bits;
}
/* dHash of a loaded image from its own 9x8 Mitchell grid: the bits recomputed from
* `grid` and checked against ph_compute_dhash(). Returns 0 on a match. */
static int dhash_run(ph_context_t *ctx, uint8_t grid[W * H], uint64_t *bits) {
const uint8_t *gray = ph_get_gray(ctx);
if (!gray || !ph_resize_mitchell(gray, ctx->image.width, ctx->image.height, grid, W, H)) {
return fail("cannot reduce the image to 9x8", NULL);
}
*bits = dhash_bits(grid);
uint64_t lib = 0;
if (ph_compute_dhash(ctx, &lib) != PH_SUCCESS) {
return fail("ph_compute_dhash failed", NULL);
}
return check_hash64("dHash", *bits, lib);
}
/* The same image's 9x8 grid by the exact area average aHash reduces with, which dHash
* does not use. */
static int dhash_area_grid(ph_context_t *ctx, uint8_t grid[W * H]) {
return ph_area_downscale(ctx, W, H, grid) ? 0 : fail("cannot reduce the image to 9x8", NULL);
}
/* One image of `site_stages dhash-variants`: its dHash as ph_compute_dhash() computes it
* (dhash_run() checks), and the hash the same comparisons give on an area-average grid. */
static int dhash_variants_row(ph_context_t *ctx, json_t *row, const char *path) {
(void)path;
uint8_t grid[W * H];
uint64_t bits = 0;
if (dhash_run(ctx, grid, &bits) || dhash_area_grid(ctx, grid)) {
return 1;
}
json_hex64(row, "mitchell", bits);
json_hex64(row, "area", dhash_bits(grid));
return 0;
}
/* site_stages dhash-variants <image>...: one JSON line per image, as above. */
int mode_dhash_variants(int argc, char **argv) {
return for_each_image(argc, argv, dhash_variants_row);
}
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
def variant_distances(images, variant, to_bits):
"""(copies, different) for one setting a `site_stages <algo>-variants` mode hashes
with: bits that differ between each original and its copies, and between every pair
of distinct originals. `images` are the rows measure_settings() returns, `to_bits`
turns one hexadecimal digest into its 0/1 bits."""
b = np.array([[to_bits(row[variant]) for row in img] for img in images], np.uint8)
copies = (b[:, 1:] != b[:, :1]).sum(axis=2).ravel()
orig = b[:, 0].astype(np.int32)
ones = orig.sum(axis=1)
# Hamming distance of every pair i < j, from dot products of 0/1 vectors.
same_ones = orig @ orig.T
dist = ones[:, None] + ones[None, :] - 2 * same_ones
iu = np.triu_indices(len(images), 1)
return copies, dist[iu]
Bit layout¶
Reading the hash in hexadecimal reads the grid row by row: the first two hex digits are the eight comparisons of the top row, the last two those of the bottom row. A bit belongs to the boundary between two cells, not to a cell.
What changes the hash¶
A bit records which of two neighbors is darker. An edit that keeps that order keeps the bit; where two neighbors are almost equal, as across a flat wall or sky, almost any edit can tip it.
- Resizing and blur leave the order of neighboring cells as it was, and the hash with it.
- Recompression, brightness, contrast, gamma and noise move a few bits at most: not the strong edges, but the pairs of nearly equal neighbors, which a level or two of change tips one way or the other.
- Rotation and cropping move content between cells, and the hash follows them more steeply than aHash's: a degree of rotation already moves several bits. dHash is not the algorithm for collections where images are rotated or reframed.
These are measurements on one photograph; the same edits over two whole corpora follow below.
The numbers behind the chart
| Transform | Strength | Bits that differ |
|---|---|---|
| JPEG quality | 95 | 0 |
| JPEG quality | 75 | 0 |
| JPEG quality | 50 | 0 |
| JPEG quality | 30 | 2 |
| JPEG quality | 15 | 1 |
| JPEG quality | 5 | 2 |
| Downscale | 0.75 | 0 |
| Downscale | 0.5 | 0 |
| Downscale | 0.25 | 0 |
| Downscale | 0.125 | 0 |
| Rotation | 1 | 4 |
| Rotation | 2 | 5 |
| Rotation | 5 | 7 |
| Rotation | 10 | 11 |
| Rotation | 20 | 22 |
| Rotation | 45 | 34 |
| Brightness | 0.5 | 1 |
| Brightness | 0.7 | 1 |
| Brightness | 0.85 | 2 |
| Brightness | 1.15 | 0 |
| Brightness | 1.3 | 2 |
| Brightness | 1.5 | 3 |
| Contrast | 0.5 | 2 |
| Contrast | 0.7 | 1 |
| Contrast | 0.85 | 1 |
| Contrast | 1.15 | 2 |
| Contrast | 1.3 | 0 |
| Contrast | 1.5 | 1 |
| Gamma | 0.5 | 4 |
| Gamma | 0.7 | 1 |
| Gamma | 0.85 | 0 |
| Gamma | 1.2 | 0 |
| Gamma | 1.5 | 1 |
| Gamma | 2 | 1 |
| Gaussian blur | 0.5 | 0 |
| Gaussian blur | 1 | 0 |
| Gaussian blur | 2 | 0 |
| Gaussian blur | 4 | 0 |
| Gaussian blur | 8 | 0 |
| Noise | 2 | 0 |
| Noise | 5 | 0 |
| Noise | 10 | 0 |
| Noise | 20 | 0 |
| Noise | 40 | 1 |
| Crop | 5 | 3 |
| Crop | 10 | 9 |
| Crop | 20 | 17 |
| Crop | 30 | 26 |
How this was measured
Each transform is applied alone to the photograph above, and the edited copy is
saved losslessly, as PPM (as a JPEG of the given quality for that panel). The library
hashes the original and every copy, and compares each copy with the original by
ph_similarity_digest(). The chart shows
\(64 \times (1 - \text{similarity})\), which is the Hamming distance: how many of the
64 bits the edit flipped. Below is the code that ran, not a copy of it.
The edits, in Python with Pillow:
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;
}
}
The number on the chart:
def bits_that_differ(similarity, bits_total):
"""The number on a bit hash's chart. ph_similarity_digest() is 1 - distance / bits,
so this is the Hamming distance itself: how many of the hash's bits the edit flipped."""
return (1.0 - similarity) * bits_total
To reproduce, make site builds the tool and redraws every figure. The tool can also
be run by hand on any pair of images; it prints one JSON line per variant, with each
algorithm's comparison:
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.
On the photographs, rotation and cropping are again the edits that matter; every other edit moves the median by a few bits at most, a little more than it moves aHash's, and blur and resizing hardly at all. The synthetic images are made of repeating patterns and flat fields, where many pairs of neighboring cells come out nearly equal: there noise, and to a lesser degree heavy recompression, tip many of the bits, where aHash's barely move.
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 at a few bits, different images spread around 32, half of the 64, where two unrelated hashes are expected to fall. The dashed line is the distance that accepts 95 % of the copies, and its label says how many different pairs it would accept as well. d′ sums up the gap in one number, the distance between the two means in units of their spread.
This is where dHash earns its place beside aHash. Its copies sit a little further from the original, so the threshold is higher; but on the photographs, different images are spread more tightly around 32, and the gap between the two is wider than aHash's, with fewer different pairs inside the threshold. On the synthetic images, where its copies move more, the order is reversed.
The numbers behind the charts
| Corpus | Copies | Different pairs | d′ | Threshold | Different pairs within it |
|---|---|---|---|---|---|
| synthetic corpus, 24 images; not photographs | 216 | 276 | 3.73 | 15 | 4.7% |
| 200 public-domain photographs from Wikimedia Commons | 1800 | 19900 | 6.94 | 7 | 4 of 19,900 |
Median bits that differ over each corpus, with the 25th and 75th percentiles:
| Transform | Strength | synthetic corpus, 24 images; not photographs | 200 public-domain photographs from Wikimedia Commons |
|---|---|---|---|
| JPEG quality | 95 | 0 (0–1) | 0 (0–0) |
| JPEG quality | 75 | 0 (0–6.5) | 0 (0–1) |
| JPEG quality | 50 | 0.5 (0–2.5) | 0 (0–1) |
| JPEG quality | 30 | 1 (0–3) | 0 (0–1) |
| JPEG quality | 15 | 2.5 (0–8) | 1 (0–2) |
| JPEG quality | 5 | 3.5 (1–16.2) | 2 (1–3) |
| Downscale | 0.75 | 0 (0–1.2) | 0 (0–0) |
| Downscale | 0.5 | 0 (0–1.5) | 0 (0–1) |
| Downscale | 0.25 | 0 (0–1.5) | 0 (0–1) |
| Downscale | 0.125 | 0 (0–2.8) | 0 (0–1) |
| Rotation | 1 | 3 (1–7) | 3 (2–5) |
| Rotation | 2 | 4 (3–9.2) | 5 (3–6) |
| Rotation | 5 | 9 (6–15.5) | 9 (6–11) |
| Rotation | 10 | 14 (8.8–22.8) | 15.5 (12–18) |
| Rotation | 20 | 20 (11.8–28.5) | 24 (21–28) |
| Rotation | 45 | 29.5 (16.2–32.5) | 29 (26–33) |
| Brightness | 0.5 | 2.5 (0.8–6.2) | 1 (1–2) |
| Brightness | 0.7 | 1.5 (0–7) | 1 (0–1) |
| Brightness | 0.85 | 0.5 (0–3.2) | 1 (0–1) |
| Brightness | 1.15 | 0 (0–2) | 1 (0–2) |
| Brightness | 1.3 | 3 (0–6.5) | 1 (1–2.2) |
| Brightness | 1.5 | 4.5 (0.8–8.5) | 2 (1–4) |
| Contrast | 0.5 | 2 (0–6) | 1 (1–2) |
| Contrast | 0.7 | 1 (0–6.2) | 1 (0–2) |
| Contrast | 0.85 | 1 (0–6) | 1 (0–1.2) |
| Contrast | 1.15 | 0 (0–2.8) | 1 (0–1) |
| Contrast | 1.3 | 0.5 (0–3.2) | 1 (0–2) |
| Contrast | 1.5 | 0.5 (0–3.2) | 1 (0–2) |
| Gamma | 0.5 | 2 (0–5.2) | 2 (1–3) |
| Gamma | 0.7 | 0.5 (0–4) | 1 (1–2) |
| Gamma | 0.85 | 0 (0–2.5) | 1 (0–1) |
| Gamma | 1.2 | 1 (0–3) | 1 (0–2) |
| Gamma | 1.5 | 2 (0–4) | 2 (1–2) |
| Gamma | 2 | 3 (1.8–8) | 3 (2–4) |
| Gaussian blur | 0.5 | 0 (0–0.2) | 0 (0–0) |
| Gaussian blur | 1 | 0 (0–4.5) | 1 (0–1) |
| Gaussian blur | 2 | 0.5 (0–2) | 0 (0–1) |
| Gaussian blur | 4 | 1.5 (0–3.8) | 0 (0–1) |
| Gaussian blur | 8 | 4.5 (2–10.2) | 0 (0–1) |
| Noise | 2 | 0.5 (0–2.2) | 0 (0–1) |
| Noise | 5 | 1.5 (0–4) | 0 (0–1) |
| Noise | 10 | 3.5 (0–7.2) | 0 (0–1) |
| Noise | 20 | 7 (1–13.2) | 1 (0–1) |
| Noise | 40 | 13 (1.8–18) | 1 (0–2) |
| Crop | 5 | 5.5 (1–12) | 4 (3–6) |
| Crop | 10 | 8 (1.8–14.5) | 8 (6–11) |
| Crop | 20 | 12.5 (2–26.5) | 16.5 (12.8–20) |
| Crop | 30 | 21 (2.8–30.5) | 23 (19–27) |
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 below it would be taken for a copy.
- A patch changes the differences only along the rows it crosses: over 4 % of the frame nearly every edited photograph still passes for a copy, over 16 % fewer than half do.
- Turning the hue hardly moves the hash on photographs, whose muted colors keep their luminance under a hue rotation, which keeps each pixel's saturation and brightest channel. On the flat, saturated colors of the synthetic images it moves the hash far.
- A turn or a mirror image moves the hash as far as an unrelated image. A mirror reverses the direction of every difference along a row, so even the edit that keeps the most of the picture's layout leaves no bit to rely on.
The numbers behind the chart
Median bits that differ, and the share of edited images within the threshold that accepts 95% of the copies; synthetic: synthetic corpus, 24 images; not photographs; photographs: 200 public-domain photographs from Wikimedia Commons.
| Edit | Synthetic: median (25th–75th) | accepted as a copy | Photographs: median (25th–75th) | accepted as a copy |
|---|---|---|---|---|
| patch over 1% of the frame | 1.5 (0–3) | 100% | 1 (0–2) | 100% |
| patch over 4% of the frame | 3 (1.8–5) | 96% | 3 (2–4) | 98% |
| patch over 9% of the frame | 5.5 (2–8.5) | 100% | 5 (3–7) | 81% |
| patch over 16% of the frame | 10 (4.8–14) | 83% | 8 (7–10) | 40% |
| hue turned 30° | 7.5 (2.5–15.2) | 75% | 2 (1–4) | 92% |
| hue turned 90° | 24 (11.5–37.8) | 29% | 2 (1–3) | 98% |
| hue turned 180° | 29 (10.8–40.2) | 38% | 4 (2–5) | 85% |
| turned 90° | 22 (9.8–32.5) | 33% | 31 (27–34) | 0% |
| turned 180° | 24 (10.8–31.2) | 33% | 30 (23–36) | 0% |
| mirrored | 24.5 (12.2–32.8) | 29% | 28 (19–34) | 4% |
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))]),
]
What a row comparison cannot see¶
Every bit compares two neighbors in a row, so dHash sees how the brightness changes from
left to right and nothing else. An image whose brightness changes only from top to bottom
has equal neighbors in every row: horizontal stripes and a sky that darkens toward the
horizon both hash to 0000000000000000, the hash of a flat image, and to each other.
Turned a quarter, the same stripes give a pattern of bits, and the gradient sets all 64.
aHash, which compares each cell with the mean of the whole grid, sees both directions: the horizontal stripes give it alternating rows of bits. Real images are seldom without any change along their rows, and over the two corpora no dHash comes out empty:
| Corpus | Bits set: fewest–most; middle 80 % | Hashes with no bit set |
|---|---|---|
| photographs (200) | 12–54; 24–36 | 0 |
| synthetic images (24) | 2–47; 9–38 | 0 |
How this was measured
The four images are drawn in Python and hashed by site_stages dhash-variants, which
checks each hash against ph_compute_dhash().
The table counts the bits of each original's hash in the measurement of the
section Why Mitchell, not an area average.
def _blind_spot_images(size=256):
"""Four images whose brightness changes in one direction only: stripes and a gradient
running across the rows, and the same turned a quarter."""
y = np.repeat(np.arange(size)[:, None], size, axis=1)
stripes = np.where((y // 32) % 2 == 0, 60, 200).astype(np.uint8)
sky = (220 - y * 140 // (size - 1)).astype(np.uint8)
gray = [("horizontal stripes", stripes), ("darker from top to bottom", sky),
("vertical stripes", stripes.T.copy()),
("brighter from left to right", sky.T[:, ::-1].copy())]
return [(name, Image.fromarray(g).convert("RGB")) for name, g in gray]
dHash and aHash¶
The two hashes read the same grayscale image at nearly the same size and differ in what a bit compares: a cell with the mean of the grid, or with the cell beside it. Here are both, edit by edit, over both corpora, as the mean number of bits that differ from the original:
- Rotation and cropping move dHash further. A small shift changes the difference between two neighbors sooner than it carries a cell across the mean of the whole grid.
- Brightness and contrast move dHash a little more. They keep the order of neighbors, but tip the pairs that were nearly equal, which aHash's comparison with the mean does not have.
- Gamma moves both about alike, dHash a little less at the strongest settings: a tone curve keeps the order of neighbors, where it moves aHash's mean against the cells.
- Noise on the synthetic images moves dHash far more. Their flat areas give equal neighbors, whose bits are clear, and noise tips them one way or the other.
The price is in the copies, and the gain in the different images: dHash's copies sit further from the original, but on the photographs its different pairs spread less widely around 32, so the gap between the two is wider than aHash's. On the synthetic images the order is reversed.
| Corpus | Hash | d′ | Threshold | Different pairs within it |
|---|---|---|---|---|
| photographs | aHash | 5.04 | 4 of 64 | 9 of 19,900 |
| photographs | dHash | 6.94 | 7 of 64 | 4 of 19,900 |
| synthetic images | aHash | 4.36 | 8 of 64 | 2.9% |
| synthetic images | dHash | 3.73 | 15 of 64 | 4.7% |
The numbers behind the chart
Mean bits that differ from the original, over each corpus:
| Transform | Strength | photographs, aHash | photographs, dHash | synthetic images, aHash | synthetic images, dHash |
|---|---|---|---|---|---|
| JPEG quality | 95 | 0.0 | 0.1 | 0.7 | 1.9 |
| JPEG quality | 75 | 0.1 | 0.4 | 4.0 | 3.5 |
| JPEG quality | 50 | 0.1 | 0.6 | 0.4 | 2.5 |
| JPEG quality | 30 | 0.2 | 0.7 | 0.6 | 2.6 |
| JPEG quality | 15 | 0.2 | 1.1 | 4.5 | 6.1 |
| JPEG quality | 5 | 0.7 | 2.1 | 4.6 | 8.5 |
| Downscale | 0.75 | 0.0 | 0.3 | 0.3 | 1.5 |
| Downscale | 0.5 | 0.1 | 0.4 | 0.7 | 2.1 |
| Downscale | 0.25 | 0.1 | 0.6 | 0.5 | 2.4 |
| Downscale | 0.125 | 0.1 | 0.7 | 2.8 | 2.6 |
| Rotation | 1 | 1.0 | 3.4 | 1.1 | 5.2 |
| Rotation | 2 | 1.9 | 4.9 | 5.1 | 7.2 |
| Rotation | 5 | 4.7 | 9.1 | 8.7 | 11.9 |
| Rotation | 10 | 8.8 | 15.7 | 13.6 | 17.0 |
| Rotation | 20 | 15.1 | 24.0 | 16.8 | 20.5 |
| Rotation | 45 | 23.5 | 29.4 | 23.1 | 25.0 |
| Brightness | 0.5 | 0.3 | 1.6 | 1.0 | 3.6 |
| Brightness | 0.7 | 0.3 | 1.0 | 1.0 | 4.0 |
| Brightness | 0.85 | 0.2 | 0.9 | 0.9 | 2.8 |
| Brightness | 1.15 | 0.5 | 1.2 | 1.0 | 1.5 |
| Brightness | 1.3 | 1.2 | 1.9 | 2.5 | 4.5 |
| Brightness | 1.5 | 2.7 | 3.2 | 3.4 | 5.3 |
| Contrast | 0.5 | 0.3 | 1.8 | 0.9 | 3.0 |
| Contrast | 0.7 | 0.4 | 1.1 | 0.9 | 3.3 |
| Contrast | 0.85 | 0.4 | 1.0 | 0.9 | 3.5 |
| Contrast | 1.15 | 0.3 | 0.9 | 0.6 | 2.8 |
| Contrast | 1.3 | 0.3 | 1.2 | 0.5 | 2.7 |
| Contrast | 1.5 | 0.7 | 1.6 | 1.1 | 2.5 |
| Gamma | 0.5 | 2.4 | 2.3 | 2.0 | 3.2 |
| Gamma | 0.7 | 1.4 | 1.5 | 1.3 | 2.4 |
| Gamma | 0.85 | 0.7 | 1.0 | 1.4 | 2.5 |
| Gamma | 1.2 | 0.8 | 1.0 | 2.0 | 2.9 |
| Gamma | 1.5 | 1.8 | 1.7 | 2.3 | 3.0 |
| Gamma | 2 | 3.5 | 3.0 | 5.2 | 5.0 |
| Gaussian blur | 0.5 | 0.0 | 0.1 | 0.3 | 1.2 |
| Gaussian blur | 1 | 0.3 | 0.8 | 0.8 | 3.2 |
| Gaussian blur | 2 | 0.1 | 0.4 | 1.9 | 2.0 |
| Gaussian blur | 4 | 0.2 | 0.6 | 3.0 | 3.3 |
| Gaussian blur | 8 | 0.3 | 0.7 | 2.9 | 6.7 |
| Noise | 2 | 0.1 | 0.6 | 0.7 | 2.7 |
| Noise | 5 | 0.1 | 0.8 | 0.8 | 3.0 |
| Noise | 10 | 0.1 | 0.8 | 1.7 | 4.9 |
| Noise | 20 | 0.2 | 1.0 | 2.7 | 8.0 |
| Noise | 40 | 0.3 | 1.3 | 4.3 | 10.8 |
| Crop | 5 | 2.9 | 4.5 | 5.5 | 7.2 |
| Crop | 10 | 5.7 | 8.4 | 8.5 | 9.8 |
| Crop | 20 | 11.4 | 16.1 | 14.7 | 15.5 |
| Crop | 30 | 16.4 | 22.7 | 18.1 | 19.0 |
How this was measured
The values are those of the corpus charts above, from the same cached measurement, for both algorithms; the figure and the table take their mean over the corpus instead of the median, since the median of a small edit is 0 for both.
How the nine algorithms compare is on choosing an algorithm.
Cost¶
On a small image dHash costs what aHash does. On a large one it costs more than twice as much: the Mitchell filter reads every source pixel and spreads it over neighboring cells with floating-point weights, where aHash, pHash, wHash and BMH share one integer area-average pass. On a 20-megapixel photograph that resampling pass is most of dHash's time, and it is not shared with the other hashes when they are computed together. The times are in the table at the top of the page.
How this was measured
Measured on Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz, 4 cores, Linux x86_64; compiled by 13.3.0, optimized, with libjpeg-turbo decoding the JPEGs; the minimum of the runs. Decoding, every hash at its default settings, and in bold this page's.
| Case | 400×400 | 20 Mpx |
|---|---|---|
decode |
0.53 ms | 105 ms |
ahash |
0.21 ms | 23 ms |
dhash |
0.23 ms | 27 ms |
phash |
0.21 ms | 23 ms |
whash |
0.21 ms | 23 ms |
bmh |
0.21 ms | 23 ms |
mhash |
1.8 ms | 91 ms |
radial |
1.4 ms | 120 ms |
color_hash |
0.24 ms | 31 ms |
color_moments |
0.52 ms | 66 ms |
Each case runs once to warm up, then until it has run at least five times and for at least a second; the time is the minimum. The grayscale image and the area grid the library caches are dropped before every run, so each run is the first hash on a loaded image. The cases, and the timing loop:
/* 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")
Settings that affect it¶
dHash has no parameters of its own: the grid is always 9×8. Five context settings change the pixels it reads.
| Setting | Effect on dHash |
|---|---|
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 tip a pair of nearly equal neighbors |
ph_context_set_auto_orient() |
on by default: the hash describes the image as displayed, after its EXIF rotation |
ph_context_set_alpha_mode() |
how transparent pixels are composited before grayscale (which background) |
ph_context_set_decode_scale() |
a JPEG read by libjpeg-turbo is decoded at ½, ¼ or ⅛ of its size (measured); the 9×8 grid is resampled from the smaller image into the same cells, and nearly every hash stays within the threshold for copies, down to images a few times the grid |
With ph_context_set_load_grayscale()
on, a JPEG is decoded straight to the luminance channel the file stores, which the
encoder computed with its own rounding, instead of being decoded to RGB and converted by
step 1. The values differ by a level here and there. For most cells that changes nothing,
but a bit decided by a one-level difference between neighbors can flip. The example
photograph has such a pair:
The same file hashed both ways is a near match, not an exact one: hashes meant to be compared with each other are best computed with the same setting.
In code¶
Hashing two images and counting the bits their hashes differ in, for dHash and the other three 64-bit algorithms at once:
/* Hashes two images with the four 64-bit algorithms and prints how many bits each pair
* of hashes differs in. Build/run:
* cc hash_distance.c -o hash_distance $(pkg-config --cflags --libs libphash)
* ./hash_distance original.jpg edited.jpg
*/
#include <libphash.h>
#include <stdio.h>
#define ALL_FOUR (PH_HASH_AHASH | PH_HASH_DHASH | PH_HASH_PHASH | PH_HASH_WHASH)
/* aHash, dHash, pHash and wHash of one file, in that order: ph_compute_multi() writes one
* slot per flag, in ascending bit order, from a single grayscale conversion. */
static int hash_file(const char *path, uint64_t out[PH_HASH_FLAGS_COUNT]) {
ph_context_t *ctx = NULL;
if (ph_create(&ctx) != PH_SUCCESS) {
fprintf(stderr, "ph_create failed\n");
return 1;
}
ph_error_t err = ph_load_from_file(ctx, path);
if (err == PH_SUCCESS) {
err = ph_compute_multi(ctx, ALL_FOUR, out);
}
if (err != PH_SUCCESS) {
fprintf(stderr, "%s: %s (%s)\n", path, ph_get_error_string(err),
ph_get_last_error_message(ctx));
}
ph_free(ctx);
return err != PH_SUCCESS;
}
int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "usage: %s <image-a> <image-b>\n", argv[0]);
return 1;
}
uint64_t a[PH_HASH_FLAGS_COUNT], b[PH_HASH_FLAGS_COUNT];
if (hash_file(argv[1], a) != 0 || hash_file(argv[2], b) != 0) {
return 1;
}
static const char *const names[PH_HASH_FLAGS_COUNT] = {"aHash", "dHash", "pHash", "wHash"};
for (int i = 0; i < PH_HASH_FLAGS_COUNT; i++) {
printf("%s %016llx %016llx %2d bits differ\n", names[i], (unsigned long long)a[i],
(unsigned long long)b[i], ph_hamming_distance(a[i], b[i]));
}
return 0;
}
Where it comes from¶
dHash was proposed by David Oftedal in a comment on Neal Krawetz's aHash post, and named, described and evaluated by Krawetz in a blog post in 2013; there is no paper. The post fixes the 9×8 reduction, the grayscale step, the direction of the comparison ("a '1' to indicate that P[x] < P[x+1]") and the bit order (left to right, top to bottom, big-endian), and this implementation follows all four exactly.
The post leaves the resampling filter open. This implementation uses Mitchell rather than the area average aHash uses, because an area average flattens patterns finer than a cell into equal neighbors, whose bits are all clear; on photographs the two grids are close (Why Mitchell, not an area average; the tests' numbers are in provenance § 2).
-
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. ↩