ColorMoments — Color Moments¶
Describe each of the image's three color channels by three numbers: how bright it is on average, how widely its levels spread around that average, and to which side the spread leans. Nine numbers in all, which say what the image's tones are overall, and nothing about where anything is: an image and any rearrangement of its pixels get the same digest.
| Call | ph_compute_color_moments_hash(), or PH_ALGO_COLOR_MOMENTS in ph_compute_digest() |
| Output | digest of 18 bytes, nine signed 16-bit numbers, compared with ph_l2_distance(), never bit by bit |
| Source | the color moments of Markus Stricker and Markus Orengo, "Similarity of color images", 1995, known from a restatement of the paper; computed on RGB where the restatement uses HSV (provenance) |
| Cost | 0.52 ms on a 400×400 image, 66 ms on 20 Mpx, after decoding1 |
The steps¶
ColorMoments has no parameters. Every picture on this page is computed by the library
from the same photograph, and the digest at the end is what
ph_compute_color_moments_hash() returns for it.
The histograms on the right are what the nine numbers summarize. Each channel of the example has a tall peak on the light side, the white pot and the background, and a long, low tail toward the dark, the cactus and the shadows: so its mean lies left of the peak, and its skewness is negative, the arrow pointing to the tail.
1. Channels. There is no grayscale step: the moments are taken over the \(N\) pixels of each of the channels \(R\), \(G\) and \(B\) separately, as they were decoded. Below, \(p_j\) is the level, 0 to 255, of pixel \(j\) in one channel.
2. Mean.
The library adds the levels as integers and divides once.
3. Standard deviation.
over \(N\), not \(N - 1\): it describes the image's own pixels, not a sample of anything.
4. Skewness.
The third central moment is positive when the long tail of the channel's levels lies above the mean, toward the light, and negative when it lies below; the cube root keeps that sign. It also brings the third moment back to channel levels, the unit of \(E\) and \(\sigma\), so that the three can be added in one distance. This is not the skewness of statistics, \(\frac{1}{N}\sum (p_j - E)^3 / \sigma^3\), which has no unit; that ratio appears again in A skewness near zero. The second and third moments are summed in one pass, in double precision.
5. Encode. Each of the nine numbers is rounded to the nearest \(1/128\) of a level, halves away from zero, and written as a signed 16-bit integer, most significant byte first:
The scale is the largest power of two that fits every value an 8-bit channel can give: the mean reaches at most 255, \(\sigma\) 127.5 and the skewness ±116.85, all from images of two levels, and \(255 \cdot 128 = 32640\). No value is ever clipped.
| Channel | Moment | Computed | × 128, rounded | Bytes |
|---|---|---|---|---|
| red | mean | 135.1083 | +17294 | 438e |
| red | standard deviation | 73.3310 | +9386 | 24aa |
| red | skewness | -52.6654 | -6741 | e5ab |
| green | mean | 129.0087 | +16513 | 4081 |
| green | standard deviation | 69.7412 | +8927 | 22df |
| green | skewness | -46.9983 | -6016 | e880 |
| blue | mean | 113.5037 | +14528 | 38c0 |
| blue | standard deviation | 71.0481 | +9094 | 2386 |
| blue | skewness | -41.8112 | -5352 | eb18 |
Digest layout¶
Bytes \(2i\) and \(2i + 1\) hold number \(i\), with \(i = 3 \cdot \text{channel} + \text{moment}\): red first, then green, then blue, and within each channel the mean, the standard deviation and the skewness.
In hexadecimal (ph_digest_to_hex() writes byte 0
first) every four digits are one number: 438e is 17294, a red mean of
\(17294 / 128 = 135.109\). A negative number is in two's complement: e5ab is \(-6741\), a
red skewness of \(-52.664\). PH_VECTOR16_SCALE
names the 128 for a caller that wants the numbers back.
The bytes are numbers, not bits. Two digests \(a\) and \(b\) are compared by
ph_l2_distance(), which decodes the nine numbers of
each and returns the Euclidean distance between them, in channel levels:
0 is the same nine numbers; there is no upper end short of the largest values the moments can take. Every number counts alike: a level of mean weighs as much as a level of skewness.
Turning the hue by 30 degrees raises the green mean and moves green's skewness further toward the dark tail, lowers the red mean, and leaves blue nearly where it was. The two largest differences make most of the distance, which squares each before adding.
What changes the hash¶
The nine numbers describe the levels of each channel, so anything that changes levels moves the digest and anything that only moves pixels around does not. How far depends on how many pixels change, by how much, and on which side of the mean.
- Brightness, contrast and gamma change the level of every pixel. Brightness multiplies every level by its factor and so, wherever no channel clips, all nine numbers by the same factor: the distance grows with the image's own moments, tens of levels for a change of 15 %. These are the edits that move ColorMoments most, in both directions.
- Blur and downscaling replace pixels by averages of their neighbors: the means stay where they were, and the spread and the long tails shrink, so the standard deviations and the skewnesses move. Cropping removes the pixels of the border, and moves all three by as much as the border differs from the rest.
- Recompression, noise and rotation move little. JPEG changes levels by a little, as often up as down. Noise adds its own spread to each channel's, and the two add in quadrature, \(\sqrt{\sigma^2 + \sigma_n^2}\): next to a channel's spread of tens of levels, noise of 5 or 10 adds almost nothing, and noise of 40 adds a good part. A turn fills its corners with the image's mean color, which leaves the means nearly where they were.
These are measurements on one photograph; the same edits over two whole corpora follow below.
The numbers behind the chart
| Transform | Strength | L2 distance, channel levels |
|---|---|---|
| JPEG quality | 95 | 0.3 |
| JPEG quality | 75 | 0.1 |
| JPEG quality | 50 | 1.2 |
| JPEG quality | 30 | 0.9 |
| JPEG quality | 15 | 2.8 |
| JPEG quality | 5 | 11.0 |
| Downscale | 0.75 | 1.6 |
| Downscale | 0.5 | 3.1 |
| Downscale | 0.25 | 5.8 |
| Downscale | 0.125 | 10.2 |
| Rotation | 1 | 1.3 |
| Rotation | 2 | 1.9 |
| Rotation | 5 | 3.7 |
| Rotation | 10 | 6.1 |
| Rotation | 20 | 10.6 |
| Rotation | 45 | 16.0 |
| Brightness | 0.5 | 132.5 |
| Brightness | 0.7 | 79.9 |
| Brightness | 0.85 | 40.3 |
| Brightness | 1.15 | 38.6 |
| Brightness | 1.3 | 70.2 |
| Brightness | 1.5 | 97.4 |
| Contrast | 0.5 | 74.6 |
| Contrast | 0.7 | 44.7 |
| Contrast | 0.85 | 22.4 |
| Contrast | 1.15 | 17.1 |
| Contrast | 1.3 | 30.6 |
| Contrast | 1.5 | 50.0 |
| Gamma | 0.5 | 77.1 |
| Gamma | 0.7 | 41.9 |
| Gamma | 0.85 | 19.9 |
| Gamma | 1.2 | 25.8 |
| Gamma | 1.5 | 93.8 |
| Gamma | 2 | 166.9 |
| Gaussian blur | 0.5 | 2.8 |
| Gaussian blur | 1 | 6.5 |
| Gaussian blur | 2 | 11.4 |
| Gaussian blur | 4 | 20.6 |
| Gaussian blur | 8 | 43.7 |
| Noise | 2 | 0.1 |
| Noise | 5 | 0.5 |
| Noise | 10 | 1.8 |
| Noise | 20 | 7.1 |
| Noise | 40 | 57.7 |
| Crop | 5 | 5.9 |
| Crop | 10 | 10.4 |
| Crop | 20 | 21.4 |
| Crop | 30 | 90.7 |
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_l2_distance(), whose distance is the number on
the chart. Below is the code that ran, not a copy of it.
The edits, in Python with Pillow:
def transforms():
"""(name, x-axis label, [(strength, PIL image -> (image, (file extension, quality)))]).
Each edit is applied alone to the original. A variant is saved as PPM, which is
lossless (and quick to write for a corpus of thousands of variants), so the edit is
the only difference; JPEG quality is the one edit that is the saving itself.
"""
def jpeg(q):
return lambda im: (im, ("jpg", q))
def scale(s):
return lambda im: (im.resize((max(1, round(im.width * s)), max(1, round(im.height * s))),
Image.LANCZOS), ("ppm", None))
def rotate(deg):
# The corners the turn uncovers are filled with the image's mean color, so they
# add no edge of their own.
def f(im):
fill = tuple(int(v) for v in np.asarray(im).reshape(-1, 3).mean(axis=0))
return im.rotate(deg, resample=Image.BICUBIC, fillcolor=fill), ("ppm", None)
return f
def brightness(k):
return lambda im: (ImageEnhance.Brightness(im).enhance(k), ("ppm", None))
def contrast(k):
return lambda im: (ImageEnhance.Contrast(im).enhance(k), ("ppm", None))
def gamma(g):
def f(im):
a = (np.asarray(im).astype(np.float64) / 255.0) ** g
return Image.fromarray((a * 255.0).round().astype(np.uint8)), ("ppm", None)
return f
def blur(r):
return lambda im: (im.filter(ImageFilter.GaussianBlur(r)), ("ppm", None))
def noise(sigma):
# A fixed seed: the same noise on every build, so the chart does not move.
def f(im):
rng = np.random.default_rng(1)
a = np.asarray(im).astype(np.float64) + rng.normal(0, sigma, (im.height, im.width, 3))
return Image.fromarray(np.clip(a, 0, 255).round().astype(np.uint8)), ("ppm", None)
return f
def crop(p):
def f(im):
dx, dy = round(im.width * p / 2), round(im.height * p / 2)
return im.crop((dx, dy, im.width - dx, im.height - dy)), ("ppm", None)
return f
return [
("JPEG quality", "quality", [(q, jpeg(q)) for q in (95, 75, 50, 30, 15, 5)]),
("Downscale", "scale", [(s, scale(s)) for s in (0.75, 0.5, 0.25, 0.125)]),
("Rotation", "degrees", [(d, rotate(d)) for d in (1, 2, 5, 10, 20, 45)]),
("Brightness", "factor", [(k, brightness(k)) for k in (0.5, 0.7, 0.85, 1.15, 1.3, 1.5)]),
("Contrast", "factor", [(k, contrast(k)) for k in (0.5, 0.7, 0.85, 1.15, 1.3, 1.5)]),
("Gamma", "exponent", [(g, gamma(g)) for g in (0.5, 0.7, 0.85, 1.2, 1.5, 2.0)]),
("Gaussian blur", "radius, px", [(r, blur(r)) for r in (0.5, 1, 2, 4, 8)]),
("Noise", "σ, gray levels", [(s, noise(s)) for s in (2, 5, 10, 20, 40)]),
("Crop", "border removed, %", [(p, crop(p / 100)) for p in (5, 10, 20, 30)]),
]
Writing the copies and handing them to the measuring tool:
def measure_variants(tool, base, ref):
"""{algorithm: {transform: [(strength, value)]}}: every edit of `base` (a Pillow
image), of both groups of transforms.py, compared with `ref`, the same pixels saved
losslessly.
Every variant is written to a file, and `site_stages measure` hashes them with the
library and compares each variant with the reference, by each algorithm's own
metric; `value` is None where the comparison does not apply.
"""
with tempfile.TemporaryDirectory() as tmp:
files, keys = [], []
for name, _, steps in transforms() + content_edits():
for strength, op in steps:
files.append(write_edit(op, base, os.path.join(tmp, str(len(files)))))
keys.append((name, strength))
rows = run_lines(tool, "measure", ref, *files)
result = {}
for (name, strength), row in zip(keys, rows):
for algo, value in row.items():
if algo != "file":
result.setdefault(algo, {}).setdefault(name, []).append((strength, value))
return result
def measure_robustness(tool, image):
"""measure_variants() for one image file."""
base = Image.open(image).convert("RGB")
with tempfile.TemporaryDirectory() as tmp:
ref = os.path.join(tmp, "reference.ppm")
base.save(ref)
return measure_variants(tool, base, ref)
The measuring tool, linked against the library: it computes every algorithm's digest of the original, then of each copy, and compares the two by that algorithm's own metric.
/* site_stages measure <reference> <variant>...: every algorithm's digest of the reference,
* then one JSON line per variant with its comparison to the reference, per algorithm (the
* similarity in [0, 1] for a bit hash, the peak correlation for Radial, the histogram
* intersection for ColorHash, the L2 distance for ColorMoments). A `--load=<settings>`
* between the images loads the ones after it so (take_load_settings()). */
int mode_measure(int argc, char **argv) {
ph_digest_t ref[PH_ALGORITHM_COUNT], d[PH_ALGORITHM_COUNT];
int have_ref[PH_ALGORITHM_COUNT], have[PH_ALGORITHM_COUNT];
int status = 0, images = 0;
for (int v = 0; v < argc && !status; v++) {
if (take_load_settings(argv[v], &status)) {
continue;
}
ph_context_t *ctx = NULL;
if (load_image(&ctx, argv[v])) {
return 1;
}
digest_all(ctx, images ? d : ref, images ? have : have_ref);
ph_free(ctx);
if (images++ == 0) {
continue;
}
json_t j = json_begin(stdout);
json_string(&j, "file", argv[v]);
compare_all(&j, ref, have_ref, d, have);
json_end(&j);
}
return status;
}
/* One algorithm's comparison of two digests by its own metric; 0 when it does not apply. */
static int compare(const ph_digest_t *a, const ph_digest_t *b, double *out) {
switch (a->kind) {
case PH_DIGEST_KIND_BITS:
*out = ph_similarity_digest(a, b);
return *out >= 0.0;
case PH_DIGEST_KIND_COEFFICIENTS:
return ph_radial_similarity(a, b, out) == PH_SUCCESS;
case PH_DIGEST_KIND_HISTOGRAM:
return ph_histogram_intersection(a, b, out) == PH_SUCCESS;
case PH_DIGEST_KIND_VECTOR16:
*out = ph_l2_distance(a, b);
return *out >= 0.0;
default:
return 0;
}
}
To reproduce, make site builds the tool and redraws every figure. The tool can also
be run by hand on any pair of images; it prints one JSON line per variant, with each
algorithm's comparison:
Over two corpora¶
The same nine edits, applied to every image of two corpora: 200 public-domain photographs from Wikimedia Commons (the list), and the 24 generated images the library's tests measure (methodology), which are not photographs. Each line is the median over the corpus and the band around it holds the middle half of its images.
The corpora repeat the single photograph: the tonal edits, brightness, contrast and gamma, move the digest furthest, by about the same distance on both corpora, and the others less. The synthetic images move more under recompression: they are made of flat colors with sharp edges, and recompression spreads new levels around every edge, where a photograph's levels are spread already.
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.
Most copies land within a few levels of their original. The copies that change the tone do not: a brightness, contrast or gamma change of 15 to 20 % moves the digest by tens of levels, as far as some pairs of different photographs lie apart. The dashed line is the distance that accepts 95 % of the copies, and it has to reach past those tonal copies; its label says how many different pairs it accepts as well. d′ sums up the gap in one number, the distance between the two means in units of their spread. ColorHash, on the same copies, separates the photographs more widely (its page).
A few different synthetic images lie at 0 from each other: they are drawn in the same colors in the same proportions, in a different pattern.
The numbers behind the charts
| Corpus | Copies | Different pairs | d′ | Threshold | Different pairs within it |
|---|---|---|---|---|---|
| synthetic corpus, 24 images; not photographs | 216 | 276 | 2.29 | 58.8 | 14.1% |
| 200 public-domain photographs from Wikimedia Commons | 1800 | 19900 | 2.68 | 43.5 | 4.1% |
Median L2 distance, channel levels 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 | 5.4 (1.7–15.0) | 0.3 (0.1–0.6) |
| JPEG quality | 75 | 7.7 (2.2–15.6) | 1.6 (0.9–2.7) |
| JPEG quality | 50 | 7.7 (4.1–19.9) | 1.1 (0.6–1.8) |
| JPEG quality | 30 | 10.3 (4.6–22.4) | 1.8 (1.3–3.1) |
| JPEG quality | 15 | 17.9 (6.7–23.2) | 3.2 (2.1–4.5) |
| JPEG quality | 5 | 27.1 (19.0–34.8) | 8.4 (5.7–12.0) |
| Downscale | 0.75 | 1.6 (0.4–6.9) | 1.8 (0.9–3.8) |
| Downscale | 0.5 | 3.3 (0.4–7.8) | 4.1 (2.2–8.8) |
| Downscale | 0.25 | 4.4 (1.3–15.1) | 7.9 (4.4–15.8) |
| Downscale | 0.125 | 10.3 (2.8–35.0) | 11.9 (6.9–21.6) |
| Rotation | 1 | 2.0 (0.5–7.9) | 1.2 (0.9–1.7) |
| Rotation | 2 | 4.1 (1.1–10.1) | 1.6 (1.1–2.3) |
| Rotation | 5 | 7.5 (2.7–13.3) | 3.0 (2.3–4.4) |
| Rotation | 10 | 11.9 (5.8–15.0) | 5.5 (4.1–7.9) |
| Rotation | 20 | 18.7 (10.8–21.8) | 9.5 (6.8–13.2) |
| Rotation | 45 | 19.9 (12.3–26.6) | 14.8 (10.1–19.3) |
| Brightness | 0.5 | 121.9 (117.2–130.6) | 118.3 (105.9–136.0) |
| Brightness | 0.7 | 73.2 (70.4–78.9) | 71.3 (64.0–82.1) |
| Brightness | 0.85 | 37.0 (35.6–39.8) | 36.1 (32.4–41.4) |
| Brightness | 1.15 | 35.9 (34.1–38.8) | 32.2 (27.0–39.2) |
| Brightness | 1.3 | 84.7 (61.8–97.6) | 64.6 (49.7–77.9) |
| Brightness | 1.5 | 130.4 (86.7–140.9) | 101.5 (75.6–122.2) |
| Contrast | 0.5 | 69.1 (52.9–72.6) | 66.1 (57.6–75.1) |
| Contrast | 0.7 | 41.3 (32.5–43.5) | 39.7 (34.5–45.1) |
| Contrast | 0.85 | 20.8 (16.6–21.8) | 19.8 (17.2–22.5) |
| Contrast | 1.15 | 21.5 (18.5–26.0) | 16.8 (15.2–18.8) |
| Contrast | 1.3 | 37.3 (33.7–40.6) | 30.7 (28.3–35.3) |
| Contrast | 1.5 | 53.1 (48.9–58.3) | 46.8 (42.3–52.0) |
| Gamma | 0.5 | 104.1 (90.0–127.9) | 104.4 (77.2–128.0) |
| Gamma | 0.7 | 67.8 (47.3–84.2) | 51.6 (42.1–73.2) |
| Gamma | 0.85 | 44.2 (21.5–54.7) | 22.8 (19.7–26.4) |
| Gamma | 1.2 | 26.3 (23.3–63.7) | 26.0 (22.1–29.4) |
| Gamma | 1.5 | 61.2 (51.1–91.4) | 57.9 (47.9–83.1) |
| Gamma | 2 | 92.7 (81.4–121.7) | 100.8 (79.3–126.8) |
| Gaussian blur | 0.5 | 1.5 (0.3–4.7) | 3.6 (2.0–6.9) |
| Gaussian blur | 1 | 4.4 (1.5–13.4) | 8.0 (4.6–14.9) |
| Gaussian blur | 2 | 6.3 (4.2–20.9) | 12.1 (7.4–21.9) |
| Gaussian blur | 4 | 16.5 (10.8–47.9) | 17.7 (11.2–29.4) |
| Gaussian blur | 8 | 30.3 (18.2–92.8) | 25.5 (16.8–39.1) |
| Noise | 2 | 0.6 (0.1–2.8) | 0.1 (0.1–0.2) |
| Noise | 5 | 1.5 (0.4–5.6) | 0.5 (0.4–0.8) |
| Noise | 10 | 3.5 (1.4–10.2) | 1.7 (1.3–2.5) |
| Noise | 20 | 10.7 (4.7–19.4) | 6.3 (4.8–8.5) |
| Noise | 40 | 26.3 (13.9–35.2) | 20.0 (15.2–27.7) |
| Crop | 5 | 4.9 (0.0–10.4) | 4.0 (2.6–6.6) |
| Crop | 10 | 11.2 (0.0–17.8) | 8.4 (5.2–13.9) |
| Crop | 20 | 24.0 (0.0–46.5) | 19.0 (11.0–33.4) |
| Crop | 30 | 28.2 (0.0–44.0) | 33.3 (17.6–64.6) |
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 moves the digest by about as much as its pixels differ from those it covers, and a patch of the image's own corner repeats levels the image already has: up to 16 % of the frame, most patched images stay below the threshold.
- Turning the hue moves the digest less than the tonal copies do: most photographs with their hue turned 30 degrees, and about half with it turned 90, stay below the threshold. The next section measures why.
- Turns and the mirror keep every pixel, so the distance is exactly 0.
The numbers behind the chart
Median L2 distance, channel levels, 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 | 3.8 (2.7–14.1) | 100% | 1.7 (1.0–2.9) | 100% |
| patch over 4% of the frame | 11.4 (7.8–19.5) | 100% | 5.7 (3.9–10.2) | 96% |
| patch over 9% of the frame | 22.2 (7.5–25.9) | 100% | 12.5 (8.1–20.6) | 92% |
| patch over 16% of the frame | 25.9 (13.1–40.2) | 79% | 18.1 (11.5–28.4) | 86% |
| hue turned 30° | 47.8 (23.8–93.8) | 62% | 13.9 (7.7–24.4) | 88% |
| hue turned 90° | 77.8 (42.9–96.3) | 42% | 36.4 (20.0–72.5) | 56% |
| hue turned 180° | 107.7 (69.6–147.7) | 17% | 52.0 (27.4–104.9) | 43% |
| turned 90° | 0.0 (0.0–0.0) | 100% | 0.0 (0.0–0.0) | 100% |
| turned 180° | 0.0 (0.0–0.0) | 100% | 0.0 (0.0–0.0) | 100% |
| mirrored | 0.0 (0.0–0.0) | 100% | 0.0 (0.0–0.0) | 100% |
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))]),
]
Tone against color¶
ColorMoments is a color hash, and a color hash is the tool for images that are the same picture in different colors, where the structural hashes move a few bits at most. On that task it loses to the tone. Turning the hue keeps each pixel's lightest and darkest channel levels and moves them from one channel to another; for the muted colors most photographs are made of, the three channels of a pixel are close, and swapping them changes little. A change of brightness moves every level of every channel.
On the photographs, a hue turned by 30 degrees moves the digest less than a brightness change of 15 % does, and the threshold that keeps those copies keeps most of the 30-degree turns and about half of the larger ones. ColorHash, which counts colors rather than levels, takes most of the same recolorings for different images. To tell a recoloring from the original, ColorHash is the hash; ColorMoments' distance says first how far the tone moved.
| Corpus | Edit | ColorMoments: median (25th–75th) | Taken for a copy by ColorMoments | by ColorHash |
|---|---|---|---|---|
| photographs | brightness 1.15 | 32.2 (27.0–39.2) | 86% | 80% |
| photographs | contrast 1.15 | 16.8 (15.2–18.8) | 100% | 96% |
| photographs | gamma 1.2 | 26.0 (22.1–29.4) | 83% | 95% |
| photographs | hue 30° | 13.9 (7.7–24.4) | 88% | 26% |
| photographs | hue 90° | 36.4 (20.0–72.5) | 56% | 3% |
| photographs | hue 180° | 52.0 (27.4–104.9) | 43% | 6% |
| synthetic images | brightness 1.15 | 35.9 (34.1–38.8) | 92% | 100% |
| synthetic images | contrast 1.15 | 21.5 (18.5–26.0) | 100% | 100% |
| synthetic images | gamma 1.2 | 26.3 (23.3–63.7) | 71% | 100% |
| synthetic images | hue 30° | 47.8 (23.8–93.8) | 62% | 25% |
| synthetic images | hue 90° | 77.8 (42.9–96.3) | 42% | 12% |
| synthetic images | hue 180° | 107.7 (69.6–147.7) | 17% | 29% |
How this was measured
The values are those of the corpus charts above, the copies of Over two corpora and the hue rotations of Edits that change the picture, each read against its corpus's threshold; ColorHash's column reads its own measurements against its own threshold.
A skewness near zero¶
The cube root of step 4 is steep around zero. For a channel whose distribution is nearly symmetric, the third central moment is a small fraction of \(\sigma^3\), and its cube root is not small: a third moment of 1 % of \(\sigma^3\) is a skewness of \(0.22\,\sigma\), about 13 levels for a channel of \(\sigma = 60\). A copy that tips such a channel's balance to the other side changes the sign of its skewness, a jump of twice that, from one of the nine numbers.
On the right, each point is one channel of one photograph under one copy. A skewness mostly moves by a level or less; the few that change sign jump by tens of levels, and they are all channels that were close to symmetric. The skewness carries most of the distance of the copies that keep the tone, noise apart. On the synthetic images, whose flat colors often make a nearly symmetric channel, a copy changes the sign of up to a quarter of the channels.
Dropping the skewness would not help: the copies would land closer, but different images closer still, and d′ falls without it.
| Corpus | Copy | Share of the squared distance from the skewness | Channels whose skewness changes sign |
|---|---|---|---|
| photographs | JPEG quality 50 | 97% | 0.8% |
| photographs | Downscale 0.5 | 85% | 2.0% |
| photographs | Rotation 2 | 76% | 0.2% |
| photographs | Brightness 1.15 | 33% | 3.8% |
| photographs | Contrast 1.15 | 42% | 0.8% |
| photographs | Gamma 1.2 | 61% | 8.2% |
| photographs | Gaussian blur 1 | 85% | 4.2% |
| photographs | Noise 5 | 29% | 0 of 600 |
| photographs | Crop 5 | 94% | 3.2% |
| synthetic images | JPEG quality 50 | 62% | 26.4% |
| synthetic images | Downscale 0.5 | 65% | 8.3% |
| synthetic images | Rotation 2 | 95% | 22.2% |
| synthetic images | Brightness 1.15 | 34% | 9.7% |
| synthetic images | Contrast 1.15 | 39% | 11.1% |
| synthetic images | Gamma 1.2 | 77% | 20.8% |
| synthetic images | Gaussian blur 1 | 19% | 20.8% |
| synthetic images | Noise 5 | 99% | 27.8% |
| synthetic images | Crop 5 | 84% | 9.7% |
d′ of the copies against different images, from the same digests, with all nine numbers and without the three skewnesses:
| Corpus | All nine | Without the skewness |
|---|---|---|
| photographs | 2.68 | 2.32 |
| synthetic images | 2.29 | 2.14 |
How this was measured
The library computes the digest of every original of both corpora and of its nine
copies, through site_stages color_moments-digests; the nine numbers are decoded from
each digest, and the distances recomputed from them are checked against the corpus
charts' before anything is drawn.
/* One image of `site_stages color_moments-digests`: its ColorMoments digest, as
* ph_compute_color_moments_hash() computes it. */
static int digests_row(ph_context_t *ctx, json_t *row, const char *path) {
ph_digest_t d;
if (ph_compute_color_moments_hash(ctx, &d) != PH_SUCCESS) {
return fail("ph_compute_color_moments_hash failed", path);
}
json_hexbytes(row, "digest", d.data, d.size);
return 0;
}
/* site_stages color_moments-digests <image>...: one JSON line per image, as above. */
int mode_color_moments_digests(int argc, char **argv) {
return for_each_image(argc, argv, digests_row);
}
def moments(hexstr):
"""A ColorMoments digest as its nine numbers, [channel][moment]: each pair of bytes a
signed 16-bit big-endian number, divided by 128 (PH_VECTOR16_SCALE)."""
return (np.frombuffer(bytes.fromhex(hexstr), ">i2").astype(float) / 128.0).reshape(3, 3)
def l2(a, b):
"""ph_l2_distance() on two decoded digests."""
return float(np.sqrt(((np.asarray(a) - np.asarray(b)) ** 2).sum()))
def standardized(v):
"""Per channel, the third central moment over σ³ (the standardized skewness, which
does not depend on the channel's spread), from the decoded skewness s = cbrt(m3);
`v` is [...][channel][moment]."""
with np.errstate(divide="ignore", invalid="ignore"):
return (v[..., 2] / v[..., 1]) ** 3
def skew_measurement(tool):
"""Per corpus: (originals [image][channel][moment], copies [image][edit][channel]
[moment]), from the digests the library computes for every original and its nine
copies (corpus.measure_settings)."""
measured = corpus.measure_settings(tool, "color_moments-digests")
out = {}
for key, d in measured.items():
if d["n"]:
v = np.array([[moments(row["digest"]) for row in img] for img in d["images"]])
out[key] = (v[:, 0], v[:, 1:])
return out
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
How the nine algorithms compare is on choosing an algorithm.
Cost¶
ColorMoments reads every pixel twice, in color: once to add up the levels for the means, and once more for the second and third powers of each level's distance from them, in double precision. It shares nothing with the grayscale hashes: it neither uses nor makes the grayscale image they cache. Its cost grows with the image's pixel count, and it costs more than ColorHash, which reads every pixel once, with three table lookups. The nine numbers at the end cost nothing next to the pixels.
How this was measured
Measured on Intel(R) Xeon(R) Platinum 8370C CPU @ 2.80GHz, 4 cores, Linux x86_64; compiled by 13.3.0, optimized, with libjpeg-turbo decoding the JPEGs; the minimum of the runs. Decoding, every hash at its default settings, and in bold this page's.
| Case | 400×400 | 20 Mpx |
|---|---|---|
decode |
0.53 ms | 105 ms |
ahash |
0.21 ms | 23 ms |
dhash |
0.23 ms | 27 ms |
phash |
0.21 ms | 23 ms |
whash |
0.21 ms | 23 ms |
bmh |
0.21 ms | 23 ms |
mhash |
1.8 ms | 91 ms |
radial |
1.4 ms | 120 ms |
color_hash |
0.24 ms | 31 ms |
color_moments |
0.52 ms | 66 ms |
Each case runs once to warm up, then until it has run at least five times and for at least a second; the time is the minimum. The grayscale image and the area grid the library caches are dropped before every run, so each run is the first hash on a loaded image. The cases, and the timing loop:
/* Seconds on a monotonic clock, as tests/src/bench_hash.c reads it. The calendar clock
* (timespec_get() with TIME_UTC) would step back when the system corrects its time, and a
* run spanning the step would come out negative and become the minimum. On Darwin,
* CLOCK_MONOTONIC counts whole microseconds, 2 % of the cheapest hash; mach_absolute_time()
* counts nanoseconds. */
static double seconds(void) {
#ifdef __APPLE__
static mach_timebase_info_data_t tb;
if (tb.denom == 0) {
mach_timebase_info(&tb);
}
return (double)mach_absolute_time() * tb.numer / tb.denom * 1e-9;
#else
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
#endif
}
/* One timed case: `run` does the work once on a loaded image and returns nonzero on
* failure; `arg` selects a variant of it. A case named after an algorithm, or starting
* with its name and "_", is shown on that algorithm's page. */
typedef struct {
const char *name;
int (*run)(ph_context_t *ctx, const char *path, int arg);
int arg;
} time_case_t;
static int time_decode(ph_context_t *ctx, const char *path, int arg) {
(void)arg;
return ph_load_from_file(ctx, path) != PH_SUCCESS;
}
/* The first hash computed on a loaded image, which is what a caller who loads an image
* and hashes it pays: the grayscale conversion and the shared area grid are dropped
* before every run, so each run computes them (the dropping is untimed). */
static int time_hash(ph_context_t *ctx, const char *path, int algo) {
(void)path;
ph_digest_t d;
return ph_compute_digest(ctx, (ph_algorithm_t)algo, &d) != PH_SUCCESS;
}
/* The four 64-bit hashes in one ph_compute_multi() call, which computes the grayscale
* image and the area grid once for all of them. */
static int time_multi(ph_context_t *ctx, const char *path, int arg) {
(void)path;
(void)arg;
uint64_t h[PH_HASH_FLAGS_COUNT];
return ph_compute_multi(ctx, PH_HASH_AHASH | PH_HASH_DHASH | PH_HASH_PHASH | PH_HASH_WHASH,
h) != PH_SUCCESS;
}
static int time_whash_full(ph_context_t *ctx, const char *path, int arg) {
(void)path;
(void)arg;
uint64_t h;
int bad = ph_context_set_whash_mode(ctx, PH_WHASH_FULL) != PH_SUCCESS ||
ph_compute_whash(ctx, &h) != PH_SUCCESS;
return ph_context_set_whash_mode(ctx, PH_WHASH_FAST) != PH_SUCCESS || bad;
}
static int time_mhash_size(ph_context_t *ctx, const char *path, int size) {
(void)path;
ph_digest_t d;
int bad = ph_context_set_mhash_params(ctx, PH_MH_ALPHA, PH_MH_LEVEL, size) != PH_SUCCESS ||
ph_compute_mhash(ctx, &d) != PH_SUCCESS;
return ph_context_set_mhash_params(ctx, PH_MH_ALPHA, PH_MH_LEVEL, PH_MH_IMAGE_SIZE) !=
PH_SUCCESS ||
bad;
}
/* pHash with a dct_size other than the default, and the default block. */
static int time_phash_dct(ph_context_t *ctx, const char *path, int size) {
(void)path;
uint64_t h;
int bad = ph_context_set_phash_params(ctx, size, PH_DCT_REDUCTION_SIZE) != PH_SUCCESS ||
ph_compute_phash(ctx, &h) != PH_SUCCESS;
return ph_context_set_phash_params(ctx, PH_DCT_SIZE, PH_DCT_REDUCTION_SIZE) != PH_SUCCESS ||
bad;
}
/* Radial with the settings of radial_variants[variant]. */
static int time_radial(ph_context_t *ctx, const char *path, int variant) {
(void)path;
ph_digest_t d;
int bad = !radial_settings(ctx, &radial_variants[variant]) ||
ph_compute_radial_hash(ctx, &d) != PH_SUCCESS;
return !radial_settings(ctx, &radial_variants[RADIAL_DEFAULT]) || bad;
}
/* Loading the image, then, unless `algo` is LOAD_ONLY, computing that algorithm: what a
* caller pays for a load and a hash. `arg` packs the load -- a ph_decode_scale_t, plus
* LOAD_GRAY for ph_context_set_load_grayscale() and LOAD_FRESH for a context created and
* freed around the load, as a caller who creates one per image does -- and the algorithm
* above it. Without LOAD_FRESH, a context of its own per kind of load, kept across runs,
* so the cases after it hash the image as it was first loaded. */
enum {
LOAD_GRAY = 4,
LOAD_FRESH = 8,
LOAD_ALGO = 16,
LOAD_ONLY = -1,
};
static int time_load(ph_context_t *ctx, const char *path, int arg) {
(void)ctx;
static ph_context_t *kept[LOAD_FRESH];
int kind = arg % LOAD_FRESH, fresh = arg & LOAD_FRESH, algo = arg / LOAD_ALGO - 1;
ph_context_t *c = fresh ? NULL : kept[kind];
if (!c && ph_create(&c) != PH_SUCCESS) {
return 1;
}
ph_digest_t d;
int bad = ph_context_set_decode_scale(c, (ph_decode_scale_t)(kind & 3)) != PH_SUCCESS ||
ph_context_set_load_grayscale(c, (kind & LOAD_GRAY) != 0) != PH_SUCCESS ||
ph_load_from_file(c, path) != PH_SUCCESS ||
(algo != LOAD_ONLY && ph_compute_digest(c, (ph_algorithm_t)algo, &d) != PH_SUCCESS);
if (fresh) {
ph_free(c);
} else {
kept[kind] = c;
}
return bad;
}
#define LOADED(name, flags, algo) {name, time_load, (flags) + ((algo) + 1) * LOAD_ALGO}
static const time_case_t time_cases[] = {
{"decode", time_decode, 0},
{"ahash", time_hash, PH_ALGO_AHASH},
{"dhash", time_hash, PH_ALGO_DHASH},
{"phash", time_hash, PH_ALGO_PHASH},
{"whash", time_hash, PH_ALGO_WHASH},
{"whash_full", time_whash_full, 0},
{"bmh", time_hash, PH_ALGO_BMH},
{"mhash", time_hash, PH_ALGO_MHASH},
{"radial", time_hash, PH_ALGO_RADIAL},
{"color_hash", time_hash, PH_ALGO_COLOR_HASH},
{"color_moments", time_hash, PH_ALGO_COLOR_MOMENTS},
{"multi", time_multi, 0},
{"mhash_size_62", time_mhash_size, 62},
{"mhash_size_128", time_mhash_size, 128},
{"mhash_size_256", time_mhash_size, 256},
{"mhash_size_512", time_mhash_size, 512},
{"mhash_size_1024", time_mhash_size, 1024},
{"mhash_size_2048", time_mhash_size, 2048},
{"mhash_size_4096", time_mhash_size, 4096},
{"phash_dct_8", time_phash_dct, 8},
{"phash_dct_16", time_phash_dct, 16},
{"phash_dct_24", time_phash_dct, 24},
{"radial_sigma_1", time_radial, RADIAL_SIGMA_1},
{"radial_sigma_8", time_radial, RADIAL_SIGMA_8},
{"radial_gamma_2", time_radial, RADIAL_GAMMA_2},
{"radial_grid_40x32", time_radial, RADIAL_GRID_40X32},
{"radial_grid_90x64", time_radial, RADIAL_GRID_90X64},
{"radial_grid_360x256", time_radial, RADIAL_GRID_360X256},
{"radial_grid_1440x1024", time_radial, RADIAL_GRID_1440X1024},
{"radial_grid_4096x4096", time_radial, RADIAL_GRID_4096X4096},
LOADED("scale_half_decode", PH_DECODE_SCALE_HALF, LOAD_ONLY),
LOADED("scale_quarter_decode", PH_DECODE_SCALE_QUARTER, LOAD_ONLY),
LOADED("scale_eighth_decode", PH_DECODE_SCALE_EIGHTH, LOAD_ONLY),
LOADED("scale_full_phash", PH_DECODE_SCALE_FULL, PH_ALGO_PHASH),
LOADED("scale_half_phash", PH_DECODE_SCALE_HALF, PH_ALGO_PHASH),
LOADED("scale_quarter_phash", PH_DECODE_SCALE_QUARTER, PH_ALGO_PHASH),
LOADED("scale_eighth_phash", PH_DECODE_SCALE_EIGHTH, PH_ALGO_PHASH),
LOADED("scale_full_mhash", PH_DECODE_SCALE_FULL, PH_ALGO_MHASH),
LOADED("scale_half_mhash", PH_DECODE_SCALE_HALF, PH_ALGO_MHASH),
LOADED("scale_quarter_mhash", PH_DECODE_SCALE_QUARTER, PH_ALGO_MHASH),
LOADED("scale_eighth_mhash", PH_DECODE_SCALE_EIGHTH, PH_ALGO_MHASH),
LOADED("scale_full_radial", PH_DECODE_SCALE_FULL, PH_ALGO_RADIAL),
LOADED("scale_half_radial", PH_DECODE_SCALE_HALF, PH_ALGO_RADIAL),
LOADED("scale_quarter_radial", PH_DECODE_SCALE_QUARTER, PH_ALGO_RADIAL),
LOADED("scale_eighth_radial", PH_DECODE_SCALE_EIGHTH, PH_ALGO_RADIAL),
LOADED("gray_decode", LOAD_GRAY, LOAD_ONLY),
LOADED("gray_phash", LOAD_GRAY, PH_ALGO_PHASH),
LOADED("gray_mhash", LOAD_GRAY, PH_ALGO_MHASH),
LOADED("gray_radial", LOAD_GRAY, PH_ALGO_RADIAL),
LOADED("fresh_decode", LOAD_FRESH, LOAD_ONLY),
LOADED("fresh_phash", LOAD_FRESH, PH_ALGO_PHASH),
};
static int cmp_double(const void *a, const void *b) {
double x = *(const double *)a, y = *(const double *)b;
return (x > y) - (x < y);
}
/* One warm-up run, then at least TIME_MIN_RUNS runs and TIME_MIN_SECONDS, at most
* TIME_MAX_RUNS; the minimum is what the pages quote. */
enum {
TIME_MIN_RUNS = 5,
TIME_MAX_RUNS = 300,
};
#define TIME_MIN_SECONDS 1.0
/* Whether case `name` is among the `n` names of `only`; every case is when `n` is 0. */
static int time_chosen(const char *name, int n, char **only) {
for (int i = 0; i < n; i++) {
if (strcmp(name, only[i]) == 0) {
return 1;
}
}
return n == 0;
}
/* site_stages time <image> [case...]: every case of time_cases[] on the image, or the cases
* named; the minimum and the median of its runs, in milliseconds, as one JSON object, with
* the build that ran them. */
int mode_time(int argc, char **argv) {
for (int i = 1; i < argc; i++) {
int known = 0;
for (size_t c = 0; c < COUNT(time_cases); c++) {
known |= strcmp(argv[i], time_cases[c].name) == 0;
}
if (!known) {
return fail("no such timed case", argv[i]);
}
}
ph_context_t *ctx = NULL;
if (load_image(&ctx, argv[0])) {
return 1;
}
json_t j = json_begin(stdout);
json_int(&j, "width", ctx->image.width);
json_int(&j, "height", ctx->image.height);
json_string(&j, "build_info", ph_get_build_info());
#ifdef __VERSION__
json_string(&j, "compiler", __VERSION__);
#endif
json_t cases = json_object(&j, "cases");
static double ms[TIME_MAX_RUNS];
for (size_t c = 0; c < COUNT(time_cases); c++) {
const time_case_t *tc = &time_cases[c];
if (!time_chosen(tc->name, argc - 1, argv + 1)) {
continue;
}
int runs = 0;
double total = 0.0;
for (int k = -1; k < TIME_MAX_RUNS; k++) { /* k = -1: the warm-up */
ph_drop_gray_cache(ctx);
double t0 = seconds();
if (tc->run(ctx, argv[0], tc->arg)) {
ph_free(ctx);
return fail("timed case failed", tc->name);
}
double t = seconds() - t0;
if (k >= 0) {
ms[runs++] = t * 1e3;
total += t;
if (runs >= TIME_MIN_RUNS && total >= TIME_MIN_SECONDS) {
break;
}
}
}
qsort(ms, (size_t)runs, sizeof(ms[0]), cmp_double);
json_t o = json_object(&cases, tc->name);
json_double(&o, "min_ms", ms[0]);
json_double(&o, "median_ms", ms[runs / 2]);
json_int(&o, "runs", runs);
json_close_object(&o);
}
json_close_object(&cases);
json_end(&j);
ph_free(ctx);
return 0;
}
The times are measured again only when the library, the tool or the machine changes:
def measure_timing(tool):
"""{"machine": {...}, "small": {...}, "large": {...}, "scan": {...}}, each image's
entry as `site_stages time` prints it (width, height, build_info, compiler, and per
case the minimum and median in milliseconds) and the comparisons of a linear search as
`site_stages scan` prints them (per case, nanoseconds per comparison), from the cache
when nothing that decides them has changed, the machine included."""
host = machine()
paths = [os.path.join(ROOT, p) for p in IMAGES.values()]
key = fingerprint(KEY_FILES, [tool] + paths, json.dumps(host, sort_keys=True))
def measure():
data = {"machine": host}
for name, path in zip(IMAGES, paths):
out = subprocess.run([tool, "time", path], check=True, capture_output=True,
text=True).stdout
data[name] = json.loads(out)
data["scan"] = json.loads(subprocess.run([tool, "scan"], check=True,
capture_output=True, text=True).stdout)
return data
return cached("timing", key, measure, "timing")
Settings that affect it¶
ColorMoments has no parameters of its own, and the grayscale settings do not reach it. Three context settings change the pixels it reads.
| Setting | Effect on ColorMoments |
|---|---|
ph_context_set_load_grayscale() |
a decoded single-channel image has no color, and ColorMoments refuses it with PH_ERR_REQUIRES_COLOR rather than compute three identical channels |
ph_context_set_auto_orient() |
none: turning or mirroring an image keeps every pixel |
ph_context_set_alpha_mode() |
the color transparent pixels are composited onto, whose levels become part of every moment (which background) |
ph_context_set_decode_scale() |
a JPEG read by libjpeg-turbo is decoded at ½, ¼ or ⅛ of its size (measured); a smaller decode keeps the proportions of the colors, and most digests stay within the threshold for copies at every scale |
ph_context_set_gray_weights() has no
effect: ColorMoments reads \(R\), \(G\) and \(B\) directly. An image that is gray but stored
with three channels gets a digest whose three channels hold the same three numbers.
With ph_context_set_load_grayscale() enabled, the example photograph is decoded to one channel, and ph_compute_color_moments_hash() refuses it: "Algorithm requires a color image, but the loaded image is grayscale".
In code¶
ColorMoments returns a digest, like mHash, BMH, Radial and ColorHash. This example
computes all five for two images and compares each pair with the function its kind calls
for, ColorMoments' through ph_l2_distance(), and
shows that a bit-vector comparison refuses a digest that is not bits:
/* 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¶
Color moments are the method of "Similarity of color images" by Markus Stricker and Markus Orengo (Proc. SPIE 2420, 1995). The paper is paywalled and the library could not read it. The formulas come from the one restatement found, N. Keen's "Color Moments" (University of Edinburgh course notes, 2005), student coursework and a third-party account: the weakest source of any algorithm in the library. The implementation follows that restatement's three formulas exactly, including the cube root that keeps the skewness's sign, and anything changed on the strength of the restatement is to be checked against the paper first.
Two choices differ from the restatement, each recorded in
provenance § 9. The restatement computes the
moments in HSV, and says that another encoding could be used as well; the library takes
them on the RGB channels as decoded, and does not change the color space on the word of
a source it cannot check against the paper. And the restatement compares two images by a
weighted sum of absolute differences, with the weights left to the user, so there is no
default to conform to; the library uses the unweighted Euclidean distance, in channel
levels, through ph_l2_distance().
-
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. ↩