1use super::heatmap::Rgb;
10
11#[derive(Debug, Clone)]
18pub struct SsimMetric {
19 pub window_size: u32,
21 pub perfect_threshold: f32,
23 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#[derive(Debug, Clone)]
39pub struct SsimResult {
40 pub score: f32,
42 pub is_perfect: bool,
44 pub is_acceptable: bool,
46 pub channel_scores: [f32; 3],
48}
49
50impl SsimMetric {
51 #[must_use]
53 pub fn new(window_size: u32) -> Self {
54 Self {
55 window_size,
56 ..Default::default()
57 }
58 }
59
60 #[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 #[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 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 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 fn calculate_channel_ssim(
112 &self,
113 reference: &[f32],
114 generated: &[f32],
115 _width: u32,
116 _height: u32,
117 ) -> f32 {
118 let k1: f32 = 0.01;
120 let k2: f32 = 0.03;
121 let l: f32 = 255.0; let c1 = (k1 * l).powi(2);
123 let c2 = (k2 * l).powi(2);
124
125 let n = reference.len() as f32;
128
129 let mean_ref: f32 = reference.iter().sum::<f32>() / n;
131 let mean_gen: f32 = generated.iter().sum::<f32>() / n;
132
133 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 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 }
160 }
161}
162
163#[derive(Debug, Clone)]
169pub struct PsnrMetric {
170 pub max_value: f32,
172 pub excellent_threshold: f32,
174 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#[derive(Debug, Clone)]
190pub struct PsnrResult {
191 pub psnr_db: f32,
193 pub mse: f32,
195 pub quality: PsnrQuality,
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub enum PsnrQuality {
202 Identical,
204 Excellent,
206 Good,
208 Acceptable,
210 Poor,
212}
213
214impl PsnrMetric {
215 #[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 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#[derive(Debug, Clone, Copy, Default)]
269pub struct Lab {
270 pub l: f32,
272 pub a: f32,
274 pub b: f32,
276}
277
278impl Lab {
279 #[must_use]
281 pub fn new(l: f32, a: f32, b: f32) -> Self {
282 Self { l, a, b }
283 }
284
285 #[must_use]
287 #[allow(clippy::excessive_precision)] #[allow(clippy::many_single_char_names)] pub fn from_rgb(rgb: &Rgb) -> Self {
290 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 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 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#[derive(Debug, Clone)]
336pub struct CieDe2000Metric {
337 pub jnd_threshold: f32,
339 pub accept_threshold: f32,
341 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), }
352 }
353}
354
355#[derive(Debug, Clone)]
357pub struct DeltaEResult {
358 pub mean_delta_e: f32,
360 pub max_delta_e: f32,
362 pub percent_imperceptible: f32,
364 pub percent_acceptable: f32,
366 pub classification: DeltaEClassification,
368}
369
370#[derive(Debug, Clone, Copy, PartialEq, Eq)]
372pub enum DeltaEClassification {
373 Imperceptible,
375 JustNoticeable,
377 Acceptable,
379 Noticeable,
381 Unacceptable,
383}
384
385impl CieDe2000Metric {
386 #[must_use]
388 pub fn delta_e(&self, lab1: &Lab, lab2: &Lab) -> f32 {
389 let (kl, kc, kh) = self.weights;
390
391 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 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 let a1_prime = lab1.a * (1.0 + g);
402 let a2_prime = lab2.a * (1.0 + g);
403
404 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 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 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 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 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 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 #[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 #[must_use]
542 pub fn is_imperceptible(&self, delta_e: f32) -> bool {
543 delta_e < self.jnd_threshold
544 }
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
553pub enum PhashAlgorithm {
554 AHash,
556 #[default]
558 DHash,
559 PHash,
561}
562
563#[derive(Debug, Clone)]
565pub struct PerceptualHash {
566 pub algorithm: PhashAlgorithm,
568 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 #[must_use]
584 pub fn new(algorithm: PhashAlgorithm) -> Self {
585 Self {
586 algorithm,
587 ..Default::default()
588 }
589 }
590
591 #[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 fn average_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
603 let resized = self.resize_grayscale(image, width, height, 8, 8);
605
606 let mean: f32 = resized.iter().sum::<f32>() / 64.0;
608
609 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 fn difference_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
621 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 fn perceptual_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
640 let resized = self.resize_grayscale(image, width, height, 32, 32);
642
643 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 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 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 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 #[must_use]
706 pub fn distance(hash1: u64, hash2: u64) -> u32 {
707 (hash1 ^ hash2).count_ones()
708 }
709
710 #[must_use]
712 pub fn is_similar(hash1: u64, hash2: u64, threshold: u32) -> bool {
713 Self::distance(hash1, hash2) <= threshold
714 }
715}
716
717#[derive(Debug, Clone, Default)]
723pub struct PixelVerificationSuite {
724 pub ssim: SsimMetric,
726 pub psnr: PsnrMetric,
728 pub delta_e: CieDe2000Metric,
730 pub phash: PerceptualHash,
732}
733
734#[derive(Debug, Clone)]
736pub struct PixelVerificationResult {
737 pub ssim: SsimResult,
739 pub psnr: PsnrResult,
741 pub delta_e: DeltaEResult,
743 pub phash_distance: u32,
745 pub passes: bool,
747}
748
749impl PixelVerificationSuite {
750 #[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 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 #[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 let zeros = vec![Rgb::new(0, 0, 0); 64];
863 let ssim = SsimMetric::default();
864 let result = ssim.compare(&zeros, &zeros, 8, 8);
865 assert!(result.score >= 0.99);
867 }
868
869 #[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); }
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 let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
928 let gray2: Vec<Rgb> = vec![Rgb::new(132, 132, 132); 100]; let psnr = PsnrMetric::default();
930 let result = psnr.compare(&gray1, &gray2);
931 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 let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
941 let gray2: Vec<Rgb> = vec![Rgb::new(135, 135, 135); 100]; 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 let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
952 let gray2: Vec<Rgb> = vec![Rgb::new(129, 129, 129); 100]; 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 #[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); }
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); }
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); assert!(lab.a.abs() < 2.0); assert!(lab.b.abs() < 2.0); }
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 let lab = Lab::from_rgb(&Rgb::new(5, 5, 5));
996 assert!(lab.l < 5.0); }
998
999 #[test]
1000 fn h0_lab_06_srgb_gamma_branch() {
1001 let lab = Lab::from_rgb(&Rgb::new(200, 100, 50));
1004 assert!(lab.l > 40.0); }
1006
1007 #[test]
1008 fn h0_lab_07_f_xyz_linear_branch() {
1009 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 let red = Lab::from_rgb(&Rgb::new(255, 0, 0));
1018 assert!(red.a > 0.0); let green = Lab::from_rgb(&Rgb::new(0, 255, 0));
1021 assert!(green.a < 0.0); let blue = Lab::from_rgb(&Rgb::new(0, 0, 255));
1024 assert!(blue.b < 0.0); let yellow = Lab::from_rgb(&Rgb::new(255, 255, 0));
1027 assert!(yellow.b > 0.0); }
1029
1030 #[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); }
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 let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
1102 let gray2: Vec<Rgb> = vec![Rgb::new(132, 132, 132); 100]; 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 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 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 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 let metric = CieDe2000Metric::default();
1142
1143 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 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 let lab1 = Lab::new(50.0, 50.0, 5.0); let lab2 = Lab::new(50.0, -50.0, -5.0); let de = metric.delta_e(&lab1, &lab2);
1164 assert!(de > 0.0);
1165
1166 let lab3 = Lab::new(50.0, -50.0, 5.0); let lab4 = Lab::new(50.0, 50.0, -5.0); 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 let lab1 = Lab::new(50.0, 40.0, 10.0); let lab2 = Lab::new(50.0, 10.0, -40.0); let de = metric.delta_e(&lab1, &lab2);
1181 assert!(de > 0.0);
1182
1183 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 assert!(result.percent_imperceptible > 0.0);
1198 assert!(result.percent_acceptable > 0.0);
1199 }
1200
1201 #[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); }
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 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 let _ = hash; }
1252
1253 #[test]
1254 fn h0_phash_06_phash_algorithm() {
1255 let img = test_image_gray(1024); let hasher = PerceptualHash::new(PhashAlgorithm::PHash);
1258 let hash = hasher.compute(&img, 32, 32);
1259 let hash2 = hasher.compute(&img, 32, 32);
1261 assert_eq!(hash, hash2);
1262 }
1263
1264 #[test]
1265 fn h0_phash_07_dhash_gradient() {
1266 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 assert_ne!(hash, 0);
1277 }
1278
1279 #[test]
1280 fn h0_phash_08_resize_out_of_bounds() {
1281 let small_img = vec![Rgb::new(128, 128, 128); 4]; let hasher = PerceptualHash::default();
1284 let hash = hasher.compute(&small_img, 2, 2);
1287 let _ = hash; }
1289
1290 #[test]
1291 fn h0_phash_09_average_hash_varied() {
1292 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 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 #[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 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 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#[cfg(test)]
1378mod proptest_tests {
1379 use super::*;
1380 use proptest::prelude::*;
1381
1382 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 fn image_strategy(size: usize) -> impl Strategy<Value = Vec<Rgb>> {
1389 proptest::collection::vec(rgb_strategy(), size)
1390 }
1391
1392 proptest! {
1397 #[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 #[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 #[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 proptest! {
1436 #[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 #[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 #[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 proptest! {
1480 #[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 #[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 proptest! {
1503 #[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 #[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 #[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 #[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 prop_assert!(de <= 150.0, "ΔE should be bounded, got {}", de);
1554 }
1555 }
1556
1557 proptest! {
1562 #[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 #[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 #[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 #[test]
1589 fn prop_phash_04_self_distance_zero(h: u64) {
1590 prop_assert_eq!(PerceptualHash::distance(h, h), 0);
1591 }
1592 }
1593
1594 proptest! {
1599 #[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}