Skip to content

Algorithm provenance

Where each of the nine hashes in libphash comes from, what its source actually specifies, what this implementation does, and where the two differ.

Agreement with ImageHash is not a specification: a difference from it is not evidence of a bug, and agreement with it is not evidence of correctness. This document separates the two.

How to read this

Every algorithm gets the same five headings: Author, Primary source, What the source specifies, What this implementation does, and Delta. Each row of a Delta table is classified as one of:

  • conforms — does what the source states.
  • matches pHash's code (or matches the reference implementation) — follows the code that defines the algorithm where the prose descriptions of it disagree.
  • pinned — the source leaves a choice open, and this library fixes it one way for a reason recorded here.
  • deliberate — differs from the source, for a reason recorded here.
  • undefined — the source does not say, so there is nothing to conform to.

Source trust ranking

The question a rank answers is who defined this algorithm, not what genre the document belongs to. A thesis is not automatically above a source file; a thesis by the algorithm's author is, and a thesis describing somebody else's program is a third-party account of it. Applied when sources disagree, strongest first:

  1. Peer-reviewed paper, thesis or technical report by the algorithm's author.
  2. Preprint or unrefereed write-up by the author.
  3. Code published by the author — which is the top rank whenever there is no paper, as for pHash's DCT hash, aHash and dHash, where the implementation is the algorithm.
  4. Third-party description or implementation: ImageHash, OpenCV, a blog restatement, or a thesis analyzing code the author of the thesis did not write.

Zauner's thesis is rank 4 for the DCT hash: it is one careful reader's account of Klinger and Starkweather's program, and two of its statements about that program do not match it (§3).

A third-party implementation is a hint about where to look, never itself the basis for a claim of correctness — §6 follows the BMH paper against OpenCV, which is far more widely used than this library. Where a paper could not be read directly (IEEE and SPIE paywalls), the restatement used is named inline and its rank is stated, so the weight of each claim stays visible.

On reading other people's implementations

Several of the algorithms here are pinned down by reading code: pHash is GPL-3.0, OpenCV's img_hash is Apache-2.0, ImageHash is BSD. This library is MIT and contains no third-party hashing code at all. Those projects are read the way a paper is read — to establish what the method is, what constants it uses and what its defaults are. Methods, parameter values and defaults are facts; they carry no copyright, and they are quoted here with a precise citation so a reader can check them.

What is not done: copying source, transcribing a function's structure, or reproducing documentation prose. Where this document needs to say what another implementation does, it says so in words. Every hash in src/hashes/ is written from the specification, and where the specification is somebody's program, from a description of that program's behavior — not from its text.

What could not be read directly

These primary documents are paywalled and were not retrieved. Every statement attributed to them here comes from the named restatement, not from the paper:

Paper Restatement used Rank of restatement
Yang, Gu, Niu (IIH-MSP 2006) Zauner's thesis §3.1.4, which reproduces all four methods step by step thesis citing the paper (rank 4: a third-party account)
De Roover et al. (ICIP 2005); Lefèbvre et al. (EUSIPCO 2002); Standaert et al. (ITCC 2005) Zauner's thesis §3.1.3 and §3.2.3 thesis citing the papers (rank 4: a third-party account)
Stricker & Orengo (SPIE 1995) N. Keen, Color Moments, University of Edinburgh CVonline course notes, 2005 student coursework (rank 4)

The Stricker & Orengo formulas therefore rest on the weakest evidence in this document. They are marked as such in that section.

Attribution at a glance

Algorithm Author Source Known to diverge
aHash Neal Krawetz blog post, 2011 no
dHash David Oftedal, described by Neal Krawetz blog post, 2013 no
pHash pHash project; documented by Zauner; coefficient rule from Coskun & Sankur thesis, 2010 no — follows the reference implementation
wHash this library, after ImageHash none (§4) n/a — judged by measurement
mHash pHash (construction); Marr & Hildreth 1980 (operator) implementation + paper no
BMH Yang, Gu & Niu paper, 2006 no
Radial De Roover, De Vleeschouwer, Lefèbvre & Macq paper, 2005 no
ColorHash Swain & Ballard (method); this library (quantization) paper, 1991 — not read n/a — no conformance claimed
ColorMoments Stricker & Orengo paper, 1995 yes — color space (RGB, not HSV)

wHash has no primary source, so for it "correct" can only mean measured robustness, discrimination and separability, never conformance to a specification.

Constants no source defines

None of the nine primary sources specify a grayscale formula or a resampling filter, and most leave the tie at the threshold and the bit layout unstated too. Each choice the library makes in their place is a row of the delta table of the algorithm it touches; this section gathers the ones that cut across algorithms.

Grayscale. PH_GRAY_R/G/B = 38/75/15 over 128 (src/image/image.h), an integer approximation of the ITU-R BT.601 luma coefficients (0.299/0.587/0.114), cited as an external standard rather than because any source asks for it. Every algorithm that reduces to grayscale uses it: aHash, dHash, pHash, wHash, mHash, BMH, Radial. ColorHash and ColorMoments work in color. Why these and not a closer approximation: Image preparation.

Alpha. None of the sources hash transparent images. The color stored under alpha 0 is invisible and arbitrary, so an image with alpha is composited onto mid-gray at load time and every algorithm sees the visible image only; why gray, and what each background costs: Image preparation.

Resampling. aHash, pHash, wHash and BMH reduce by an exact area average, dHash through stb_image_resize2's Mitchell filter; neither is the filter ImageHash uses, and nothing in the sources is violated (§1, §2).

Threshold and bit order. Bit order never affects a distance, but it decides what a hash looks like in hex, which matters when comparing against another implementation. Two tie rules are in force, and unifying them would mean overriding either a source or a reference implementation for the sake of a convention:

Algorithm Bit set when Bit order Why
aHash >= the mean MSB first, 1ULL << (63 - i), row-major The tie is unpinned by the source, and no reference implementation is cited to defer to: >=, to agree with BMH. The order is the source's own ("left to right, top to bottom using big-endian"), the only layout any of the nine sources states.
dHash left < right MSB first, 1ULL << (63 - i), row-major Same source, same statement of the order.
pHash > the median plus a margin LSB first, 1ULL << i, DCT block row-major > as pHash's ph_dct_imagehash(); the order is undefined by Zauner or Krawetz, a choice not verified against pHash's own code.
wHash > the median LSB first, 1ULL << i, low band row-major > as ImageHash's whash(); the order is undefined, and not verified against ImageHash's layout.
mHash > the window mean MSB first within each byte, windows in raster order pHash's ph_mh_imagehash() construction; neither source states the direction. The packing matches how pHash's own page describes it.
BMH >= the median LSB first within each byte, blocks in raster order >= is Zauner's equation 3.9, the one place among the nine sources that states a direction; the paper defines a bit sequence, not a byte layout.

ColorHash (108 one-byte bins), ColorMoments (nine signed 16-bit numbers) and Radial (40 quantized coefficients) are not bit-packed.


1. aHash — Average Hash

Author: Neal Krawetz.

Primary source: "Looks Like It", The Hacker Factor Blog, 26 May 2011. This is a blog post, not a paper. There is no peer-reviewed publication of aHash; the post is the author's own description and is therefore rank 3 — the best that exists for this algorithm.

What the source specifies:

  1. "Reduce size" — shrink to 8×8, 64 pixels, ignoring aspect ratio.
  2. "Reduce color" — convert to grayscale.
  3. "Average the colors" — the mean of the 64 values.
  4. "Compute the bits" — "each bit is simply set based on whether the color value is above or below the mean."
  5. "Construct the hash" — "The order does not matter, just as long as you are consistent. (I set the bits from left to right, top to bottom using big-endian.)"

The post specifies no resampling filter, no grayscale coefficients, and no rule for a pixel exactly equal to the mean.

What this implementation does (src/hashes/ahash.c): grayscale via BT.601-approximate integer weights (38/75/15 over 128, configurable), resize to 8×8 through an area average (ph_area_downscale()), bit set when the pixel is at or above the exact mean of the 64 bytes (pixel * 64 >= sum, no rounding), bit index 63 - i in row-major order — that is, MSB first, left to right, top to bottom, big-endian.

Delta:

Difference Class Note
Resampling filter undefined Source says only "shrink". The reduction is an exact area average — each of the 64 values is the mean of the part of the image it covers — which is what "shrink" computes when nothing more is said. Nothing in the source is violated, and it is not the filter ImageHash uses (PIL LANCZOS). Against stb's Mitchell filter it measures better: separability 3.63 → 4.55 and false matches at 95 % recall 8.0 % → 2.9 % on the synthetic corpus; on 800 photographs 1208 → 1051 pairs of different images with identical hashes, the rest level. It is also shared: aHash, pHash, wHash and BMH read one area-average pass over the image.
Grayscale coefficients pinned Source says only "convert to a grayscale". PH_GRAY_R/G/B = 38/75/15 over 128 (src/image/image.h) — an integer approximation of the ITU-R BT.601 luma coefficients (0.299/0.587/0.114), cited as an external standard because none of this library's nine primary sources define a grayscale formula at all. The 77/150/29-over-256 triple, closer to BT.601 in decimal, separates copies from different images no better: the same on photographs, a little worse for aHash, wHash, BMH and mHash on the synthetic corpus, a little better for Radial (measured) — so 38/75/15 is used. Affects every algorithm that reduces to grayscale — aHash, dHash, pHash, wHash, mHash, BMH, Radial (all seven that call ph_get_gray()); noted once here, cross-referenced from the others.
Ties (pixel == mean → 1) pinned "Above or below" leaves the tie unstated, and no reference implementation is cited here to defer to (contrast pHash/wHash below, which are). >= is the library-wide rule for an unpinned tie: it agrees with the one source that states a direction (Zauner eq. 3.9, for BMH).
Mean compared exactly, not rounded conforms "Above or below the mean" is a comparison with the mean itself. A mean truncated to an integer would move every pixel equal to floor(mean) — below a fractional mean — onto the tie and set its bit, which on a low-contrast image sets nearly all 64.

Verdict: conforms. Including the bit order, which the source explicitly leaves free but happens to describe exactly as implemented here.


2. dHash — Difference Hash

Author: David Oftedal proposed it as a comment on Krawetz's 2011 post; Neal Krawetz named, described and evaluated it. Attribution should name both.

Primary source: "Kind of Like That", The Hacker Factor Blog, 21 January 2013. Again a blog post, rank 3, and again the only description by the people responsible for the algorithm.

What the source specifies:

  1. Shrink to 9×8 — 72 pixels — ignoring aspect ratio.
  2. Convert to grayscale.
  3. "The 9 pixels per row yields 8 differences between adjacent pixels. Eight rows of eight differences becomes 64 bits."
  4. "I use a '1' to indicate that P[x] < P[x+1] and set the bits from left to right, top to bottom using big-endian."

What this implementation does (src/hashes/dhash.c): resize to 9×8 through ph_resize_mitchell(), stb_image_resize2's Mitchell filter, bit set when row[col] < row[col+1], bit index 63 - (row*8 + col).

Delta:

Difference Class Note
Resampling filter undefined Source says only "shrink". Mitchell, not the area average aHash uses: an area average flattens a pattern finer than a cell into equal neighbors, which compare as 0, so different images made of such patterns collide. Measured with an area average, straight to 9×8 or through any intermediate grid from 72×64 to 288×256: separability down (3.49 → 3.23 synthetic) and more pairs of different photographs sharing a hash (721 → 900–1100 of 800 photographs). On the site's 200 photographs the two are close, the area average slightly ahead (dHash).
Grayscale coefficients pinned As for aHash.

Verdict: conforms, down to the direction of the comparison and the bit order, both of which the source states explicitly.


3. pHash — DCT-based hash

Author: No single one. The construction is described independently by Neal Krawetz (blog, 2011) and by the pHash library, whose DCT hash Zauner records as "inspired by" a DCT-based video hash by Coskun and Sankur.

Primary sources:

  • Christoph Zauner, Implementation and Benchmarking of Perceptual Image Hash Functions, Diplomarbeit, University of Applied Sciences Hagenberg, July 2010 — PDF. §3.1.1 gives the DCT definitions, §3.2.1 documents what pHash's ph_dct_imagehash() actually computes. Rank 4 for the construction (a third-party account of pHash's code), and the best description of it.
  • B. Coskun and B. Sankur, "Robust video hash extraction", Proc. Signal Processing and Communications Applications Conference, IEEE, April 2004, pp. 292–295 — the origin of the "select 64 low-frequency coefficients, omitting the lowest" rule, cited by Zauner as [9].
  • Krawetz, "Looks Like It" (as above) — an independent description of the same idea with different details.

What the sources specify:

Zauner §3.2.1, on pHash:

The method ph_dct_imagehash() first converts the image to gray scale using only its luminance. […] Then a mean filter is applied to the image. A kernel with dimension 7x7 is used. […] After this operation the image is resized to 32x32 pixels. Consequently, a DCT matrix is generated and the two-dimensional type-II DCT coefficients are calculated using matrix multiplications. […] As proposed in [9], 64 low-frequency DCT coefficients, omitting the lowest frequency coefficients, are selected for hash extraction. pHash therefore selects 8x8 transform coefficients. The coefficient DCT(1, 1) being the upper left corner of the matrix and the coefficient DCT(8/8) being the lower right corner.

and the thresholding rule, Zauner equation 3.10:

Once the median m of the 64 DCT coefficients has been determined […] h_i = 0 if C_i < m, 1 if C_i ≥ m.

Krawetz says the same thing about the DC term and quotes David Starkweather of pHash directly:

Compute the average value […] using only the 8x8 DCT low-frequency values and excluding the first term since the DC coefficient can be significantly different from the other values and will throw off the average […]

"the dct hash is based on the low 2D DCT coefficients starting at the second from lowest, leaving out the first DC term. This excludes completely flat image information (i.e. solid colors) from being included in the hash description."

The two sources disagree on the threshold: Krawetz says mean, Zauner/pHash says median. They agree, independently, that the DC term is excluded — but they do not agree with pHash's code, which is the third thing in the room and the one that actually defines the algorithm. ph_dct_imagehash() crops the 8×8 block at (0,0), so the DC term is among the 64 values; takes the median of elements 1 through 63 only, so DC has no say in the threshold; and thresholds all 64 against that median with a strict >.

So DC keeps its bit and loses its vote. Zauner's "the coefficient DCT(1,1) being the upper left corner" is his reading of that code, not what it does; Starkweather's "leaving out the first DC term" describes the median and not the block.

The code decides here, and it is worth being exact about why, because the reason is not "code beats prose". This algorithm has no paper. Its authors are Evan Klinger and David Starkweather, and what they authored is the pHash implementation; there is nothing behind it to appeal to. Zauner's thesis and Krawetz's post are third parties describing someone else's program, however carefully. Where an algorithm does have a paper by its author — Yang, Gu and Niu for BMH, Stricker and Orengo for the color moments — the paper outranks every implementation, including implementations more popular than this library will ever be. §6 follows the BMH paper against OpenCV for exactly that reason.

What this implementation does (src/hashes/phash.c): grayscale, area-average resize to 32×32, type-II DCT by matrix multiplication with the same matrix definition as Zauner's equation 3.3, coefficients (0,0) through (7,7), median over the 63 AC coefficients, bit set when value > median + 0.001 × (AC range) — pHash's construction with a margin (see the delta table and "Where the weakness is" below). The bit order is this library's own: LSB first (see Constants no source defines).

Delta:

Difference Class Note
DC excluded from the median matches pHash's code As ph_dct_imagehash() does, and as both descriptions ask. For the median alone it changes no bit except on an exact tie; it also keeps DC out of the range the margin is taken from (both measured below).
DC keeps its bit, so one bit of the 64 is constant deliberate — pHash's own behavior DCT(0,0) is non-negative and larger than every AC term, so it is above the median every time and its bit is 1 every time. The hash is effectively 63 bits. Removing the dead bit means moving the block to (1,1), which the prose describes and the code does not; measured below and rejected.
No 7×7 mean prefilter before the resize deliberate The exact area average already averages over each source region, which is a low-pass step of a similar kind. Not identical to a 7×7 mean at full resolution; worth measuring rather than assuming.
Threshold is the median deliberate The two sources disagree; the rank-1 source (Zauner/pHash) says median.
Threshold is the median plus 0.1 % of the AC range, not the bare median deliberate Coefficients crowding the median decide their bits by noise; the margin sends them to 0 together. Measured below: false matches at 95 % recall on photographs halve. ph_dct_imagehash() thresholds at the bare median, so pHash values differ from it.
> rather than ≥ matches pHash's code Zauner's 3.10 says ≥; ph_dct_imagehash() writes >, and so does this. With floating-point coefficients the two differ only on an exact tie against the median, i.e. on degenerate input such as a solid color. > because it is pinned to the reference implementation's code, which outranks a library-wide convention.
Area-average resampling undefined No source specifies a filter. Zauner's account of pHash has a 7×7 mean filter and then a resize, so an exact area average — each value the mean of the region it covers, shared with aHash, wHash and BMH — is at least the same kind of operation.
Grayscale coefficients pinned As for aHash — see §1. ph_median_bitpack_from() (shared with wHash) operates on the DCT of the grayscale buffer, so the same BT.601 triple applies here too.

What the DC term actually costs, measured. The explanation quoted above — that DC "will throw off the average" of the other 63 — holds for a mean and not for the median pHash takes. A median is not dragged by an outlier. With the 64 values sorted ascending as v0..v63 and DC the largest, the median of all 64 is (v31 + v32)/2 and the median of the 63 without DC is v31; nothing lies strictly between them, so the same coefficients clear the threshold either way. The two can only disagree when v31 and v32 are so close that the float average rounds onto v32, and then by one bit.

The margin is a different matter. It is a fraction of the range of the values that set the median, and DC is many times larger than any AC term: counted in, it would stretch that range and with it the margin, and the margin would send most AC bits to 0. Measured on the synthetic corpus with DC counted in both: inter-image distance 0.425 → 0.319, intra-image 0.073 → 0.035, separability 3.17 → 3.23 — hashes pulled towards zero rather than a better descriptor. DC therefore stays out of the median and out of the margin's range, as in pHash.

Taking the 8×8 block at DCT(1,1) instead, so all 64 bits carry information, measures worse:

mean intra mean inter separability
block at (0,0), median over AC (pHash) 0.168 0.486 2.69
block at (1,1), median over all 64 0.182 0.483 2.33

Trading the dead DC bit for one more row and column of higher-frequency coefficients buys a bit of width and loses more robustness than it gains, so the block is at (0,0), as in pHash.

Both measurements take the threshold at the bare median, without the margin described below. The DC term does not explain pHash's weaker robustness there (mean intra-distance 0.168 against 0.03–0.06 for the other structural hashes): neither treatment of DC moves that number.

Where the weakness is: coefficients crowding the median. Every bit is a comparison with the median of the 63 AC coefficients, so a coefficient close to the median is decided by whatever nudges it, and an image with many of them has many such bits. On an image with little low-frequency structure most AC coefficients are near zero, and so is their median. Measured over 800 photographs and textures (JPEG and PNG images shipped with macOS, reduced to 512 px on the long side):

AC coefficients within 0.1 % of the AC range of the median photographs
under 5 % 520 (65 %)
5–25 % 153 (19 %)
25–50 % 77 (10 %)
50 % or more 50 (6 %)

The same effect seen through the hash: ±1 gray level of uniform noise, invisible, moves pHash by 11 bits or more on 140 of the 800 (aHash 53, dHash 45, wHash 68). photo_complex.png in tests/data is one of the 50: 55 of its 63 AC coefficients lie within 0.1 % of the median. It and the uniform photo.png therefore pin pHash's behavior in that regime, not in the typical one.

So the threshold is not the bare median. It is raised by a margin of 0.1 % of the AC range (PH_PHASH_MEDIAN_MARGIN), which sends the crowd around the median to 0 as a block:

margin photographs: separability false matches at 95 % recall moved 11+ bits by ±1 level of noise
none (pHash's ph_dct_imagehash()) 3.67 14.2 % 140 of 800
0.1 % 4.00 6.8 % 98
0.2 % 4.01 6.5 % 92
0.5 % 3.77 8.2 % 75

The synthetic corpus agrees (separability 2.69 → 3.17). A uniform image, whose AC coefficients are float residue, hashes within 11 bits of zero instead of to rounding noise, and any two flat grays within 13 bits of each other. The price is conformance: pHash values are not ph_dct_imagehash()'s, and three near-flat synthetic images of 24 share a hash.


4. wHash — Wavelet hash

Author: Johannes Buchner, as whash in the ImageHash library.

Primary source: none. ImageHash's README cites a blog post by Alexander Petrov, not a paper. No academic publication describes this construction. Rank 4 is the best available, and the honest statement is that wHash has no primary source.

Not the source: Venkatesan et al. 2000. Venkatesan, Koon, Jakubowski and Moulin, "Robust Image Hashing", ICIP 2000, vol. 3, pp. 664–666, DOI 10.1109/ICIP.2000.899541, is a real paper and an early wavelet-based image hash. It is not the source of this algorithm: it builds a hash from statistics of randomly tiled wavelet subbands under a secret key, followed by error-correction decoding. None of that — keying, random tiling, ECC — appears in ImageHash's whash or here.

It cannot serve as a source for wHash either, by replacing it with the paper's algorithm, for reasons recorded in references.md under [VKJM00]. In short: the paper specifies the shape of the algorithm but not the constants — the tiling distribution, the quantizer, the Reed–Muller parameters and the whole of its fourth step are absent, and it says so itself ("a formal analysis of the steps involved in the hash computation will appear elsewhere"). Implementing it would mean inventing half of it and then citing a paper for the result, which is the exact failure this document exists to prevent. Its randomization is also load-bearing rather than incidental, so pinning the key to obtain a deterministic hash discards what the paper demonstrates.

It belongs in a related-work list, not in an attribution header.

wHash is an algorithm of this library: an unkeyed Haar descriptor, justified by measurement (separability 4.10, third of the nine) rather than by a citation. A keyed algorithm would break determinism, which is the premise this library is built on. The gap is not in the implementation: none of the papers in references.md describes an unkeyed wavelet hash.

What the reference implementation does (ImageHash whash, the thing this was ported from, rank 4): grayscale; resize to image_scale, the largest power of two not exceeding the smaller image dimension; divide by 255; Haar wavedec2 to level log2(image_scale) − log2(hash_size); with remove_max_haar_ll=True by default, zero the LL coefficients of a full decomposition before the main one; median of the remaining hash_size × hash_size low band; bit set when value > median.

Note that remove_max_haar_ll=True cannot do for ImageHash what its name promises either, for the same reason given in the delta table below: its working band is thresholded at a median too.

What this implementation does (src/hashes/whash.c): two modes. In both, only the final 8×8 LL band reaches the hash, and after k levels an LL value is 2ᵏ times the mean of its block, so the band is the image averaged over an 8×8 grid: the hash comes close to aHash's grid thresholded at its median, and the two modes to each other (measured).

  • PH_WHASH_FAST (default): exact area average to a fixed 16×16, divide by 255, one Haar level (orthonormal, both sums and differences divided by √2), take the top-left 8×8, median, >.
  • PH_WHASH_FULL: image_scale = largest power of two ≤ the smaller dimension, floored at 8; cascade Haar levels down to 8×8; same median and comparison.

Delta — against the reference implementation, since there is no source to conform to:

Difference Class Note
remove_max_haar_ll implemented, defaults to off deliberate (a proved identity, except at ties) Under a median threshold the operation changes nothing but ties, here and in ImageHash. Zeroing the single coarsest LL coefficient and reconstructing subtracts the image mean from every sample and nothing else (verified: max deviation 1.9e-07 against orig − mean, on a cascade whose round-trip error is 4.2e-07). A constant subtracted from every sample shifts every working-LL coefficient and their median by that same constant, so value > median is unchanged for every coefficient off the median. A coefficient on it sets no bit as computed, and after the extra transform pair lands a rounding error above or below, which decides its bit. Ties come from flat regions, which the synthetic corpus is rich in: there 41 of its 192 images (24 originals and their 168 edited copies) change in the fast mode, 532 bit flips in total, and separability falls 4.10 → 3.41; a disc on a flat field hashes to all zeros as computed and flips 31 bits. Photographs tie less, but in the fast mode, whose 16×16 grid holds whole numbers, some do: the three test fixtures hash identically in both modes, and over 200 photographs the fast mode changes on about one in ten by a bit or two, the full mode on none (measured). Omitting it does not leave brightness in the hash: the +25 brightness row of the property test is 0.008 without the removal and 0.039 with it. Exposed as ph_context_set_whash_remove_max_haar_ll() for callers mirroring ImageHash's configuration; default off, because the only thing it can do is let rounding error decide ties. Pinned by test_remove_max_haar_ll_subtracts_the_mean, test_remove_max_haar_ll_leaves_the_hash_alone and test_remove_max_haar_ll_on_a_solid_fill.
Default mode fixes the scale at 16×16 deliberate PH_WHASH_FULL implements the power-of-two rule. Speed/robustness trade-off.
Area-average resampling (fast mode) and box resampling (full mode), where the reference implementation resamples with PIL's LANCZOS undefined Neither ImageHash's own choice of LANCZOS nor this library's averaging filters is asked for by anything upstream of ImageHash — there being no primary source for wHash at all (see above), there is nothing to conform to or diverge from, only a reference implementation to differ from by choice. Averaging was picked because it is cheap and a defensible low-pass step ahead of a wavelet decomposition that is itself a filter bank; the fast mode's 16×16 comes from the exact area average shared with aHash, pHash and BMH, the full mode's power-of-two size from ph_resize_box().
Grayscale coefficients pinned As for aHash — see §1.
Tie (value == median → >) matches the reference implementation Same convention ImageHash's whash uses ("what the reference implementation does" above). Not changed to ≥ for the same reason as pHash's: an actual reference implementation to match beats a general convention adopted for everything that has neither.

5. mHash — Marr–Hildreth hash

Two sources, covering different halves of it.

The operator is the Laplacian of Gaussian of D. Marr and E. Hildreth, "Theory of edge detection", Proc. R. Soc. Lond. B 207:187–217, 1980 — rank 1. The kernel is the sampled Mexican hat, (2 − A)·exp(−A/2), with A the squared distance from the center in units of the scale.

The hash built on it has no paper. It is pHash's ph_mh_imagehash(), by Evan Klinger and David Starkweather, and Zauner §3.2.2 says outright that the construction "has not been proposed previously". By the ranking above, that makes pHash's own code the primary source for the construction — not a reference implementation of something else — and the steps and constants below are taken from it as facts about the algorithm.

What the source specifies: luminance, blur at σ = 1, resize to 512×512, histogram equalization over 256 levels, correlation with the LoG kernel at α = 2 and level = 1, the response normalized and summed over 16×16 blocks into a 31×31 grid, and nine bits per 3×3 window of that grid at stride 4, each thresholded against its window's mean — 64 windows, 576 bits, 72 bytes. pHash's own page states the 72 bytes; its header states the α and level defaults.

What this implementation does (src/hashes/mhash.c): that construction, with two differences in kind and one in arithmetic.

Difference Class Note
Separable truncated Gaussian at σ = 1, not Deriche's recursive approximation deliberate CImg's blur() is a recursive filter; this is a direct kernel truncated at 3σ. Both approximate the same Gaussian.
Resize through stb's Mitchell filter (ph_resize_mitchell()), not CImg's quintic deliberate Different interpolator, same step.
Block sums computed through an integral image rather than by filtering every pixel deliberate, and strictly better See below. This is the one change that moves the numbers, and it moves them the right way.
Values are not bit-identical to pHash's consequence of the three above Reproducing them would mean reimplementing CImg's blur, resize and equalizer, for a comparison nothing here can run: there is no pHash build to check against. The construction and every parameter of it are reproduced; the arithmetic is not.
Zero-crossings are not detected not a divergence Worth stating because the name invites it: Marr and Hildreth find edges as the zero-crossings of the filtered image, and neither pHash nor this code looks for one. The response is block-summed and thresholded against a local mean. The operator is theirs; the edge detector is not being implemented, by either.
Grayscale coefficients (the "luminance" step) pinned As for aHash — see §1.

Folding the block sum into the kernel: faster, and more accurate

The definition filters every pixel and then sums the response over each 16×16 block. The kernel tap does not depend on the pixel, so it comes out of the inner sum:

B(by,bx) = Σky,kx K[ky][kx] · Σ(y,x) ∈ block I(y+ky−half, x+kx−half)

and what is left inside is a 16×16 box over the edge-replicated image, which an integral image answers in four lookups. That is 31·31·289 multiply-adds instead of 496·496·289, 256 times fewer.

The folded sums are also exact: the inner sums are integers and only 289 terms accumulate, in double, where evaluating the near-zero-sum LoG kernel per pixel in single precision sums large products that almost entirely cancel. The reason for folding is the time; exactness is what makes it safe, and test_mh_block_sums_match_the_direct_definition() checks the folded result against the definition evaluated in double. Over the site's two corpora the definition in double gives the same digest on every image, and in single precision another on two of 224: one bit on a photograph, and on a synthetic image of flat areas over a hundred, where the nine sums of a window are equal and rounding breaks their tie with the mean (the mHash page has the counts).

One step of the source is dropped, provably without effect: it normalizes the response to [0,1] before summing. The block sums are affine in the response, the window mean is affine in the block sums, and value > mean is invariant under an affine map with positive scale, so no bit can change.

Parameters, and why the defaults are pHash's

ph_context_set_mhash_params() exposes α and level — pHash's own two parameters, which set the kernel's scale — and the size the image is normalized to, which pHash fixes at 512. The ratio between the kernel's scale and the picture is the only thing in this algorithm that decides what it sees, and both knobs move it.

The defaults are pHash's (α = 2, level = 1, 512). Across 24 settings (4 levels × 6 sizes) on a 300×300 corpus every one lands between 2.46 and 3.03, with no trend in either parameter, so none beats the source's values. The sweep has to use a corpus larger than the normalization sizes: on a corpus near a normalization size the base image is not resampled at all while its transformed copies are, and the measurement rewards the setting that happens to match the corpus.

Two properties follow:

  • This library's property corpus understates any algorithm that normalizes to a size larger than the corpus (less so since its feature sizes scale with the corpus; see "The corpus" in methodology.md). On a 300×300 corpus mHash separates at 2.70 (BMH 3.25, wHash 2.88, Radial 2.72, aHash 2.31, dHash 2.07, pHash 1.89, ColorHash 1.82); on the 160×160 property corpus it separates at 2.62.
  • A small local edit moves this hash no more than a rescale does. On 200 photographs a patch over 4 % of the frame moves it about as far as halving the image's size, and a 2-degree turn or a 5 % crop several times further than a patch over 16 %; on the synthetic corpus recompression and noise move it further than small patches. For finding an edited copy of a picture, that ordering is the wrong way round. The algorithm describes where detail lies, and should not be relied on to notice small edits (the mHash page has the measurement).

6. BMH — Block Mean Value hash

Author: Bian Yang, Fan Gu, Xiamu Niu.

Primary source: "Block Mean Value Based Image Perceptual Hashing", Proc. International Conference on Intelligent Information Hiding and Multimedia Signal Processing (IIH-MSP), IEEE, 2006, pp. 167–172, ISBN 0-7695-2745-0. Paywalled and not read directly; the steps below are Zauner's §3.1.4 reproduction of the paper's method 1, which he implemented into pHash as part of the thesis: a rank 4 restatement of a rank 1 source.

What the source specifies (method 1):

  1. Convert to grayscale and normalize to a preset size.
  2. Divide the pixels into N non-overlapping blocks I₁…I_N, N being the bit length.
  3. Encrypt the block indices with a secret key K to permute the scan order.
  4. Compute the mean of each block, giving M₁…M_N, and obtain the median value M_d of the mean value sequence.
  5. Equation 3.9: h(i) = 0 if M_i < M_d, 1 if M_i ≥ M_d.

Methods 2–4 add overlapping blocks and rotation; neither is implemented here, and both are out of scope. Step 3 is omitted by pHash's own implementation too, and the paper does not name an encryption algorithm.

What this implementation does (src/hashes/bmh.c): grayscale, an exact area average (ph_area_downscale()) straight to block_size × block_size (default 16×16 → 256 bits), median of the resulting values, bit set when value >= median, packed LSB-first within each byte.

There is no reference implementation to check the prose against, which is the situation this document keeps running into from the other side. pHash carries no block-mean hash today, although Zauner says he contributed one. The other implementation in wide use, OpenCV's cv::img_hash::BlockMeanHash, resizes to 256×256 and then thresholds against the arithmetic mean of the image — which it stores in a variable it names median. The name says the intent and the value says the slip. This library follows the paper, so BMH values differ from OpenCV's.

Delta:

Difference Class Note
Threshold is the median of the block means conforms Per step 4 and equation 3.9. It makes the bit distribution balanced by construction, which the paper depends on. Measured, neither threshold wins: at the distance that accepts 95 % of copies, the mean lets fewer different photographs through, mostly because ties at the median collapse photographs on a black background to one digest, and the median separates the synthetic corpus better (the numbers). The median is kept for conformance and balance.
Median of an even count taken as the upper of the two central values undefined The paper does not say. This is the choice that preserves its property: with ≥, exactly half the blocks clear the upper central value. Ties among block values can still unbalance it — they are bytes, and a flat image has many — and nothing in the method addresses that. In the extreme, an image with more than half of its blocks at its darkest value sets every bit (the median and ties).
No preset normalization size; the image is resampled straight to the block grid deliberate, and measured better See below.
Key-permuted block order omitted deliberate Also omitted by pHash. It is a security feature (unpredictability under a key), not a perceptual one, and the paper leaves the cipher unspecified.
≥ at the threshold conforms Matches equation 3.9. This is also the library-wide rule for aHash's own unpinned tie (§1).
Bit packing LSB-first within a byte documented The paper defines a bit sequence, not a byte layout, so there is nothing to conform to or diverge from — only a choice to record. See Constants no source defines for all nine algorithms; bmh.c's own file header states it too.
Grayscale coefficients pinned As for aHash — see §1.

The missing normalization step, and why it is not needed. Step (a) normalizes the image to a preset size before blocking, and both implementations of the paper do it at 256×256. This library resamples straight to the block grid. Resampling straight to the grid is the block means for any source size, not only multiples of the grid: ph_area_downscale() computes each block as the coverage-weighted sum of exactly the source region behind it, fractional edges included, in integers, and rounds once. It is the paper's block mean to the nearest byte, which test_area_downscale_matches_brute_force() checks against a brute-force sum.

Normalizing first, the paper's way — to the largest multiple of the grid at or below 256, then averaging integer blocks — gains nothing: separability on the synthetic corpus is 5.43 direct and 5.42 normalized. It adds a stage that rounds the intermediate to integer pixels, an error the direct area average does not have, and it would tie the grid to divisors of the preset: most grid sizes this library accepts (2..32, e.g. 3×3 or 22×22) do not divide 256.

So the step is skipped deliberately, and the invariant that licenses skipping it is pinned by test_block_means_on_a_non_multiple().


7. Radial — Radial variance hash

Authors: Frédéric Lefèbvre, Benoît Macq and Jean-Didier Legat proposed the original RASH; Christophe De Roover, Christophe De Vleeschouwer, Lefèbvre and Macq published the radial variance algorithm that pHash implements.

Primary sources:

  • C. De Roover, C. De Vleeschouwer, F. Lefèbvre, B. Macq, "Robust image hashing based on radial variance of pixels", Proc. ICIP, vol. 3, IEEE, Sept. 2005, pp. 77–80 — the algorithm actually implemented by pHash (Zauner [35], and §3.2.3: "pHash implements the algorithm as proposed in [35]").
  • F. Lefèbvre, B. Macq, J.-D. Legat, "RASh: RAdon Soft Hash algorithm", Proc. EUSIPCO, vol. I, Sept. 2002, pp. 299–302 — the predecessor, which the same authors later reported as suffering "some troubles".
  • F.-X. Standaert et al., "Practical evaluation of a radial soft hash algorithm", Proc. ITCC, vol. 2, IEEE, April 2005, pp. 89–94.

All three paywalled; content below from Zauner §3.1.3 and §3.2.3.

What the sources specify:

Definition 3.6 — the radial variance vector, for α = 0, 1, …, 179, over Γ(α), the one-pixel-wide strip of pixels on the projection line through the image center:

R[α] = ( Σ I²(x,y) / #Γ(α) ) − ( Σ I(x,y) / #Γ(α) )²

180 rather than 360 angles because the Radon transform is symmetric. And then, crucially:

Finally, in [35], the perceptual image hash function was further improved by applying the DCT to the radial variance vector. The first 40 coefficients of the transformed radial variance vector form the so-called radial hash vector in the end. This omits redundant components of the radial variance vector and efficiently decorrelates it.

pHash's implementation, per §3.2.3: 40-byte hash; Gaussian blur σ and gamma correction, for which [Z10] reports that "the authors suggest 1 for both variables" — though pHash's own header defaults σ to 3.5 and γ to 1.0, so the thesis is right about γ and wrong about σ; N = 180 angles by default; comparison by peak of cross-correlation (PCC) with a default threshold of 0.9; and, uniquely among the four hashes in the thesis, no normalization of image resolution.

What this implementation does (src/hashes/radial.c): a σ-parameterized Gaussian blur (default σ = 3.5, pHash's own header default) and gamma correction with a default of 1.0 — an exact identity, pow(v/max, 1.0) * max == v for any max > 0 — then for each of radial_projections angles (default 180) spread over [0, π), sample radial_samples points (default 128) bilinearly along the line through the image center out to min(w,h)/2 and compute the variance with exactly the source's formula. That vector is standardized to zero mean and unit variance, as pHash's ph_feature_vector() does; a vector with no spread worth the name — mean variance at most 1e-6 gray levels², or a spread across angles below 1 % of the mean (squared coefficient of variation 1e-4) — yields an all-zero digest, which ph_radial_similarity() refuses to score (PH_ERR_NO_STRUCTURE). A 1-D DCT-II follows, and its first 40 coefficients are the hash, mapped affinely onto 0–255 by their own minimum and maximum, the quantization pHash's ph_dct() uses. Digests are compared by ph_radial_similarity(), the peak of the cross-correlation over cyclic shifts, against a threshold of 0.9 — pHash's ph_crosscorr().

Measured on the synthetic corpus of tests/src/test_hash_properties.c (distances normalized to [0,1]), compared by peak cross-correlation: mean intra-distance 0.021, mean inter-distance 0.261, separability 2.72.

The vector is standardized before the transform. Without that, DCT coefficient 0 is the sum of the variances: always the largest of the 40, always quantized to 255, so one byte of every digest carries no information and pins the top of the quantization range, squeezing the rest into what is left — which pulls every pair of digests towards each other.

Delta:

Difference Class Note
DCT of the radial variance vector, first 40 coefficients conforms The DCT of the 180-angle variance vector; the first 40 coefficients are the hash — the step the paper credits for the improvement.
Comparison by the peak of cross-correlation, threshold 0.9 conforms, one deliberate difference ph_radial_similarity(), pHash's ph_crosscorr(). One deliberate difference: pHash divides by the first digest's variance alone, which makes its score asymmetric — crosscorr(x,y) and crosscorr(y,x) disagree. This uses the symmetric Pearson correlation.
Rotation robustness is a few degrees, not arbitrary a property of the algorithm Measured below. The source is not contradicted; the tolerance is a few degrees plus a half turn.
Default gamma 1.0, pixels raised to gamma directly, normalized by the buffer's own maximum before the power step and rescaled by it after matches pHash's code Default γ = 1.0 (an exact identity), as ph_compare_images() defaults it. Pixels are raised to γ after normalizing by the buffer's own maximum and rescaled by it, as pHash does. [Z10] reports 1 as the authors' suggestion; that is a suggestion the thesis records, not a formula from the paper.
Blur is σ-parameterized (ph_gaussian_blur_sigma()), default σ = 3.5 matches pHash's code pHash's own header defaults sigma to 3.5. [Z10] reports the authors as suggesting σ = 1, which pHash's header does not follow — so 3.5 is the reference implementation's choice, not a value derived from the paper.
128 samples per projection, bilinearly interpolated deliberate The source integrates over the pixels of a one-pixel-wide strip, whose count varies with the angle and the image size; a fixed sample count is a different estimator of the same quantity. Cheaper and resolution-independent, but it is an approximation, not the definition.
Radius capped at min(w,h)/2 deliberate Keeps every projection inside the image. The source does not normalize resolution and does not discuss the cap.
Grayscale coefficients pinned As for aHash — see §1.
Coefficients quantized by their own min and max deliberate, and pHash's Not in the paper, which says nothing about quantization. It is what pHash's ph_dct() does, it keeps the sign, and it makes the digest invariant to a rescaling of the whole variance vector.
All-zero digest for a profile without angular structure: mean variance at most 1e-6 gray levels², or a spread across angles of at most 1 % of the mean pinned Not in the source, and the source could not supply one — it does not discuss degenerate input. Both bounds are chosen, not inherited, and measured next to PH_RADIAL_MIN_RELATIVE_SPREAD: a flat image leaves interpolation residue around 1e-8, and below a relative spread of 1e-4 the digest of an image correlates with that of the same image plus ±2 levels of noise at 0.4–0.7, where it describes the rounding rather than the image. Without the bound the standardization and the min-max quantizer would stretch that noise across the whole byte range and manufacture detail that is not there. The bound is relative, so a faint pattern keeps a digest of its own.

What "robust to rotation" amounts to here, measured

The literature credits the radial variance hash with robustness to rotation, and it is worth being precise about how much: not arbitrary rotation.

The mechanism is real and this implementation has it: a rotation cyclically shifts the vector of per-angle variances. Measured directly on a synthetic image and its exact quarter turn, the two 180-element variance vectors correlate at 0.9997 at a shift of exactly 90 places — the rotation is in there, cleanly and completely.

The hash is not that vector. It is 40 DCT coefficients of it, and the DCT is not shift-equivariant: a cyclic shift of a signal is not a cyclic shift of its transform. So what survives is a transform's tolerance to a small perturbation, not invariance to an arbitrary rotation. On tests/data/photo.jpeg, with the source's comparison and its 0.9 threshold, the score is 0.995 at 1°, 0.957 at 3°, 0.53 at 15°, 0.35 at a quarter turn and 0.998 at a half turn (test_radial_rotation_on_a_photograph), where the variance profiles of the same pairs, compared at their best shift, stay above 0.998 at every whole degree (measured).

A few degrees — the kind a rescan, a crop-and-straighten or a re-encode introduces, and the kind the perceptual-hashing literature evaluates — and an exact half turn. The half turn is not the transform's doing: a projection line at α and at α+180 is the same line, so it is the identity on the variance vector before the DCT ever runs. On the smoother photo_complex.png the sweep holds out to 10° (0.939); on the deliberately high-frequency synthetic corpus, where a one-degree resample already moves its narrow stripes and small checkerboards, it is much weaker (mean 0.82 at 1°) — content matters, and the corpus is the pessimistic end of it.

Quarter turns are not absorbed, and no comparison of these 40 coefficients can absorb them: pHash's maximization over cyclic shifts of the coefficients is not the group a rotation acts through. pHash behaves identically. Recovering large rotations would mean storing something a shift does not destroy — the magnitudes of the first Fourier coefficients of the variance vector are exactly shift-invariant, for instance — which would be a departure from the source, and is not done.

Radial follows its source end to end: projections, standardization, transform, quantization, comparison, and pHash's blur and gamma defaults. At the default γ = 1.0 the gamma step is an exact identity ((v/max)^1.0 * max == v); the exponent convention and the normalization by the buffer's maximum matter only to a caller who sets γ explicitly.


8. ColorHash — color histogram

Authors: Michael Swain and Dana Ballard, for the method. The quantization is this library's.

Primary source: M. Swain, D. Ballard, "Color Indexing", International Journal of Computer Vision 7(1):11–32, 1991. Not read. IJCV is closed, OpenAlex reports oa_status: closed and no repository holds the full text; Swain's Rochester technical report (TR 360, 1990) is not freely available either. The intersection formula is confirmed by several independent restatements, which by this document's own ranking leaves the basis at rank 4. No conformance to the paper is claimed. The honest description, used in the code and in references.md alike: a color histogram with histogram intersection, after Swain & Ballard (1991), implemented from secondary descriptions.

What the secondary descriptions agree on: quantize the color space, count pixels per bin, compare two histograms by their intersection

H(t, r) = Σ min(t_i, r_i) / Σ r_i

which is 1.0 for identical distributions and falls towards 0 as they diverge. The property it is taken for is that it counts only what the two images have in common, so a change of background or a partial overlap costs only the part that differs. The axes the paper uses are the opponent ones: rg = R − G, by = 2B − R − G, wb = R + G + B.

What this implementation does (src/hashes/color_histogram.c): those three axes at 6 × 6 × 3 = 108 bins, one byte per bin scaled against the largest bin, compared by ph_histogram_intersection() with each side normalized by its own total.

Measured separability: 4.01, and 3.87 on a second corpus at a different resolution.

Choosing the quantization, since the paper cannot supply it

This is the measured-property regime, the same one wHash is in, and the measurement is the justification. Sixteen candidates, on both corpora; the two agree throughout, which is expected — a histogram does not resample the image, so the resolution confound that distorts §5's numbers does not arise here.

quantization bins separability
RGB 3×3×3 27 2.74
RGB 4×4×4 64 2.38
RGB 5×5×5 125 2.02
HSV 8×4×4 128 3.07
HSV 12×3×3 108 2.90
opponent 4×4×4 64 2.09
opponent 5×5×4 100 2.47
opponent 5×5×5 125 2.83
opponent 6×4×4 96 2.40
opponent 6×5×4 120 2.56
opponent 6×6×2 72 2.67
opponent 6×6×3 108 4.01
opponent 8×4×4 128 2.47
opponent 9×9×1 81 3.92
opponent 6×6×1 36 4.28

The two highest scores were rejected. 6×6×1 and 9×9×1 drop the light–dark axis, which buys invariance to exposure — and makes a black image and a white image produce the same hash, along with every other pair of flat grays. A corpus of colorful pictures never notices; test_color_hash_separates_flat_colours() does, and exists so that a future retuning cannot make that trade quietly. The corpus is never the only instrument: here its best score is the wrong answer.

A perceptually spaced intensity axis measures worse than the uniform one (3.67, and more gray collisions).

What 6 × 6 × 3 still cannot separate, from the same check: flat colors whose chroma matches and whose total intensity falls in the same third — black against dark gray, light gray against white. Three intensity bins is what fits beside 6 × 6 chroma inside PH_DIGEST_MAX_BYTES, and chroma resolution is worth more here than intensity resolution (5×5×5 has no such collisions and separates at 2.83). The limit is asserted as a limit in the tests rather than left to be discovered.

What 6 × 6 × 3 separates too finely: with six bins on each chroma axis, neutral gray is the corner of four bins, not the middle of one, so a tint of one level, invisible, moves a near-gray pixel to another bin. An image with large neutral areas can lose much of its histogram to it (measured).

Delta:

Difference Class Note
Quantization is 6×6×3 of the opponent axes this library's, justified by measurement The paper's own resolution is 16×16×8 = 2048 bins, far past a 128-byte digest.
Bins scaled against the largest bin, not the pixel count deliberate With 108 bins the average bin holds under 1% of the image, which as a fraction of the total quantizes to two or three of 255 levels. The comparison renormalizes each side by its own sum, so nothing depends on the choice.
Intersection normalized by each side's own total deliberate The formula as stated normalizes by the reference histogram, which makes the score asymmetric when the two images hold different pixel counts. A comparison that depends on the order of its arguments is a defect. The two agree whenever the counts match.
Spatial layout discarded entirely inherent to the method An image and a shuffling of its pixels hash identically, and a 90° rotation does not move the hash at all — asserted in the tests, since it is the property the algorithm exists for. Use it alongside a structural hash.
A small local edit barely moves it inherent, measured A global statistic notices a local change at most in proportion to its area: no patch up to 16 % of the frame takes an image below the score that accepts 95 % of copies (measured).

9. ColorMoments

Authors: Markus Stricker and Markus Orengo.

Primary source: "Similarity of color images", Proc. SPIE 2420, Storage and Retrieval for Image and Video Databases III, 1995, pp. 381–392, DOI 10.1117/12.205308. Paywalled and not read. The formulas below come from N. Keen, Color Moments, University of Edinburgh CVonline course notes, 10 February 2005 — rank 4. This is the weakest evidence in this document and the statements below should be re-checked against the paper before anything is changed on their basis.

What the source specifies (per that restatement): three central moments per color channel i over N pixels p_ij —

  • mean E_i = (1/N) Σ p_ij
  • standard deviation σ_i = sqrt( (1/N) Σ (p_ij − E_i)² )
  • skewness s_i = cbrt( (1/N) Σ (p_ij − E_i)³ )

giving a 9-element feature vector; computed in HSV; and compared by a weighted sum of absolute differences, d = Σ_i w_i1|ΔE_i| + w_i2|Δσ_i| + w_i3|Δs_i|, with the weights left to the user.

What this implementation does (src/hashes/color_moments.c): exactly those three formulas, in double, including the signed cube root — cbrt(), not pow(x, 1/3), so a negative third moment is handled correctly. Computed on the raw RGB channels. Each value is then written into an 18-byte digest as a signed 16-bit big-endian fixed-point number in units of 1/128, tagged PH_DIGEST_KIND_VECTOR16.

Delta:

Difference Class Note
Signed skewness kept conforms Skewness measures the direction of asymmetry, and its sign is half the information: stored as signed 16-bit fixed point, so mirror-image distributions differ. A 16-bit field rather than skew + 128 in one byte also gives the finer resolution below. Pinned by test_colour_moments_digest_keeps_the_skew_sign.
Moments computed on RGB, not HSV deliberate The restatement specifies HSV and notes that "alternative encoding could just as easily be used"; this library uses RGB. Unverified against the paper itself (rank 4 source).
16-bit fixed point, scale 128 deliberate The scale of 128 is the largest power of two for which the whole attainable range still encodes: over every distribution an 8-bit channel admits, the extremes are a mean of 255, a σ of 127.5 and a skewness of ±116.85 (two-point distributions are extremal for all three), so 255 is the largest magnitude any moment can take and 255 × 128 = 32640 ≤ 32767. Nothing clamps, and the resolution is 1/128 of a channel level instead of a whole one. A _Static_assert in src/hashes/hashes.h holds the scale to that bound.
Distance is L2 over the nine features, not the weighted L1 of the source deliberate The source leaves the weights to the application, so there is no defined default to conform to. ph_l2_distance() decodes the 16-bit pairs and computes the distance in the moments' own units; reading them as bytes would treat each feature's two halves as independent features, which is why PH_DIGEST_KIND_VECTOR16 is a separate tag rather than a wider PH_DIGEST_KIND_VECTOR.