Skip to main content

jugar_probar/pixel_coverage/
metrics.rs

1//! Pixel-Perfect Verification Metrics (PIXEL-001 v2.1 Phase 6)
2//!
3//! Film-study methodology image comparison metrics:
4//! - SSIM (Structural Similarity Index)
5//! - PSNR (Peak Signal-to-Noise Ratio)
6//! - CIEDE2000 (CIE Delta E 2000 color difference)
7//! - PHash (Perceptual Hashing)
8
9use super::heatmap::Rgb;
10
11// ============================================================================
12// SSIM - Structural Similarity Index (Wang et al., 2004)
13// ============================================================================
14
15/// Structural Similarity Index Measure
16/// Range: -1 to 1 (1 = identical, 0 = no similarity)
17#[derive(Debug, Clone)]
18pub struct SsimMetric {
19    /// Window size for local comparison
20    pub window_size: u32,
21    /// Threshold for "pixel perfect"
22    pub perfect_threshold: f32,
23    /// Threshold for "acceptable"
24    pub accept_threshold: f32,
25}
26
27impl Default for SsimMetric {
28    fn default() -> Self {
29        Self {
30            window_size: 11,
31            perfect_threshold: 0.99,
32            accept_threshold: 0.95,
33        }
34    }
35}
36
37/// Result of SSIM comparison
38#[derive(Debug, Clone)]
39pub struct SsimResult {
40    /// Overall SSIM score (-1.0 to 1.0)
41    pub score: f32,
42    /// Whether images are pixel-perfect
43    pub is_perfect: bool,
44    /// Whether images are acceptable
45    pub is_acceptable: bool,
46    /// Per-channel SSIM (R, G, B)
47    pub channel_scores: [f32; 3],
48}
49
50impl SsimMetric {
51    /// Create new SSIM metric
52    #[must_use]
53    pub fn new(window_size: u32) -> Self {
54        Self {
55            window_size,
56            ..Default::default()
57        }
58    }
59
60    /// Set thresholds
61    #[must_use]
62    pub fn with_thresholds(mut self, perfect: f32, acceptable: f32) -> Self {
63        self.perfect_threshold = perfect;
64        self.accept_threshold = acceptable;
65        self
66    }
67
68    /// Compare two images represented as RGB pixel arrays
69    /// Images must have same dimensions
70    #[must_use]
71    pub fn compare(
72        &self,
73        reference: &[Rgb],
74        generated: &[Rgb],
75        width: u32,
76        height: u32,
77    ) -> SsimResult {
78        if reference.len() != generated.len() {
79            return SsimResult {
80                score: 0.0,
81                is_perfect: false,
82                is_acceptable: false,
83                channel_scores: [0.0, 0.0, 0.0],
84            };
85        }
86
87        // Calculate SSIM for each channel
88        let r_ref: Vec<f32> = reference.iter().map(|p| p.r as f32).collect();
89        let r_gen: Vec<f32> = generated.iter().map(|p| p.r as f32).collect();
90        let g_ref: Vec<f32> = reference.iter().map(|p| p.g as f32).collect();
91        let g_gen: Vec<f32> = generated.iter().map(|p| p.g as f32).collect();
92        let b_ref: Vec<f32> = reference.iter().map(|p| p.b as f32).collect();
93        let b_gen: Vec<f32> = generated.iter().map(|p| p.b as f32).collect();
94
95        let r_ssim = self.calculate_channel_ssim(&r_ref, &r_gen, width, height);
96        let g_ssim = self.calculate_channel_ssim(&g_ref, &g_gen, width, height);
97        let b_ssim = self.calculate_channel_ssim(&b_ref, &b_gen, width, height);
98
99        // Average across channels (luminance-weighted would be more accurate)
100        let score = (r_ssim + g_ssim + b_ssim) / 3.0;
101
102        SsimResult {
103            score,
104            is_perfect: score >= self.perfect_threshold,
105            is_acceptable: score >= self.accept_threshold,
106            channel_scores: [r_ssim, g_ssim, b_ssim],
107        }
108    }
109
110    /// Calculate SSIM for a single channel
111    fn calculate_channel_ssim(
112        &self,
113        reference: &[f32],
114        generated: &[f32],
115        _width: u32,
116        _height: u32,
117    ) -> f32 {
118        // SSIM constants (for 8-bit images)
119        let k1: f32 = 0.01;
120        let k2: f32 = 0.03;
121        let l: f32 = 255.0; // Dynamic range
122        let c1 = (k1 * l).powi(2);
123        let c2 = (k2 * l).powi(2);
124
125        // For simplicity, use global statistics (full-image SSIM)
126        // A proper implementation would use sliding windows
127        let n = reference.len() as f32;
128
129        // Mean
130        let mean_ref: f32 = reference.iter().sum::<f32>() / n;
131        let mean_gen: f32 = generated.iter().sum::<f32>() / n;
132
133        // Variance and covariance
134        let var_ref: f32 = reference
135            .iter()
136            .map(|&x| (x - mean_ref).powi(2))
137            .sum::<f32>()
138            / n;
139        let var_gen: f32 = generated
140            .iter()
141            .map(|&x| (x - mean_gen).powi(2))
142            .sum::<f32>()
143            / n;
144        let covar: f32 = reference
145            .iter()
146            .zip(generated.iter())
147            .map(|(&r, &g)| (r - mean_ref) * (g - mean_gen))
148            .sum::<f32>()
149            / n;
150
151        // SSIM formula
152        let numerator = (2.0 * mean_ref * mean_gen + c1) * (2.0 * covar + c2);
153        let denominator = (mean_ref.powi(2) + mean_gen.powi(2) + c1) * (var_ref + var_gen + c2);
154
155        if denominator > 0.0 {
156            numerator / denominator
157        } else {
158            1.0 // Identical zero images
159        }
160    }
161}
162
163// ============================================================================
164// PSNR - Peak Signal-to-Noise Ratio
165// ============================================================================
166
167/// Peak Signal-to-Noise Ratio metric
168#[derive(Debug, Clone)]
169pub struct PsnrMetric {
170    /// Maximum pixel value (255 for 8-bit)
171    pub max_value: f32,
172    /// Threshold for excellent quality (dB)
173    pub excellent_threshold: f32,
174    /// Threshold for acceptable quality (dB)
175    pub acceptable_threshold: f32,
176}
177
178impl Default for PsnrMetric {
179    fn default() -> Self {
180        Self {
181            max_value: 255.0,
182            excellent_threshold: 40.0,
183            acceptable_threshold: 30.0,
184        }
185    }
186}
187
188/// Result of PSNR comparison
189#[derive(Debug, Clone)]
190pub struct PsnrResult {
191    /// PSNR value in dB (higher = better, infinity = identical)
192    pub psnr_db: f32,
193    /// Mean Squared Error
194    pub mse: f32,
195    /// Quality classification
196    pub quality: PsnrQuality,
197}
198
199/// PSNR quality classification
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum PsnrQuality {
202    /// Identical images (PSNR = infinity)
203    Identical,
204    /// Excellent quality (PSNR >= 40 dB)
205    Excellent,
206    /// Good quality (PSNR >= 35 dB)
207    Good,
208    /// Acceptable (PSNR >= 30 dB)
209    Acceptable,
210    /// Poor quality
211    Poor,
212}
213
214impl PsnrMetric {
215    /// Compare two images
216    #[must_use]
217    pub fn compare(&self, reference: &[Rgb], generated: &[Rgb]) -> PsnrResult {
218        if reference.len() != generated.len() || reference.is_empty() {
219            return PsnrResult {
220                psnr_db: 0.0,
221                mse: f32::MAX,
222                quality: PsnrQuality::Poor,
223            };
224        }
225
226        // Calculate MSE
227        let mse: f32 = reference
228            .iter()
229            .zip(generated.iter())
230            .map(|(r, g)| {
231                let dr = (r.r as f32 - g.r as f32).powi(2);
232                let dg = (r.g as f32 - g.g as f32).powi(2);
233                let db = (r.b as f32 - g.b as f32).powi(2);
234                (dr + dg + db) / 3.0
235            })
236            .sum::<f32>()
237            / reference.len() as f32;
238
239        let (psnr_db, quality) = if mse < f32::EPSILON {
240            (f32::INFINITY, PsnrQuality::Identical)
241        } else {
242            let psnr = 10.0 * (self.max_value.powi(2) / mse).log10();
243            let quality = if psnr >= self.excellent_threshold {
244                PsnrQuality::Excellent
245            } else if psnr >= 35.0 {
246                PsnrQuality::Good
247            } else if psnr >= self.acceptable_threshold {
248                PsnrQuality::Acceptable
249            } else {
250                PsnrQuality::Poor
251            };
252            (psnr, quality)
253        };
254
255        PsnrResult {
256            psnr_db,
257            mse,
258            quality,
259        }
260    }
261}
262
263// ============================================================================
264// CIEDE2000 - CIE Delta E 2000 Color Difference
265// ============================================================================
266
267/// Lab color space representation
268#[derive(Debug, Clone, Copy, Default)]
269pub struct Lab {
270    /// Lightness (0-100)
271    pub l: f32,
272    /// Green-Red axis (-128 to 127)
273    pub a: f32,
274    /// Blue-Yellow axis (-128 to 127)
275    pub b: f32,
276}
277
278impl Lab {
279    /// Create a new Lab color
280    #[must_use]
281    pub fn new(l: f32, a: f32, b: f32) -> Self {
282        Self { l, a, b }
283    }
284
285    /// Convert RGB to Lab color space
286    #[must_use]
287    #[allow(clippy::excessive_precision)] // Standard CIE colorimetric constants
288    #[allow(clippy::many_single_char_names)] // Standard colorimetric variable names (r,g,b,x,y,z)
289    pub fn from_rgb(rgb: &Rgb) -> Self {
290        // RGB to XYZ (assuming sRGB)
291        let r = Self::srgb_to_linear(rgb.r as f32 / 255.0);
292        let g = Self::srgb_to_linear(rgb.g as f32 / 255.0);
293        let b = Self::srgb_to_linear(rgb.b as f32 / 255.0);
294
295        // sRGB to XYZ (D65 illuminant)
296        let x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375;
297        let y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
298        let z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041;
299
300        // XYZ to Lab (D65 reference white)
301        let xn = 0.95047;
302        let yn = 1.00000;
303        let zn = 1.08883;
304
305        let fx = Self::f_xyz(x / xn);
306        let fy = Self::f_xyz(y / yn);
307        let fz = Self::f_xyz(z / zn);
308
309        Self {
310            l: 116.0 * fy - 16.0,
311            a: 500.0 * (fx - fy),
312            b: 200.0 * (fy - fz),
313        }
314    }
315
316    fn srgb_to_linear(c: f32) -> f32 {
317        if c <= 0.04045 {
318            c / 12.92
319        } else {
320            ((c + 0.055) / 1.055).powf(2.4)
321        }
322    }
323
324    fn f_xyz(t: f32) -> f32 {
325        let delta: f32 = 6.0 / 29.0;
326        if t > delta.powi(3) {
327            t.cbrt()
328        } else {
329            t / (3.0 * delta.powi(2)) + 4.0 / 29.0
330        }
331    }
332}
333
334/// CIEDE2000 color difference metric (ISO/CIE 11664-6:2014)
335#[derive(Debug, Clone)]
336pub struct CieDe2000Metric {
337    /// Perceptibility threshold (JND)
338    pub jnd_threshold: f32,
339    /// Acceptability threshold
340    pub accept_threshold: f32,
341    /// Weighting factors (kL, kC, kH)
342    pub weights: (f32, f32, f32),
343}
344
345impl Default for CieDe2000Metric {
346    fn default() -> Self {
347        Self {
348            jnd_threshold: 1.0,
349            accept_threshold: 2.0,
350            weights: (1.0, 1.0, 1.0), // Unity weights (typical)
351        }
352    }
353}
354
355/// Result of CIEDE2000 comparison
356#[derive(Debug, Clone)]
357pub struct DeltaEResult {
358    /// Mean ΔE₀₀ across all pixels
359    pub mean_delta_e: f32,
360    /// Maximum ΔE₀₀ found
361    pub max_delta_e: f32,
362    /// Percentage of pixels below JND
363    pub percent_imperceptible: f32,
364    /// Percentage of pixels in acceptable range
365    pub percent_acceptable: f32,
366    /// Perceptibility classification
367    pub classification: DeltaEClassification,
368}
369
370/// CIEDE2000 perceptibility classification
371#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum DeltaEClassification {
373    /// Imperceptible (ΔE₀₀ < 0.8-1.0)
374    Imperceptible,
375    /// Just noticeable (1.0 < ΔE₀₀ < 1.8)
376    JustNoticeable,
377    /// Acceptable (1.8 < ΔE₀₀ < 2.8)
378    Acceptable,
379    /// Noticeable (2.8 < ΔE₀₀ < 3.7)
380    Noticeable,
381    /// Unacceptable (ΔE₀₀ >= 3.7)
382    Unacceptable,
383}
384
385impl CieDe2000Metric {
386    /// Compare two Lab colors and return ΔE₀₀
387    #[must_use]
388    pub fn delta_e(&self, lab1: &Lab, lab2: &Lab) -> f32 {
389        let (kl, kc, kh) = self.weights;
390
391        // Calculate C'ab and h'ab for both colors
392        let c1 = (lab1.a.powi(2) + lab1.b.powi(2)).sqrt();
393        let c2 = (lab2.a.powi(2) + lab2.b.powi(2)).sqrt();
394        let c_avg = (c1 + c2) / 2.0;
395
396        // G factor
397        let c_avg_7 = c_avg.powi(7);
398        let g = 0.5 * (1.0 - (c_avg_7 / (c_avg_7 + 25.0_f32.powi(7))).sqrt());
399
400        // a' values
401        let a1_prime = lab1.a * (1.0 + g);
402        let a2_prime = lab2.a * (1.0 + g);
403
404        // C'ab
405        let c1_prime = (a1_prime.powi(2) + lab1.b.powi(2)).sqrt();
406        let c2_prime = (a2_prime.powi(2) + lab2.b.powi(2)).sqrt();
407
408        // h'ab (in degrees)
409        let h1_prime = if a1_prime.abs() < f32::EPSILON && lab1.b.abs() < f32::EPSILON {
410            0.0
411        } else {
412            lab1.b.atan2(a1_prime).to_degrees().rem_euclid(360.0)
413        };
414        let h2_prime = if a2_prime.abs() < f32::EPSILON && lab2.b.abs() < f32::EPSILON {
415            0.0
416        } else {
417            lab2.b.atan2(a2_prime).to_degrees().rem_euclid(360.0)
418        };
419
420        // Differences
421        let delta_l_prime = lab2.l - lab1.l;
422        let delta_c_prime = c2_prime - c1_prime;
423
424        let delta_h_prime_deg = if c1_prime * c2_prime < f32::EPSILON {
425            0.0
426        } else {
427            let dh = h2_prime - h1_prime;
428            if dh.abs() <= 180.0 {
429                dh
430            } else if dh > 180.0 {
431                dh - 360.0
432            } else {
433                dh + 360.0
434            }
435        };
436
437        let delta_h_prime =
438            2.0 * (c1_prime * c2_prime).sqrt() * (delta_h_prime_deg.to_radians() / 2.0).sin();
439
440        // Means
441        let l_prime_avg = (lab1.l + lab2.l) / 2.0;
442        let c_prime_avg = (c1_prime + c2_prime) / 2.0;
443
444        let h_prime_avg = if c1_prime * c2_prime < f32::EPSILON {
445            h1_prime + h2_prime
446        } else {
447            let diff = (h1_prime - h2_prime).abs();
448            if diff <= 180.0 {
449                (h1_prime + h2_prime) / 2.0
450            } else if h1_prime + h2_prime < 360.0 {
451                (h1_prime + h2_prime + 360.0) / 2.0
452            } else {
453                (h1_prime + h2_prime - 360.0) / 2.0
454            }
455        };
456
457        // Weighting functions
458        let t = 1.0 - 0.17 * (h_prime_avg - 30.0).to_radians().cos()
459            + 0.24 * (2.0 * h_prime_avg).to_radians().cos()
460            + 0.32 * (3.0 * h_prime_avg + 6.0).to_radians().cos()
461            - 0.20 * (4.0 * h_prime_avg - 63.0).to_radians().cos();
462
463        let delta_theta = 30.0 * (-((h_prime_avg - 275.0) / 25.0).powi(2)).exp();
464
465        let c_prime_avg_7 = c_prime_avg.powi(7);
466        let rc = 2.0 * (c_prime_avg_7 / (c_prime_avg_7 + 25.0_f32.powi(7))).sqrt();
467
468        let l_50_sq = (l_prime_avg - 50.0).powi(2);
469        let sl = 1.0 + (0.015 * l_50_sq) / (20.0 + l_50_sq).sqrt();
470        let sc = 1.0 + 0.045 * c_prime_avg;
471        let sh = 1.0 + 0.015 * c_prime_avg * t;
472        let rt = -(2.0 * delta_theta).to_radians().sin() * rc;
473
474        // Final ΔE₀₀
475        let dl = delta_l_prime / (kl * sl);
476        let dc = delta_c_prime / (kc * sc);
477        let dh = delta_h_prime / (kh * sh);
478
479        (dl.powi(2) + dc.powi(2) + dh.powi(2) + rt * dc * dh).sqrt()
480    }
481
482    /// Compare two images
483    #[must_use]
484    pub fn compare(&self, reference: &[Rgb], generated: &[Rgb]) -> DeltaEResult {
485        if reference.len() != generated.len() || reference.is_empty() {
486            return DeltaEResult {
487                mean_delta_e: f32::MAX,
488                max_delta_e: f32::MAX,
489                percent_imperceptible: 0.0,
490                percent_acceptable: 0.0,
491                classification: DeltaEClassification::Unacceptable,
492            };
493        }
494
495        let mut sum_de = 0.0f32;
496        let mut max_delta_e = 0.0f32;
497        let mut imperceptible_count = 0u32;
498        let mut acceptable_count = 0u32;
499
500        for (r, g) in reference.iter().zip(generated.iter()) {
501            let lab1 = Lab::from_rgb(r);
502            let lab2 = Lab::from_rgb(g);
503            let de = self.delta_e(&lab1, &lab2);
504
505            sum_de += de;
506            max_delta_e = max_delta_e.max(de);
507
508            if de < self.jnd_threshold {
509                imperceptible_count += 1;
510            }
511            if de < self.accept_threshold {
512                acceptable_count += 1;
513            }
514        }
515
516        let n = reference.len() as f32;
517        let mean_delta_e = sum_de / n;
518
519        let classification = if mean_delta_e < 0.8 {
520            DeltaEClassification::Imperceptible
521        } else if mean_delta_e < 1.8 {
522            DeltaEClassification::JustNoticeable
523        } else if mean_delta_e < 2.8 {
524            DeltaEClassification::Acceptable
525        } else if mean_delta_e < 3.7 {
526            DeltaEClassification::Noticeable
527        } else {
528            DeltaEClassification::Unacceptable
529        };
530
531        DeltaEResult {
532            mean_delta_e,
533            max_delta_e,
534            percent_imperceptible: imperceptible_count as f32 / n * 100.0,
535            percent_acceptable: acceptable_count as f32 / n * 100.0,
536            classification,
537        }
538    }
539
540    /// Is the difference imperceptible?
541    #[must_use]
542    pub fn is_imperceptible(&self, delta_e: f32) -> bool {
543        delta_e < self.jnd_threshold
544    }
545}
546
547// ============================================================================
548// Perceptual Hash (PHash)
549// ============================================================================
550
551/// Perceptual hash algorithm selection
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
553pub enum PhashAlgorithm {
554    /// Average hash (fastest, least robust)
555    AHash,
556    /// Difference hash (good balance)
557    #[default]
558    DHash,
559    /// Perceptual hash using DCT (most robust)
560    PHash,
561}
562
563/// Perceptual hash for image fingerprinting
564#[derive(Debug, Clone)]
565pub struct PerceptualHash {
566    /// Algorithm to use
567    pub algorithm: PhashAlgorithm,
568    /// Hash size in bits (must be power of 2, max 64)
569    pub hash_bits: u32,
570}
571
572impl Default for PerceptualHash {
573    fn default() -> Self {
574        Self {
575            algorithm: PhashAlgorithm::DHash,
576            hash_bits: 64,
577        }
578    }
579}
580
581impl PerceptualHash {
582    /// Create new hasher with algorithm
583    #[must_use]
584    pub fn new(algorithm: PhashAlgorithm) -> Self {
585        Self {
586            algorithm,
587            ..Default::default()
588        }
589    }
590
591    /// Compute hash for image
592    #[must_use]
593    pub fn compute(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
594        match self.algorithm {
595            PhashAlgorithm::AHash => self.average_hash(image, width, height),
596            PhashAlgorithm::DHash => self.difference_hash(image, width, height),
597            PhashAlgorithm::PHash => self.perceptual_hash(image, width, height),
598        }
599    }
600
601    /// Average hash: compare each pixel to mean
602    fn average_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
603        // Resize to 8x8 (simple downscale)
604        let resized = self.resize_grayscale(image, width, height, 8, 8);
605
606        // Calculate mean
607        let mean: f32 = resized.iter().sum::<f32>() / 64.0;
608
609        // Generate hash
610        let mut hash: u64 = 0;
611        for (i, &pixel) in resized.iter().enumerate() {
612            if pixel > mean {
613                hash |= 1 << i;
614            }
615        }
616        hash
617    }
618
619    /// Difference hash: compare adjacent pixels
620    fn difference_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
621        // Resize to 9x8 for 8x8 = 64 bit differences
622        let resized = self.resize_grayscale(image, width, height, 9, 8);
623
624        let mut hash: u64 = 0;
625        let mut bit = 0;
626        for row in 0..8 {
627            for col in 0..8 {
628                let idx = row * 9 + col;
629                if resized[idx] < resized[idx + 1] {
630                    hash |= 1 << bit;
631                }
632                bit += 1;
633            }
634        }
635        hash
636    }
637
638    /// Perceptual hash using simplified DCT
639    fn perceptual_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
640        // Resize to 32x32
641        let resized = self.resize_grayscale(image, width, height, 32, 32);
642
643        // Simple DCT-like transform (top-left 8x8 low frequencies)
644        let mut dct = vec![0.0f32; 64];
645        for u in 0..8 {
646            for v in 0..8 {
647                let mut sum = 0.0f32;
648                for x in 0..32 {
649                    for y in 0..32 {
650                        let cu = std::f32::consts::PI * (2.0 * x as f32 + 1.0) * u as f32 / 64.0;
651                        let cv = std::f32::consts::PI * (2.0 * y as f32 + 1.0) * v as f32 / 64.0;
652                        sum += resized[(x * 32 + y) as usize] * cu.cos() * cv.cos();
653                    }
654                }
655                dct[(u * 8 + v) as usize] = sum;
656            }
657        }
658
659        // Skip DC component (index 0), use low frequencies
660        let mean: f32 = dct[1..].iter().sum::<f32>() / 63.0;
661
662        let mut hash: u64 = 0;
663        for (i, &val) in dct[1..].iter().take(64).enumerate() {
664            if val > mean {
665                hash |= 1 << i;
666            }
667        }
668        hash
669    }
670
671    /// Resize to grayscale
672    fn resize_grayscale(
673        &self,
674        image: &[Rgb],
675        width: u32,
676        height: u32,
677        new_width: u32,
678        new_height: u32,
679    ) -> Vec<f32> {
680        let mut result = vec![0.0f32; (new_width * new_height) as usize];
681
682        let x_ratio = width as f32 / new_width as f32;
683        let y_ratio = height as f32 / new_height as f32;
684
685        for y in 0..new_height {
686            for x in 0..new_width {
687                let src_x = (x as f32 * x_ratio) as u32;
688                let src_y = (y as f32 * y_ratio) as u32;
689                let src_idx = (src_y * width + src_x) as usize;
690
691                if src_idx < image.len() {
692                    let pixel = &image[src_idx];
693                    // Luminance conversion
694                    let gray =
695                        0.299 * pixel.r as f32 + 0.587 * pixel.g as f32 + 0.114 * pixel.b as f32;
696                    result[(y * new_width + x) as usize] = gray;
697                }
698            }
699        }
700
701        result
702    }
703
704    /// Hamming distance between hashes (0 = identical)
705    #[must_use]
706    pub fn distance(hash1: u64, hash2: u64) -> u32 {
707        (hash1 ^ hash2).count_ones()
708    }
709
710    /// Are images perceptually similar? (distance <= threshold)
711    #[must_use]
712    pub fn is_similar(hash1: u64, hash2: u64, threshold: u32) -> bool {
713        Self::distance(hash1, hash2) <= threshold
714    }
715}
716
717// ============================================================================
718// Combined Verification Suite
719// ============================================================================
720
721/// Complete pixel-perfect verification suite
722#[derive(Debug, Clone, Default)]
723pub struct PixelVerificationSuite {
724    /// SSIM metric
725    pub ssim: SsimMetric,
726    /// PSNR metric
727    pub psnr: PsnrMetric,
728    /// CIEDE2000 metric
729    pub delta_e: CieDe2000Metric,
730    /// Perceptual hash
731    pub phash: PerceptualHash,
732}
733
734/// Complete verification result for pixel-perfect comparison
735#[derive(Debug, Clone)]
736pub struct PixelVerificationResult {
737    /// SSIM result
738    pub ssim: SsimResult,
739    /// PSNR result
740    pub psnr: PsnrResult,
741    /// Delta E result
742    pub delta_e: DeltaEResult,
743    /// Perceptual hash distance
744    pub phash_distance: u32,
745    /// Overall pass/fail
746    pub passes: bool,
747}
748
749impl PixelVerificationSuite {
750    /// Run all verification metrics
751    #[must_use]
752    pub fn verify(
753        &self,
754        reference: &[Rgb],
755        generated: &[Rgb],
756        width: u32,
757        height: u32,
758    ) -> PixelVerificationResult {
759        let ssim = self.ssim.compare(reference, generated, width, height);
760        let psnr = self.psnr.compare(reference, generated);
761        let delta_e = self.delta_e.compare(reference, generated);
762
763        let ref_hash = self.phash.compute(reference, width, height);
764        let gen_hash = self.phash.compute(generated, width, height);
765        let phash_distance = PerceptualHash::distance(ref_hash, gen_hash);
766
767        // Overall pass: SSIM acceptable AND Delta E acceptable AND PHash similar
768        let passes = ssim.is_acceptable
769            && delta_e.classification != DeltaEClassification::Unacceptable
770            && phash_distance <= 10;
771
772        PixelVerificationResult {
773            ssim,
774            psnr,
775            delta_e,
776            phash_distance,
777            passes,
778        }
779    }
780}
781
782#[cfg(test)]
783#[allow(clippy::unwrap_used)]
784mod tests {
785    use super::*;
786
787    fn test_image_white(size: usize) -> Vec<Rgb> {
788        vec![Rgb::new(255, 255, 255); size]
789    }
790
791    fn test_image_black(size: usize) -> Vec<Rgb> {
792        vec![Rgb::new(0, 0, 0); size]
793    }
794
795    fn test_image_gray(size: usize) -> Vec<Rgb> {
796        vec![Rgb::new(128, 128, 128); size]
797    }
798
799    // =========================================================================
800    // SSIM Tests (H0-SSIM-XX)
801    // =========================================================================
802
803    #[test]
804    fn h0_ssim_01_identical_images() {
805        let img = test_image_white(100);
806        let ssim = SsimMetric::default();
807        let result = ssim.compare(&img, &img, 10, 10);
808        assert!(result.score >= 0.99);
809        assert!(result.is_perfect);
810    }
811
812    #[test]
813    fn h0_ssim_02_completely_different() {
814        let white = test_image_white(100);
815        let black = test_image_black(100);
816        let ssim = SsimMetric::default();
817        let result = ssim.compare(&white, &black, 10, 10);
818        assert!(result.score < 0.5);
819        assert!(!result.is_acceptable);
820    }
821
822    #[test]
823    fn h0_ssim_03_similar_images() {
824        let gray1: Vec<Rgb> = (0..100).map(|_| Rgb::new(128, 128, 128)).collect();
825        let gray2: Vec<Rgb> = (0..100).map(|_| Rgb::new(130, 130, 130)).collect();
826        let ssim = SsimMetric::default();
827        let result = ssim.compare(&gray1, &gray2, 10, 10);
828        assert!(result.score > 0.95);
829        assert!(result.is_acceptable);
830    }
831
832    #[test]
833    fn h0_ssim_04_mismatched_lengths() {
834        let img1 = test_image_white(100);
835        let img2 = test_image_white(50);
836        let ssim = SsimMetric::default();
837        let result = ssim.compare(&img1, &img2, 10, 10);
838        assert_eq!(result.score, 0.0);
839        assert!(!result.is_perfect);
840        assert!(!result.is_acceptable);
841        assert_eq!(result.channel_scores, [0.0, 0.0, 0.0]);
842    }
843
844    #[test]
845    fn h0_ssim_05_new_constructor() {
846        let ssim = SsimMetric::new(7);
847        assert_eq!(ssim.window_size, 7);
848        assert_eq!(ssim.perfect_threshold, 0.99);
849        assert_eq!(ssim.accept_threshold, 0.95);
850    }
851
852    #[test]
853    fn h0_ssim_06_with_thresholds() {
854        let ssim = SsimMetric::default().with_thresholds(0.98, 0.90);
855        assert_eq!(ssim.perfect_threshold, 0.98);
856        assert_eq!(ssim.accept_threshold, 0.90);
857    }
858
859    #[test]
860    fn h0_ssim_07_zero_denominator_branch() {
861        // Test the case where denominator could be zero (identical zero images)
862        let zeros = vec![Rgb::new(0, 0, 0); 64];
863        let ssim = SsimMetric::default();
864        let result = ssim.compare(&zeros, &zeros, 8, 8);
865        // Both images are identical black - should have perfect SSIM
866        assert!(result.score >= 0.99);
867    }
868
869    // =========================================================================
870    // PSNR Tests (H0-PSNR-XX)
871    // =========================================================================
872
873    #[test]
874    fn h0_psnr_01_identical_images() {
875        let img = test_image_gray(100);
876        let psnr = PsnrMetric::default();
877        let result = psnr.compare(&img, &img);
878        assert!(result.psnr_db.is_infinite());
879        assert_eq!(result.quality, PsnrQuality::Identical);
880    }
881
882    #[test]
883    fn h0_psnr_02_slight_difference() {
884        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
885        let gray2: Vec<Rgb> = vec![Rgb::new(129, 128, 128); 100];
886        let psnr = PsnrMetric::default();
887        let result = psnr.compare(&gray1, &gray2);
888        assert!(result.psnr_db > 40.0); // Very high quality
889    }
890
891    #[test]
892    fn h0_psnr_03_major_difference() {
893        let white = test_image_white(100);
894        let black = test_image_black(100);
895        let psnr = PsnrMetric::default();
896        let result = psnr.compare(&white, &black);
897        assert!(result.psnr_db < 10.0);
898        assert_eq!(result.quality, PsnrQuality::Poor);
899    }
900
901    #[test]
902    fn h0_psnr_04_mismatched_lengths() {
903        let img1 = test_image_gray(100);
904        let img2 = test_image_gray(50);
905        let psnr = PsnrMetric::default();
906        let result = psnr.compare(&img1, &img2);
907        assert_eq!(result.psnr_db, 0.0);
908        assert_eq!(result.mse, f32::MAX);
909        assert_eq!(result.quality, PsnrQuality::Poor);
910    }
911
912    #[test]
913    fn h0_psnr_05_empty_images() {
914        let empty: Vec<Rgb> = vec![];
915        let psnr = PsnrMetric::default();
916        let result = psnr.compare(&empty, &empty);
917        assert_eq!(result.psnr_db, 0.0);
918        assert_eq!(result.mse, f32::MAX);
919        assert_eq!(result.quality, PsnrQuality::Poor);
920    }
921
922    #[test]
923    fn h0_psnr_06_good_quality() {
924        // Create images with a difference that results in PSNR between 35-40 dB
925        // PSNR = 10 * log10(255^2 / MSE), MSE = diff^2 for grayscale
926        // For PSNR ~37: MSE = 65025 / 10^3.7 ≈ 13, diff ≈ 3.6
927        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
928        let gray2: Vec<Rgb> = vec![Rgb::new(132, 132, 132); 100]; // diff of 4 per channel
929        let psnr = PsnrMetric::default();
930        let result = psnr.compare(&gray1, &gray2);
931        // Should be "Good" quality (35 <= PSNR < 40)
932        assert!(result.psnr_db >= 35.0 && result.psnr_db < 40.0);
933        assert_eq!(result.quality, PsnrQuality::Good);
934    }
935
936    #[test]
937    fn h0_psnr_07_acceptable_quality() {
938        // Create images with a difference that results in PSNR between 30-35 dB
939        // For PSNR ~32: MSE = 65025 / 10^3.2 ≈ 41, diff ≈ 6.4
940        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
941        let gray2: Vec<Rgb> = vec![Rgb::new(135, 135, 135); 100]; // diff of 7 produces ~31 dB
942        let psnr = PsnrMetric::default();
943        let result = psnr.compare(&gray1, &gray2);
944        assert!(result.psnr_db >= 30.0 && result.psnr_db < 35.0);
945        assert_eq!(result.quality, PsnrQuality::Acceptable);
946    }
947
948    #[test]
949    fn h0_psnr_08_excellent_quality() {
950        // Create images with a very small difference (PSNR >= 40 dB)
951        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
952        let gray2: Vec<Rgb> = vec![Rgb::new(129, 129, 129); 100]; // tiny diff
953        let psnr = PsnrMetric::default();
954        let result = psnr.compare(&gray1, &gray2);
955        assert!(result.psnr_db >= 40.0);
956        assert_eq!(result.quality, PsnrQuality::Excellent);
957    }
958
959    // =========================================================================
960    // Lab Conversion Tests (H0-LAB-XX)
961    // =========================================================================
962
963    #[test]
964    fn h0_lab_01_white() {
965        let lab = Lab::from_rgb(&Rgb::new(255, 255, 255));
966        assert!((lab.l - 100.0).abs() < 1.0); // L should be ~100
967    }
968
969    #[test]
970    fn h0_lab_02_black() {
971        let lab = Lab::from_rgb(&Rgb::new(0, 0, 0));
972        assert!(lab.l < 1.0); // L should be ~0
973    }
974
975    #[test]
976    fn h0_lab_03_gray() {
977        let lab = Lab::from_rgb(&Rgb::new(128, 128, 128));
978        assert!(lab.l > 40.0 && lab.l < 60.0); // L should be ~53
979        assert!(lab.a.abs() < 2.0); // a should be ~0
980        assert!(lab.b.abs() < 2.0); // b should be ~0
981    }
982
983    #[test]
984    fn h0_lab_04_new_constructor() {
985        let lab = Lab::new(50.0, 25.0, -30.0);
986        assert_eq!(lab.l, 50.0);
987        assert_eq!(lab.a, 25.0);
988        assert_eq!(lab.b, -30.0);
989    }
990
991    #[test]
992    fn h0_lab_05_srgb_linear_threshold() {
993        // Test the srgb_to_linear branch for values <= 0.04045
994        // RGB values <= 10 (10/255 ≈ 0.039) should use the linear branch
995        let lab = Lab::from_rgb(&Rgb::new(5, 5, 5));
996        assert!(lab.l < 5.0); // Very dark
997    }
998
999    #[test]
1000    fn h0_lab_06_srgb_gamma_branch() {
1001        // Test the srgb_to_linear branch for values > 0.04045
1002        // RGB values > 10 should use the gamma branch
1003        let lab = Lab::from_rgb(&Rgb::new(200, 100, 50));
1004        assert!(lab.l > 40.0); // Should be reasonably bright
1005    }
1006
1007    #[test]
1008    fn h0_lab_07_f_xyz_linear_branch() {
1009        // Test f_xyz for very dark colors (t <= delta^3)
1010        let lab = Lab::from_rgb(&Rgb::new(1, 1, 1));
1011        assert!(lab.l < 2.0);
1012    }
1013
1014    #[test]
1015    fn h0_lab_08_colored_pixels() {
1016        // Test various colored pixels for a and b values
1017        let red = Lab::from_rgb(&Rgb::new(255, 0, 0));
1018        assert!(red.a > 0.0); // Red has positive a
1019
1020        let green = Lab::from_rgb(&Rgb::new(0, 255, 0));
1021        assert!(green.a < 0.0); // Green has negative a
1022
1023        let blue = Lab::from_rgb(&Rgb::new(0, 0, 255));
1024        assert!(blue.b < 0.0); // Blue has negative b
1025
1026        let yellow = Lab::from_rgb(&Rgb::new(255, 255, 0));
1027        assert!(yellow.b > 0.0); // Yellow has positive b
1028    }
1029
1030    // =========================================================================
1031    // CIEDE2000 Tests (H0-DE-XX)
1032    // =========================================================================
1033
1034    #[test]
1035    fn h0_de_01_identical_colors() {
1036        let metric = CieDe2000Metric::default();
1037        let lab = Lab::new(50.0, 0.0, 0.0);
1038        let de = metric.delta_e(&lab, &lab);
1039        assert!(de < f32::EPSILON);
1040    }
1041
1042    #[test]
1043    fn h0_de_02_just_noticeable() {
1044        let metric = CieDe2000Metric::default();
1045        let lab1 = Lab::new(50.0, 0.0, 0.0);
1046        let lab2 = Lab::new(51.0, 0.0, 0.0);
1047        let de = metric.delta_e(&lab1, &lab2);
1048        assert!(de < 2.0); // Should be small
1049    }
1050
1051    #[test]
1052    fn h0_de_03_image_comparison() {
1053        let gray1 = test_image_gray(100);
1054        let gray2: Vec<Rgb> = vec![Rgb::new(135, 135, 135); 100];
1055        let metric = CieDe2000Metric::default();
1056        let result = metric.compare(&gray1, &gray2);
1057        assert!(result.mean_delta_e < 10.0);
1058    }
1059
1060    #[test]
1061    fn h0_de_04_mismatched_lengths() {
1062        let img1 = test_image_gray(100);
1063        let img2 = test_image_gray(50);
1064        let metric = CieDe2000Metric::default();
1065        let result = metric.compare(&img1, &img2);
1066        assert_eq!(result.mean_delta_e, f32::MAX);
1067        assert_eq!(result.max_delta_e, f32::MAX);
1068        assert_eq!(result.percent_imperceptible, 0.0);
1069        assert_eq!(result.percent_acceptable, 0.0);
1070        assert_eq!(result.classification, DeltaEClassification::Unacceptable);
1071    }
1072
1073    #[test]
1074    fn h0_de_05_empty_images() {
1075        let empty: Vec<Rgb> = vec![];
1076        let metric = CieDe2000Metric::default();
1077        let result = metric.compare(&empty, &empty);
1078        assert_eq!(result.mean_delta_e, f32::MAX);
1079        assert_eq!(result.classification, DeltaEClassification::Unacceptable);
1080    }
1081
1082    #[test]
1083    fn h0_de_06_is_imperceptible() {
1084        let metric = CieDe2000Metric::default();
1085        assert!(metric.is_imperceptible(0.5));
1086        assert!(!metric.is_imperceptible(1.5));
1087    }
1088
1089    #[test]
1090    fn h0_de_07_classification_imperceptible() {
1091        let img = test_image_gray(100);
1092        let metric = CieDe2000Metric::default();
1093        let result = metric.compare(&img, &img);
1094        assert_eq!(result.classification, DeltaEClassification::Imperceptible);
1095    }
1096
1097    #[test]
1098    fn h0_de_08_classification_just_noticeable() {
1099        // Create images with small but noticeable difference (mean ΔE between 0.8 and 1.8)
1100        // Grayscale differences need larger RGB changes to reach the JustNoticeable threshold
1101        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
1102        let gray2: Vec<Rgb> = vec![Rgb::new(132, 132, 132); 100]; // diff of 4 produces ΔE ~1.0-1.5
1103        let metric = CieDe2000Metric::default();
1104        let result = metric.compare(&gray1, &gray2);
1105        assert_eq!(result.classification, DeltaEClassification::JustNoticeable);
1106    }
1107
1108    #[test]
1109    fn h0_de_09_classification_acceptable() {
1110        // Create images with acceptable difference (mean ΔE between 1.8 and 2.8)
1111        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
1112        let gray2: Vec<Rgb> = vec![Rgb::new(134, 134, 134); 100];
1113        let metric = CieDe2000Metric::default();
1114        let result = metric.compare(&gray1, &gray2);
1115        assert_eq!(result.classification, DeltaEClassification::Acceptable);
1116    }
1117
1118    #[test]
1119    fn h0_de_10_classification_noticeable() {
1120        // Create images with noticeable difference (mean ΔE between 2.8 and 3.7)
1121        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
1122        let gray2: Vec<Rgb> = vec![Rgb::new(138, 138, 138); 100];
1123        let metric = CieDe2000Metric::default();
1124        let result = metric.compare(&gray1, &gray2);
1125        assert_eq!(result.classification, DeltaEClassification::Noticeable);
1126    }
1127
1128    #[test]
1129    fn h0_de_11_classification_unacceptable() {
1130        // Create images with large difference (mean ΔE >= 3.7)
1131        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
1132        let gray2: Vec<Rgb> = vec![Rgb::new(145, 145, 145); 100];
1133        let metric = CieDe2000Metric::default();
1134        let result = metric.compare(&gray1, &gray2);
1135        assert_eq!(result.classification, DeltaEClassification::Unacceptable);
1136    }
1137
1138    #[test]
1139    fn h0_de_12_hue_angle_branches() {
1140        // Test various hue angle calculation branches in delta_e
1141        let metric = CieDe2000Metric::default();
1142
1143        // Test with achromatic colors (a=0, b=0) - should hit h_prime = 0 branch
1144        let lab1 = Lab::new(50.0, 0.0, 0.0);
1145        let lab2 = Lab::new(60.0, 0.0, 0.0);
1146        let de = metric.delta_e(&lab1, &lab2);
1147        assert!(de > 0.0);
1148
1149        // Test with chromatic colors - different hue branches
1150        let lab3 = Lab::new(50.0, 30.0, 40.0);
1151        let lab4 = Lab::new(50.0, -30.0, -40.0);
1152        let de2 = metric.delta_e(&lab3, &lab4);
1153        assert!(de2 > 0.0);
1154    }
1155
1156    #[test]
1157    fn h0_de_13_delta_h_prime_branches() {
1158        let metric = CieDe2000Metric::default();
1159
1160        // Test case where |dh| > 180 (dh > 180)
1161        let lab1 = Lab::new(50.0, 50.0, 5.0); // ~hue 6°
1162        let lab2 = Lab::new(50.0, -50.0, -5.0); // ~hue 186°
1163        let de = metric.delta_e(&lab1, &lab2);
1164        assert!(de > 0.0);
1165
1166        // Test case where dh < -180
1167        let lab3 = Lab::new(50.0, -50.0, 5.0); // ~hue 174°
1168        let lab4 = Lab::new(50.0, 50.0, -5.0); // ~hue 354°
1169        let de2 = metric.delta_e(&lab3, &lab4);
1170        assert!(de2 > 0.0);
1171    }
1172
1173    #[test]
1174    fn h0_de_14_h_prime_avg_branches() {
1175        let metric = CieDe2000Metric::default();
1176
1177        // Test h_prime_avg calculation with diff > 180 and sum < 360
1178        let lab1 = Lab::new(50.0, 40.0, 10.0); // low hue
1179        let lab2 = Lab::new(50.0, 10.0, -40.0); // high hue
1180        let de = metric.delta_e(&lab1, &lab2);
1181        assert!(de > 0.0);
1182
1183        // Test with sum >= 360
1184        let lab3 = Lab::new(50.0, -10.0, 40.0);
1185        let lab4 = Lab::new(50.0, -40.0, -10.0);
1186        let de2 = metric.delta_e(&lab3, &lab4);
1187        assert!(de2 > 0.0);
1188    }
1189
1190    #[test]
1191    fn h0_de_15_percent_calculations() {
1192        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
1193        let gray2: Vec<Rgb> = vec![Rgb::new(129, 129, 129); 100];
1194        let metric = CieDe2000Metric::default();
1195        let result = metric.compare(&gray1, &gray2);
1196        // With very small differences, most pixels should be imperceptible/acceptable
1197        assert!(result.percent_imperceptible > 0.0);
1198        assert!(result.percent_acceptable > 0.0);
1199    }
1200
1201    // =========================================================================
1202    // Perceptual Hash Tests (H0-PHASH-XX)
1203    // =========================================================================
1204
1205    #[test]
1206    fn h0_phash_01_identical_images() {
1207        let img = test_image_gray(64);
1208        let hasher = PerceptualHash::default();
1209        let hash1 = hasher.compute(&img, 8, 8);
1210        let hash2 = hasher.compute(&img, 8, 8);
1211        assert_eq!(hash1, hash2);
1212        assert_eq!(PerceptualHash::distance(hash1, hash2), 0);
1213    }
1214
1215    #[test]
1216    fn h0_phash_02_similar_images() {
1217        let gray1 = test_image_gray(64);
1218        let gray2: Vec<Rgb> = vec![Rgb::new(130, 130, 130); 64];
1219        let hasher = PerceptualHash::default();
1220        let hash1 = hasher.compute(&gray1, 8, 8);
1221        let hash2 = hasher.compute(&gray2, 8, 8);
1222        let distance = PerceptualHash::distance(hash1, hash2);
1223        assert!(distance < 10); // Should be similar
1224    }
1225
1226    #[test]
1227    fn h0_phash_03_different_images() {
1228        let white = test_image_white(64);
1229        let black = test_image_black(64);
1230        let hasher = PerceptualHash::default();
1231        let hash1 = hasher.compute(&white, 8, 8);
1232        let hash2 = hasher.compute(&black, 8, 8);
1233        // Hash of uniform images will differ based on mean comparison
1234        // Just verify it computes without panic
1235        let _ = PerceptualHash::distance(hash1, hash2);
1236    }
1237
1238    #[test]
1239    fn h0_phash_04_is_similar() {
1240        assert!(PerceptualHash::is_similar(0, 1, 5));
1241        assert!(!PerceptualHash::is_similar(0, u64::MAX, 5));
1242    }
1243
1244    #[test]
1245    fn h0_phash_05_ahash() {
1246        let img = test_image_gray(64);
1247        let hasher = PerceptualHash::new(PhashAlgorithm::AHash);
1248        let hash = hasher.compute(&img, 8, 8);
1249        // Just verify it runs without panic and produces a valid hash
1250        let _ = hash; // hash computed successfully
1251    }
1252
1253    #[test]
1254    fn h0_phash_06_phash_algorithm() {
1255        // Test the PHash (DCT-based) algorithm
1256        let img = test_image_gray(1024); // 32x32 minimum for PHash
1257        let hasher = PerceptualHash::new(PhashAlgorithm::PHash);
1258        let hash = hasher.compute(&img, 32, 32);
1259        // Verify it produces consistent results
1260        let hash2 = hasher.compute(&img, 32, 32);
1261        assert_eq!(hash, hash2);
1262    }
1263
1264    #[test]
1265    fn h0_phash_07_dhash_gradient() {
1266        // Test DHash with a gradient image
1267        let gradient: Vec<Rgb> = (0..72)
1268            .map(|i| {
1269                let v = (i * 3) as u8;
1270                Rgb::new(v, v, v)
1271            })
1272            .collect();
1273        let hasher = PerceptualHash::new(PhashAlgorithm::DHash);
1274        let hash = hasher.compute(&gradient, 9, 8);
1275        // Gradient should produce a non-trivial hash
1276        assert_ne!(hash, 0);
1277    }
1278
1279    #[test]
1280    fn h0_phash_08_resize_out_of_bounds() {
1281        // Test resize_grayscale with a very small image
1282        let small_img = vec![Rgb::new(128, 128, 128); 4]; // 2x2
1283        let hasher = PerceptualHash::default();
1284        // Compute on a 2x2 image but expecting 9x8 resize for dhash
1285        // This should handle the case where src_idx >= image.len()
1286        let hash = hasher.compute(&small_img, 2, 2);
1287        let _ = hash; // Should not panic
1288    }
1289
1290    #[test]
1291    fn h0_phash_09_average_hash_varied() {
1292        // Test average hash with varied pixel values
1293        let varied: Vec<Rgb> = (0..64)
1294            .map(|i| {
1295                let v = if i % 2 == 0 { 50 } else { 200 };
1296                Rgb::new(v, v, v)
1297            })
1298            .collect();
1299        let hasher = PerceptualHash::new(PhashAlgorithm::AHash);
1300        let hash = hasher.compute(&varied, 8, 8);
1301        // Should produce a pattern based on above/below mean
1302        assert_ne!(hash, 0);
1303        assert_ne!(hash, u64::MAX);
1304    }
1305
1306    #[test]
1307    fn h0_phash_10_default_values() {
1308        let hasher = PerceptualHash::default();
1309        assert_eq!(hasher.algorithm, PhashAlgorithm::DHash);
1310        assert_eq!(hasher.hash_bits, 64);
1311    }
1312
1313    #[test]
1314    fn h0_phash_11_algorithm_default() {
1315        let algo = PhashAlgorithm::default();
1316        assert_eq!(algo, PhashAlgorithm::DHash);
1317    }
1318
1319    // =========================================================================
1320    // Verification Suite Tests (H0-SUITE-XX)
1321    // =========================================================================
1322
1323    #[test]
1324    fn h0_suite_01_identical_images() {
1325        let img = test_image_gray(100);
1326        let suite = PixelVerificationSuite::default();
1327        let result = suite.verify(&img, &img, 10, 10);
1328        assert!(result.passes);
1329        assert!(result.ssim.is_perfect);
1330        assert_eq!(result.psnr.quality, PsnrQuality::Identical);
1331    }
1332
1333    #[test]
1334    fn h0_suite_02_different_images() {
1335        let white = test_image_white(100);
1336        let black = test_image_black(100);
1337        let suite = PixelVerificationSuite::default();
1338        let result = suite.verify(&white, &black, 10, 10);
1339        assert!(!result.passes);
1340    }
1341
1342    #[test]
1343    fn h0_suite_03_similar_but_acceptable() {
1344        let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
1345        let gray2: Vec<Rgb> = vec![Rgb::new(130, 130, 130); 100];
1346        let suite = PixelVerificationSuite::default();
1347        let result = suite.verify(&gray1, &gray2, 10, 10);
1348        // Should pass because SSIM is acceptable, Delta E is small, and PHash distance is low
1349        assert!(result.passes);
1350        assert!(result.ssim.is_acceptable);
1351        assert!(result.phash_distance <= 10);
1352    }
1353
1354    #[test]
1355    fn h0_suite_04_phash_distance_check() {
1356        let suite = PixelVerificationSuite::default();
1357        let img = test_image_gray(100);
1358        let result = suite.verify(&img, &img, 10, 10);
1359        assert_eq!(result.phash_distance, 0);
1360    }
1361
1362    #[test]
1363    fn h0_suite_05_default_metrics() {
1364        let suite = PixelVerificationSuite::default();
1365        // Verify all metrics are properly initialized with defaults
1366        assert_eq!(suite.ssim.window_size, 11);
1367        assert_eq!(suite.psnr.max_value, 255.0);
1368        assert_eq!(suite.delta_e.jnd_threshold, 1.0);
1369        assert_eq!(suite.phash.algorithm, PhashAlgorithm::DHash);
1370    }
1371}
1372
1373// =============================================================================
1374// Property-Based Tests (Extreme TDD - L2 Falsification Layer)
1375// =============================================================================
1376
1377#[cfg(test)]
1378mod proptest_tests {
1379    use super::*;
1380    use proptest::prelude::*;
1381
1382    // Strategy for generating random RGB pixels
1383    fn rgb_strategy() -> impl Strategy<Value = Rgb> {
1384        (0u8..=255, 0u8..=255, 0u8..=255).prop_map(|(r, g, b)| Rgb::new(r, g, b))
1385    }
1386
1387    // Strategy for generating random images
1388    fn image_strategy(size: usize) -> impl Strategy<Value = Vec<Rgb>> {
1389        proptest::collection::vec(rgb_strategy(), size)
1390    }
1391
1392    // =========================================================================
1393    // SSIM Property Tests (PROP-SSIM-XX)
1394    // =========================================================================
1395
1396    proptest! {
1397        /// PROP-SSIM-01: SSIM of identical images is always 1.0
1398        #[test]
1399        fn prop_ssim_01_identical_is_perfect(img in image_strategy(64)) {
1400            let ssim = SsimMetric::default();
1401            let result = ssim.compare(&img, &img, 8, 8);
1402            prop_assert!(result.score >= 0.99, "SSIM of identical images should be ~1.0, got {}", result.score);
1403        }
1404
1405        /// PROP-SSIM-02: SSIM is symmetric
1406        #[test]
1407        fn prop_ssim_02_symmetric(
1408            img1 in image_strategy(64),
1409            img2 in image_strategy(64)
1410        ) {
1411            let ssim = SsimMetric::default();
1412            let result1 = ssim.compare(&img1, &img2, 8, 8);
1413            let result2 = ssim.compare(&img2, &img1, 8, 8);
1414            let diff = (result1.score - result2.score).abs();
1415            prop_assert!(diff < 0.001, "SSIM should be symmetric, diff={}", diff);
1416        }
1417
1418        /// PROP-SSIM-03: SSIM is bounded [-1, 1]
1419        #[test]
1420        fn prop_ssim_03_bounded(
1421            img1 in image_strategy(64),
1422            img2 in image_strategy(64)
1423        ) {
1424            let ssim = SsimMetric::default();
1425            let result = ssim.compare(&img1, &img2, 8, 8);
1426            prop_assert!(result.score >= -1.0 && result.score <= 1.0,
1427                "SSIM must be in [-1, 1], got {}", result.score);
1428        }
1429    }
1430
1431    // =========================================================================
1432    // PSNR Property Tests (PROP-PSNR-XX)
1433    // =========================================================================
1434
1435    proptest! {
1436        /// PROP-PSNR-01: PSNR of identical images is infinity
1437        #[test]
1438        fn prop_psnr_01_identical_is_infinite(img in image_strategy(64)) {
1439            let psnr = PsnrMetric::default();
1440            let result = psnr.compare(&img, &img);
1441            prop_assert!(result.psnr_db.is_infinite() || result.psnr_db > 100.0,
1442                "PSNR of identical images should be infinite, got {}", result.psnr_db);
1443        }
1444
1445        /// PROP-PSNR-02: PSNR is symmetric
1446        #[test]
1447        fn prop_psnr_02_symmetric(
1448            img1 in image_strategy(64),
1449            img2 in image_strategy(64)
1450        ) {
1451            let psnr = PsnrMetric::default();
1452            let result1 = psnr.compare(&img1, &img2);
1453            let result2 = psnr.compare(&img2, &img1);
1454            let diff = if result1.psnr_db.is_infinite() && result2.psnr_db.is_infinite() {
1455                0.0
1456            } else {
1457                (result1.psnr_db - result2.psnr_db).abs()
1458            };
1459            prop_assert!(diff < 0.001, "PSNR should be symmetric, diff={}", diff);
1460        }
1461
1462        /// PROP-PSNR-03: PSNR is non-negative (or infinite)
1463        #[test]
1464        fn prop_psnr_03_non_negative(
1465            img1 in image_strategy(64),
1466            img2 in image_strategy(64)
1467        ) {
1468            let psnr = PsnrMetric::default();
1469            let result = psnr.compare(&img1, &img2);
1470            prop_assert!(result.psnr_db >= 0.0 || result.psnr_db.is_infinite(),
1471                "PSNR must be non-negative, got {}", result.psnr_db);
1472        }
1473    }
1474
1475    // =========================================================================
1476    // Lab Conversion Property Tests (PROP-LAB-XX)
1477    // =========================================================================
1478
1479    proptest! {
1480        /// PROP-LAB-01: L (lightness) is bounded [0, 100]
1481        #[test]
1482        fn prop_lab_01_lightness_bounded(rgb in rgb_strategy()) {
1483            let lab = Lab::from_rgb(&rgb);
1484            prop_assert!(lab.l >= -1.0 && lab.l <= 101.0,
1485                "Lightness should be ~[0, 100], got {}", lab.l);
1486        }
1487
1488        /// PROP-LAB-02: Grayscale pixels have near-zero a,b
1489        #[test]
1490        fn prop_lab_02_grayscale_neutral(v in 0u8..=255) {
1491            let rgb = Rgb::new(v, v, v);
1492            let lab = Lab::from_rgb(&rgb);
1493            prop_assert!(lab.a.abs() < 2.0, "Grayscale a should be ~0, got {}", lab.a);
1494            prop_assert!(lab.b.abs() < 2.0, "Grayscale b should be ~0, got {}", lab.b);
1495        }
1496    }
1497
1498    // =========================================================================
1499    // Delta E Property Tests (PROP-DE-XX)
1500    // =========================================================================
1501
1502    proptest! {
1503        /// PROP-DE-01: Delta E of identical colors is 0
1504        #[test]
1505        fn prop_de_01_identical_is_zero(rgb in rgb_strategy()) {
1506            let metric = CieDe2000Metric::default();
1507            let lab = Lab::from_rgb(&rgb);
1508            let de = metric.delta_e(&lab, &lab);
1509            prop_assert!(de < 0.001, "ΔE of identical colors should be 0, got {}", de);
1510        }
1511
1512        /// PROP-DE-02: Delta E is symmetric
1513        #[test]
1514        fn prop_de_02_symmetric(
1515            rgb1 in rgb_strategy(),
1516            rgb2 in rgb_strategy()
1517        ) {
1518            let metric = CieDe2000Metric::default();
1519            let lab1 = Lab::from_rgb(&rgb1);
1520            let lab2 = Lab::from_rgb(&rgb2);
1521            let de1 = metric.delta_e(&lab1, &lab2);
1522            let de2 = metric.delta_e(&lab2, &lab1);
1523            let diff = (de1 - de2).abs();
1524            prop_assert!(diff < 0.001, "ΔE should be symmetric, diff={}", diff);
1525        }
1526
1527        /// PROP-DE-03: Delta E is non-negative
1528        #[test]
1529        fn prop_de_03_non_negative(
1530            rgb1 in rgb_strategy(),
1531            rgb2 in rgb_strategy()
1532        ) {
1533            let metric = CieDe2000Metric::default();
1534            let lab1 = Lab::from_rgb(&rgb1);
1535            let lab2 = Lab::from_rgb(&rgb2);
1536            let de = metric.delta_e(&lab1, &lab2);
1537            prop_assert!(de >= 0.0, "ΔE must be non-negative, got {}", de);
1538        }
1539
1540        /// PROP-DE-04: Delta E is bounded (typical max ~100 for most colors)
1541        /// Note: CIEDE2000 does NOT satisfy triangle inequality by design
1542        /// (optimized for perceptual matching, not metric space properties)
1543        #[test]
1544        fn prop_de_04_bounded(
1545            rgb1 in rgb_strategy(),
1546            rgb2 in rgb_strategy()
1547        ) {
1548            let metric = CieDe2000Metric::default();
1549            let lab1 = Lab::from_rgb(&rgb1);
1550            let lab2 = Lab::from_rgb(&rgb2);
1551            let de = metric.delta_e(&lab1, &lab2);
1552            // ΔE₀₀ is typically bounded by ~100 for 8-bit RGB
1553            prop_assert!(de <= 150.0, "ΔE should be bounded, got {}", de);
1554        }
1555    }
1556
1557    // =========================================================================
1558    // Perceptual Hash Property Tests (PROP-PHASH-XX)
1559    // =========================================================================
1560
1561    proptest! {
1562        /// PROP-PHASH-01: Hash of identical images has distance 0
1563        #[test]
1564        fn prop_phash_01_identical_distance_zero(img in image_strategy(64)) {
1565            let hasher = PerceptualHash::default();
1566            let hash1 = hasher.compute(&img, 8, 8);
1567            let hash2 = hasher.compute(&img, 8, 8);
1568            prop_assert_eq!(hash1, hash2, "Hash of identical images should be equal");
1569            prop_assert_eq!(PerceptualHash::distance(hash1, hash2), 0);
1570        }
1571
1572        /// PROP-PHASH-02: Hamming distance is symmetric
1573        #[test]
1574        fn prop_phash_02_distance_symmetric(h1: u64, h2: u64) {
1575            let d1 = PerceptualHash::distance(h1, h2);
1576            let d2 = PerceptualHash::distance(h2, h1);
1577            prop_assert_eq!(d1, d2, "Hamming distance should be symmetric");
1578        }
1579
1580        /// PROP-PHASH-03: Hamming distance bounded by 64
1581        #[test]
1582        fn prop_phash_03_distance_bounded(h1: u64, h2: u64) {
1583            let d = PerceptualHash::distance(h1, h2);
1584            prop_assert!(d <= 64, "Hamming distance should be <= 64, got {}", d);
1585        }
1586
1587        /// PROP-PHASH-04: Distance to self is 0
1588        #[test]
1589        fn prop_phash_04_self_distance_zero(h: u64) {
1590            prop_assert_eq!(PerceptualHash::distance(h, h), 0);
1591        }
1592    }
1593
1594    // =========================================================================
1595    // Verification Suite Property Tests (PROP-SUITE-XX)
1596    // =========================================================================
1597
1598    proptest! {
1599        /// PROP-SUITE-01: Identical images always pass verification
1600        #[test]
1601        fn prop_suite_01_identical_passes(img in image_strategy(64)) {
1602            let suite = PixelVerificationSuite::default();
1603            let result = suite.verify(&img, &img, 8, 8);
1604            prop_assert!(result.passes, "Identical images should pass verification");
1605            prop_assert!(result.ssim.is_acceptable);
1606        }
1607    }
1608}