Changelog¶
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Unreleased¶
Nothing yet.
2.0.2 - 2026-10-15¶
Security¶
- A truncated TGA or HDR file is rejected instead of hashed from uninitialized
memory. The
stb_imagefallback, present in every build, decoded an uncompressed TGA or a narrow HDR whose pixel data ended early withPH_SUCCESS, leaving the missing pixels as whatever the heap or stack held, so the same file hashed differently from load to load. Such a file isPH_ERR_CORRUPT_DATA(GHSA-xjg6-832m-2xwg).
Fixed¶
- A FIFO with no writer is rejected at once.
ph_load_from_file()on such a FIFO waited inopen()for a writer that never came, so the call did not return, and one FIFO among the paths ofph_hash_files()held up its worker and the rest of the batch. It returnsPH_ERR_IO, "not a regular file", as for any other path that is not a regular file (#15). - A static Debug or MSVC install names the libraries it installed. The CMake package
and
libphash.pcnamed the vendored decoder archives by their Release, Unix names, so a consumer could not link: a Debug install haslibpng16d.awhere both saidlibpng16.a, and under MSVClibphash.pcsaid-lwebpdecoder -lzforlibwebpdecoder.libandzlibstatic.lib. Both take each name from the file the build produced, in the configuration that is installed. A prefix holds one configuration; the install guide says so (#14).
2.0.1 - 2026-10-09¶
Added¶
- A documentation site: https://gudoshnikovn.github.io/libphash/. Every algorithm
has a page that follows
ph_compute_*()step by step on one photograph, lays out its digest, and measures it: how far common edits (resizing, JPEG recompression, brightness, crops, rotation) move the hash, how well it separates copies from different images over a synthetic corpus and 200 CC0 photographs, what it costs, and what each parameter changes. Theory pages cover image preparation, comparing hashes and choosing a threshold, and choosing among the nine algorithms; guides cover installing, loading, configuring a context, handling errors, hashing many files, storing and searching hashes, performance and migrating from 1.x. The API reference is generated fromlibphash.h, every function with the error codes it returns. Every figure and measured table is computed by the library being documented, and the build fails when a figure's intermediate stages do not reproduce the library's hash. The source isdocs/, built withmake site. - Four examples, built and run in CI with the others:
load_sources.c(one picture from pixels, from an encoded buffer and from a file, the same hash from each),configure.c(configuring every context alike, and when a setting takes effect),hash_distance.c(four 64-bit hashes of two images from oneph_compute_multi()call each) andfind_duplicates.c(hashing many files with pHash and listing every pair within a threshold).
Changed¶
ph_radial_similarity()is about ten times faster and exact up to its last rounding. It summed the correlation at each shift indouble, 1600 dependent additions for two 40-byte digests (946 ns a comparison on an Apple M3 Pro). The bytes are integers, so every sum is now an exact integer, the peak is taken over the exact numerators, and one division and one square root come last (86.5 ns). The score can differ from the old one in its last bits (at most about 1e-15); it is closer to the exact correlation and no longer depends on the order of a sum, and the documented symmetry holds bit for bit, where the old sums could differ between(a, b)and(b, a)in the last place. Radial digests are unchanged.
Fixed¶
- A load rejected for its arguments leaves no image behind.
ph_load_from_file()with a NULL path, andph_load_from_memory()with a NULL buffer or a length of 0, returnedPH_ERR_INVALID_ARGUMENTbut kept the previously loaded image, so a followingph_compute_*call hashed that image instead of failing. They drop it, as every other failed load does: the context holds no image and a hash returnsPH_ERR_EMPTY_IMAGE. An empty file already behaved this way (PH_ERR_IO); an empty buffer now matches it.ph_load_from_pixels()is unchanged and keeps the previous image on any error. - mHash, BMH and Radial leave the digest untouched when they fail. On a failed
allocation (and, for mHash, a kernel that does not build)
ph_compute_mhash(),ph_compute_bmh()andph_compute_radial_hash()returned the error with the caller's digest already cleared. They write it only on success, as every other hash does, and the header says so for all three and forph_compute_digest(). - A batch item whose hash fails has every slot zeroed. When an image loaded but one of
the requested hashes then failed (
PH_ERR_ALLOCATION_FAILED), the item ofph_hash_files()orph_hash_buffers()kept the hashes computed before the failing one, against the documented promise that every slot of a failed item is zero. It is zero. - A file's error message keeps its reason however long the path. The message of
ph_get_last_error_message()was cut at 159 bytes, and a file message puts the path first, so a path of about 130 bytes or more lost the reason (No such file or directory,file is empty) and kept only the path. The message holds the whole path, up to the longest the system opens (4096 bytes), and the reason after it; a longer path keeps its start and its end around.... - Loading a PNG as grayscale costs what loading it in color does. With
ph_context_set_load_grayscale()on, a color PNG was decoded whole to RGB(A) and folded to gray in a scalar pass, which made the load about 10 % slower than in color (117.7 against 105.6 ms for 20 megapixels on an Apple M3 Pro). Each row is folded as it is decoded, vectorized: 107.3 against 104.7 ms, the difference being the conversion the hash would otherwise make, and the RGB(A) is held one row at a time instead of whole. The gray pixels are the same bytes. - A WebP loaded as grayscale has one channel. With
ph_context_set_load_grayscale()on, every format was loaded as one gray channel except WebP, which libwebp decodes only in color: it came back with three channels, soph_compute_color_hash()andph_compute_color_moments_hash()worked on it instead of returningPH_ERR_REQUIRES_COLOR, and the load saved neither memory nor the conversion. The WebP decoder folds the image to gray with the library's default weights, as the PNG decoder does. The hashes of a grayscale-loaded WebP change in two cases: with an alpha channel the gray is composited instead of the color, which can move a pixel by a level; and weights set withph_context_set_gray_weights()no longer reach it, as they reach no other grayscale load. A WebP without alpha, at the default weights, hashes as before. ph_get_last_error_message()is documented as the detail of the last load. Its doc comment called it the most recent failure on the context, empty until something fails. Only theph_load_from_*()functions write it: each clears it on the way in, and hashes, setters and comparisons leave it as it was, so after a failed hash it holds the previous load's detail, and after a successful load it is empty. The behavior is unchanged.- The pHash hash has
reduction_size² bits, the DC coefficient's included. The doc comment ofph_context_set_phash_params()said the DC coefficient is excluded, givingreduction_size² − 1 bits. The DC coefficient is left out of choosing the threshold only; its bit is in the hash and always set, so the default hash has 64 bits, 63 of which carry information, asph_compute_phash()said. The hashes are unchanged. ph_context_set_whash_remove_max_haar_ll()can change a wHash. Its doc comment said the option does nothing either way. Removing the mean shifts the band and its median alike, but a value that ties with the median is then decided by rounding error; in the fast mode, whose grid holds whole numbers, that happens on about one photograph in ten. The option stays off by default.
2.0.0 - 2026-10-07¶
First major release. It is a major because some changes are invisible at upgrade time:
the same input can produce a different hash, several functions changed signature, and
two error constants were removed. Read the BREAKING CHANGES section before upgrading, and
see MIGRATION.md for the 1.x → 2.0 walkthrough.
BREAKING CHANGES¶
-
ph_compute_color_hash()is a color histogram, returns a 108-byte digest. 1.x returned auint64_tof 42 bits ported from ImageHash'scolorhash, which cites no source. It is 108 bins of the opponent color space (red–green × blue–yellow × light–dark, 6 × 6 × 3), compared withph_histogram_intersection()— a color histogram with histogram intersection, after Swain & Ballard (1991), implemented from secondary descriptions of the paper. The signature takes aph_digest_t *. Restore the old behavior: not possible; recompute any stored ColorHash values. -
ph_compute_mhash()is a Marr–Hildreth hash and returns a 72-byte digest. 1.x returned auint64_t: the sign of a four-neighbor discrete Laplacian on an 18×18 grid. It normalizes the image to 512×512, equalizes it, correlates it with the Laplacian-of-Gaussian operator of Marr and Hildreth, and emits 576 bits — the construction pHash'sph_mh_imagehash()defines. The signature takes aph_digest_t *.ph_context_set_mhash_params()exposes the kernel's scale (alpha,level) and the normalization size; the defaults are the reference implementation's. Restore the old behavior: not possible; recompute any stored mHash values. -
ph_compute_color_moments_hash()returns an 18-byte digest, not 9, and keeps the sign of the skewness. 1.x stored each moment in one unsigned byte, clamped at 255, and stored the skewness asfabs(), so mirror-image channel distributions were identical. Each of the nine features is a signed 16-bit big-endian fixed-point number in units of 1/128 (PH_VECTOR16_SCALE), taggedPH_DIGEST_KIND_VECTOR16. No attainable moment exceeds a magnitude of 255, so nothing clamps.ph_l2_distance()decodes the pairs and returns the distance in the moments' own units. Restore the old behavior: not possible; recompute any stored ColorMoments values. -
ph_digest_tis 136 bytes, not 72, and carries akindtag.PH_DIGEST_MAX_BYTESis 128 (1.x: 64): the Marr–Hildreth hash is 576 bits, and the rest is headroom for the whole 2.x series. The byte aftersizeiskind, aph_digest_kind_tsaying what the bytes are — a bit vector, transform coefficients, a feature vector, a histogram — so a comparison meant for one refuses the others instead of returning a plausible number that means nothing:ph_hamming_distance_digest()returns -1 for a radial or color-moments digest,ph_l2_distance()for a BMH digest. The tag never chooses a metric for you.PH_DIGEST_KIND_UNSPECIFIEDis zero, so a hand-filled struct is accepted everywhere and gets no protection. Restore the old behavior: rebuild any FFI binding that hardcodes the layout; where a comparison returns -1, switch to the metric for that digest. -
aHash reduces the image by an exact area average and sets the bit of a pixel exactly equal to the mean. 1.x resampled to 8×8 with stb's Mitchell filter and cleared a pixel on the mean (
pixel > mean). Each of the 64 values is the mean of the part of the image it covers, which measures better — on the synthetic test corpus separability 3.63 → 4.55 and false matches at 95 % recall 8.0 % → 2.9 %; on 800 photographs fewer pairs of different images share a hash (1208 → 1051) — and the reduction is shared with pHash, wHash and BMH, soph_compute_multi()pays for it once. The tie-break is>=, the rule BMH follows, against the exact mean: an image that is uniform at 8×8 hashes to all ones instead of all zeros. Across 807 photographs and textures about seven in ten aHash values move, half of those by 5 bits or fewer. Restore the old behavior: not possible; recompute stored aHash values. -
pHash thresholds its DCT block at the median plus 0.1 % of the AC coefficients' range, not at the bare median. On an image with little low-frequency structure many AC coefficients crowd the median and their bits followed rounding and noise: ±1 gray level moved pHash by 11 bits or more on 140 of 800 photographs, and flat grays hashed like unrelated images. The margin sends that crowd to 0 together: 98 of 800, and on the same photographs false matches at 95 % recall fall from 14.2 % to 6.8 %. pHash values differ from the reference
ph_dct_imagehash()(and from 1.x) wherever coefficients sit within the margin of the median: across 807 photographs and textures, about three in five, half of those by 5 bits or fewer. Restore the old behavior: not possible; recompute stored pHash values. -
Images with transparency are hashed as they look. 1.x dropped the alpha channel and hashed the color stored under it — invisible, and different from encoder to encoder: two copies differing only in the color under alpha 0 hashed as far apart as unrelated images. Every image with alpha (an alpha channel or a PNG
tRNSchunk, from any decoder, or RGBA passed toph_load_from_pixels()) is composited onto mid-gray at load time, and such copies hash identically. The newph_context_set_alpha_mode()chooses a white or black background instead, orPH_ALPHA_IGNORE. A loaded image never stores alpha:ph_context_get_dimensions()reports 3 channels (1 when loaded as grayscale). Restore the old behavior:ph_context_set_alpha_mode(ctx, PH_ALPHA_IGNORE). -
JPEG is decoded with libjpeg-turbo's accurate IDCT. 1.x used the fast integer IDCT (
TJFLAG_FASTDCT), whose approximation error lands in the hashes: over 120 crops of a camera photo at JPEG quality 50–95 it adds up to 13 % more error energy than the accurate one (at q90–95) and puts dHash, pHash and BMH 1.7–3× further from the hash of the original pixels. Every hash of a JPEG decoded by the native backend can change, by a few bits; the stb_image build, PNG and WebP are unaffected. Decoding costs about 5 % more. Restore the old behavior: not possible — recompute stored JPEG hashes. -
The Block Mean Hash thresholds against the median of the block means, not their arithmetic mean, so most BMH values change. That is what Yang, Gu and Niu's method 1 specifies (step d and equation 3.9), and the median makes the bit distribution balanced by construction — under the mean, a dark image with a few bright blocks produces a lopsided hash. Across 807 photographs and textures four in five values move, half of those by 23 of 256 bits or fewer; on images with skewed block values they move a great deal. BMH values differ from OpenCV's
BlockMeanHash, which thresholds on the mean (in a variable it callsmedian). Restore the old behavior: not possible; recompute any stored BMH digests. -
The Radial hash is reimplemented after its source, and every radial value changes. It takes the variance along one projection line per degree over 180°, standardizes that vector, applies a 1-D DCT and keeps the first 40 coefficients — always a 40-byte digest (De Roover et al. 2005, as pHash implements it). 1.x took 40 angles, no transform, and a digest one byte per angle.
ph_context_set_radial_params()'s first argument is the number of angles (40..4096, default 180), and it takes a new third argument,sigma, the Gaussian blur before the projections (default 3.5, pHash's own default; 1.x used a fixed 3×3 kernel). Gamma follows pHash: default 1.0, pixels normalized by the buffer's maximum and raised togamma(1.x raised them to1/gamma, default 2.2). Compare digests withph_radial_similarity()— the peak of their cross-correlation over cyclic shifts, with the source's threshold asPH_RADIAL_PCC_THRESHOLD(0.9); the bit and vector metrics accept a radial digest but return a number that does not mean what it appears to. The hash tolerates a few degrees of rotation plus an exact half turn (measured on a photograph: 1° → 0.993, 5° → 0.870, 15° → 0.437, 90° → 0.243, 180° → 0.993, against 0.69 for an unrelated image). Restore the old behavior: not possible; recompute any stored radial digests. A caller that sets gamma explicitly must review the value: the same number means the inverse exponent. -
Automatic EXIF orientation is on by default. Images carrying an
Orientationtag other than 1 (most photos straight from phones and cameras) are rotated/mirrored before hashing, so a hash describes what a viewer displays rather than the stored pixels. Every hash you have stored for such an image changes. There is no error and no warning — only a silently lower recall in deduplication, so plan a rehash of the affected corpus. Restore the old behavior:ph_context_set_auto_orient(ctx, 0)afterph_create(). -
PH_ERR_DECODE_FAILEDis removed fromph_error_t. In 1.x it was returned for every load failure; 2.0 returns the specific cause (see "Specific error codes" under Added). Removing the name makes the change visible at compile time. Its value-2is reserved and never reused. Restore the old behavior: not possible, and not desirable — replace the check with the specific codes. A code-to-code mapping table is inMIGRATION.md. -
Hashing a context with no image returns
PH_ERR_EMPTY_IMAGE, andPH_ERR_NOT_IMPLEMENTEDis removed. Everyph_compute_*function reported "no image loaded" asPH_ERR_INVALID_ARGUMENT, indistinguishable from a NULL pointer, whilePH_ERR_EMPTY_IMAGE— the code that describes exactly that — was never returned. Arguments are checked first, so a NULL pointer staysPH_ERR_INVALID_ARGUMENT.PH_ERR_NOT_IMPLEMENTEDwas returned from nowhere; its value-4is reserved like-2. Restore the old behavior: treatPH_ERR_EMPTY_IMAGEthe way you treatedPH_ERR_INVALID_ARGUMENTafter a hash call, and delete any check forPH_ERR_NOT_IMPLEMENTED. SeeMIGRATION.md. -
Every
ph_context_set_*function returnsph_error_tinstead ofvoid, and invalid input is rejected. 1.x ignored an out-of-range value, or, for the gray weights, replaced it with the defaults, so a caller could hash a whole batch with a configuration it never asked for. One contract for all of them: a valid argument returnsPH_SUCCESS; anything else returnsPH_ERR_INVALID_ARGUMENTand leaves the configuration completely unchanged — never clamped, never partially applied, never reset to defaults. Existing call sites still compile (the setters are deliberately notwarn_unused_result). The bounds are implementation limits:gammafinite and in (0.001, 1000]; gray weights each ≥ 0 with a sum in (0, INT_MAX/255];reduction_size4..8 (below 4 most unrelated images share a pHash) anddct_sizereduction_size..32; radialprojections40..4096 (the lower bound is the coefficient count the DCT keeps) andsamples2..4096 (the digest has converged well below both ceilings, and one call at the ceiling costs about 56 ms instead of tens of seconds),sigmain (0, 64/3];block_size2..32 (a single block cannot threshold against a median; 32×32 bits is the largest grid a digest holds — 1.x accepted any positive value and truncated the BMH digest to 64 bytes above 22×22);whash_modea declared enumerator only. Restore the old behavior: not possible — pass values inside the documented bounds, and check the return value wherever the argument comes from outside your own code. -
Public helpers reading a
ph_digest_trejectsize > PH_DIGEST_MAX_BYTESinstead of reading pastdata.ph_digest_tis a flat struct that FFI bindings assemble by hand, and in 1.x, with 64 data bytes,size = 200read 136 bytes past the end of the array. Functions returningph_error_treturnPH_ERR_INVALID_ARGUMENT; distance/similarity functions return-1. Asizeof0is-1from every comparison function (1.x returned0, i.e. "identical", fromph_hamming_distance_digest()andph_l2_distance()for two digests without a bit). Restore the old behavior: not possible — it was an out-of-bounds read. Make sure hand-assembled digests carry asizeof at mostPH_DIGEST_MAX_BYTES. -
The color algorithms refuse grayscale images.
ph_compute_color_hash()andph_compute_color_moments_hash()return the newPH_ERR_REQUIRES_COLORwhen the loaded image has fewer than 3 channels, and leave the output untouched. 1.x read r/g/b out of one replicated channel and returnedPH_SUCCESSwith an outwardly valid but meaningless result. Restore the old behavior: not possible — load the image in color (ph_context_set_load_grayscale(ctx, 0), the default, or pass ¾ channels toph_load_from_pixels()) before asking for a color hash. -
Images above 256 Mi pixels are rejected by default. 1.x decoded any size. 2.0 refuses an image whose width × height exceeds
max_pixels(default 268,435,456) before allocating it, withPH_ERR_IMAGE_TOO_LARGE; seeph_context_set_max_pixels()under Added. Restore the old behavior: raise the limit withph_context_set_max_pixels(); an implementation ceiling ofINT_MAXpixels always applies. -
A single image dimension may not exceed 1000000 pixels. The cap applies to every format and to both
ph_load_from_file()andph_load_from_memory(), on top ofmax_pixelsand independently of it — settingmax_pixelshigher, or to0, does not lift it. The area limit on its own permits an absurd aspect ratio: a 268435456 × 1 image sits exactly on the default limit, yet makes the decoder size a single row of ~800 MB. Real photographs are nowhere near this.ph_load_from_pixels()is not subject to the cap. Restore the old behavior: not possible — split such an image yourself, or decode it with your own decoder and pass the pixels toph_load_from_pixels(). -
Non-regular files are rejected instead of decoded. Passing a FIFO, a character device or
/dev/stdintoph_load_from_file()returnsPH_ERR_IO; 1.x'sstb_imagefallback read such a path. Regular files are unaffected. Restore the old behavior: not possible by path — read the stream into memory yourself and callph_load_from_memory(), the supported way to hash something that is not a file on disk. -
ph_can_use_libjpeg()/ph_can_use_libpng()are renamedph_can_use_jpeg()/ph_can_use_png(), so all three capability checks, withph_can_use_webp(), are named after the format they answer for;ph_get_build_info()names the library behind each. Restore the old behavior: rename the calls; the return values are unchanged. -
Public enums are 32 bits wide under
-fshort-enums. Each public enum ends in a*_FORCE_INT32_enumerator that is not a real value. Under-fshort-enums— the default ABI on ARM EABI — 1.x's enums shrank to one byte, so a library and a consumer built with different settings silently disagreed on everyph_error_treturn value. Only code built with that flag sees a change. Restore the old behavior: not applicable — rebuild against the 2.0 header. -
Shared builds carry a versioned soname,
SOVERSION = 2(libphash.so.2/libphash.2.dylib). Consumers linked against an unversioned 1.x shared library must relink. Restore the old behavior: not applicable — relink against the installed 2.x library. -
The test-only mock decoder is not compiled into the library. 1.x registered, in every build, a backend that claimed any buffer starting with
DE ADand "decoded" it into a 1×1 image. Such a buffer yieldsPH_ERR_UNSUPPORTED_FORMAT. Restore the old behavior: configure with-DPHASH_ENABLE_MOCK_BACKEND=ON(OFF by default; it warns at configure time). It is for testing only and must not be enabled in a shipped build. -
The spng PNG backend is removed; libpng is the native PNG decoder. 1.x decoded PNG with spng when configured with
-DPHASH_USE_SPNG=ON -DPHASH_USE_LIBPNG=OFF. spng is no faster than libpng — within a few percent on 8-bit color images, 30–70% slower on 16-bit and grayscale ones, on x86-64 and arm64 — and its last release is v0.7.4 of May 2023. CMake reports-DPHASH_USE_SPNGas an unused variable. Restore the old behavior: not possible — drop the flag; the default build decodes PNG with libpng, and-DPHASH_USE_LIBPNG=OFFdecodes it withstb_image. -
The JPEG backend option is
PHASH_USE_LIBJPEG_TURBO(1.x:PHASH_USE_TURBOJPEG), named after the codec. CMake reports the old name as an unused variable and builds the default backend set, so-DPHASH_USE_TURBOJPEG=OFFbuilds with libjpeg-turbo. Restore the old behavior: not applicable — pass-DPHASH_USE_LIBJPEG_TURBO=….
Added¶
-
Prebuilt release artifacts. Each tagged release publishes static and shared archives (headers, library,
LICENSE,THIRD-PARTY-NOTICES.md) for linux-x86_64, linux-arm64, macos-arm64 and windows-x86_64 on the GitHub Releases page, each built with the full vendored decoder set and smoke-tested in isolation before publishing. The archives are reproducible: rebuilt from the same tag with the same toolchain (for windows-x86_64, also in the same build directory), they matchSHA256SUMS.txtbyte for byte. Each carries a build provenance attestation thatgh attestation verifychecks against this repository's release workflow (SECURITY.md). See the README's "Prebuilt binaries" section. -
Batch API.
ph_hash_files()andph_hash_buffers()hash a batch of files or in-memory buffers, optionally across an internal thread pool (threads: 0 = one worker per CPU the process may use — the affinity mask on Linux and Windows, the cgroup CPU quota on Linux; 1 = sequential on the calling thread; >1 = that many workers). On Windows the automatic count covers the current processor group (at most 64 logical processors); pass an explicit count for more. Per-item failures are reported inph_batch_item_t::status/ph_batch_buffer_item_t::statusand never abort the batch; the return value reports only failures that stopped the batch from being worked on at all, and the arguments are validated even for an empty batch. Each item'shashes[]holdsPH_BATCH_HASHES_CAPACITY(8) slots rather than one per currently defined algorithm, so newuint64_talgorithms can be added inside 2.x without changing the structs' size or layout. Peak memory grows linearly with the worker count; the header documents the bound. ph_hash_files_ex()/ph_hash_buffers_ex()take aph_batch_options_t(initialize it withph_batch_options_init()): a template context whose whole configuration,max_pixelsincluded, applies to every item; ashould_continuecallback that stops the batch, returning the newPH_ERR_CANCELLEDand storing it in every item not started; and anon_progresscallback. The plainph_hash_files()/ph_hash_buffers()run on the default configuration.ph_compute_multi()computes several of the fouruint64_talgorithms (aHash, dHash, pHash, wHash) in one call, selected by aph_hash_flags_tbitmask. The work over the full image is cached on the context and done once whichever algorithms ask for it — the grayscale conversion and one area-average pass that aHash, pHash, wHash and BMH all reduce from — so on a 20-megapixel photograph the four cost 7.6 ms rather than 15.8. Results are bit-for-bit identical to the individualph_compute_*calls.- Algorithms as values.
ph_algorithm_tnames every algorithm (contiguous from 0 toPH_ALGORITHM_COUNT - 1; for the fouruint64_tones theph_hash_flags_tbit is1 << value).ph_compute_digest()computes any of them into aph_digest_t— theuint64_talgorithms as 8-bytebitsdigests — so one code path can store and compare every algorithm.ph_digest_info()reports a digest's size and kind without an image.ph_algorithm_name()/ph_algorithm_from_name()convert to and from stable names such as"radial". ph_load_from_pixels()hashes an already-decoded pixel buffer (OpenCV, PIL, numpy, a video frame), skipping the encode/decode round-trip. Accepts 1, 3 or 4 channels and an arbitrary row stride; themax_pixelslimit applies to it too.ph_radial_similarity()andph_histogram_intersection(), the comparisons the Radial hash and ColorHash are defined with (see BREAKING CHANGES). An image with no angular structure (blank, or radially symmetric: a variance profile spread across angles by less than 1 % of its mean) hashes to an all-zero radial digest, andph_radial_similarity()answers any comparison with it withPH_ERR_NO_STRUCTURErather than a score — the threshold is relative, so a faint pattern still gets a digest of its own. Histogram intersection is computed exactly: identical or proportionally scaled histograms score exactly1.0, swapping the arguments gives a bit-identical result, and the value is the same on every platform.- Hex and similarity helpers:
ph_digest_to_hex(),ph_digest_from_hex(),ph_hash_to_hex(),ph_hash_from_hex(),ph_similarity(),ph_similarity_digest(). A digest's text form is<kind>:<hex>(e.g.coefficients:1f80…), and decoding restores the kind, so digests read back from storage still refuse the wrong comparison metric. Hex digits are accepted in either case.PH_DIGEST_HEX_BUFFER_SIZEis a buffer size that fits any digest. ph_context_get_gray_weights()reads back the grayscale weights a context uses — the normalized values, which differ from what was passed to the setter.ph_context_set_decode_scale()lets a caller opt into decoding JPEG at ½, ¼ or ⅛ linear resolution instead of natively, trading accuracy for decode speed via libjpeg-turbo's DCT-domain scaling. Default isPH_DECODE_SCALE_FULL. Only the JPEG backend honors it; PNG has no format-level scaled decode and libwebp's scaling API resizes after a full decode (no decode-time saving), so both backends decode at full resolution regardless of the setting. Measured tradeoffs (on a 20-megapixel photo, from full to ⅛, the decode alone is 1.3× faster since entropy decoding is not skipped, while load plus pHash is 1.4× and load plus Radial 2.5×; accuracy holds on photographic content but pHash and mHash can exceed this library's own same-scene-transform contract on fine periodic textures) are in the setter's doc comment ininclude/libphash.h. Radial, ColorMoments and ColorHash are not resize-based, so at any scale other than full they operate directly on the reduced buffer as their actual input — their accuracy at reduced scale has not been measured.ph_context_set_whash_remove_max_haar_ll()exposes ImageHash'sremove_max_haar_ll, which zeroes the coarsest Haar LL band before the working decomposition. It defaults to off, and turning it on does not change a bit except by rounding a tie: zeroing that single coefficient and reconstructing subtracts the image mean from every sample, a constant subtraction shifts the working LL band and its median alike, and a hash thresholded at the median is blind to it — equally true of ImageHash, where the option is on by default. The setter exists for callers who need to mirror ImageHash's configuration.ph_context_set_max_pixels()— a decompression-bomb guard applied before any pixel buffer is allocated, in every decoder and every build. Defaults to 256 Mi pixels; exceeding it fails withPH_ERR_IMAGE_TOO_LARGE.0removes the caller's own limit, but an implementation ceiling ofINT_MAX(2147483647) pixels always applies and also caps any larger configured value, because pixel indexing inside the library is done inint.- EXIF/metadata orientation, applied automatically by default (see BREAKING CHANGES)
and controllable via
ph_context_set_auto_orient(). Orientation is read from JPEG APP1, the WebPEXIFchunk and the PNGeXIfchunk. Missing or malformed metadata is treated as "no transform needed", never as an error; an allocation failure while applying a real orientation fails the load withPH_ERR_ALLOCATION_FAILED, so an image is never hashed in an orientation the caller did not ask for. - Specific error codes replacing 1.x's single decode failure:
PH_ERR_IMAGE_TOO_LARGE,PH_ERR_UNSUPPORTED_FORMAT,PH_ERR_CORRUPT_DATA,PH_ERR_DECODER_UNAVAILABLE,PH_ERR_IO, plusPH_ERR_REQUIRES_COLOR,PH_ERR_CANCELLEDandPH_ERR_NO_STRUCTURE. Every build and every decoder answers the same input with the same code: PH_ERR_IO— a missing path (including a dangling symlink), no read permission, a non-regular file, an empty file, or a file larger than the address space (a 32-bit build fed something above 4 GB), decided before any decoder sees the bytes;PH_ERR_IMAGE_TOO_LARGE— abovemax_pixels, the per-dimension cap or theINT_MAXceiling, or encoded input overINT_MAXbytes;PH_ERR_ALLOCATION_FAILED— a decoder ran out of memory, in every backend (the vendoredstb_imageis patched locally so its fallback path reports this too);PH_ERR_DECODER_UNAVAILABLE— a WebP file in a build withoutPHASH_USE_WEBP.ph_error_tdocuments its ABI rule: values are spelled out explicitly, new codes are only appended, and a value is never reused.ph_get_last_error_message()returns a short diagnostic string about the most recent failure on a context (e.g. the decoder-reported reason a load failed). Every decoder backend fills it on every failure path, and the message is always valid UTF-8, cut on a character boundary.ph_get_build_info()returns one line describing how the library was built — version, JPEG/PNG/WebP backends, zlib, whether the batch functions can use threads, the SIMD target, and whether the test-only mock decoder is compiled in — for logs and bug reports.ph_version_number()and thephash_version.hheader, included bylibphash.h: the version as one comparable integer,major*1000000 + minor*1000 + patch(2.0.0 → 2000000), at run time and asPH_VERSION_NUMBERat compile time. The two headers are installed side by side; code that copies the header by hand needs both.- Packaging:
install()rules, aphashConfig.cmakepackage usable viafind_package(phash)(COMPATIBILITY SameMajorVersion), and alibphash.pcfor pkg-config that finds its files relative to its own location, so a plainpkg-config --cflags --libs libphashworks on an install tree that was moved or unpacked elsewhere. A shared build's.pcadds an rpath to${libdir}, so its consumers start withoutLD_LIBRARY_PATH/DYLD_LIBRARY_PATH. Both carry the backend libraries the build was configured with (a static build's.pcinLibs), the vendored JPEG codec installed as-lphash_jpegso it cannot shadow a system libjpeg.PHASH_STATIC_DEFINEis the macro a build system other than this project's own CMake must define when linking libphash statically on Windows; the exported target and the.pcfile set it automatically.add_subdirectory()works for static and shared parents and leaves the parent project's settings and cache as it found them. CMakePresets.jsonwith a configure, build and test preset for each configuration CI builds —release,shared,minimal,asan,tsan,fuzzand others — each in its ownbuild/<preset>:cmake --preset release && cmake --build --preset release && ctest --preset release. The compiler comes fromCC.make install/make uninstallfor the Makefile build:libphash.a, the two headers and a relocatablelibphash.pcunderPREFIX(default/usr/local;DESTDIRfor staging).PHASH_USE_ZLIB_NGbuild option to build libpng against the vendored zlib-ng, safe for concurrent first decodes; a build without libpng does not configure it. The JPEG backend decodes through libjpeg-turbo's libjpeg API, whose archive carries no zlib of its own to compete with it.PHASH_STRICT_DEPSbuild option turning the "vendored decoder submodules are not checked out" warning into a configure-time error, so a green build proves the vendored decoders were built and linked.docs/algorithm-provenance.mdtraces each of the nine hashes to its primary source and records, per algorithm, where this implementation departs from it; wHash has no primary source.docs/references.mdis the matching bibliography, and everysrc/hashes/*.cfile opens with its own citation.- A stated scope and threat model in the README: the library is for deduplicating a collection you control. Every hash here is deterministic and unkeyed, which is what makes deduplication work and what makes the hashes straightforward to defeat deliberately; do not use them as a moderation filter, a copyright blocklist, or an integrity check on untrusted input.
- Fuzzing and sanitizers: a libFuzzer harness for the decode path
(
PHASH_BUILD_FUZZERS) and ASan/UBSan CI jobs. - CI across platforms: Linux x86_64 (gcc and clang), Linux arm64 and macOS arm64 for
the full build — so the NEON paths actually run — plus a minimal (stb_image-only) matrix
over Linux/macOS/Windows that exercises the MSVC path, an
install()/pkg-config/find_packagesmoke test, anadd_subdirectory()smoke test, and a benchmark job that gates on a regression against the base branch. - A native linux/arm64 Docker development environment.
- Tests: a perceptual robustness suite and a golden-hash regression suite.
Changed¶
- Vendored decoder submodules are at their latest stable tags.
THIRD-PARTY-NOTICES.mdnames the exact version of the two copied stb headers (stb_imagev2.30,stb_image_resize2v2.18), records that both are modified copies, and gives two hashes for each: the file as vendored and the upstream file it was derived from.- pHash excludes the DC coefficient from the median it thresholds against, as pHash's
own
ph_dct_imagehash()does; 1.x included it. A median is not dragged by an outlier, so this can move a bit only on an exact tie: across 807 photographs no pHash value changes against a 1.x build without FMA contraction. ph_load_from_file()opens the file once (1.x could open it twice) and decodes through exactly the same code path asph_load_from_memory(), so auto-orientation, the size limits and format detection behave identically for a path and a buffer. It holds the encoded bytes in memory (mapped where the platform allows it) alongside the decoded image, where 1.x'sstb_imagepath read them incrementally; for ordinary images the encoded bytes are a small fraction of the decoded ones, but callers hashing very large files on a tight memory budget should be aware of it.- CMake pins
CMAKE_C_STANDARDto 17 (1.x left it to the compiler's default), withCMAKE_C_EXTENSIONSoff; the Makefile uses-std=c17too. The minimum CMake version is 3.21 (1.x: 3.10), needed to recognize the C17/C23 standard values. - Each CMake build type gets its own optimization level (
Debugunoptimised,Release-O3,RelWithDebInfo-O2,MinSizeRel-Os); 1.x forced-O3on top of every build type. A standalone configure that names no build type defaults toRelease; underadd_subdirectory()the parent's build type applies. The project's warning flags are not applied to the vendored decoders. libphash.h's include guard isPH_LIBPHASH_H(1.x:LIBPHASH_H).- The shared library exports only the functions of
libphash.h. 1.x exported every internal helper, the bundledstb_image, and all of the libjpeg-turbo, libpng and libwebp linked into it, so an application with its own copy of any of those got whichever the dynamic linker found first. Both build systems compile with-fvisibility=hidden, which also keeps the internals out of a shared library a consumer builds from the static archive. SeeMIGRATION.md. - Clang is the Makefile's default compiler (1.x:
gcc);CC=gcc makeoverrides it. - A clone without its submodules configures and builds. 1.x stopped with a cascade of
CMake errors from inside
add_subdirectory()whenvendor/libpngorvendor/libwebpwas empty, none of them naming the cause. One warning now lists every enabled decoder whose submodule is missing, withgit submodule update --init --recursive, and the build goes on with stb_image for JPEG and PNG, no WebP decoder and the system zlib;-DPHASH_STRICT_DEPS=ONmakes the same message an error. - CMake builds the vendored libjpeg-turbo itself, inside each build tree. 1.x looked for
a
libturbojpeg.athat had to be built by hand invendor/libjpeg-turbo/buildand fell back to stb_image without it; every build tree then shared that one archive, whatever compiler, flags or architecture it was built for. libjpeg-turbo is now configured with the build tree's compiler, C flags, build type, toolchain file andCMAKE_OSX_*, so a gcc, sanitizer or cross build gets a matching decoder, and nothing is written intovendor/. make debuginheritsCFLAGSinstead of replacing it.- libphash builds with
-ffp-contract=off, so GCC and Clang builds produce identical hashes on arm64. On arm64 a 1.x build made by a compiler that contracts multiply-adds into FMA (Clang's default) can give different pHash values on some images (20 of 807 photographs, all PNG, in one measurement); x86-64 hashes are unaffected. - An x86-64 build does not require AVX2. 1.x compiled the Hamming distance with
-mavx2whenever the compiler accepted it, so the CMake build and anything packaged from it died with an illegal instruction on a CPU without AVX2. The x86-64 baseline is SSE4.2 with POPCNT (x86-64-v2), and it is stated in the README. - Architecture flags follow the target compiler, not the build host. 1.x chose them
from the host's processor name, so a build for another architecture on the same machine
(
CMAKE_OSX_ARCHITECTURES=x86_64on Apple silicon, a macOS universal build) failed on a foreign-march, and the Makefile gave Linux arm64 (aarch64) no flag while naming one for macOS. arm64 gets no architecture flag at all: Advanced SIMD is part of the base architecture. A universal macOS build works with every bundled decoder except libjpeg-turbo, which it refuses at configure time. The Makefile takesEXTRA_CFLAGS/EXTRA_LDFLAGS, appended to its own, somake EXTRA_CFLAGS=-m32 EXTRA_LDFLAGS=-m32builds 32-bit x86 on a 64-bit host. - Minimum supported 32-bit x86 CPU is one with SSE2 (~Pentium 4/Athlon 64, 2000-2003
onward). Both build systems force
-mfpmath=sse -msse2there to avoid x87 extended-precision float math, whose results depend on the compiler/CPU in a way SSE2's don't — see "Fixed". Every other architecture this library targets is unaffected. -
The same image hashes to the same values on every platform measured — arm64 and x86-64, Linux and macOS, GCC and Clang, and 32-bit x86 with SSE2 — whichever PNG decoder the build uses. The one difference between builds is the JPEG decoder: libjpeg-turbo and stb_image round their IDCT differently, so a JPEG reaches the hashes as slightly different pixels. 1.x differed between architectures on near-uniform images: pHash's DCT summed in a different order on arm64, and its bare median threshold turned the rounding into flipped bits.
-
The bundled libjpeg-turbo is 3.2.0 (1.10.4 bundled 3.1.4).
Fixed¶
- A truncated JPEG or PNG loaded successfully when decoded by
stb_image. It decoded what it could read of a half-downloaded file and reported success, while the native decoders rejected the same bytes. Every build requires the file to reach its own end (JPEG end-of-image marker, PNGIEND, the WebP RIFF size) and returnsPH_ERR_CORRUPT_DATAotherwise. Data after that end is still accepted. - A PNG with a damaged chunk checksum loaded successfully unless libpng decoded it.
stb_image (the Makefile build) reads no checksum, so a corrupted file got a hash in one
build and an error in another. A bad checksum on a critical chunk (
IHDR,PLTE,IDAT,IEND) returnsPH_ERR_CORRUPT_DATAin every build; an ancillary chunk with a bad checksum is dropped and the image decodes, as libpng does. - An encoded buffer over 2 GiB was decoded from a truncated length. On the
stb_imagepath the length was cut to anint: a valid image in a 2 GiB + 4 KiB buffer was reported as not an image, and past 4 GiB the length wrapped and the start of the buffer was decoded as if it were the whole file. Encoded input overINT_MAXbytes fails withPH_ERR_IMAGE_TOO_LARGEin every build. - A file declaring a zero width or height loaded as an image with no pixels. An HDR
header of
+X 0(or a widthstrtol()reads as 0) decoded "successfully", and every hash then described nothing. A decoded image without a positive width and height fails withPH_ERR_CORRUPT_DATA, whichever decoder produced it. - pHash with an odd
dct_sizeread and wrote misalignedfloats. Every odd value from 3 to 31 placed the DCT buffers at an odd address: undefined behavior, reported by UBSan and a crash on strict-alignment targets. Hash values do not change. ph_compute_phash()returned a hash computed from uninitialized memory when the DCT parameters were out of range; out-of-range parameters are rejected.ph_context_get_dimensions()reported an image that was no longer loaded. A failedph_load_from_file()/ph_load_from_memory()drops the previous image, but its width, height and channel count stayed behind; they are reset to 0 with it. It andph_is_loaded()take aconst ph_context_t *.- Resizing and Gaussian blur reported an allocation failure as
PH_SUCCESSwith a hash computed over garbage. aHash, dHash, pHash, wHash, mHash, BMH and Radial returnPH_ERR_ALLOCATION_FAILEDinstead. No hash value changes on the success path. - Pixel counts were computed in
intand could overflow (undefined behavior); they are computed insize_t. - A gray + alpha image decoded by stb_image read one byte past its pixel buffer and hashed a mixture of gray and alpha values, in every build without a native PNG decoder (the Makefile build). stb_image also hands back a grayscale PNG as one channel, so ColorHash and ColorMoments refused an image that a libpng build accepted, and grayscale loading used stb_image's own gray weights instead of the library's. libpng, for its part, converted a 16-bit color PNG to gray before reducing it to 8 bits, a level away from the other decoders. Every PNG decoder produces the same channel layout and the same gray values.
ph_hamming_distance_digest()silently undercounted on x86_64 for a digest whose size in bytes wasn't a multiple of 32: the AVX2 loop advanced its index as a byte offset, but the SSE4.2 loop after it compared that index against a word count, so the last bytes of the digest were dropped from the popcount without any error. A 32-bit x86 build with AVX2 available failed to link, on an intrinsic that exists only on x86-64. Both lived in hand-written vector paths that bought nothing on a digest of at most 128 bytes; the function is one portable loop over 64-bit words on every target.- 32-bit x86 (
i686) builds could produce a different hash than a 64-bit build for the same image, including a degenerate all-zero pHash for a uniform-color input that a 64-bit build hashes normally: GCC/Clang default to x87 extended-precision intermediates on 32-bit x86. See "Changed" for the SSE2 floor. - The library did not compile for any target where
size_tis notunsigned long. The native decoder backends declared their compressed-buffer length asunsigned longwhile the backend table declares it assize_t: GCC 14+/Clang 16+ reject that on a 32-bit target, and on Windows x64 it compiled but the decoder read a 32-bit length out of a 64-bit one. Buffer lengths aresize_tthroughout. libphash.hcould not be included by GCC in C23 mode — and, since GCC 15 defaults to C23, by any program compiled with plaingcc.PH_NODISCARDstood after the visibility attribute, a position C23 does not allow; the attributes are in the conforming order.- A Windows build could not succeed with a native toolchain. A static build failed to
compile (
PH_APIhad no case for a static library), and a build with any of the bundled decoders failed on POSIX-only file-mapping headers,<stdatomic.h>under MSVC, the missingM_PIandlibm, and Unix archive naming in the link paths. Both linkages build with MSVC; seePHASH_STATIC_DEFINEunder Added. - PNG decoder:
setjmpis armed before the info struct is created, so an early libpng error does not longjmp into an unprepared state; therow_ptrsallocation is checked for overflow; an error while reading the pixels frees every buffer the decoder allocated. add_subdirectory()forced libpng'sPNG_SHARED/PNG_STATICinto the parent project's cache; see Packaging under Added.- The vendored
stb_image_resize2crashed or leaked when one of its internal allocations failed under AddressSanitizer's separate-allocation mode. Patched locally pending an upstream fix.
Security¶
- Decompression bombs are rejected before any pixel buffer is allocated (1.x had no
size limit), via a configurable
max_pixelslimit (256 Mi pixels by default), an unconditionalINT_MAX-pixel implementation ceiling, and a per-dimension cap of 1000000 pixels that nomax_pixelssetting can lift. - Heap overflow / signed overflow reachable through
intpixel-count arithmetic on large images. Fixed by the ceiling and bysize_tarithmetic. - Out-of-bounds read in every public helper that reads a
ph_digest_t: a hand-assembled digest withsize > 64read past the end of the struct. Rejected. - The mock decoder does not ship. 1.x registered it in every build, ahead of the real
catch-all backend, where it intercepted any input beginning with
DE AD. - Heap out-of-bounds write / segfault in the Block Mean Hash on a 32-bit build:
block_size * block_sizewas cast tosize_tbefore multiplying, which only prevents overflow wheresize_tis wider thanint— on 32-bitsize_tit wrapped and produced a 1-byte scratch buffer that the resize step then wrote a full block into. The size goes through the library's overflow-checked allocation-size helper. - PNG decoder hardening: overflow-checked
row_ptrsallocation, the dimension cap applied straight from the IHDR, andsetjmparmed before the info struct exists. - The decode path is fuzzed (libFuzzer) and built under ASan/UBSan in CI.
- The bundled libpng is 1.6.59 (1.10.4 bundled 1.6.56): it fixes CVE-2026-46675, a
use-after-free in
png_read_end()after an incompletezTXt,iTXtoriCCPdecompression, and a double free after an allocation failure.
1.10.4 - 2026-04-19¶
Changed¶
- Version bump only; no functional change over 1.10.3.
1.10.3 - 2026-04-19¶
Changed¶
- Resizing moved to the vendored
stb_image_resize2, with Lanczos as the filter; the previous hand-written bilinear and mipmap resize paths were removed. - Vendored decoder submodules updated to stable tags.
- THIRD-PARTY-NOTICES updated.
1.10.2 - 2026-03-31¶
Fixed¶
- wHash accuracy: switched to box resizing for better feature preservation.
ph_median_bitpack()computed the wrong median for even-sized arrays.
1.10.1 - 2026-03-30¶
Changed¶
- Version bump only; no functional change over 1.10.0.
1.10.0 - 2026-03-30¶
Added¶
- Native libwebp decoder backend.
ph_get_error_string()for human-readable error descriptions.- Unit tests for the hash algorithms and math-rigor tests for DCT, median and HSV classification, bringing coverage above 95%.
- CI with benchmark comparison.
- THIRD-PARTY-NOTICES.
Changed¶
- Complete modular reorganization of the sources (
src/hashes/,src/loaders/,src/image/). - SIMD optimizations for Linux and x86_64; Radial hash optimized and wHash modes benchmarked.
- The scratchpad is trimmed automatically to prevent unbounded memory growth.
Fixed¶
- Several architectural and mathematical bugs across the hash algorithms.
- Global AVX2 and LTO were reverted after they caused a performance regression.
1.9.0 - 2026-02-23¶
Earlier releases (1.0.0 – 1.9.0) predate this changelog. See the git history and the release tags for details.