Added
Bundled libjpeg-turbo (TurboJPEG API)
- Added
libjpeg-turbo v3.1.3 as git submodule (vendor/libjpeg-turbo)
- Statically linked
libturbojpeg.a with ARM NEON SIMD and x86 SSE/AVX2
- New decoder function
ph_decode_jpeg_tj() using TurboJPEG tjDecompress2 — a single-call full-image decompressor replacing the per-scanline jpeg_read_scanlines approach
- Enabled
TJFLAG_FASTDCT (fast integer iDCT, ~14% faster, negligible quality impact)
Bundled libpng
- Added
libpng v1.6.55 as git submodule (vendor/libpng)
- Statically linked
libpng16.a with ARM NEON hardware-optimized filter processing
- New decoder function
ph_decode_png_mem() using png_set_read_fn for zero-copy memory-based reading from mmap'd buffers, replacing FILE*-based I/O (fdopen → png_init_io)
Bundled spng (alternative PNG decoder for x86)
- Added
spng v0.7.4 as git submodule (vendor/spng)
- Single-file decoder (
spng.c) compiled alongside library sources — no separate build step
- Uses
spng_set_png_buffer → spng_decode_image(SPNG_FMT_RGB8) for single-call decoding
- 43% faster than libpng on x86/Linux (300 MP/s vs 209 MP/s, Zen 4 benchmarks)
- On ARM/Mac: same speed as libpng (bottleneck is zlib inflate, not filter processing)
Compile-time build flags
PH_USE_TURBOJPEG — enables bundled libjpeg-turbo (default: ON)
PH_USE_LIBPNG — enables bundled libpng for PNG (default: ON on ARM)
PH_USE_SPNG — enables bundled spng for PNG (default: ON on x86)
PH_USE_LIBPNG and PH_USE_SPNG are mutually exclusive
- When all disabled:
stb_image handles JPEG and PNG (zero external dependencies)
- CMake options:
PHASH_USE_TURBOJPEG, PHASH_USE_LIBPNG (in CMakeLists.txt)
Benchmarking Consolidation
- Consolidated
bench_raw.c and benchmark.c into a single, high-resolution utility: tests/test_benchmark.c
- Added high-resolution timing support (Mach/Monotonic) for micro-benchmarking
- New multi-mode CLI:
hash: Per-algorithm performance on a single image
dir: Bulk loading throughput for a directory
full: End-to-end pipeline (load + hash) performance
- Integrated
benchmark_smoke into CTest and make test for performance regression tracking
Structured Technical Documentation
- Created a comprehensive
docs/ directory with structured technical guides:
architecture.md: System design and component breakdown
development.md: Coding standards, testing procedures, and naming conventions
algorithms.md: Mathematical foundations of the implemented hashes
- Updated root
README.md to act as an entry point for technical deep-dives
CMakeLists.txt
- Full CMake build system with
add_subdirectory for vendor libraries
- Architecture-specific compiler flags (
-msse4.2 on x86, -march=armv8-a+simd on ARM)
- Test target auto-discovery from
tests/test_*.c
- Added
benchmark_smoke test and performance verification targets
Makefile Refinement
- Updated
Makefile to support compiler overrides via CC ?= gcc (e.g., make CC=clang)
- Improved architecture-specific flag detection (SSE4.2/NEON)
- Synchronized with the new
test_*.c naming convention for automated test discovery
Improved
JPEG decoding: 3.6× faster (500 images: 0.51s → 0.14s)
- Replaced
dlopen/dlsym runtime loading of system libjpeg.dylib with static libturbojpeg.a
- Replaced
jpeg_stdio_src (FILE* with 4KB fread syscalls) with mmap() + jpeg_mem_src (zero-copy, zero syscalls)
- Replaced per-scanline
jpeg_read_scanlines API with single-call tjDecompress2 (eliminates per-line function call overhead)
- NEON SIMD acceleration for iDCT and YCbCr→RGB color conversion on Apple Silicon
- End-to-end result: 2.6× faster than Pillow/imagehash for load+aHash (500 JPEG images)
PNG decoding: eliminated FILE* overhead
- Replaced
fdopen(fd, "rb") → png_init_io(f) with direct mmap() → png_set_read_fn() memory reading
- Eliminated
fdopen/fclose syscall overhead and FILE* buffering layer
- PNG load time: 1.3× faster than Pillow (500 PNG images: 0.88s vs 1.14s)
Code simplification
loader.h: reduced from 200+ lines (ABI-compat structs, function pointer tables) to 40 lines (clean declarations)
loader.c: reduced from 350+ lines (dlopen, dlsym, error handling, function pointer dispatch) to 210 lines (three clean decoders behind #ifdef guards)
- Removed all
dlerror, dlclose cleanup code
- Removed all
jpeg_* / png_* function pointer typedefs and manual ABI struct definitions
ph_load_from_file in core.c: unified mmap path for both JPEG and PNG with clean #ifdef conditional compilation
File I/O architecture
- All image files are now
mmap()'d once, then the raw pointer is passed directly to the decoder
- Unified flow:
open() → fstat() → mmap() → decode → munmap() → close()
- Fallback to
stbi_load() for unsupported formats or when bundled decoders are disabled
Benchmarks
JPEG (500 augmented images, Mac M-series)
| Metric |
Before (dlopen libjpeg) |
After (static TurboJPEG) |
Speedup |
| Raw C load |
0.51s |
0.14s |
3.6× |
| Python load+aHash |
0.53s |
0.16s |
3.3× |
| vs Pillow (load only) |
1.75× slower |
1.9× faster |
— |
| vs imagehash (load+aHash) |
~same |
2.6× faster |
— |
PNG (500 augmented images, Mac M-series)
| Metric |
libphash (libpng) |
Pillow |
Speedup |
| Load only |
0.88s |
1.14s |
1.30× |
| aHash |
0.88s |
1.42s |
1.62× |
Hash quality (PR-AUC, unchanged)
| Algorithm |
imagehash |
libphash |
| aHash |
0.626 |
0.617 |
| pHash |
0.608 |
0.616 |
| dHash |
0.598 |
0.581 |
Fixed
- New mmap-based flow has deterministic cleanup:
munmap() + close() always called in the same code path regardless of decode success/failure
Build and Header Fixes
- Fixed circular dependency/conflict in
ph_can_use_libjpeg declaration between loader.h and public headers.
- Fixed incorrect include paths for
turbojpeg.h and png.h when using bundled submodules.
- Standardized
fPIC and architecture-specific flags in CMake for consistent cross-compilation.
- Fixed
pHash stability thresholds in test_stability.c to accommodate subtle grayscale conversion differences in minimal/fallback build modes.
- Ensured consistent ITU-R BT.601 weights for internal grayscale conversion across all high-performance decoders.
Technical Details
Zero-Copy I/O Implementation
The high-performance loading path (ph_load_from_file) now follows a zero-copy philosophy:
- File opened via
open(O_RDONLY).
- Mapped into process memory using
mmap().
- Header check (first 8 bytes) performed in-memory.
- Pointer passed directly to TurboJPEG or libpng/spng.
- Decoder decompresses directly into the context's pixel buffer.
This bypasses memory copies between kernel space and user space, and between the FILE* buffer and the decoder.
Compile-time Optimization
- iDCT: Enabled
TJFLAG_FASTDCT for JPEG decoding. This uses integer math instead of floating-point for the Inverse Discrete Cosine Transform, significantly improving speed on ARM and older x86 CPUs with negligible impact on hash stability.
- Filter Heuristics: libpng is compiled with hardware-accelerated NEON/SSE filter functions, which speed up the "unfiltering" step of PNG decoding.
Migration from v1.8.x
Internal Changes
- No more runtime searching:
ph_can_use_libjpeg() no longer scans the system for .so or .dylib files. It returns a constant 1 or 0 determined at compile-time.
- Header simplified:
loader.h is no longer a public-facing header for ABI compatibility; it is now an internal private header.
- Binary size: Expect a slight increase (~300-500KB) in the static library size due to the bundling of
libjpeg-turbo and libpng.
- Version bump: Upgraded to v1.9.0 with major performance optimizations.
Build System
- Requires CMake 3.10+.
- Bundled submodules must be initialized:
git submodule update --init --recursive.
- If building in a restricted environment, use
PHASH_USE_TURBOJPEG=OFF to disable bundled dependencies.
What's Changed
- release: v1.9.0 - Major performance optimizations and technical docs by @gudoshnikovn in #8
Full Changelog: 1.8.1...1.9.0