BMH — Block Mean Hash¶
Divide the image into a 16×16 grid of blocks, take the mean of each, and record for each block whether it is at or above the median of the 256 means. The idea is aHash's on a grid four times as fine, cut at the median instead of the mean: the digest describes where the image is light and where dark, at a finer scale, in 256 bits instead of 64.
| Call | ph_compute_bmh(), or PH_ALGO_BMH in ph_compute_digest() |
| Output | digest of block_size² bits, 256 at the default 16×16, compared with ph_hamming_distance_digest() |
| Source | Bian Yang, Fan Gu and Xiamu Niu, "Block Mean Value Based Image Perceptual Hashing", 2006, method 1 (provenance) |
| Cost | 0.21 ms on a 400×400 image, 23 ms on 20 Mpx, after decoding1 |
The steps¶
The steps below are those of the default grid, 16×16;
ph_context_set_block_params() sets
another, below. Every picture on this page is computed by the library from
the same photograph, and the digest at the end is what ph_compute_bmh() 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. Block means. The image is divided into a 16×16 grid of equal areas, regardless of its aspect ratio, and each block becomes the exact mean of the pixels it covers, rounded to the nearest integer: the same area average as aHash's 8×8. A pixel cut by a block boundary counts in each block by the fraction it covers, so the means are exact for any image size, not only for a multiple of 16. With the blocks read row by row, \(M_0 \dots M_{255}\) are whole numbers from 0 to 255.
3. The median. The means are sorted, and the median \(m\) is the 129th smallest, \(m = M_{(128)}\) counting from \(M_{(0)}\): of the two central values of an even count, the upper one. Since the means are bytes, the library counts how many blocks take each of the 256 values and walks the counts, rather than sorting.
4. Threshold. A bit is set where its block is at or above the median:
Unless blocks tie at the median, exactly 128 bits are set: the 128 blocks from the upper central value up. Any image gives a digest with as many ones as zeros, which is the property the paper's threshold is chosen for.
5. Pack the bits. Block \(i\) sets bit \(i \bmod 8\) of byte \(\lfloor i/8 \rfloor\):
The median and ties¶
The paper thresholds at the median of the block means, where aHash takes their mean. On most photographs the two sit close, but where they part they describe different things. Here is the photograph of the corpus whose mean threshold sets the fewest or the most bits, with both:
The sky is most of the frame and most of the blocks. The mean, pulled up by the few bright blocks of the birds, marks the birds; the median has to put half of the blocks on each side, so it cuts the sky along its gradient. Neither is wrong: the mean describes the object against its background, the median the order of the blocks, which a tone curve does not change.
Blocks equal to the median all set their bit, so ties tip the balance upward. They are bytes, and an image with large flat areas gives many equal means. In the extreme, when more than half of the blocks have the image's lowest value, the median is that value and every block is at or above it: all 256 bits are set, whatever the rest of the image shows. A product photographed on a black background does this, and two such photographs get the same digest. Over the two corpora, the two thresholds at the default grid:
| Images | Same digest | Bits apart: median, most | Bits set, median threshold | Bits set, mean threshold |
|---|---|---|---|---|
| 200 public-domain photographs from Wikimedia Commons | 17 | 15, 178 | 128–256; middle 80 %: 128–136 | 46–206; middle 80 %: 96–152 |
| synthetic corpus, 24 images; not photographs | 15 | 0, 64 | 128–256; middle 80 %: 128–179 | 122–256; middle 80 %: 127–196 |
How the two thresholds separate copies from different images is in the table under block_size.
Digest layout¶
Bit \(i\) is block \(i\), row by row: byte 0 holds the first eight blocks of the top row,
least significant bit first, byte 1 the next eight, and the top row ends at byte 1. In
hexadecimal (ph_digest_to_hex() writes byte 0
first) each pair of digits is eight blocks, and within a pair the digits read their
blocks backwards: 01 is the first block of its eight, 80 the last. The paper defines a
sequence of bits and no byte layout, so the order is this library's choice.
What changes the hash¶
The digest describes the layout of light and dark, as aHash does, at a finer scale, and the same edits move it; with four times as many blocks, more of them lie near the median.
- Recompression, resizing and noise average out over the blocks: the digest does not move, or moves by a bit or two at the strongest settings.
- Brightness, contrast, gamma and strong blur keep the order of most block means, and the median with it; a few blocks lie close enough to the median to cross. They move a few of the 256 bits.
- 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 BMH, and, as a share of the bits, they move it further than aHash: a finer block is crossed by a smaller shift.
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 | 2 |
| Rotation | 1 | 6 |
| Rotation | 2 | 8 |
| Rotation | 5 | 22 |
| Rotation | 10 | 42 |
| Rotation | 20 | 64 |
| Rotation | 45 | 93 |
| Brightness | 0.5 | 0 |
| Brightness | 0.7 | 0 |
| Brightness | 0.85 | 0 |
| Brightness | 1.15 | 3 |
| Brightness | 1.3 | 2 |
| Brightness | 1.5 | 4 |
| Contrast | 0.5 | 1 |
| Contrast | 0.7 | 0 |
| Contrast | 0.85 | 0 |
| Contrast | 1.15 | 0 |
| Contrast | 1.3 | 1 |
| Contrast | 1.5 | 3 |
| Gamma | 0.5 | 2 |
| Gamma | 0.7 | 2 |
| Gamma | 0.85 | 0 |
| Gamma | 1.2 | 1 |
| Gamma | 1.5 | 6 |
| Gamma | 2 | 9 |
| Gaussian blur | 0.5 | 0 |
| Gaussian blur | 1 | 0 |
| Gaussian blur | 2 | 0 |
| Gaussian blur | 4 | 4 |
| Gaussian blur | 8 | 8 |
| Noise | 2 | 0 |
| Noise | 5 | 0 |
| Noise | 10 | 0 |
| Noise | 20 | 0 |
| Noise | 40 | 1 |
| Crop | 5 | 14 |
| Crop | 10 | 33 |
| Crop | 20 | 67 |
| Crop | 30 | 81 |
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
\(256 \times (1 - \text{similarity})\), which is the Hamming distance: how many of the
256 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 digest, and the other edits move a few of its 256 bits at their strongest. The synthetic images also answer to strong blur and noise. Their flat areas give many blocks of one value, which tie at the median, and a small change to a tied block decides its bit.
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 128, half of the 256, where two unrelated digests 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.
Rotated and cropped copies spread widest, and they set the threshold: a finer grid moves more of its blocks across the median under a shift. On the photographs a few pairs of different images land at 0 bits, the same digest. They are pictures whose blocks are mostly black, and the median explains why they collide.
The numbers behind the charts
| Corpus | Copies | Different pairs | d′ | Threshold | Different pairs within it |
|---|---|---|---|---|---|
| synthetic corpus, 24 images; not photographs | 216 | 276 | 6.26 | 43 | 2.2% |
| 200 public-domain photographs from Wikimedia Commons | 1800 | 19900 | 5.72 | 23 | 0.2% |
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–1) |
| JPEG quality | 50 | 0 (0–2) | 0 (0–1) |
| JPEG quality | 30 | 0.5 (0–4) | 1 (0–2) |
| JPEG quality | 15 | 1 (0–4.5) | 1 (0–2) |
| JPEG quality | 5 | 6 (0–15.3) | 2 (1–5) |
| Downscale | 0.75 | 0 (0–0) | 0 (0–0) |
| Downscale | 0.5 | 0 (0–0) | 0 (0–0) |
| Downscale | 0.25 | 0 (0–6) | 0 (0–1) |
| Downscale | 0.125 | 4 (0–9.2) | 0 (0–1) |
| Rotation | 1 | 2 (0–10.5) | 8 (5–10) |
| Rotation | 2 | 7.5 (0–17.5) | 14 (10–19) |
| Rotation | 5 | 24.5 (6.8–57.8) | 31 (23.8–40.2) |
| Rotation | 10 | 37 (13.8–83.5) | 54 (42–64.2) |
| Rotation | 20 | 68.5 (36–116) | 78 (67–94) |
| Rotation | 45 | 123.5 (70–128) | 108 (95–123) |
| Brightness | 0.5 | 0 (0–2.2) | 1 (1–3) |
| Brightness | 0.7 | 0 (0–2) | 1 (0–2) |
| Brightness | 0.85 | 0 (0–1.2) | 1 (0–2) |
| Brightness | 1.15 | 0 (0–1.7) | 1 (0–2) |
| Brightness | 1.3 | 0 (0–9.2) | 2 (1–5) |
| Brightness | 1.5 | 1.5 (0–16) | 5 (2–10) |
| Contrast | 0.5 | 0 (0–2.5) | 2 (1–3) |
| Contrast | 0.7 | 0 (0–1.2) | 1 (0–2) |
| Contrast | 0.85 | 0 (0–1.2) | 0 (0–1.2) |
| Contrast | 1.15 | 0 (0–1.2) | 1 (0–2) |
| Contrast | 1.3 | 0 (0–1.2) | 1 (0–2) |
| Contrast | 1.5 | 0 (0–8.2) | 2 (1–3.3) |
| Gamma | 0.5 | 0 (0–7.8) | 5 (3–9) |
| Gamma | 0.7 | 0 (0–5.2) | 3 (2–6) |
| Gamma | 0.85 | 0 (0–1) | 2 (0–3) |
| Gamma | 1.2 | 0 (0–2) | 2 (1–4) |
| Gamma | 1.5 | 0 (0–6.8) | 4 (2–7) |
| Gamma | 2 | 0 (0–14.2) | 8 (4–12.2) |
| Gaussian blur | 0.5 | 0 (0–0) | 0 (0–0) |
| Gaussian blur | 1 | 0 (0–0) | 1 (0–2) |
| Gaussian blur | 2 | 0 (0–4.2) | 0 (0–1) |
| Gaussian blur | 4 | 0.5 (0–14) | 1 (0–2) |
| Gaussian blur | 8 | 15 (1.7–31.8) | 2 (1–4) |
| Noise | 2 | 0 (0–2.2) | 0 (0–1) |
| Noise | 5 | 0 (0–10.7) | 0 (0–1) |
| Noise | 10 | 1 (0–13.2) | 0 (0–1) |
| Noise | 20 | 4.5 (0.7–14.5) | 1 (0–2) |
| Noise | 40 | 14 (2.8–37.5) | 2 (1–3) |
| Crop | 5 | 8 (0–34.8) | 18 (11–25) |
| Crop | 10 | 19 (3–64.5) | 34 (24.7–46) |
| Crop | 20 | 57.5 (13–114) | 62 (47–75.3) |
| Crop | 30 | 89 (22.2–120.5) | 80 (62.8–98.2) |
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. With 256 blocks a patch over a few percent of the frame covers several of them, and the digest notices it about as aHash does: over 4 % of the frame most edited photographs still pass for copies, over 16 % most do not.
- Turning the hue hardly moves the digest on photographs, whose muted colors keep their luminance under a hue rotation. On the flat, saturated colors of the synthetic images it moves the digest far.
- A quarter or half turn moves the digest as far as an unrelated image, and a mirror image about half as far on photographs: 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 | 1 (0–3) | 96% | 1 (0–5) | 100% |
| patch over 4% of the frame | 7 (1.7–13.7) | 92% | 8 (4–14) | 95% |
| patch over 9% of the frame | 12 (3.2–20) | 92% | 19 (12–27) | 64% |
| patch over 16% of the frame | 23 (17–32) | 92% | 32 (22–39) | 28% |
| hue turned 30° | 28.5 (0–63.3) | 62% | 8 (4–16) | 85% |
| hue turned 90° | 106.5 (54.3–207.5) | 25% | 5 (2–11) | 94% |
| hue turned 180° | 152.5 (99.8–227.5) | 21% | 13 (5–27.3) | 70% |
| turned 90° | 128 (82.5–128) | 21% | 124 (110–132) | 4% |
| turned 180° | 140 (20–235.5) | 29% | 140 (106–166) | 6% |
| mirrored | 126 (14–184) | 33% | 74 (50–102) | 10% |
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))]),
]
How the nine algorithms compare is on choosing an algorithm.
Cost¶
BMH costs what aHash does (the times are in the table at the top of the page): a grayscale
conversion and one area-average pass over the image, which read every pixel and so grow
with it, and then work on 256 numbers whatever the image's size. The area pass is cached
and shared: aHash, pHash and wHash reduce from the same grid, so BMH computed after one of
them on the same loaded image adds almost nothing. A block_size that divides 32 (2, 4,
8, 16, 32) is assembled from that cached pass; any other takes a pass of its own over the
grayscale image, as costly as the first.
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")
Parameters¶
ph_context_set_block_params() sets the
grid.
| Setting | Values | Default | Meaning |
|---|---|---|---|
block_size |
2 … 32 | 16 | the grid is block_size × block_size blocks, one bit each |
Digests are comparable only when computed with the same block_size. Another size
almost always gives another length, and the comparison functions refuse digests of
different lengths; sizes 3 and 4 both give two bytes, and a comparison cannot tell those
apart.
block_size¶
The grid sets the scale the digest describes and its length, block_size² bits:
The bounds are set by the digest. At 32 the 1024 bits fill a digest's 128 bytes exactly, and 33 would not fit. At 1 the single block is its own median and always at or above it, so every image would give the same bit; 2 is the smallest grid whose blocks can differ.
More bits do not by themselves separate better. Here is each size over both corpora, with aHash's 64 bits for reference: d′, the gap between copies and different images in units of their spread, and the share of different pairs the threshold that accepts 95 % of the copies lets through.
- d′ grows with the grid. Different images spread less, as a share of the bits, the more bits there are, and the gap to the copies widens in those units.
- The threshold does not follow. A finer grid is moved further by a rotation or a crop, so the threshold that keeps 95 % of the copies has to reach further, and on the photographs the 8×8 grid lets fewer different pairs through than the default 16×16. The 4×4 grid, 16 bits, is too coarse for either corpus.
- The default's extra false matches are mostly the photographs on a black background that collide; the table has the mean threshold for comparison.
The numbers behind the chart
| Corpus | Hash | Bits | d′ | Threshold | Different pairs within it |
|---|---|---|---|---|---|
| photographs | block_size 4 | 16 | 3.89 | 2 of 16 (12%) | 3.1% |
| photographs | block_size 8 | 64 | 5.07 | 5 of 64 (8%) | 7 of 19,900 |
| photographs | block_size 16 | 256 | 5.72 | 23 of 256 (9%) | 0.2% |
| photographs | block_size 32 | 1024 | 6.14 | 120 of 1024 (12%) | 0.3% |
| photographs | block_size 16, mean threshold | 256 | 5.62 | 19 of 256 (7%) | 9 of 19,900 |
| photographs | aHash | 64 | 5.04 | 4 of 64 (6%) | 9 of 19,900 |
| synthetic images | block_size 4 | 16 | 3.53 | 4 of 16 (25%) | 12.0% |
| synthetic images | block_size 8 | 64 | 5.65 | 8 of 64 (12%) | 2.2% |
| synthetic images | block_size 16 | 256 | 6.26 | 43 of 256 (17%) | 2.2% |
| synthetic images | block_size 32 | 1024 | 6.32 | 184 of 1024 (18%) | 2.2% |
| synthetic images | block_size 16, mean threshold | 256 | 4.93 | 48 of 256 (19%) | 2.5% |
| synthetic images | aHash | 64 | 4.36 | 8 of 64 (12%) | 2.9% |
How this was measured
The copies are the ones of the separability chart above, one moderate strength of
each of the nine edits; each original and its copies are hashed at every block size
by site_stages bmh-variants, which also thresholds the default grid at its mean.
The distances, d′ and the threshold are computed as for the corpus charts, and the
tool checks that the default size gives the same d′ as they do. aHash's numbers are
those of its page.
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]
/* One image of `site_stages bmh-variants`: its BMH digest at each block size of
* bmh_sizes[], as ph_compute_bmh() computes it (bmh_run() checks), and the same grid
* thresholded at its mean. */
static int bmh_variants_row(ph_context_t *ctx, json_t *row, const char *path) {
(void)path;
for (size_t k = 0; k < COUNT(bmh_sizes); k++) {
bmh_stages_t s;
if (bmh_run(ctx, bmh_sizes[k], &s)) {
return 1;
}
char key[32];
const int bytes = (s.size * s.size + 7) / 8;
snprintf(key, sizeof(key), "median_%d", s.size);
json_hexbytes(row, key, s.digest, bytes);
snprintf(key, sizeof(key), "mean_%d", s.size);
json_hexbytes(row, key, s.digest_mean, bytes);
}
return 0;
}
/* site_stages bmh-variants <image>...: one JSON line per image, as above. */
int mode_bmh_variants(int argc, char **argv) {
return for_each_image(argc, argv, bmh_variants_row);
}
The digests are computed from the grid as in the steps above, and each is checked
against ph_compute_bmh() with the same block size:
/* One BMH computation at a given block size, recomputed from its grid and checked against
* ph_compute_bmh() with that size: the grid of block means, the median as the library
* takes it (the upper of the two central values), the digest (bit i set where block i >=
* the median, LSB first within each byte); and, for comparison, the digest thresholded
* at the mean of the blocks instead, packed the same way. */
typedef struct {
int size;
uint8_t grid[PH_BLOCK_MAX_SIZE * PH_BLOCK_MAX_SIZE];
int median;
double mean;
uint8_t digest[PH_DIGEST_MAX_BYTES], digest_mean[PH_DIGEST_MAX_BYTES];
} bmh_stages_t;
static int bmh_run(ph_context_t *ctx, int size, bmh_stages_t *s) {
const int n = size * size, bytes = (n + 7) / 8;
s->size = size;
if (!ph_area_downscale(ctx, size, size, s->grid)) {
return fail("ph_area_downscale failed", NULL);
}
int count[256] = {0}, seen = 0;
uint64_t sum = 0;
for (int i = 0; i < n; i++) {
count[s->grid[i]]++;
sum += s->grid[i];
}
s->median = 255;
for (int v = 0; v < 256; v++) {
seen += count[v];
if (seen > n / 2) {
s->median = v;
break;
}
}
s->mean = (double)sum / n;
memset(s->digest, 0, sizeof(s->digest));
memset(s->digest_mean, 0, sizeof(s->digest_mean));
for (int i = 0; i < n; i++) {
if (s->grid[i] >= s->median) {
s->digest[i / 8] |= (uint8_t)(1u << (i % 8));
}
if ((uint64_t)s->grid[i] * (uint64_t)n >= sum) {
s->digest_mean[i / 8] |= (uint8_t)(1u << (i % 8));
}
}
ph_digest_t lib;
int bad = ph_context_set_block_params(ctx, size) != PH_SUCCESS ||
ph_compute_bmh(ctx, &lib) != PH_SUCCESS;
if (ph_context_set_block_params(ctx, PH_BLOCK_SIZE) != PH_SUCCESS || bad) {
return fail("ph_compute_bmh failed", NULL);
}
char what[32];
snprintf(what, sizeof(what), "BMH (block_size %d)", size);
return check_digest(what, s->digest, bytes, &lib);
}
Settings that affect it¶
Five context settings change the pixels BMH reads.
| Setting | Effect on BMH |
|---|---|
ph_context_set_gray_weights() |
the grayscale formula of step 1 |
ph_context_set_load_grayscale() |
a JPEG decoder converts to grayscale itself (measured), which can move a value by one level |
ph_context_set_auto_orient() |
on by default: the digest describes the image as displayed, after its EXIF rotation |
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 block grid averages the smaller image into the same blocks, and nearly every digest stays within the threshold for copies, down to images a few times the grid |
A level here and there moves a block mean by a fraction of a level, and can tip only a block that lies next to the median.
The example photograph gives the same digest both ways.
In code¶
BMH returns a digest, like mHash, Radial and the two color hashes. This example computes
all five for two images, compares each pair with the function its kind calls for, BMH's
through ph_hamming_distance_digest(),
and stores the BMH digest as text and reads it back:
/* Hashes two images with every digest algorithm, compares each pair with the function
* that digest's kind calls for, shows what a mismatched comparison returns, and stores a
* digest as text and reads it back. Build/run:
* cc digest_and_metrics.c -o digest_and_metrics $(pkg-config --cflags --libs libphash)
* ./digest_and_metrics a.jpg b.jpg
*/
#include <libphash.h>
#include <stdio.h>
#include <string.h>
/* Loads `path` into `ctx` in color, so the color hashes can run on it too. */
static int load(ph_context_t *ctx, const char *path) {
ph_error_t err = ph_load_from_file(ctx, path);
if (err != PH_SUCCESS) {
fprintf(stderr, "failed to load %s: %s (%s)\n", path, ph_get_error_string(err),
ph_get_last_error_message(ctx));
return 0;
}
return 1;
}
/* Compares two digests of one algorithm with the function its kind calls for. */
static void compare(const char *name, const ph_digest_t *a, const ph_digest_t *b) {
double score = 0.0;
switch ((ph_digest_kind_t)a->kind) {
case PH_DIGEST_KIND_BITS:
printf("%-13s %3d of %d bits differ, similarity %.3f\n", name,
ph_hamming_distance_digest(a, b), a->size * 8, ph_similarity_digest(a, b));
break;
case PH_DIGEST_KIND_COEFFICIENTS:
if (ph_radial_similarity(a, b, &score) == PH_SUCCESS) {
printf("%-13s peak correlation %.3f (same image at %.1f and above)\n", name, score,
PH_RADIAL_PCC_THRESHOLD);
} else {
printf("%-13s no angular structure to compare\n", name);
}
break;
case PH_DIGEST_KIND_HISTOGRAM:
if (ph_histogram_intersection(a, b, &score) == PH_SUCCESS) {
printf("%-13s histogram intersection %.3f\n", name, score);
}
break;
case PH_DIGEST_KIND_VECTOR16:
case PH_DIGEST_KIND_VECTOR:
printf("%-13s L2 distance %.2f\n", name, ph_l2_distance(a, b));
break;
default:
printf("%-13s kind %d: no comparison\n", name, a->kind);
break;
}
}
int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "usage: %s <image-a> <image-b>\n", argv[0]);
return 1;
}
static const ph_algorithm_t algos[] = {PH_ALGO_MHASH, PH_ALGO_BMH, PH_ALGO_RADIAL,
PH_ALGO_COLOR_HASH, PH_ALGO_COLOR_MOMENTS};
enum {
N_ALGOS = sizeof(algos) / sizeof(algos[0]),
};
ph_digest_t da[N_ALGOS], db[N_ALGOS];
ph_context_t *ctx = NULL;
if (ph_create(&ctx) != PH_SUCCESS) {
fprintf(stderr, "ph_create failed\n");
return 1;
}
/* One context, reused: each load replaces the previous image and its cached work. */
for (int img = 0; img < 2; img++) {
if (!load(ctx, argv[1 + img])) {
ph_free(ctx);
return 1;
}
for (int i = 0; i < N_ALGOS; i++) {
ph_error_t err = ph_compute_digest(ctx, algos[i], img == 0 ? &da[i] : &db[i]);
if (err != PH_SUCCESS) {
fprintf(stderr, "%s failed: %s\n", ph_algorithm_name(algos[i]),
ph_get_error_string(err));
ph_free(ctx);
return 1;
}
}
}
for (int i = 0; i < N_ALGOS; i++) {
compare(ph_algorithm_name(algos[i]), &da[i], &db[i]);
}
/* The wrong metric is refused, not answered with a meaningless number: a radial
* digest is quantized coefficients, not a bit vector. */
printf("hamming distance of two radial digests: %d (refused)\n",
ph_hamming_distance_digest(&da[2], &db[2]));
/* Store a digest as text and read it back; the text carries the kind. */
char hex[PH_DIGEST_HEX_BUFFER_SIZE];
ph_digest_t back;
if (ph_digest_to_hex(&da[1], hex, sizeof(hex)) != PH_SUCCESS ||
ph_digest_from_hex(hex, &back) != PH_SUCCESS) {
fprintf(stderr, "hex round trip failed\n");
ph_free(ctx);
return 1;
}
printf("BMH as text: %s\n", hex);
printf("read back: %s\n", (back.size == da[1].size && back.kind == da[1].kind &&
memcmp(back.data, da[1].data, back.size) == 0)
? "identical"
: "DIFFERENT");
ph_free(ctx);
return 0;
}
Where it comes from¶
BMH is method 1 of "Block Mean Value Based Image Perceptual Hashing" by Bian Yang, Fan Gu and Xiamu Niu (IIH-MSP 2006). The paper is paywalled; its steps are known from Christoph Zauner's thesis (2010), which restates the method and implemented it in pHash. pHash carries no block-mean hash today, so there is no reference implementation by the source's own author to compare against.
The method normalizes the image to a preset size, divides it into blocks, takes their
means, and sets a bit where a mean is at or above the median of all of them (equation
3.9). This library follows it with two differences. It averages straight to the block
grid, which gives the exact block means for any image size, so the preset size adds only
a rounding. And it leaves out the paper's step that permutes the blocks under a secret
key: that is a security property, and the paper names no cipher. The paper leaves open
which central value is the median of an even count; the upper one keeps the digest
balanced with the ≥ of equation 3.9.
OpenCV's BlockMeanHash, the other implementation in wide use, thresholds at the mean of
the image, in a variable it calls median; its digests differ from this library's. The
comparison is in provenance § 6.
-
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. ↩