wHash — Wavelet Hash¶
Shrink the image to 16×16, split it with one level of the Haar wavelet into a half-size average and three bands of differences, and keep the average: 64 values, one per 2×2 block. Each bit records whether its value is above the median of the 64. The differences are computed and set aside, so what the hash describes is where the image is light and where dark, at the scale of an 8×8 grid, the same layout aHash reads, cut at the median instead of the mean.
| Call | ph_compute_whash(), or PH_HASH_WHASH in ph_compute_multi() |
| Output | 64-bit hash, compared with ph_hamming_distance() |
| Source | none: the whash of Johannes Buchner's ImageHash library is the reference implementation (provenance) |
| Cost | 0.21 ms on a 400×400 image, 23 ms on 20 Mpx, after decoding; 0.27 and 32 ms in the full mode1 |
The steps¶
The steps below are those of the default mode,
PH_WHASH_FAST, which is the one to use unless a hash
must follow ImageHash's choice of scale; the other mode, PH_WHASH_FULL, differs only in
steps 2 and 3 and is below. Every picture on this page is computed by
the library from the same photograph, and the hash at the end is what
ph_compute_whash() 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 16×16. The image is divided into a 16×16 grid of equal areas, regardless of its aspect ratio, and each cell becomes the exact mean of the pixels it covers, rounded to the nearest integer: the same area average as aHash's 8×8, on a grid twice as fine. The values are then divided by 255, so the grid \(g\) holds numbers from 0 to 1.
3. One Haar level. The Haar wavelet replaces each pair of neighbors \(x, y\) by their sum and their difference, both divided by \(\sqrt 2\):
Applied to the pairs of every row, it puts eight sums in the left half and eight differences in the right; applied then to the pairs of every column, it does the same top and bottom. The 16×16 grid becomes four 8×8 bands. The top-left one, LL, holds sums both ways; the other three hold the differences across, down, and both ways, which are the image's edges at this scale.
The LL band is a smaller copy of the grid, and the bands of differences are near zero wherever the image is smooth. The division by \(\sqrt 2\) makes the transform orthonormal: the grid can be rebuilt from the four bands, and nothing is lost.
4. Keep the LL band. The hash reads only LL. Written out, each of its values is the sum of a 2×2 block of the grid, halved:
which is twice the block's mean. Since the 16×16 cells nest exactly in the 8×8 ones, a block's mean is the mean of the image over one cell of an 8×8 grid, the grid aHash thresholds, up to the rounding of step 2. The three bands of differences take no part in the hash.
5. Threshold at the median. With 64 values the median \(m\) is the mean of the 32nd and 33rd smallest, and a bit is set where its value is above it:
with the band read row by row, \(L_i = L_{\lfloor i/8 \rfloor,\, i \bmod 8}\). Unless values tie at the median, exactly 32 bits are set. An edit that lifts one value above the median therefore pushes another one below it, and two hashes of the same image tend to differ in an even number of bits.
6. Pack the bits. Value \(i\) sets bit \(i\), so the top-left value goes into the least significant bit:
What the wavelet adds¶
Steps 3 and 4 take a 16×16 grid to the 8×8 grid of its block means, which the area average of step 2 could have produced directly. The hash is therefore, up to rounding and bit order, aHash's grid thresholded at its median rather than at its mean. Measured, the two agree to the bit on most images; where they do not, a value lies within rounding of the median, and a flat image, whose values tie, can differ in many bits. Here is the FAST hash against the 8×8 area grid thresholded at its median and packed the same way:
| Images | Same hash | One bit apart | Most bits apart |
|---|---|---|---|
| synthetic corpus, 24 images; not photographs | 22 | 0 | 32 |
| 200 public-domain photographs from Wikimedia Commons | 151 | 39 | 31 |
Reading the same grid does not make the two the same hash: where they cut it decides which edits move them, as measured below.
Bit layout¶
Bit \(i\) is value \(i\) of the LL band, row by row, from the least significant bit up. In hexadecimal the hash therefore reads the band backwards: the last hex digit holds the first four values of the top row, the first hex digit the last four of the bottom row. This is the order pHash uses for its block, and the reverse of aHash's, so a wHash and an aHash of the same image do not line up bit for bit even where they agree.
What changes the hash¶
The hash describes the coarse layout of light and dark, as aHash does, and the same edits move it.
- Recompression, resizing, blur and noise average out over the cells of the grid: the hash does not move, or moves by a pair of bits at the strongest settings.
- Brightness and contrast scale the values, which keeps their order and the median's, until the brightest pixels clip at white. Gamma bends them instead, and can swap two block means that lie close. Either moves a pair of bits at most here.
- Rotation and cropping move the image's content across the grid, and every block mean changes with them. These are the edits that matter for wHash, as for aHash.
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 | 0 |
| JPEG quality | 15 | 0 |
| JPEG quality | 5 | 0 |
| Downscale | 0.75 | 0 |
| Downscale | 0.5 | 0 |
| Downscale | 0.25 | 0 |
| Downscale | 0.125 | 0 |
| Rotation | 1 | 2 |
| Rotation | 2 | 2 |
| Rotation | 5 | 6 |
| Rotation | 10 | 7 |
| Rotation | 20 | 12 |
| Rotation | 45 | 20 |
| Brightness | 0.5 | 0 |
| Brightness | 0.7 | 0 |
| Brightness | 0.85 | 0 |
| Brightness | 1.15 | 0 |
| Brightness | 1.3 | 2 |
| Brightness | 1.5 | 2 |
| Contrast | 0.5 | 0 |
| Contrast | 0.7 | 0 |
| Contrast | 0.85 | 0 |
| Contrast | 1.15 | 0 |
| Contrast | 1.3 | 2 |
| Contrast | 1.5 | 2 |
| Gamma | 0.5 | 1 |
| Gamma | 0.7 | 0 |
| Gamma | 0.85 | 0 |
| Gamma | 1.2 | 0 |
| Gamma | 1.5 | 2 |
| Gamma | 2 | 2 |
| Gaussian blur | 0.5 | 0 |
| Gaussian blur | 1 | 0 |
| Gaussian blur | 2 | 0 |
| Gaussian blur | 4 | 0 |
| Gaussian blur | 8 | 2 |
| Noise | 2 | 0 |
| Noise | 5 | 0 |
| Noise | 10 | 0 |
| Noise | 20 | 0 |
| Noise | 40 | 0 |
| Crop | 5 | 4 |
| Crop | 10 | 9 |
| Crop | 20 | 18 |
| Crop | 30 | 22 |
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.
The photographs repeat the single image: rotation and cropping move the hash, and the other edits move it by a pair of bits at most, at their strongest. On the synthetic images cropping spreads widest. Several of them are regular patterns, stripes, checks and rings, whose block means sit close together, and a crop that shifts the pattern against the grid reorders many of them at once; a uniform field, like the background of a disc, gives many equal means, which the median splits by the smallest change.
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.
On both corpora wHash separates copies from different images about as well as aHash, which is what reading the same grid predicts: the two differ in where they cut it, and on these images the cut matters little.
The numbers behind the charts
| Corpus | Copies | Different pairs | d′ | Threshold | Different pairs within it |
|---|---|---|---|---|---|
| synthetic corpus, 24 images; not photographs | 216 | 276 | 4.34 | 16 | 3.6% |
| 200 public-domain photographs from Wikimedia Commons | 1800 | 19900 | 5.12 | 4 | 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–0) | 0 (0–0) |
| JPEG quality | 75 | 0 (0–0) | 0 (0–0) |
| JPEG quality | 50 | 0 (0–0) | 0 (0–0) |
| JPEG quality | 30 | 0 (0–0.2) | 0 (0–0) |
| JPEG quality | 15 | 0 (0–2) | 0 (0–1) |
| JPEG quality | 5 | 0 (0–6.2) | 0 (0–2) |
| Downscale | 0.75 | 0 (0–0) | 0 (0–0) |
| Downscale | 0.5 | 0 (0–0) | 0 (0–0) |
| Downscale | 0.25 | 0 (0–0) | 0 (0–0) |
| Downscale | 0.125 | 0 (0–2) | 0 (0–0) |
| Rotation | 1 | 0 (0–2.5) | 2 (0–2) |
| Rotation | 2 | 1 (0–5.2) | 2 (2–4) |
| Rotation | 5 | 6.5 (1.5–12.5) | 6 (4–8) |
| Rotation | 10 | 10.5 (7–30) | 10 (8–13) |
| Rotation | 20 | 16.5 (8–28) | 17 (14–20.2) |
| Rotation | 45 | 28 (18–32) | 26 (22–30) |
| Brightness | 0.5 | 0 (0–0) | 0 (0–0) |
| Brightness | 0.7 | 0 (0–0) | 0 (0–0) |
| Brightness | 0.85 | 0 (0–0) | 0 (0–0) |
| Brightness | 1.15 | 0 (0–0) | 0 (0–1) |
| Brightness | 1.3 | 0 (0–0) | 0 (0–2) |
| Brightness | 1.5 | 0 (0–4) | 2 (0–4) |
| Contrast | 0.5 | 0 (0–0) | 0 (0–0) |
| Contrast | 0.7 | 0 (0–0) | 0 (0–0) |
| Contrast | 0.85 | 0 (0–0) | 0 (0–0) |
| Contrast | 1.15 | 0 (0–0) | 0 (0–0) |
| Contrast | 1.3 | 0 (0–0) | 0 (0–0.2) |
| Contrast | 1.5 | 0 (0–0) | 0 (0–2) |
| Gamma | 0.5 | 0 (0–0) | 2 (0–3) |
| Gamma | 0.7 | 0 (0–0) | 1 (0–2) |
| Gamma | 0.85 | 0 (0–0) | 0 (0–1) |
| Gamma | 1.2 | 0 (0–0) | 0 (0–1) |
| Gamma | 1.5 | 0 (0–0) | 2 (0–2) |
| Gamma | 2 | 0 (0–0.8) | 2 (0.8–4) |
| Gaussian blur | 0.5 | 0 (0–0) | 0 (0–0) |
| Gaussian blur | 1 | 0 (0–0) | 0 (0–0) |
| Gaussian blur | 2 | 0 (0–0) | 0 (0–0) |
| Gaussian blur | 4 | 0 (0–4) | 0 (0–0) |
| Gaussian blur | 8 | 1 (0–8) | 0 (0–0) |
| Noise | 2 | 0 (0–0) | 0 (0–0) |
| Noise | 5 | 0 (0–0.8) | 0 (0–0) |
| Noise | 10 | 0 (0–3) | 0 (0–0) |
| Noise | 20 | 0 (0–4) | 0 (0–0) |
| Noise | 40 | 4 (0–6) | 0 (0–1) |
| Crop | 5 | 1 (0–11.5) | 4 (2–4) |
| Crop | 10 | 8.5 (0–17) | 6 (4–9) |
| Crop | 20 | 11.5 (0–26.2) | 12 (8–15) |
| Crop | 30 | 20 (0–32) | 16 (12.8–21) |
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 block means it covers, as for aHash: over 4 % of the frame most edited photographs still pass for copies, over 16 % most do not.
- Turning the hue hardly moves the hash on photographs, whose muted colors keep their luminance under a hue rotation. On the flat, saturated colors of the synthetic images it moves the hash far.
- A quarter or half turn moves the hash as far as an unrelated image, and a mirror image about half as far: it keeps the layout from top to bottom.
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 | 0 (0–0) | 100% | 0 (0–0) | 100% |
| patch over 4% of the frame | 2 (0–3.2) | 96% | 2 (0–4) | 79% |
| patch over 9% of the frame | 2 (0.8–4) | 96% | 5 (4–7.2) | 46% |
| patch over 16% of the frame | 5.5 (1.8–8.5) | 92% | 8 (6–10) | 22% |
| hue turned 30° | 7 (0–17) | 75% | 2 (0–4) | 78% |
| hue turned 90° | 26 (8.2–52) | 33% | 2 (0–3) | 89% |
| hue turned 180° | 36 (16.8–57) | 25% | 3.5 (1–6) | 64% |
| turned 90° | 32 (0–32) | 33% | 32 (28–34) | 1% |
| turned 180° | 28 (0–64) | 42% | 36 (24–44) | 2% |
| mirrored | 20 (0–47.5) | 50% | 20 (12–26.5) | 9% |
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))]),
]
wHash and aHash¶
The two hashes read the same 8×8 grid and differ in one thing, the threshold: aHash compares each cell with the grid's mean, wHash with its median. That one difference shows in what moves them. Here are both, edit by edit, over both corpora, as the mean number of bits that differ from the original, for the edits where they part and for contrast, where they do not:
- Gamma: wHash moves less. A median depends only on the order of the values. A tone curve keeps the order of the block means nearly intact, so the median stays between the same two cells, while the mean moves against the cells and those next to it cross. The gap is widest on the synthetic images.
- Brightness and contrast: no difference on photographs. Scaling the values moves the mean and the median alike. On the synthetic images the strongest settings move one hash or the other a bit more, in either direction (the table has the numbers).
- Rotation and cropping: wHash moves a little more. Unless values tie, wHash always has 32 bits set, so a value that crosses the median pushes another one back across it, and a reordering costs two bits where it costs aHash one.
- Noise on the synthetic images: wHash moves more. Their flat areas give many equal block means, which tie at the median; noise breaks the tie one way or the other. On photographs ties are rare, and neither hash moves.
Separating copies from different images, the two are close on both corpora (the tables of this page and of aHash's). The median also sets exactly half of the bits on every image, which a mean does not:
Over the 200 photographs, aHash sets between 16 and 48 of its 64 bits, and on the middle 80 % of them between 25 and 37.
On photographs, then, aHash's count stays near half on its own, and the median's balance seldom changes the outcome.
The numbers behind the chart
Mean bits that differ from the original, over each corpus:
| Transform | Strength | photographs, aHash | photographs, wHash | synthetic, aHash | synthetic, wHash |
|---|---|---|---|---|---|
| JPEG quality | 95 | 0.0 | 0.0 | 0.7 | 0.9 |
| JPEG quality | 75 | 0.1 | 0.1 | 4.0 | 3.0 |
| JPEG quality | 50 | 0.1 | 0.2 | 0.4 | 3.2 |
| JPEG quality | 30 | 0.2 | 0.3 | 0.6 | 2.2 |
| JPEG quality | 15 | 0.2 | 0.6 | 4.5 | 4.0 |
| JPEG quality | 5 | 0.7 | 1.2 | 4.6 | 6.9 |
| Downscale | 0.75 | 0.0 | 0.0 | 0.3 | 0.0 |
| Downscale | 0.5 | 0.1 | 0.1 | 0.7 | 0.0 |
| Downscale | 0.25 | 0.1 | 0.1 | 0.5 | 0.1 |
| Downscale | 0.125 | 0.1 | 0.1 | 2.8 | 3.8 |
| Rotation | 1 | 1.0 | 1.5 | 1.1 | 2.0 |
| Rotation | 2 | 1.9 | 2.6 | 5.1 | 4.0 |
| Rotation | 5 | 4.7 | 5.9 | 8.7 | 9.2 |
| Rotation | 10 | 8.8 | 10.4 | 13.6 | 15.4 |
| Rotation | 20 | 15.1 | 17.4 | 16.8 | 17.8 |
| Rotation | 45 | 23.5 | 25.8 | 23.1 | 25.7 |
| Brightness | 0.5 | 0.3 | 0.4 | 1.0 | 0.1 |
| Brightness | 0.7 | 0.3 | 0.3 | 1.0 | 0.1 |
| Brightness | 0.85 | 0.2 | 0.3 | 0.9 | 0.7 |
| Brightness | 1.15 | 0.5 | 0.5 | 1.0 | 0.6 |
| Brightness | 1.3 | 1.2 | 1.3 | 2.5 | 2.8 |
| Brightness | 1.5 | 2.7 | 2.8 | 3.4 | 4.1 |
| Contrast | 0.5 | 0.3 | 0.4 | 0.9 | 0.1 |
| Contrast | 0.7 | 0.4 | 0.3 | 0.9 | 0.0 |
| Contrast | 0.85 | 0.4 | 0.3 | 0.9 | 0.6 |
| Contrast | 1.15 | 0.3 | 0.3 | 0.6 | 0.1 |
| Contrast | 1.3 | 0.3 | 0.4 | 0.5 | 0.6 |
| Contrast | 1.5 | 0.7 | 0.7 | 1.1 | 2.7 |
| Gamma | 0.5 | 2.4 | 2.0 | 2.0 | 1.0 |
| Gamma | 0.7 | 1.4 | 1.2 | 1.3 | 0.8 |
| Gamma | 0.85 | 0.7 | 0.6 | 1.4 | 0.7 |
| Gamma | 1.2 | 0.8 | 0.7 | 2.0 | 0.3 |
| Gamma | 1.5 | 1.8 | 1.6 | 2.3 | 1.6 |
| Gamma | 2 | 3.5 | 2.6 | 5.2 | 2.0 |
| Gaussian blur | 0.5 | 0.0 | 0.0 | 0.3 | 0.1 |
| Gaussian blur | 1 | 0.3 | 0.2 | 0.8 | 0.0 |
| Gaussian blur | 2 | 0.1 | 0.1 | 1.9 | 1.8 |
| Gaussian blur | 4 | 0.2 | 0.2 | 3.0 | 2.8 |
| Gaussian blur | 8 | 0.3 | 0.4 | 2.9 | 5.5 |
| Noise | 2 | 0.1 | 0.1 | 0.7 | 0.2 |
| Noise | 5 | 0.1 | 0.2 | 0.8 | 3.2 |
| Noise | 10 | 0.1 | 0.2 | 1.7 | 4.9 |
| Noise | 20 | 0.2 | 0.3 | 2.7 | 5.6 |
| Noise | 40 | 0.3 | 0.5 | 4.3 | 7.4 |
| Crop | 5 | 2.9 | 3.4 | 5.5 | 7.5 |
| Crop | 10 | 5.7 | 6.7 | 8.5 | 10.8 |
| Crop | 20 | 11.4 | 11.9 | 14.7 | 14.3 |
| Crop | 30 | 16.4 | 16.9 | 18.1 | 18.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. The count of
aHash bits is ph_compute_ahash() on each photograph, by site_stages whash-modes.
How the nine algorithms compare is on choosing an algorithm.
Cost¶
In the default mode wHash costs what aHash does, on a small image and on a large one (the times are in the table at the top of the page). The 16×16 grid comes from the same cached area pass over the grayscale image as aHash's, pHash's and BMH's, which is what grows with the image; the transform that follows works on 256 values whatever the image's size. The full mode resamples the whole grayscale image to a power of two of its own size, and costs about three times as much on a large one.
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 |
whash_full |
0.27 ms | 32 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_whash_mode() chooses the
mode, and
ph_context_set_whash_remove_max_haar_ll()
an extra step taken from ImageHash.
| Setting | Values | Default | Meaning |
|---|---|---|---|
| mode | PH_WHASH_FAST, PH_WHASH_FULL |
PH_WHASH_FAST |
a 16×16 grid and one level, or a grid as large as the image allows and as many levels as it takes to reach 8×8 |
remove_max_haar_ll |
0, 1 | 0 | subtract the image's mean before the transform |
Hashes are comparable only when computed with the same settings.
The full mode¶
PH_WHASH_FULL follows ImageHash's choice of scale. The grayscale image is resampled
with a box filter to the largest power of two that fits its shorter side, at least 8 (256
for the 400×400 example), and the Haar level of step 3 is applied repeatedly, each
time to the LL band of the previous one, until LL is 8×8. Every detail band of every level
is set aside; only the last LL is thresholded, as in step 5.
After \(k\) levels an LL value is \(2^k\) times the mean of its \(2^k \times 2^k\) block, so the 8×8 band is the image averaged over an 8×8 grid: the same 64 numbers as the fast mode's, up to resampling and rounding. Here they are side by side, both divided by \(2^k\) and scaled back to gray levels; cells whose bit differs between the two would be outlined in orange.
Over both corpora the two modes give the same hash on most images and a few bits apart on the rest; FAST against FULL:
| Images | Same hash | One bit apart | Most bits apart |
|---|---|---|---|
| synthetic corpus, 24 images; not photographs | 23 | 0 | 3 |
| 200 public-domain photographs from Wikimedia Commons | 173 | 19 | 3 |
The full mode buys closeness to the reference implementation's scale rule, not a different hash, at about three times the cost on a large image. Use it when hashes must be computed the way ImageHash computes them; otherwise the fast mode gives the same result for less.
remove_max_haar_ll¶
ImageHash, by default, decomposes the image all the way down to a single LL value, zeroes it, and reconstructs the image before computing the hash, to keep overall brightness out of it. That single value is the image's mean, scaled, so zeroing it and reconstructing subtracts the mean \(\mu\) from every pixel and changes nothing else. Every LL value then drops by the same amount, \(2^k \mu\), and so does the median: every comparison \(L_i > m\) comes out as before. The median threshold has already removed what the option is meant to remove.
The one exception is a value that equals the median. As computed, it sets no bit; after the subtraction, the extra transform and its inverse leave it a rounding error above or below the median, and that error decides its bit. On an image with a uniform area many values tie: here is the synthetic image where the option changes the most bits.
The uniform background gives more than half of the band one value, which is therefore the median, and as computed none of it is above: the hash is all zeros. With the mean subtracted, each of those values lands a little above or below the median by rounding alone. On photographs ties are rarer; in the fast mode, whose grid holds whole numbers, they still occur. Each mode's hash against the same mode's with the option on:
| Mode | Images | Same hash | One bit apart | Most bits apart |
|---|---|---|---|---|
| FAST | synthetic corpus, 24 images; not photographs | 20 | 1 | 31 |
| FAST | 200 public-domain photographs from Wikimedia Commons | 178 | 21 | 2 |
| FULL | synthetic corpus, 24 images; not photographs | 21 | 0 | 32 |
| FULL | 200 public-domain photographs from Wikimedia Commons | 200 | 0 | 0 |
It is therefore off by default, and offered for callers who need ImageHash's configuration. On the tests' synthetic corpus, turning it on lowers separability (provenance has the numbers).
Settings that affect it¶
Five context settings change the pixels wHash reads.
| Setting | Effect on wHash |
|---|---|
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 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 16×16 grid averages 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 |
A level here and there in the grid moves a block mean by a fraction of a level, and can tip only a value that lies next to the median.
The example photograph hashes to ffffc38386263100 both ways.
In code¶
wHash is one of the four 64-bit hashes
ph_compute_multi() computes from one grayscale
conversion. This example hashes two images with all four and prints how far apart each
pair is:
/* 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¶
wHash has no paper. It is the whash of Johannes Buchner's ImageHash library, whose only
reference is a blog post by Alexander Petrov; ImageHash is a third-party implementation,
the closest thing to a specification there is. "Robust Image Hashing" by Venkatesan, Koon,
Jakubowski and Moulin (ICIP 2000) is an earlier wavelet-based hash, but a different
algorithm: it draws statistics from randomly tiled wavelet bands under a secret key, which
makes it a keyed hash, and a keyed hash cannot give the same value on every machine with
no shared secret, the property this library is built on.
With no source to conform to, wHash is judged by what can be measured: robustness, discrimination, and above all the separation of copies from different images, on this page and in the library's property tests (methodology).
Three things differ from ImageHash's whash. The default mode fixes the grid at 16×16,
where ImageHash takes the largest power of two that fits the image; PH_WHASH_FULL takes
that. The image is resampled by an area average, or a box filter in the full mode, where
ImageHash uses Lanczos. And remove_max_haar_ll, on by default in ImageHash, is off here,
for the reason above. The bit order, which nothing specifies, is this library's choice.
The comparison is in provenance § 4.
-
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. ↩