Skip to main content

oximedia_dedup/
content_signature.rs

1//! Content-signature types for robust media identification.
2//!
3//! Provides `SignatureType`, `ContentSignature`, and `SignatureDatabase`
4//! for storing and matching content signatures across a media library.
5//!
6//! # Robust Signatures
7//!
8//! The [`RobustSignature`] type combines multiple format-agnostic signals
9//! into a single composite fingerprint that survives common transformations:
10//!
11//! - **Transcoding** (codec/container changes)
12//! - **Cropping** (letterboxing, aspect ratio changes)
13//! - **Watermarking** (overlaid logos, text)
14//! - **Colour grading** (brightness, contrast, saturation shifts)
15//! - **Scaling** (resolution changes)
16//!
17//! This is achieved by fusing perceptual hashes (rotation-invariant DCT
18//! domain), radial variance profiles, temporal rhythm signatures, and
19//! audio spectral peaks into a single matchable descriptor.
20
21#![allow(dead_code)]
22#![allow(clippy::cast_precision_loss)]
23
24use std::collections::HashMap;
25
26/// The type of content signature.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
28pub enum SignatureType {
29    /// Perceptual hash derived from visual content.
30    PerceptualVisual,
31    /// Perceptual hash derived from audio content.
32    PerceptualAudio,
33    /// Cryptographic (exact) hash of raw bytes.
34    Cryptographic,
35    /// Fingerprint generated by a machine-learning model.
36    NeuralEmbedding,
37    /// Lightweight thumbnail-based signature.
38    Thumbnail,
39}
40
41impl SignatureType {
42    /// Return `true` if this signature type is perceptual (approximate matching).
43    #[must_use]
44    pub const fn is_perceptual(self) -> bool {
45        matches!(
46            self,
47            Self::PerceptualVisual | Self::PerceptualAudio | Self::NeuralEmbedding
48        )
49    }
50
51    /// Return `true` if this signature supports exact equality matching.
52    #[must_use]
53    pub const fn supports_exact_match(self) -> bool {
54        matches!(self, Self::Cryptographic)
55    }
56
57    /// Return a short label for this type.
58    #[must_use]
59    pub const fn label(self) -> &'static str {
60        match self {
61            Self::PerceptualVisual => "perceptual-visual",
62            Self::PerceptualAudio => "perceptual-audio",
63            Self::Cryptographic => "cryptographic",
64            Self::NeuralEmbedding => "neural-embedding",
65            Self::Thumbnail => "thumbnail",
66        }
67    }
68}
69
70/// A content signature for a single piece of media.
71#[derive(Debug, Clone)]
72pub struct ContentSignature {
73    /// Unique identifier of the media asset.
74    pub asset_id: String,
75    /// Type of signature.
76    pub sig_type: SignatureType,
77    /// Raw signature bytes.
78    pub data: Vec<u8>,
79    /// Optional confidence score (0.0–1.0).
80    pub confidence: f64,
81}
82
83impl ContentSignature {
84    /// Create a new `ContentSignature`.
85    #[must_use]
86    pub fn new(
87        asset_id: impl Into<String>,
88        sig_type: SignatureType,
89        data: Vec<u8>,
90        confidence: f64,
91    ) -> Self {
92        Self {
93            asset_id: asset_id.into(),
94            sig_type,
95            data,
96            confidence,
97        }
98    }
99
100    /// Return `true` if this signature matches `other` within `tolerance` bytes differing.
101    ///
102    /// For exact (cryptographic) signatures `tolerance` is ignored and byte-equality is required.
103    #[must_use]
104    pub fn matches(&self, other: &Self, tolerance: u32) -> bool {
105        if self.sig_type != other.sig_type {
106            return false;
107        }
108        if self.data.len() != other.data.len() {
109            return false;
110        }
111        if self.sig_type.supports_exact_match() {
112            return self.data == other.data;
113        }
114        // Perceptual: count differing bytes and compare against tolerance.
115        let diff: u32 = self
116            .data
117            .iter()
118            .zip(&other.data)
119            .map(|(a, b)| u32::from(*a != *b))
120            .sum();
121        diff <= tolerance
122    }
123
124    /// Return the length of the signature data in bytes.
125    #[must_use]
126    pub fn data_len(&self) -> usize {
127        self.data.len()
128    }
129}
130
131/// An in-memory database of `ContentSignature` values.
132#[derive(Debug, Default)]
133pub struct SignatureDatabase {
134    entries: HashMap<String, Vec<ContentSignature>>,
135}
136
137impl SignatureDatabase {
138    /// Create a new, empty database.
139    #[must_use]
140    pub fn new() -> Self {
141        Self::default()
142    }
143
144    /// Store a signature, appending it to the list for its `asset_id`.
145    pub fn store(&mut self, sig: ContentSignature) {
146        self.entries
147            .entry(sig.asset_id.clone())
148            .or_default()
149            .push(sig);
150    }
151
152    /// Look up all signatures associated with `asset_id`.
153    #[must_use]
154    pub fn lookup(&self, asset_id: &str) -> &[ContentSignature] {
155        self.entries.get(asset_id).map(Vec::as_slice).unwrap_or(&[])
156    }
157
158    /// Return the total number of signatures stored across all assets.
159    #[must_use]
160    pub fn match_count(&self) -> usize {
161        self.entries.values().map(Vec::len).sum()
162    }
163
164    /// Find all assets whose signatures match `query` within `tolerance`.
165    ///
166    /// Returns a list of `(asset_id, matching_signature_count)` pairs.
167    #[must_use]
168    pub fn find_matches(&self, query: &ContentSignature, tolerance: u32) -> Vec<(String, usize)> {
169        self.entries
170            .iter()
171            .filter_map(|(id, sigs)| {
172                let count = sigs.iter().filter(|s| query.matches(s, tolerance)).count();
173                if count > 0 && id != &query.asset_id {
174                    Some((id.clone(), count))
175                } else {
176                    None
177                }
178            })
179            .collect()
180    }
181
182    /// Remove all signatures for `asset_id`, returning the removed list.
183    pub fn remove_asset(&mut self, asset_id: &str) -> Vec<ContentSignature> {
184        self.entries.remove(asset_id).unwrap_or_default()
185    }
186
187    /// Return the number of distinct assets tracked.
188    #[must_use]
189    pub fn asset_count(&self) -> usize {
190        self.entries.len()
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    fn make_sig(asset_id: &str, sig_type: SignatureType, data: Vec<u8>) -> ContentSignature {
199        ContentSignature::new(asset_id, sig_type, data, 1.0)
200    }
201
202    #[test]
203    fn test_sig_type_is_perceptual_visual() {
204        assert!(SignatureType::PerceptualVisual.is_perceptual());
205    }
206
207    #[test]
208    fn test_sig_type_is_perceptual_audio() {
209        assert!(SignatureType::PerceptualAudio.is_perceptual());
210    }
211
212    #[test]
213    fn test_sig_type_not_perceptual_crypto() {
214        assert!(!SignatureType::Cryptographic.is_perceptual());
215    }
216
217    #[test]
218    fn test_sig_type_supports_exact_match() {
219        assert!(SignatureType::Cryptographic.supports_exact_match());
220        assert!(!SignatureType::PerceptualVisual.supports_exact_match());
221    }
222
223    #[test]
224    fn test_sig_type_label_nonempty() {
225        for t in [
226            SignatureType::PerceptualVisual,
227            SignatureType::PerceptualAudio,
228            SignatureType::Cryptographic,
229            SignatureType::NeuralEmbedding,
230            SignatureType::Thumbnail,
231        ] {
232            assert!(!t.label().is_empty());
233        }
234    }
235
236    #[test]
237    fn test_signature_exact_match_identical() {
238        let s1 = make_sig("a1", SignatureType::Cryptographic, vec![1, 2, 3, 4]);
239        let s2 = make_sig("a2", SignatureType::Cryptographic, vec![1, 2, 3, 4]);
240        assert!(s1.matches(&s2, 0));
241    }
242
243    #[test]
244    fn test_signature_exact_match_different() {
245        let s1 = make_sig("a1", SignatureType::Cryptographic, vec![1, 2, 3, 4]);
246        let s2 = make_sig("a2", SignatureType::Cryptographic, vec![1, 2, 3, 5]);
247        assert!(!s1.matches(&s2, 0));
248    }
249
250    #[test]
251    fn test_signature_perceptual_within_tolerance() {
252        let s1 = make_sig("a1", SignatureType::PerceptualVisual, vec![0, 0, 0, 0]);
253        let s2 = make_sig("a2", SignatureType::PerceptualVisual, vec![1, 0, 0, 0]);
254        assert!(s1.matches(&s2, 1));
255    }
256
257    #[test]
258    fn test_signature_perceptual_exceeds_tolerance() {
259        let s1 = make_sig("a1", SignatureType::PerceptualVisual, vec![0, 0, 0, 0]);
260        let s2 = make_sig("a2", SignatureType::PerceptualVisual, vec![1, 1, 0, 0]);
261        assert!(!s1.matches(&s2, 1));
262    }
263
264    #[test]
265    fn test_signature_type_mismatch() {
266        let s1 = make_sig("a1", SignatureType::PerceptualVisual, vec![0; 4]);
267        let s2 = make_sig("a2", SignatureType::Cryptographic, vec![0; 4]);
268        assert!(!s1.matches(&s2, 10));
269    }
270
271    #[test]
272    fn test_database_store_and_lookup() {
273        let mut db = SignatureDatabase::new();
274        db.store(make_sig(
275            "asset1",
276            SignatureType::Cryptographic,
277            vec![0xAB; 4],
278        ));
279        let sigs = db.lookup("asset1");
280        assert_eq!(sigs.len(), 1);
281    }
282
283    #[test]
284    fn test_database_lookup_missing() {
285        let db = SignatureDatabase::new();
286        assert!(db.lookup("nonexistent").is_empty());
287    }
288
289    #[test]
290    fn test_database_match_count() {
291        let mut db = SignatureDatabase::new();
292        db.store(make_sig("a", SignatureType::Cryptographic, vec![1; 4]));
293        db.store(make_sig("a", SignatureType::PerceptualVisual, vec![1; 4]));
294        db.store(make_sig("b", SignatureType::Cryptographic, vec![1; 4]));
295        assert_eq!(db.match_count(), 3);
296    }
297
298    #[test]
299    fn test_database_find_matches() {
300        let mut db = SignatureDatabase::new();
301        db.store(make_sig(
302            "other",
303            SignatureType::PerceptualVisual,
304            vec![0, 0, 0, 0],
305        ));
306        let query = make_sig("query", SignatureType::PerceptualVisual, vec![0, 0, 0, 1]);
307        let matches = db.find_matches(&query, 1);
308        assert_eq!(matches.len(), 1);
309        assert_eq!(matches[0].0, "other");
310    }
311
312    #[test]
313    fn test_database_remove_asset() {
314        let mut db = SignatureDatabase::new();
315        db.store(make_sig("x", SignatureType::Cryptographic, vec![0; 4]));
316        assert_eq!(db.asset_count(), 1);
317        let removed = db.remove_asset("x");
318        assert_eq!(removed.len(), 1);
319        assert_eq!(db.asset_count(), 0);
320    }
321}
322
323// ===========================================================================
324// Robust Content Signatures
325// ===========================================================================
326
327/// Number of radial zones for the radial variance profile.
328const RADIAL_ZONES: usize = 8;
329
330/// Number of temporal bins for the rhythm signature.
331const TEMPORAL_BINS: usize = 16;
332
333/// Number of spectral peaks stored in the audio peak constellation.
334const SPECTRAL_PEAKS: usize = 32;
335
336// ---------------------------------------------------------------------------
337// RadialVariance
338// ---------------------------------------------------------------------------
339
340/// Radial variance profile of an image — invariant to translation, robust to
341/// cropping and scaling.
342///
343/// Divides a centred circle into concentric annular zones and computes the
344/// variance of luminance within each zone.  Because the measurement is
345/// relative to the image centre and averaged over angular position, mild
346/// cropping or letterbox changes only affect the outermost zone.
347#[derive(Debug, Clone)]
348pub struct RadialVarianceProfile {
349    /// Variance per zone (inner to outer).
350    pub zones: [f64; RADIAL_ZONES],
351}
352
353impl RadialVarianceProfile {
354    /// Compute from a grayscale image (flat row-major `u8` data).
355    #[must_use]
356    pub fn compute(width: usize, height: usize, data: &[u8]) -> Self {
357        let cx = width as f64 / 2.0;
358        let cy = height as f64 / 2.0;
359        let max_r = cx.min(cy).max(1.0);
360
361        let mut sums = [0.0f64; RADIAL_ZONES];
362        let mut sq_sums = [0.0f64; RADIAL_ZONES];
363        let mut counts = [0usize; RADIAL_ZONES];
364
365        for y in 0..height {
366            for x in 0..width {
367                let dx = x as f64 - cx;
368                let dy = y as f64 - cy;
369                let r = (dx * dx + dy * dy).sqrt();
370                let zone_idx = ((r / max_r) * RADIAL_ZONES as f64) as usize;
371                let zone_idx = zone_idx.min(RADIAL_ZONES - 1);
372
373                let idx = y * width + x;
374                if idx < data.len() {
375                    let val = f64::from(data[idx]);
376                    sums[zone_idx] += val;
377                    sq_sums[zone_idx] += val * val;
378                    counts[zone_idx] += 1;
379                }
380            }
381        }
382
383        let mut zones = [0.0f64; RADIAL_ZONES];
384        for i in 0..RADIAL_ZONES {
385            if counts[i] > 1 {
386                let mean = sums[i] / counts[i] as f64;
387                let variance = sq_sums[i] / counts[i] as f64 - mean * mean;
388                zones[i] = variance.max(0.0);
389            }
390        }
391
392        Self { zones }
393    }
394
395    /// Cosine similarity to another profile (0.0 - 1.0).
396    #[must_use]
397    pub fn similarity(&self, other: &Self) -> f64 {
398        let dot: f64 = self
399            .zones
400            .iter()
401            .zip(other.zones.iter())
402            .map(|(a, b)| a * b)
403            .sum();
404        let mag_a: f64 = self.zones.iter().map(|x| x * x).sum::<f64>().sqrt();
405        let mag_b: f64 = other.zones.iter().map(|x| x * x).sum::<f64>().sqrt();
406        if mag_a < f64::EPSILON || mag_b < f64::EPSILON {
407            return 0.0;
408        }
409        (dot / (mag_a * mag_b)).clamp(0.0, 1.0)
410    }
411}
412
413// ---------------------------------------------------------------------------
414// TemporalRhythm
415// ---------------------------------------------------------------------------
416
417/// Temporal rhythm signature — captures the temporal structure of visual
418/// changes across a video.
419///
420/// Computed by measuring the average frame-to-frame luminance change over
421/// `TEMPORAL_BINS` equal time segments.  This signature is invariant to
422/// codec and resolution, and robust to colour grading and watermarking.
423#[derive(Debug, Clone)]
424pub struct TemporalRhythm {
425    /// Normalised change intensity per temporal bin (0.0 - 1.0).
426    pub bins: [f64; TEMPORAL_BINS],
427}
428
429impl TemporalRhythm {
430    /// Construct from a series of per-frame luminance change values.
431    ///
432    /// `frame_changes` should contain one value per inter-frame transition
433    /// (i.e., `num_frames - 1` entries), each representing the mean absolute
434    /// pixel difference between consecutive frames.
435    #[must_use]
436    pub fn from_frame_changes(frame_changes: &[f64]) -> Self {
437        let mut bins = [0.0f64; TEMPORAL_BINS];
438        if frame_changes.is_empty() {
439            return Self { bins };
440        }
441
442        let n = frame_changes.len();
443        let bin_size = (n as f64 / TEMPORAL_BINS as f64).max(1.0);
444
445        for (i, &val) in frame_changes.iter().enumerate() {
446            let bin_idx = (i as f64 / bin_size) as usize;
447            let bin_idx = bin_idx.min(TEMPORAL_BINS - 1);
448            bins[bin_idx] += val;
449        }
450
451        // Count entries per bin for averaging.
452        let mut counts = [0usize; TEMPORAL_BINS];
453        for i in 0..n {
454            let bin_idx = ((i as f64 / bin_size) as usize).min(TEMPORAL_BINS - 1);
455            counts[bin_idx] += 1;
456        }
457        for i in 0..TEMPORAL_BINS {
458            if counts[i] > 0 {
459                bins[i] /= counts[i] as f64;
460            }
461        }
462
463        // Normalise to [0, 1].
464        let max_val = bins.iter().cloned().fold(0.0f64, f64::max);
465        if max_val > f64::EPSILON {
466            for b in &mut bins {
467                *b /= max_val;
468            }
469        }
470
471        Self { bins }
472    }
473
474    /// Cosine similarity to another rhythm signature.
475    #[must_use]
476    pub fn similarity(&self, other: &Self) -> f64 {
477        let dot: f64 = self
478            .bins
479            .iter()
480            .zip(other.bins.iter())
481            .map(|(a, b)| a * b)
482            .sum();
483        let mag_a: f64 = self.bins.iter().map(|x| x * x).sum::<f64>().sqrt();
484        let mag_b: f64 = other.bins.iter().map(|x| x * x).sum::<f64>().sqrt();
485        if mag_a < f64::EPSILON || mag_b < f64::EPSILON {
486            return 0.0;
487        }
488        (dot / (mag_a * mag_b)).clamp(0.0, 1.0)
489    }
490}
491
492// ---------------------------------------------------------------------------
493// SpectralPeakConstellation
494// ---------------------------------------------------------------------------
495
496/// Audio spectral peak constellation — a set of (time_bin, freq_bin) pairs
497/// representing the strongest spectral peaks in the audio.
498///
499/// This is the core primitive behind audio fingerprinting systems like
500/// Shazam.  Because peaks are identified by relative position in the
501/// time-frequency plane, the signature is robust to transcoding, volume
502/// changes, and mild noise.
503#[derive(Debug, Clone)]
504pub struct SpectralPeakConstellation {
505    /// Sorted list of (time_bin, frequency_bin) peak positions.
506    pub peaks: Vec<(u32, u32)>,
507}
508
509impl SpectralPeakConstellation {
510    /// Create from raw peak positions.
511    #[must_use]
512    pub fn new(mut peaks: Vec<(u32, u32)>) -> Self {
513        peaks.sort();
514        if peaks.len() > SPECTRAL_PEAKS {
515            peaks.truncate(SPECTRAL_PEAKS);
516        }
517        Self { peaks }
518    }
519
520    /// Jaccard similarity to another constellation (0.0 - 1.0).
521    #[must_use]
522    pub fn similarity(&self, other: &Self) -> f64 {
523        if self.peaks.is_empty() && other.peaks.is_empty() {
524            return 1.0;
525        }
526        if self.peaks.is_empty() || other.peaks.is_empty() {
527            return 0.0;
528        }
529
530        // Count matching peaks (allowing ±1 tolerance in each dimension).
531        let mut matched = 0usize;
532        for &(t1, f1) in &self.peaks {
533            for &(t2, f2) in &other.peaks {
534                let dt = (t1 as i64 - t2 as i64).unsigned_abs();
535                let df = (f1 as i64 - f2 as i64).unsigned_abs();
536                if dt <= 1 && df <= 1 {
537                    matched += 1;
538                    break;
539                }
540            }
541        }
542
543        let union = self.peaks.len() + other.peaks.len() - matched;
544        if union == 0 {
545            return 0.0;
546        }
547        matched as f64 / union as f64
548    }
549}
550
551// ---------------------------------------------------------------------------
552// RobustSignature
553// ---------------------------------------------------------------------------
554
555/// A robust multi-signal content signature that survives transcoding,
556/// cropping, watermarking, colour grading, and resolution changes.
557///
558/// Combines:
559/// - **Perceptual hash** (64-bit DCT pHash — invariant to scale/compression)
560/// - **Radial variance profile** (robust to cropping/letterboxing)
561/// - **Temporal rhythm** (robust to codec/colour grading)
562/// - **Spectral peaks** (robust to audio transcoding/noise)
563#[derive(Debug, Clone)]
564pub struct RobustSignature {
565    /// Asset identifier.
566    pub asset_id: String,
567    /// 64-bit perceptual hash (visual).
568    pub phash: Option<u64>,
569    /// Radial variance profile (visual).
570    pub radial: Option<RadialVarianceProfile>,
571    /// Temporal rhythm (video).
572    pub temporal: Option<TemporalRhythm>,
573    /// Spectral peak constellation (audio).
574    pub spectral: Option<SpectralPeakConstellation>,
575    /// Duration in seconds.
576    pub duration_secs: Option<f64>,
577}
578
579impl RobustSignature {
580    /// Create a new signature with only an asset ID.
581    #[must_use]
582    pub fn new(asset_id: impl Into<String>) -> Self {
583        Self {
584            asset_id: asset_id.into(),
585            phash: None,
586            radial: None,
587            temporal: None,
588            spectral: None,
589            duration_secs: None,
590        }
591    }
592
593    /// Builder: set perceptual hash.
594    #[must_use]
595    pub fn with_phash(mut self, hash: u64) -> Self {
596        self.phash = Some(hash);
597        self
598    }
599
600    /// Builder: set radial variance profile.
601    #[must_use]
602    pub fn with_radial(mut self, profile: RadialVarianceProfile) -> Self {
603        self.radial = Some(profile);
604        self
605    }
606
607    /// Builder: set temporal rhythm.
608    #[must_use]
609    pub fn with_temporal(mut self, rhythm: TemporalRhythm) -> Self {
610        self.temporal = Some(rhythm);
611        self
612    }
613
614    /// Builder: set spectral peaks.
615    #[must_use]
616    pub fn with_spectral(mut self, peaks: SpectralPeakConstellation) -> Self {
617        self.spectral = Some(peaks);
618        self
619    }
620
621    /// Builder: set duration.
622    #[must_use]
623    pub fn with_duration(mut self, secs: f64) -> Self {
624        self.duration_secs = Some(secs);
625        self
626    }
627
628    /// Number of signal components present.
629    #[must_use]
630    pub fn signal_count(&self) -> usize {
631        let mut count = 0;
632        if self.phash.is_some() {
633            count += 1;
634        }
635        if self.radial.is_some() {
636            count += 1;
637        }
638        if self.temporal.is_some() {
639            count += 1;
640        }
641        if self.spectral.is_some() {
642            count += 1;
643        }
644        count
645    }
646
647    /// Compute weighted similarity to another robust signature.
648    ///
649    /// Returns `(overall_score, RobustMatchDetail)`.
650    #[must_use]
651    pub fn compare(&self, other: &Self) -> RobustMatchResult {
652        let mut total_weight = 0.0f64;
653        let mut weighted_sum = 0.0f64;
654
655        // Duration pre-check: if both have duration and they differ by more
656        // than 2 seconds, early-reject.
657        let duration_ok = match (self.duration_secs, other.duration_secs) {
658            (Some(a), Some(b)) => (a - b).abs() <= 2.0,
659            _ => true,
660        };
661
662        if !duration_ok {
663            return RobustMatchResult {
664                overall_score: 0.0,
665                phash_score: None,
666                radial_score: None,
667                temporal_score: None,
668                spectral_score: None,
669            };
670        }
671
672        // pHash comparison (weight = 0.35)
673        let phash_score = match (self.phash, other.phash) {
674            (Some(a), Some(b)) => {
675                let dist = (a ^ b).count_ones();
676                let sim = 1.0 - dist as f64 / 64.0;
677                total_weight += 0.35;
678                weighted_sum += sim * 0.35;
679                Some(sim)
680            }
681            _ => None,
682        };
683
684        // Radial variance (weight = 0.20)
685        let radial_score = match (&self.radial, &other.radial) {
686            (Some(a), Some(b)) => {
687                let sim = a.similarity(b);
688                total_weight += 0.20;
689                weighted_sum += sim * 0.20;
690                Some(sim)
691            }
692            _ => None,
693        };
694
695        // Temporal rhythm (weight = 0.25)
696        let temporal_score = match (&self.temporal, &other.temporal) {
697            (Some(a), Some(b)) => {
698                let sim = a.similarity(b);
699                total_weight += 0.25;
700                weighted_sum += sim * 0.25;
701                Some(sim)
702            }
703            _ => None,
704        };
705
706        // Spectral peaks (weight = 0.20)
707        let spectral_score = match (&self.spectral, &other.spectral) {
708            (Some(a), Some(b)) => {
709                let sim = a.similarity(b);
710                total_weight += 0.20;
711                weighted_sum += sim * 0.20;
712                Some(sim)
713            }
714            _ => None,
715        };
716
717        let overall = if total_weight > f64::EPSILON {
718            weighted_sum / total_weight
719        } else {
720            0.0
721        };
722
723        RobustMatchResult {
724            overall_score: overall,
725            phash_score,
726            radial_score,
727            temporal_score,
728            spectral_score,
729        }
730    }
731}
732
733/// Result of comparing two `RobustSignature` instances.
734#[derive(Debug, Clone)]
735pub struct RobustMatchResult {
736    /// Weighted overall score (0.0 - 1.0).
737    pub overall_score: f64,
738    /// Per-signal scores.
739    pub phash_score: Option<f64>,
740    /// Radial variance similarity.
741    pub radial_score: Option<f64>,
742    /// Temporal rhythm similarity.
743    pub temporal_score: Option<f64>,
744    /// Spectral peak similarity.
745    pub spectral_score: Option<f64>,
746}
747
748impl RobustMatchResult {
749    /// Returns `true` if the overall score is above `threshold`.
750    #[must_use]
751    pub fn is_match(&self, threshold: f64) -> bool {
752        self.overall_score >= threshold
753    }
754
755    /// Number of signals that contributed to the score.
756    #[must_use]
757    pub fn contributing_signals(&self) -> usize {
758        let mut count = 0;
759        if self.phash_score.is_some() {
760            count += 1;
761        }
762        if self.radial_score.is_some() {
763            count += 1;
764        }
765        if self.temporal_score.is_some() {
766            count += 1;
767        }
768        if self.spectral_score.is_some() {
769            count += 1;
770        }
771        count
772    }
773}
774
775// ---------------------------------------------------------------------------
776// RobustSignature tests
777// ---------------------------------------------------------------------------
778
779#[cfg(test)]
780mod robust_tests {
781    use super::*;
782
783    #[test]
784    fn test_radial_variance_uniform_image() {
785        // Uniform image: all pixels = 128, variance should be ~0.
786        let data = vec![128u8; 64 * 64];
787        let profile = RadialVarianceProfile::compute(64, 64, &data);
788        for &v in &profile.zones {
789            assert!(
790                v < 1e-6,
791                "uniform image should have near-zero variance: {v}"
792            );
793        }
794    }
795
796    #[test]
797    fn test_radial_variance_self_similarity() {
798        let data: Vec<u8> = (0..64 * 64).map(|i| (i % 256) as u8).collect();
799        let profile = RadialVarianceProfile::compute(64, 64, &data);
800        let sim = profile.similarity(&profile);
801        assert!((sim - 1.0).abs() < 1e-10, "self-similarity should be 1.0");
802    }
803
804    #[test]
805    fn test_radial_variance_different_images() {
806        let data_a = vec![100u8; 64 * 64];
807        let data_b: Vec<u8> = (0..64 * 64).map(|i| ((i * 7) % 256) as u8).collect();
808        let pa = RadialVarianceProfile::compute(64, 64, &data_a);
809        let pb = RadialVarianceProfile::compute(64, 64, &data_b);
810        let sim = pa.similarity(&pb);
811        // Uniform vs noisy: should be low similarity.
812        assert!(
813            sim < 0.5,
814            "different images should have low radial similarity: {sim}"
815        );
816    }
817
818    #[test]
819    fn test_temporal_rhythm_constant() {
820        let changes = vec![5.0; 100];
821        let rhythm = TemporalRhythm::from_frame_changes(&changes);
822        // All bins should be 1.0 (constant normalised to max).
823        for &b in &rhythm.bins {
824            assert!((b - 1.0).abs() < 1e-6, "constant changes -> all bins = 1.0");
825        }
826    }
827
828    #[test]
829    fn test_temporal_rhythm_empty() {
830        let rhythm = TemporalRhythm::from_frame_changes(&[]);
831        for &b in &rhythm.bins {
832            assert_eq!(b, 0.0);
833        }
834    }
835
836    #[test]
837    fn test_temporal_rhythm_self_similarity() {
838        let changes: Vec<f64> = (0..200)
839            .map(|i| (i as f64 * 0.1).sin().abs() * 10.0)
840            .collect();
841        let rhythm = TemporalRhythm::from_frame_changes(&changes);
842        let sim = rhythm.similarity(&rhythm);
843        assert!((sim - 1.0).abs() < 1e-10);
844    }
845
846    #[test]
847    fn test_spectral_peaks_identical() {
848        let peaks = vec![(1, 10), (2, 20), (5, 50)];
849        let a = SpectralPeakConstellation::new(peaks.clone());
850        let b = SpectralPeakConstellation::new(peaks);
851        let sim = a.similarity(&b);
852        assert!((sim - 1.0).abs() < 1e-10, "identical peaks should be 1.0");
853    }
854
855    #[test]
856    fn test_spectral_peaks_no_overlap() {
857        let a = SpectralPeakConstellation::new(vec![(0, 0), (1, 1)]);
858        let b = SpectralPeakConstellation::new(vec![(100, 100), (200, 200)]);
859        let sim = a.similarity(&b);
860        assert_eq!(sim, 0.0);
861    }
862
863    #[test]
864    fn test_spectral_peaks_tolerance() {
865        // Peaks differ by ±1 in each dimension — should still match.
866        let a = SpectralPeakConstellation::new(vec![(10, 20)]);
867        let b = SpectralPeakConstellation::new(vec![(11, 21)]);
868        let sim = a.similarity(&b);
869        assert!(sim > 0.0, "peaks within tolerance should match");
870    }
871
872    #[test]
873    fn test_spectral_peaks_empty() {
874        let a = SpectralPeakConstellation::new(vec![]);
875        let b = SpectralPeakConstellation::new(vec![]);
876        assert_eq!(a.similarity(&b), 1.0);
877    }
878
879    #[test]
880    fn test_spectral_peaks_truncation() {
881        let many: Vec<(u32, u32)> = (0..100).map(|i| (i, i * 2)).collect();
882        let constellation = SpectralPeakConstellation::new(many);
883        assert!(constellation.peaks.len() <= SPECTRAL_PEAKS);
884    }
885
886    #[test]
887    fn test_robust_signature_identical() {
888        let peaks = vec![(1, 10), (5, 50)];
889        let radial_data: Vec<u8> = (0..32 * 32).map(|i| (i % 256) as u8).collect();
890        let changes: Vec<f64> = (0..100).map(|i| (i as f64).sin().abs() * 20.0).collect();
891
892        let sig_a = RobustSignature::new("asset_a")
893            .with_phash(0xDEAD_BEEF_CAFE_BABE)
894            .with_radial(RadialVarianceProfile::compute(32, 32, &radial_data))
895            .with_temporal(TemporalRhythm::from_frame_changes(&changes))
896            .with_spectral(SpectralPeakConstellation::new(peaks.clone()))
897            .with_duration(120.0);
898
899        let sig_b = RobustSignature::new("asset_b")
900            .with_phash(0xDEAD_BEEF_CAFE_BABE)
901            .with_radial(RadialVarianceProfile::compute(32, 32, &radial_data))
902            .with_temporal(TemporalRhythm::from_frame_changes(&changes))
903            .with_spectral(SpectralPeakConstellation::new(peaks))
904            .with_duration(120.0);
905
906        let result = sig_a.compare(&sig_b);
907        assert!(
908            result.overall_score > 0.99,
909            "identical sigs should match: {}",
910            result.overall_score
911        );
912        assert!(result.is_match(0.95));
913        assert_eq!(result.contributing_signals(), 4);
914    }
915
916    #[test]
917    fn test_robust_signature_different() {
918        let sig_a = RobustSignature::new("a")
919            .with_phash(0x0000_0000_0000_0000)
920            .with_duration(120.0);
921        let sig_b = RobustSignature::new("b")
922            .with_phash(0xFFFF_FFFF_FFFF_FFFF)
923            .with_duration(120.0);
924
925        let result = sig_a.compare(&sig_b);
926        assert!(
927            result.overall_score < 0.1,
928            "very different sigs: {}",
929            result.overall_score
930        );
931    }
932
933    #[test]
934    fn test_robust_signature_duration_reject() {
935        let sig_a = RobustSignature::new("a")
936            .with_phash(0xDEAD_BEEF)
937            .with_duration(60.0);
938        let sig_b = RobustSignature::new("b")
939            .with_phash(0xDEAD_BEEF)
940            .with_duration(120.0);
941
942        let result = sig_a.compare(&sig_b);
943        assert_eq!(result.overall_score, 0.0, "duration mismatch should reject");
944    }
945
946    #[test]
947    fn test_robust_signature_partial_signals() {
948        // Only phash available on both.
949        let sig_a = RobustSignature::new("a").with_phash(0xAAAA);
950        let sig_b = RobustSignature::new("b").with_phash(0xAAAA);
951
952        let result = sig_a.compare(&sig_b);
953        assert!(result.overall_score > 0.99);
954        assert_eq!(result.contributing_signals(), 1);
955    }
956
957    #[test]
958    fn test_robust_signature_no_signals() {
959        let sig_a = RobustSignature::new("a");
960        let sig_b = RobustSignature::new("b");
961        let result = sig_a.compare(&sig_b);
962        assert_eq!(result.overall_score, 0.0);
963        assert_eq!(result.contributing_signals(), 0);
964    }
965
966    #[test]
967    fn test_robust_signature_signal_count() {
968        let sig = RobustSignature::new("a")
969            .with_phash(0x1234)
970            .with_spectral(SpectralPeakConstellation::new(vec![(1, 2)]));
971        assert_eq!(sig.signal_count(), 2);
972    }
973
974    #[test]
975    fn test_robust_signature_watermark_resilience() {
976        // Simulating watermark: same base image with a few pixel changes.
977        // The radial variance should remain similar because the global
978        // structure is unchanged.
979        let base: Vec<u8> = (0..64 * 64).map(|i| (i % 256) as u8).collect();
980        let mut watermarked = base.clone();
981        // Add a "watermark" in the corner (change 100 pixels).
982        for i in 0..100 {
983            if i < watermarked.len() {
984                watermarked[i] = 255;
985            }
986        }
987
988        let pa = RadialVarianceProfile::compute(64, 64, &base);
989        let pb = RadialVarianceProfile::compute(64, 64, &watermarked);
990        let sim = pa.similarity(&pb);
991        assert!(
992            sim > 0.8,
993            "watermarked image should still be similar: {sim}"
994        );
995    }
996}