Skip to content

Commit 095485a

Browse files
fix: critical bug fixes and improvements from code review
- Fix DCT-II implementation with proper twiddle factors - Fix duplicate TLS contexts (single shared context) - Fix unsafe set_len with safe resize - Fix EXIF orientation (read from raw bytes before decode) - Fix similarity consistency (block-level in MultiHashFingerprint) - Fix serde feature gating (properly optional) - Add model_id support to Embedding - Add kamadak-exif dependency - Update CHANGELOG, README, USAGE docs
1 parent aca94db commit 095485a

12 files changed

Lines changed: 276 additions & 62 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121
- Fixed buffer reclamation order bug in image preprocessing
2222
- Fixed `apply_orientation()` unnecessarily cloning entire image - now uses `Cow::Borrowed` to avoid expensive clone when no EXIF orientation transform is needed
2323
- Fixed SIMD CPU extension detection being performed on every `Preprocessor::new()` call - now cached globally using `OnceLock` for better performance
24+
- **Fixed DCT-II implementation** in PHash: Added proper twiddle factors for mathematically correct DCT-II computation
25+
- **Fixed duplicate thread-local contexts**: Both `fingerprint()` and `fingerprint_with()` now share a single module-level TLS slot instead of creating separate ones
26+
- **Fixed unsafe memory operations**: Replaced `unsafe { set_len() }` with safe `resize()` in preprocessing buffers
27+
- **Fixed EXIF orientation handling**: Now reads EXIF orientation from raw bytes before decoding and applies transformations (rotate/flip) to decoded image
28+
- **Fixed similarity algorithm consistency**: `MultiHashFingerprint::compare()` now includes block-level similarity (60% weight) for consistent crop resistance with single-algorithm mode
29+
- **Fixed error handling in DCT**: Changed `unwrap()` to proper `Result` propagation in `dct2_32()`
2430

2531
- **API improvements**:
2632
- Made `hash_similarity()` and `hamming_distance()` public utilities
2733
- Added fast-path for exact match in similarity comparison
2834
- Added compile-time assertions verifying `ImgFprintError` implements `Send + Sync` for safe use in async and multi-threaded contexts
35+
- **Added model ID support to embeddings**: `Embedding::new_with_model()` allows tagging embeddings with model identifiers to prevent comparing incompatible models
36+
- **Fixed serde feature gating**: `serde` crate is now properly optional (not compiled unless feature enabled)
2937

3038
- **Code quality**:
3139
- Fixed clippy warnings (needless_range_loop, manual_clamp)
40+
- Added `kamadak-exif` dependency for EXIF orientation parsing
41+
- Updated bincode dev-dependency comment documenting v2 API changes
3242

3343
## [0.3.1] - 2025-03-16
3444

Cargo.lock

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,21 @@ readme = "README.md"
1313

1414
[dependencies]
1515
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] }
16+
exif = { package = "kamadak-exif", version = "0.6" }
1617
fast_image_resize = { version = "6.0", features = ["image"] }
1718
blake3 = "1.8"
1819
realfft = "3.4"
1920
rustfft = "6.2"
2021
thiserror = "2.0"
21-
serde = { version = "1.0", features = ["derive"] }
22+
serde = { version = "1.0", features = ["derive"], optional = true }
2223
rayon = { version = "1.10", optional = true }
2324
tract-onnx = { version = "0.22.1", optional = true }
2425
tracing = { version = "0.1", optional = true }
2526
subtle = "2.6"
2627

2728
[features]
2829
default = ["serde", "parallel"]
29-
serde = []
30+
serde = ["dep:serde"]
3031
parallel = ["dep:rayon"]
3132
local-embedding = ["dep:tract-onnx"]
3233
local-embedding-model = ["local-embedding"]
@@ -36,7 +37,7 @@ tracing = ["dep:tracing"]
3637
criterion = { version = "0.8.2", features = ["html_reports"] }
3738
image = { version = "0.25", default-features = false, features = ["png"] }
3839
serde_json = "1.0"
39-
bincode = "1.3.3"
40+
bincode = "1.3.3" # v2 has breaking API changes; pin until upgrade
4041
proptest = "1.5"
4142

4243
[[example]]

README.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,9 @@ Perfect for:
3434
- **Deterministic Output** - Same input always produces same fingerprint
3535
- **BLAKE3 Exact Hash** - Byte-identical detection (6-8x faster than SHA256)
3636
- **Block-Level Hashing** - 4x4 grid for crop resistance
37+
- **EXIF Orientation** - Automatically corrects JPEG orientation from camera metadata
3738
- **Semantic Embeddings** - CLIP-style vector representations via external providers or local ONNX models
39+
- **Embedding Model ID** - Tag embeddings with model identifiers to prevent comparing incompatible models
3840
- **SIMD Acceleration** - AVX2/NEON optimized resizing
3941
- **Parallel Processing** - Multi-core batch operations
4042
- **Zero-Copy APIs** - Minimal allocations in hot paths
@@ -148,7 +150,7 @@ ImageFingerprint
148150

149151
### Algorithm Pipeline
150152

151-
1. **Decode** - Parse any supported format (PNG, JPEG, GIF, WebP, BMP) into RGB
153+
1. **Decode** - Parse any supported format (PNG, JPEG, GIF, WebP, BMP) into RGB with EXIF orientation correction for JPEG
152154
2. **Normalize** - Resize to 256x256 using SIMD-accelerated Lanczos3 filter
153155
3. **Convert** - RGB to Grayscale (luminance)
154156
4. **Parallel Hash Computation** - All three algorithms computed simultaneously:
@@ -159,13 +161,17 @@ ImageFingerprint
159161

160162
### Multi-Algorithm Comparison
161163

162-
When using `MultiHashFingerprint`, the similarity score uses weighted combination:
164+
When using `MultiHashFingerprint`, the similarity score uses weighted combination with block-level similarity:
163165

164166
- **10%** AHash similarity (average hash, fastest, simplest)
165167
- **60%** PHash similarity (DCT-based, robust to compression)
166168
- **30%** DHash similarity (gradient-based, good for structural changes)
167169

168-
This provides better accuracy than any single algorithm alone.
170+
Within each algorithm, similarity is computed as:
171+
- **40%** global hash similarity (overall structure)
172+
- **60%** block-level similarity (crop resistance via 4x4 grid)
173+
174+
This provides superior crop resistance compared to global-only comparison.
169175

170176
## Performance
171177

USAGE.md

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -567,13 +567,19 @@ let fp = multi.get(HashAlgorithm::PHash);
567567
pub fn compare(&self, other: &MultiHashFingerprint) -> Similarity
568568
```
569569

570-
Compares two multi-hash fingerprints using weighted combination.
570+
Compares two multi-hash fingerprints using weighted combination with block-level similarity for crop resistance.
571571

572-
**Weights:**
572+
**Algorithm Weights:**
573573
- **10%** AHash similarity (average hash, fastest)
574-
- **60%** PHash similarity (DCT-based, robust to compression)
574+
- **60%** PHash similarity (DCT-based, robust to compression)
575575
- **30%** DHash similarity (gradient-based, good for structural changes)
576576

577+
**Per-Algorithm Composition:**
578+
- **40%** global hash similarity (overall structure)
579+
- **60%** block-level similarity (4x4 grid, crop resistance)
580+
581+
This provides superior crop resistance compared to global-only comparison.
582+
577583
**Example:**
578584
```rust
579585
let multi1 = ImageFingerprinter::fingerprint(&img1)?;
@@ -774,6 +780,29 @@ println!("Semantic similarity: {:.4}", similarity);
774780

775781
**Note:** The library does not include built-in embedding providers. You must implement `EmbeddingProvider` for your use case (OpenAI CLIP, HuggingFace, local models, etc.).
776782

783+
#### Model ID Support
784+
785+
To prevent accidental comparison of embeddings from different models (e.g., comparing 512-dim CLIP with 768-dim CLIP), you can tag embeddings with a model identifier:
786+
787+
```rust
788+
use imgfprint::Embedding;
789+
790+
// Create embedding with model ID
791+
let embedding = Embedding::new_with_model(
792+
vec![0.1, 0.2, 0.3, /* ... 512 dimensions */],
793+
Some("clip-vit-base-patch32".to_string())
794+
)?;
795+
796+
// The model ID is validated during similarity comparison
797+
let sim = imgfprint::semantic_similarity(&emb1, &emb2)?;
798+
// Will error if model IDs differ (prevents meaningless comparisons)
799+
```
800+
801+
**Benefits:**
802+
- Prevents comparing incompatible embeddings
803+
- Explicit model tracking for audit trails
804+
- Backward compatible (model_id is optional)
805+
777806
#### Local ONNX Models
778807

779808
With the `local-embedding` feature:

src/core/fingerprint.rs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -185,11 +185,15 @@ impl MultiHashFingerprint {
185185

186186
/// Compares two multi-hash fingerprints using weighted combination.
187187
///
188-
/// Uses weighted combination:
188+
/// Uses weighted combination of algorithm similarities:
189189
/// - 10% AHash similarity (average hash, fastest)
190190
/// - 60% PHash similarity (DCT-based, robust to compression)
191191
/// - 30% DHash similarity (gradient-based, good for structural changes)
192192
///
193+
/// Each algorithm's similarity includes both global and block-level hashing
194+
/// for improved crop resistance. Block hashes are weighted at 60% and global
195+
/// hashes at 40% within each algorithm.
196+
///
193197
/// Returns a Similarity struct with combined score and component distances.
194198
///
195199
/// Uses constant-time comparison to prevent timing attacks on exact match.
@@ -198,6 +202,7 @@ impl MultiHashFingerprint {
198202
/// * `other` - The fingerprint to compare against
199203
#[must_use]
200204
pub fn compare(&self, other: &MultiHashFingerprint) -> Similarity {
205+
use crate::core::similarity::{compute_similarity, hamming_distance};
201206
use subtle::ConstantTimeEq;
202207

203208
let exact_match = self.exact.ct_eq(&other.exact).into();
@@ -211,18 +216,19 @@ impl MultiHashFingerprint {
211216
};
212217
}
213218

214-
let ahash_dist = self.ahash.distance(&other.ahash);
215-
let phash_dist = self.phash.distance(&other.phash);
216-
let dhash_dist = self.dhash.distance(&other.dhash);
217-
218-
// Use shared hash_similarity function for consistency
219-
let ahash_sim = hash_similarity(ahash_dist);
220-
let phash_sim = hash_similarity(phash_dist);
221-
let dhash_sim = hash_similarity(dhash_dist);
219+
// Compute per-algorithm similarities including block-level comparison
220+
let ahash_sim = compute_similarity(&self.ahash, &other.ahash).score;
221+
let phash_sim = compute_similarity(&self.phash, &other.phash).score;
222+
let dhash_sim = compute_similarity(&self.dhash, &other.dhash).score;
222223

223224
let weighted_score =
224225
ahash_sim * AHASH_WEIGHT + phash_sim * PHASH_WEIGHT + dhash_sim * DHASH_WEIGHT;
225226

227+
// Use global hash distances for perceptual_distance (backward compatibility)
228+
let ahash_dist = hamming_distance(self.ahash.global_hash, other.ahash.global_hash);
229+
let phash_dist = hamming_distance(self.phash.global_hash, other.phash.global_hash);
230+
let dhash_dist = hamming_distance(self.dhash.global_hash, other.dhash.global_hash);
231+
226232
let avg_distance = ((ahash_dist as f32 * AHASH_WEIGHT)
227233
+ (phash_dist as f32 * PHASH_WEIGHT)
228234
+ (dhash_dist as f32 * DHASH_WEIGHT)) as u32;

src/core/fingerprinter.rs

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ use crate::imgproc::preprocess::{extract_blocks, extract_global_region, Preproce
1010
use blake3::Hasher;
1111
use std::cell::RefCell;
1212

13+
// Module-level shared thread-local context to avoid duplication
14+
thread_local! {
15+
static SHARED_CTX: RefCell<FingerprinterContext> = RefCell::new(FingerprinterContext::new());
16+
}
17+
1318
#[cfg(feature = "tracing")]
1419
use tracing::{debug, instrument};
1520

@@ -317,11 +322,7 @@ impl ImageFingerprinter {
317322
#[cfg(feature = "tracing")]
318323
let start = std::time::Instant::now();
319324

320-
thread_local! {
321-
static CTX: RefCell<FingerprinterContext> = RefCell::new(FingerprinterContext::new());
322-
}
323-
324-
let result = CTX.with(|ctx| ctx.borrow_mut().fingerprint(image_bytes));
325+
let result = SHARED_CTX.with(|ctx| ctx.borrow_mut().fingerprint(image_bytes));
325326

326327
#[cfg(feature = "tracing")]
327328
debug!(
@@ -348,11 +349,8 @@ impl ImageFingerprinter {
348349
#[cfg(feature = "tracing")]
349350
let start = std::time::Instant::now();
350351

351-
thread_local! {
352-
static CTX: RefCell<FingerprinterContext> = RefCell::new(FingerprinterContext::new());
353-
}
354-
355-
let result = CTX.with(|ctx| ctx.borrow_mut().fingerprint_with(image_bytes, algorithm));
352+
let result =
353+
SHARED_CTX.with(|ctx| ctx.borrow_mut().fingerprint_with(image_bytes, algorithm));
356354

357355
#[cfg(feature = "tracing")]
358356
debug!(

src/core/similarity.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ pub fn hash_similarity(distance: u32) -> f32 {
3535
///
3636
/// Uses a pre-computed population count table for faster computation
3737
/// than the built-in count_ones() on 64-bit values.
38+
///
39+
/// Note: On modern x86-64 CPUs with POPCNT instruction, `count_ones()`
40+
/// may be faster. Benchmark both approaches for your specific use case.
3841
#[inline(always)]
3942
pub fn hamming_distance(a: u64, b: u64) -> u32 {
4043
let xor = a ^ b;
@@ -133,7 +136,7 @@ pub fn compute_similarity(a: &ImageFingerprint, b: &ImageFingerprint) -> Similar
133136
/// For example, if image A is cropped to show only the top-left quadrant,
134137
/// blocks in the bottom-right of A won't match B, but the top-left blocks
135138
/// will still contribute to the similarity score.
136-
fn compute_block_similarity(a: &[u64; 16], b: &[u64; 16]) -> f32 {
139+
pub fn compute_block_similarity(a: &[u64; 16], b: &[u64; 16]) -> f32 {
137140
let mut total_similarity = 0.0f32;
138141
let mut valid_comparisons = 0u32;
139142

0 commit comments

Comments
 (0)