use super::heatmap::Rgb;
#[derive(Debug, Clone)]
pub struct SsimMetric {
pub window_size: u32,
pub perfect_threshold: f32,
pub accept_threshold: f32,
}
impl Default for SsimMetric {
fn default() -> Self {
Self {
window_size: 11,
perfect_threshold: 0.99,
accept_threshold: 0.95,
}
}
}
#[derive(Debug, Clone)]
pub struct SsimResult {
pub score: f32,
pub is_perfect: bool,
pub is_acceptable: bool,
pub channel_scores: [f32; 3],
}
impl SsimMetric {
#[must_use]
pub fn new(window_size: u32) -> Self {
Self {
window_size,
..Default::default()
}
}
#[must_use]
pub fn with_thresholds(mut self, perfect: f32, acceptable: f32) -> Self {
self.perfect_threshold = perfect;
self.accept_threshold = acceptable;
self
}
#[must_use]
pub fn compare(
&self,
reference: &[Rgb],
generated: &[Rgb],
width: u32,
height: u32,
) -> SsimResult {
if reference.len() != generated.len() {
return SsimResult {
score: 0.0,
is_perfect: false,
is_acceptable: false,
channel_scores: [0.0, 0.0, 0.0],
};
}
let r_ref: Vec<f32> = reference.iter().map(|p| p.r as f32).collect();
let r_gen: Vec<f32> = generated.iter().map(|p| p.r as f32).collect();
let g_ref: Vec<f32> = reference.iter().map(|p| p.g as f32).collect();
let g_gen: Vec<f32> = generated.iter().map(|p| p.g as f32).collect();
let b_ref: Vec<f32> = reference.iter().map(|p| p.b as f32).collect();
let b_gen: Vec<f32> = generated.iter().map(|p| p.b as f32).collect();
let r_ssim = self.calculate_channel_ssim(&r_ref, &r_gen, width, height);
let g_ssim = self.calculate_channel_ssim(&g_ref, &g_gen, width, height);
let b_ssim = self.calculate_channel_ssim(&b_ref, &b_gen, width, height);
let score = (r_ssim + g_ssim + b_ssim) / 3.0;
SsimResult {
score,
is_perfect: score >= self.perfect_threshold,
is_acceptable: score >= self.accept_threshold,
channel_scores: [r_ssim, g_ssim, b_ssim],
}
}
fn calculate_channel_ssim(
&self,
reference: &[f32],
generated: &[f32],
_width: u32,
_height: u32,
) -> f32 {
let k1: f32 = 0.01;
let k2: f32 = 0.03;
let l: f32 = 255.0; let c1 = (k1 * l).powi(2);
let c2 = (k2 * l).powi(2);
let n = reference.len() as f32;
let mean_ref: f32 = reference.iter().sum::<f32>() / n;
let mean_gen: f32 = generated.iter().sum::<f32>() / n;
let var_ref: f32 = reference
.iter()
.map(|&x| (x - mean_ref).powi(2))
.sum::<f32>()
/ n;
let var_gen: f32 = generated
.iter()
.map(|&x| (x - mean_gen).powi(2))
.sum::<f32>()
/ n;
let covar: f32 = reference
.iter()
.zip(generated.iter())
.map(|(&r, &g)| (r - mean_ref) * (g - mean_gen))
.sum::<f32>()
/ n;
let numerator = (2.0 * mean_ref * mean_gen + c1) * (2.0 * covar + c2);
let denominator = (mean_ref.powi(2) + mean_gen.powi(2) + c1) * (var_ref + var_gen + c2);
if denominator > 0.0 {
numerator / denominator
} else {
1.0 }
}
}
#[derive(Debug, Clone)]
pub struct PsnrMetric {
pub max_value: f32,
pub excellent_threshold: f32,
pub acceptable_threshold: f32,
}
impl Default for PsnrMetric {
fn default() -> Self {
Self {
max_value: 255.0,
excellent_threshold: 40.0,
acceptable_threshold: 30.0,
}
}
}
#[derive(Debug, Clone)]
pub struct PsnrResult {
pub psnr_db: f32,
pub mse: f32,
pub quality: PsnrQuality,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PsnrQuality {
Identical,
Excellent,
Good,
Acceptable,
Poor,
}
impl PsnrMetric {
#[must_use]
pub fn compare(&self, reference: &[Rgb], generated: &[Rgb]) -> PsnrResult {
if reference.len() != generated.len() || reference.is_empty() {
return PsnrResult {
psnr_db: 0.0,
mse: f32::MAX,
quality: PsnrQuality::Poor,
};
}
let mse: f32 = reference
.iter()
.zip(generated.iter())
.map(|(r, g)| {
let dr = (r.r as f32 - g.r as f32).powi(2);
let dg = (r.g as f32 - g.g as f32).powi(2);
let db = (r.b as f32 - g.b as f32).powi(2);
(dr + dg + db) / 3.0
})
.sum::<f32>()
/ reference.len() as f32;
let (psnr_db, quality) = if mse < f32::EPSILON {
(f32::INFINITY, PsnrQuality::Identical)
} else {
let psnr = 10.0 * (self.max_value.powi(2) / mse).log10();
let quality = if psnr >= self.excellent_threshold {
PsnrQuality::Excellent
} else if psnr >= 35.0 {
PsnrQuality::Good
} else if psnr >= self.acceptable_threshold {
PsnrQuality::Acceptable
} else {
PsnrQuality::Poor
};
(psnr, quality)
};
PsnrResult {
psnr_db,
mse,
quality,
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct Lab {
pub l: f32,
pub a: f32,
pub b: f32,
}
impl Lab {
#[must_use]
pub fn new(l: f32, a: f32, b: f32) -> Self {
Self { l, a, b }
}
#[must_use]
#[allow(clippy::excessive_precision)] #[allow(clippy::many_single_char_names)] pub fn from_rgb(rgb: &Rgb) -> Self {
let r = Self::srgb_to_linear(rgb.r as f32 / 255.0);
let g = Self::srgb_to_linear(rgb.g as f32 / 255.0);
let b = Self::srgb_to_linear(rgb.b as f32 / 255.0);
let x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375;
let y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
let z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041;
let xn = 0.95047;
let yn = 1.00000;
let zn = 1.08883;
let fx = Self::f_xyz(x / xn);
let fy = Self::f_xyz(y / yn);
let fz = Self::f_xyz(z / zn);
Self {
l: 116.0 * fy - 16.0,
a: 500.0 * (fx - fy),
b: 200.0 * (fy - fz),
}
}
fn srgb_to_linear(c: f32) -> f32 {
if c <= 0.04045 {
c / 12.92
} else {
((c + 0.055) / 1.055).powf(2.4)
}
}
fn f_xyz(t: f32) -> f32 {
let delta: f32 = 6.0 / 29.0;
if t > delta.powi(3) {
t.cbrt()
} else {
t / (3.0 * delta.powi(2)) + 4.0 / 29.0
}
}
}
#[derive(Debug, Clone)]
pub struct CieDe2000Metric {
pub jnd_threshold: f32,
pub accept_threshold: f32,
pub weights: (f32, f32, f32),
}
impl Default for CieDe2000Metric {
fn default() -> Self {
Self {
jnd_threshold: 1.0,
accept_threshold: 2.0,
weights: (1.0, 1.0, 1.0), }
}
}
#[derive(Debug, Clone)]
pub struct DeltaEResult {
pub mean_delta_e: f32,
pub max_delta_e: f32,
pub percent_imperceptible: f32,
pub percent_acceptable: f32,
pub classification: DeltaEClassification,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeltaEClassification {
Imperceptible,
JustNoticeable,
Acceptable,
Noticeable,
Unacceptable,
}
impl CieDe2000Metric {
#[must_use]
pub fn delta_e(&self, lab1: &Lab, lab2: &Lab) -> f32 {
let (kl, kc, kh) = self.weights;
let c1 = (lab1.a.powi(2) + lab1.b.powi(2)).sqrt();
let c2 = (lab2.a.powi(2) + lab2.b.powi(2)).sqrt();
let c_avg = (c1 + c2) / 2.0;
let c_avg_7 = c_avg.powi(7);
let g = 0.5 * (1.0 - (c_avg_7 / (c_avg_7 + 25.0_f32.powi(7))).sqrt());
let a1_prime = lab1.a * (1.0 + g);
let a2_prime = lab2.a * (1.0 + g);
let c1_prime = (a1_prime.powi(2) + lab1.b.powi(2)).sqrt();
let c2_prime = (a2_prime.powi(2) + lab2.b.powi(2)).sqrt();
let h1_prime = if a1_prime.abs() < f32::EPSILON && lab1.b.abs() < f32::EPSILON {
0.0
} else {
lab1.b.atan2(a1_prime).to_degrees().rem_euclid(360.0)
};
let h2_prime = if a2_prime.abs() < f32::EPSILON && lab2.b.abs() < f32::EPSILON {
0.0
} else {
lab2.b.atan2(a2_prime).to_degrees().rem_euclid(360.0)
};
let delta_l_prime = lab2.l - lab1.l;
let delta_c_prime = c2_prime - c1_prime;
let delta_h_prime_deg = if c1_prime * c2_prime < f32::EPSILON {
0.0
} else {
let dh = h2_prime - h1_prime;
if dh.abs() <= 180.0 {
dh
} else if dh > 180.0 {
dh - 360.0
} else {
dh + 360.0
}
};
let delta_h_prime =
2.0 * (c1_prime * c2_prime).sqrt() * (delta_h_prime_deg.to_radians() / 2.0).sin();
let l_prime_avg = (lab1.l + lab2.l) / 2.0;
let c_prime_avg = (c1_prime + c2_prime) / 2.0;
let h_prime_avg = if c1_prime * c2_prime < f32::EPSILON {
h1_prime + h2_prime
} else {
let diff = (h1_prime - h2_prime).abs();
if diff <= 180.0 {
(h1_prime + h2_prime) / 2.0
} else if h1_prime + h2_prime < 360.0 {
(h1_prime + h2_prime + 360.0) / 2.0
} else {
(h1_prime + h2_prime - 360.0) / 2.0
}
};
let t = 1.0 - 0.17 * (h_prime_avg - 30.0).to_radians().cos()
+ 0.24 * (2.0 * h_prime_avg).to_radians().cos()
+ 0.32 * (3.0 * h_prime_avg + 6.0).to_radians().cos()
- 0.20 * (4.0 * h_prime_avg - 63.0).to_radians().cos();
let delta_theta = 30.0 * (-((h_prime_avg - 275.0) / 25.0).powi(2)).exp();
let c_prime_avg_7 = c_prime_avg.powi(7);
let rc = 2.0 * (c_prime_avg_7 / (c_prime_avg_7 + 25.0_f32.powi(7))).sqrt();
let l_50_sq = (l_prime_avg - 50.0).powi(2);
let sl = 1.0 + (0.015 * l_50_sq) / (20.0 + l_50_sq).sqrt();
let sc = 1.0 + 0.045 * c_prime_avg;
let sh = 1.0 + 0.015 * c_prime_avg * t;
let rt = -(2.0 * delta_theta).to_radians().sin() * rc;
let dl = delta_l_prime / (kl * sl);
let dc = delta_c_prime / (kc * sc);
let dh = delta_h_prime / (kh * sh);
(dl.powi(2) + dc.powi(2) + dh.powi(2) + rt * dc * dh).sqrt()
}
#[must_use]
pub fn compare(&self, reference: &[Rgb], generated: &[Rgb]) -> DeltaEResult {
if reference.len() != generated.len() || reference.is_empty() {
return DeltaEResult {
mean_delta_e: f32::MAX,
max_delta_e: f32::MAX,
percent_imperceptible: 0.0,
percent_acceptable: 0.0,
classification: DeltaEClassification::Unacceptable,
};
}
let mut sum_de = 0.0f32;
let mut max_delta_e = 0.0f32;
let mut imperceptible_count = 0u32;
let mut acceptable_count = 0u32;
for (r, g) in reference.iter().zip(generated.iter()) {
let lab1 = Lab::from_rgb(r);
let lab2 = Lab::from_rgb(g);
let de = self.delta_e(&lab1, &lab2);
sum_de += de;
max_delta_e = max_delta_e.max(de);
if de < self.jnd_threshold {
imperceptible_count += 1;
}
if de < self.accept_threshold {
acceptable_count += 1;
}
}
let n = reference.len() as f32;
let mean_delta_e = sum_de / n;
let classification = if mean_delta_e < 0.8 {
DeltaEClassification::Imperceptible
} else if mean_delta_e < 1.8 {
DeltaEClassification::JustNoticeable
} else if mean_delta_e < 2.8 {
DeltaEClassification::Acceptable
} else if mean_delta_e < 3.7 {
DeltaEClassification::Noticeable
} else {
DeltaEClassification::Unacceptable
};
DeltaEResult {
mean_delta_e,
max_delta_e,
percent_imperceptible: imperceptible_count as f32 / n * 100.0,
percent_acceptable: acceptable_count as f32 / n * 100.0,
classification,
}
}
#[must_use]
pub fn is_imperceptible(&self, delta_e: f32) -> bool {
delta_e < self.jnd_threshold
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PhashAlgorithm {
AHash,
#[default]
DHash,
PHash,
}
#[derive(Debug, Clone)]
pub struct PerceptualHash {
pub algorithm: PhashAlgorithm,
pub hash_bits: u32,
}
impl Default for PerceptualHash {
fn default() -> Self {
Self {
algorithm: PhashAlgorithm::DHash,
hash_bits: 64,
}
}
}
impl PerceptualHash {
#[must_use]
pub fn new(algorithm: PhashAlgorithm) -> Self {
Self {
algorithm,
..Default::default()
}
}
#[must_use]
pub fn compute(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
match self.algorithm {
PhashAlgorithm::AHash => self.average_hash(image, width, height),
PhashAlgorithm::DHash => self.difference_hash(image, width, height),
PhashAlgorithm::PHash => self.perceptual_hash(image, width, height),
}
}
fn average_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
let resized = self.resize_grayscale(image, width, height, 8, 8);
let mean: f32 = resized.iter().sum::<f32>() / 64.0;
let mut hash: u64 = 0;
for (i, &pixel) in resized.iter().enumerate() {
if pixel > mean {
hash |= 1 << i;
}
}
hash
}
fn difference_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
let resized = self.resize_grayscale(image, width, height, 9, 8);
let mut hash: u64 = 0;
let mut bit = 0;
for row in 0..8 {
for col in 0..8 {
let idx = row * 9 + col;
if resized[idx] < resized[idx + 1] {
hash |= 1 << bit;
}
bit += 1;
}
}
hash
}
fn perceptual_hash(&self, image: &[Rgb], width: u32, height: u32) -> u64 {
let resized = self.resize_grayscale(image, width, height, 32, 32);
let mut dct = vec![0.0f32; 64];
for u in 0..8 {
for v in 0..8 {
let mut sum = 0.0f32;
for x in 0..32 {
for y in 0..32 {
let cu = std::f32::consts::PI * (2.0 * x as f32 + 1.0) * u as f32 / 64.0;
let cv = std::f32::consts::PI * (2.0 * y as f32 + 1.0) * v as f32 / 64.0;
sum += resized[(x * 32 + y) as usize] * cu.cos() * cv.cos();
}
}
dct[(u * 8 + v) as usize] = sum;
}
}
let mean: f32 = dct[1..].iter().sum::<f32>() / 63.0;
let mut hash: u64 = 0;
for (i, &val) in dct[1..].iter().take(64).enumerate() {
if val > mean {
hash |= 1 << i;
}
}
hash
}
fn resize_grayscale(
&self,
image: &[Rgb],
width: u32,
height: u32,
new_width: u32,
new_height: u32,
) -> Vec<f32> {
let mut result = vec![0.0f32; (new_width * new_height) as usize];
let x_ratio = width as f32 / new_width as f32;
let y_ratio = height as f32 / new_height as f32;
for y in 0..new_height {
for x in 0..new_width {
let src_x = (x as f32 * x_ratio) as u32;
let src_y = (y as f32 * y_ratio) as u32;
let src_idx = (src_y * width + src_x) as usize;
if src_idx < image.len() {
let pixel = &image[src_idx];
let gray =
0.299 * pixel.r as f32 + 0.587 * pixel.g as f32 + 0.114 * pixel.b as f32;
result[(y * new_width + x) as usize] = gray;
}
}
}
result
}
#[must_use]
pub fn distance(hash1: u64, hash2: u64) -> u32 {
(hash1 ^ hash2).count_ones()
}
#[must_use]
pub fn is_similar(hash1: u64, hash2: u64, threshold: u32) -> bool {
Self::distance(hash1, hash2) <= threshold
}
}
#[derive(Debug, Clone, Default)]
pub struct PixelVerificationSuite {
pub ssim: SsimMetric,
pub psnr: PsnrMetric,
pub delta_e: CieDe2000Metric,
pub phash: PerceptualHash,
}
#[derive(Debug, Clone)]
pub struct PixelVerificationResult {
pub ssim: SsimResult,
pub psnr: PsnrResult,
pub delta_e: DeltaEResult,
pub phash_distance: u32,
pub passes: bool,
}
impl PixelVerificationSuite {
#[must_use]
pub fn verify(
&self,
reference: &[Rgb],
generated: &[Rgb],
width: u32,
height: u32,
) -> PixelVerificationResult {
let ssim = self.ssim.compare(reference, generated, width, height);
let psnr = self.psnr.compare(reference, generated);
let delta_e = self.delta_e.compare(reference, generated);
let ref_hash = self.phash.compute(reference, width, height);
let gen_hash = self.phash.compute(generated, width, height);
let phash_distance = PerceptualHash::distance(ref_hash, gen_hash);
let passes = ssim.is_acceptable
&& delta_e.classification != DeltaEClassification::Unacceptable
&& phash_distance <= 10;
PixelVerificationResult {
ssim,
psnr,
delta_e,
phash_distance,
passes,
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
fn test_image_white(size: usize) -> Vec<Rgb> {
vec![Rgb::new(255, 255, 255); size]
}
fn test_image_black(size: usize) -> Vec<Rgb> {
vec![Rgb::new(0, 0, 0); size]
}
fn test_image_gray(size: usize) -> Vec<Rgb> {
vec![Rgb::new(128, 128, 128); size]
}
#[test]
fn h0_ssim_01_identical_images() {
let img = test_image_white(100);
let ssim = SsimMetric::default();
let result = ssim.compare(&img, &img, 10, 10);
assert!(result.score >= 0.99);
assert!(result.is_perfect);
}
#[test]
fn h0_ssim_02_completely_different() {
let white = test_image_white(100);
let black = test_image_black(100);
let ssim = SsimMetric::default();
let result = ssim.compare(&white, &black, 10, 10);
assert!(result.score < 0.5);
assert!(!result.is_acceptable);
}
#[test]
fn h0_ssim_03_similar_images() {
let gray1: Vec<Rgb> = (0..100).map(|_| Rgb::new(128, 128, 128)).collect();
let gray2: Vec<Rgb> = (0..100).map(|_| Rgb::new(130, 130, 130)).collect();
let ssim = SsimMetric::default();
let result = ssim.compare(&gray1, &gray2, 10, 10);
assert!(result.score > 0.95);
assert!(result.is_acceptable);
}
#[test]
fn h0_ssim_04_mismatched_lengths() {
let img1 = test_image_white(100);
let img2 = test_image_white(50);
let ssim = SsimMetric::default();
let result = ssim.compare(&img1, &img2, 10, 10);
assert_eq!(result.score, 0.0);
assert!(!result.is_perfect);
assert!(!result.is_acceptable);
assert_eq!(result.channel_scores, [0.0, 0.0, 0.0]);
}
#[test]
fn h0_ssim_05_new_constructor() {
let ssim = SsimMetric::new(7);
assert_eq!(ssim.window_size, 7);
assert_eq!(ssim.perfect_threshold, 0.99);
assert_eq!(ssim.accept_threshold, 0.95);
}
#[test]
fn h0_ssim_06_with_thresholds() {
let ssim = SsimMetric::default().with_thresholds(0.98, 0.90);
assert_eq!(ssim.perfect_threshold, 0.98);
assert_eq!(ssim.accept_threshold, 0.90);
}
#[test]
fn h0_ssim_07_zero_denominator_branch() {
let zeros = vec![Rgb::new(0, 0, 0); 64];
let ssim = SsimMetric::default();
let result = ssim.compare(&zeros, &zeros, 8, 8);
assert!(result.score >= 0.99);
}
#[test]
fn h0_psnr_01_identical_images() {
let img = test_image_gray(100);
let psnr = PsnrMetric::default();
let result = psnr.compare(&img, &img);
assert!(result.psnr_db.is_infinite());
assert_eq!(result.quality, PsnrQuality::Identical);
}
#[test]
fn h0_psnr_02_slight_difference() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(129, 128, 128); 100];
let psnr = PsnrMetric::default();
let result = psnr.compare(&gray1, &gray2);
assert!(result.psnr_db > 40.0); }
#[test]
fn h0_psnr_03_major_difference() {
let white = test_image_white(100);
let black = test_image_black(100);
let psnr = PsnrMetric::default();
let result = psnr.compare(&white, &black);
assert!(result.psnr_db < 10.0);
assert_eq!(result.quality, PsnrQuality::Poor);
}
#[test]
fn h0_psnr_04_mismatched_lengths() {
let img1 = test_image_gray(100);
let img2 = test_image_gray(50);
let psnr = PsnrMetric::default();
let result = psnr.compare(&img1, &img2);
assert_eq!(result.psnr_db, 0.0);
assert_eq!(result.mse, f32::MAX);
assert_eq!(result.quality, PsnrQuality::Poor);
}
#[test]
fn h0_psnr_05_empty_images() {
let empty: Vec<Rgb> = vec![];
let psnr = PsnrMetric::default();
let result = psnr.compare(&empty, &empty);
assert_eq!(result.psnr_db, 0.0);
assert_eq!(result.mse, f32::MAX);
assert_eq!(result.quality, PsnrQuality::Poor);
}
#[test]
fn h0_psnr_06_good_quality() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(132, 132, 132); 100]; let psnr = PsnrMetric::default();
let result = psnr.compare(&gray1, &gray2);
assert!(result.psnr_db >= 35.0 && result.psnr_db < 40.0);
assert_eq!(result.quality, PsnrQuality::Good);
}
#[test]
fn h0_psnr_07_acceptable_quality() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(135, 135, 135); 100]; let psnr = PsnrMetric::default();
let result = psnr.compare(&gray1, &gray2);
assert!(result.psnr_db >= 30.0 && result.psnr_db < 35.0);
assert_eq!(result.quality, PsnrQuality::Acceptable);
}
#[test]
fn h0_psnr_08_excellent_quality() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(129, 129, 129); 100]; let psnr = PsnrMetric::default();
let result = psnr.compare(&gray1, &gray2);
assert!(result.psnr_db >= 40.0);
assert_eq!(result.quality, PsnrQuality::Excellent);
}
#[test]
fn h0_lab_01_white() {
let lab = Lab::from_rgb(&Rgb::new(255, 255, 255));
assert!((lab.l - 100.0).abs() < 1.0); }
#[test]
fn h0_lab_02_black() {
let lab = Lab::from_rgb(&Rgb::new(0, 0, 0));
assert!(lab.l < 1.0); }
#[test]
fn h0_lab_03_gray() {
let lab = Lab::from_rgb(&Rgb::new(128, 128, 128));
assert!(lab.l > 40.0 && lab.l < 60.0); assert!(lab.a.abs() < 2.0); assert!(lab.b.abs() < 2.0); }
#[test]
fn h0_lab_04_new_constructor() {
let lab = Lab::new(50.0, 25.0, -30.0);
assert_eq!(lab.l, 50.0);
assert_eq!(lab.a, 25.0);
assert_eq!(lab.b, -30.0);
}
#[test]
fn h0_lab_05_srgb_linear_threshold() {
let lab = Lab::from_rgb(&Rgb::new(5, 5, 5));
assert!(lab.l < 5.0); }
#[test]
fn h0_lab_06_srgb_gamma_branch() {
let lab = Lab::from_rgb(&Rgb::new(200, 100, 50));
assert!(lab.l > 40.0); }
#[test]
fn h0_lab_07_f_xyz_linear_branch() {
let lab = Lab::from_rgb(&Rgb::new(1, 1, 1));
assert!(lab.l < 2.0);
}
#[test]
fn h0_lab_08_colored_pixels() {
let red = Lab::from_rgb(&Rgb::new(255, 0, 0));
assert!(red.a > 0.0);
let green = Lab::from_rgb(&Rgb::new(0, 255, 0));
assert!(green.a < 0.0);
let blue = Lab::from_rgb(&Rgb::new(0, 0, 255));
assert!(blue.b < 0.0);
let yellow = Lab::from_rgb(&Rgb::new(255, 255, 0));
assert!(yellow.b > 0.0); }
#[test]
fn h0_de_01_identical_colors() {
let metric = CieDe2000Metric::default();
let lab = Lab::new(50.0, 0.0, 0.0);
let de = metric.delta_e(&lab, &lab);
assert!(de < f32::EPSILON);
}
#[test]
fn h0_de_02_just_noticeable() {
let metric = CieDe2000Metric::default();
let lab1 = Lab::new(50.0, 0.0, 0.0);
let lab2 = Lab::new(51.0, 0.0, 0.0);
let de = metric.delta_e(&lab1, &lab2);
assert!(de < 2.0); }
#[test]
fn h0_de_03_image_comparison() {
let gray1 = test_image_gray(100);
let gray2: Vec<Rgb> = vec![Rgb::new(135, 135, 135); 100];
let metric = CieDe2000Metric::default();
let result = metric.compare(&gray1, &gray2);
assert!(result.mean_delta_e < 10.0);
}
#[test]
fn h0_de_04_mismatched_lengths() {
let img1 = test_image_gray(100);
let img2 = test_image_gray(50);
let metric = CieDe2000Metric::default();
let result = metric.compare(&img1, &img2);
assert_eq!(result.mean_delta_e, f32::MAX);
assert_eq!(result.max_delta_e, f32::MAX);
assert_eq!(result.percent_imperceptible, 0.0);
assert_eq!(result.percent_acceptable, 0.0);
assert_eq!(result.classification, DeltaEClassification::Unacceptable);
}
#[test]
fn h0_de_05_empty_images() {
let empty: Vec<Rgb> = vec![];
let metric = CieDe2000Metric::default();
let result = metric.compare(&empty, &empty);
assert_eq!(result.mean_delta_e, f32::MAX);
assert_eq!(result.classification, DeltaEClassification::Unacceptable);
}
#[test]
fn h0_de_06_is_imperceptible() {
let metric = CieDe2000Metric::default();
assert!(metric.is_imperceptible(0.5));
assert!(!metric.is_imperceptible(1.5));
}
#[test]
fn h0_de_07_classification_imperceptible() {
let img = test_image_gray(100);
let metric = CieDe2000Metric::default();
let result = metric.compare(&img, &img);
assert_eq!(result.classification, DeltaEClassification::Imperceptible);
}
#[test]
fn h0_de_08_classification_just_noticeable() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(132, 132, 132); 100]; let metric = CieDe2000Metric::default();
let result = metric.compare(&gray1, &gray2);
assert_eq!(result.classification, DeltaEClassification::JustNoticeable);
}
#[test]
fn h0_de_09_classification_acceptable() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(134, 134, 134); 100];
let metric = CieDe2000Metric::default();
let result = metric.compare(&gray1, &gray2);
assert_eq!(result.classification, DeltaEClassification::Acceptable);
}
#[test]
fn h0_de_10_classification_noticeable() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(138, 138, 138); 100];
let metric = CieDe2000Metric::default();
let result = metric.compare(&gray1, &gray2);
assert_eq!(result.classification, DeltaEClassification::Noticeable);
}
#[test]
fn h0_de_11_classification_unacceptable() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(145, 145, 145); 100];
let metric = CieDe2000Metric::default();
let result = metric.compare(&gray1, &gray2);
assert_eq!(result.classification, DeltaEClassification::Unacceptable);
}
#[test]
fn h0_de_12_hue_angle_branches() {
let metric = CieDe2000Metric::default();
let lab1 = Lab::new(50.0, 0.0, 0.0);
let lab2 = Lab::new(60.0, 0.0, 0.0);
let de = metric.delta_e(&lab1, &lab2);
assert!(de > 0.0);
let lab3 = Lab::new(50.0, 30.0, 40.0);
let lab4 = Lab::new(50.0, -30.0, -40.0);
let de2 = metric.delta_e(&lab3, &lab4);
assert!(de2 > 0.0);
}
#[test]
fn h0_de_13_delta_h_prime_branches() {
let metric = CieDe2000Metric::default();
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);
assert!(de > 0.0);
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);
assert!(de2 > 0.0);
}
#[test]
fn h0_de_14_h_prime_avg_branches() {
let metric = CieDe2000Metric::default();
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);
assert!(de > 0.0);
let lab3 = Lab::new(50.0, -10.0, 40.0);
let lab4 = Lab::new(50.0, -40.0, -10.0);
let de2 = metric.delta_e(&lab3, &lab4);
assert!(de2 > 0.0);
}
#[test]
fn h0_de_15_percent_calculations() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(129, 129, 129); 100];
let metric = CieDe2000Metric::default();
let result = metric.compare(&gray1, &gray2);
assert!(result.percent_imperceptible > 0.0);
assert!(result.percent_acceptable > 0.0);
}
#[test]
fn h0_phash_01_identical_images() {
let img = test_image_gray(64);
let hasher = PerceptualHash::default();
let hash1 = hasher.compute(&img, 8, 8);
let hash2 = hasher.compute(&img, 8, 8);
assert_eq!(hash1, hash2);
assert_eq!(PerceptualHash::distance(hash1, hash2), 0);
}
#[test]
fn h0_phash_02_similar_images() {
let gray1 = test_image_gray(64);
let gray2: Vec<Rgb> = vec![Rgb::new(130, 130, 130); 64];
let hasher = PerceptualHash::default();
let hash1 = hasher.compute(&gray1, 8, 8);
let hash2 = hasher.compute(&gray2, 8, 8);
let distance = PerceptualHash::distance(hash1, hash2);
assert!(distance < 10); }
#[test]
fn h0_phash_03_different_images() {
let white = test_image_white(64);
let black = test_image_black(64);
let hasher = PerceptualHash::default();
let hash1 = hasher.compute(&white, 8, 8);
let hash2 = hasher.compute(&black, 8, 8);
let _ = PerceptualHash::distance(hash1, hash2);
}
#[test]
fn h0_phash_04_is_similar() {
assert!(PerceptualHash::is_similar(0, 1, 5));
assert!(!PerceptualHash::is_similar(0, u64::MAX, 5));
}
#[test]
fn h0_phash_05_ahash() {
let img = test_image_gray(64);
let hasher = PerceptualHash::new(PhashAlgorithm::AHash);
let hash = hasher.compute(&img, 8, 8);
let _ = hash; }
#[test]
fn h0_phash_06_phash_algorithm() {
let img = test_image_gray(1024); let hasher = PerceptualHash::new(PhashAlgorithm::PHash);
let hash = hasher.compute(&img, 32, 32);
let hash2 = hasher.compute(&img, 32, 32);
assert_eq!(hash, hash2);
}
#[test]
fn h0_phash_07_dhash_gradient() {
let gradient: Vec<Rgb> = (0..72)
.map(|i| {
let v = (i * 3) as u8;
Rgb::new(v, v, v)
})
.collect();
let hasher = PerceptualHash::new(PhashAlgorithm::DHash);
let hash = hasher.compute(&gradient, 9, 8);
assert_ne!(hash, 0);
}
#[test]
fn h0_phash_08_resize_out_of_bounds() {
let small_img = vec![Rgb::new(128, 128, 128); 4]; let hasher = PerceptualHash::default();
let hash = hasher.compute(&small_img, 2, 2);
let _ = hash; }
#[test]
fn h0_phash_09_average_hash_varied() {
let varied: Vec<Rgb> = (0..64)
.map(|i| {
let v = if i % 2 == 0 { 50 } else { 200 };
Rgb::new(v, v, v)
})
.collect();
let hasher = PerceptualHash::new(PhashAlgorithm::AHash);
let hash = hasher.compute(&varied, 8, 8);
assert_ne!(hash, 0);
assert_ne!(hash, u64::MAX);
}
#[test]
fn h0_phash_10_default_values() {
let hasher = PerceptualHash::default();
assert_eq!(hasher.algorithm, PhashAlgorithm::DHash);
assert_eq!(hasher.hash_bits, 64);
}
#[test]
fn h0_phash_11_algorithm_default() {
let algo = PhashAlgorithm::default();
assert_eq!(algo, PhashAlgorithm::DHash);
}
#[test]
fn h0_suite_01_identical_images() {
let img = test_image_gray(100);
let suite = PixelVerificationSuite::default();
let result = suite.verify(&img, &img, 10, 10);
assert!(result.passes);
assert!(result.ssim.is_perfect);
assert_eq!(result.psnr.quality, PsnrQuality::Identical);
}
#[test]
fn h0_suite_02_different_images() {
let white = test_image_white(100);
let black = test_image_black(100);
let suite = PixelVerificationSuite::default();
let result = suite.verify(&white, &black, 10, 10);
assert!(!result.passes);
}
#[test]
fn h0_suite_03_similar_but_acceptable() {
let gray1: Vec<Rgb> = vec![Rgb::new(128, 128, 128); 100];
let gray2: Vec<Rgb> = vec![Rgb::new(130, 130, 130); 100];
let suite = PixelVerificationSuite::default();
let result = suite.verify(&gray1, &gray2, 10, 10);
assert!(result.passes);
assert!(result.ssim.is_acceptable);
assert!(result.phash_distance <= 10);
}
#[test]
fn h0_suite_04_phash_distance_check() {
let suite = PixelVerificationSuite::default();
let img = test_image_gray(100);
let result = suite.verify(&img, &img, 10, 10);
assert_eq!(result.phash_distance, 0);
}
#[test]
fn h0_suite_05_default_metrics() {
let suite = PixelVerificationSuite::default();
assert_eq!(suite.ssim.window_size, 11);
assert_eq!(suite.psnr.max_value, 255.0);
assert_eq!(suite.delta_e.jnd_threshold, 1.0);
assert_eq!(suite.phash.algorithm, PhashAlgorithm::DHash);
}
}
#[cfg(test)]
mod proptest_tests {
use super::*;
use proptest::prelude::*;
fn rgb_strategy() -> impl Strategy<Value = Rgb> {
(0u8..=255, 0u8..=255, 0u8..=255).prop_map(|(r, g, b)| Rgb::new(r, g, b))
}
fn image_strategy(size: usize) -> impl Strategy<Value = Vec<Rgb>> {
proptest::collection::vec(rgb_strategy(), size)
}
proptest! {
#[test]
fn prop_ssim_01_identical_is_perfect(img in image_strategy(64)) {
let ssim = SsimMetric::default();
let result = ssim.compare(&img, &img, 8, 8);
prop_assert!(result.score >= 0.99, "SSIM of identical images should be ~1.0, got {}", result.score);
}
#[test]
fn prop_ssim_02_symmetric(
img1 in image_strategy(64),
img2 in image_strategy(64)
) {
let ssim = SsimMetric::default();
let result1 = ssim.compare(&img1, &img2, 8, 8);
let result2 = ssim.compare(&img2, &img1, 8, 8);
let diff = (result1.score - result2.score).abs();
prop_assert!(diff < 0.001, "SSIM should be symmetric, diff={}", diff);
}
#[test]
fn prop_ssim_03_bounded(
img1 in image_strategy(64),
img2 in image_strategy(64)
) {
let ssim = SsimMetric::default();
let result = ssim.compare(&img1, &img2, 8, 8);
prop_assert!(result.score >= -1.0 && result.score <= 1.0,
"SSIM must be in [-1, 1], got {}", result.score);
}
}
proptest! {
#[test]
fn prop_psnr_01_identical_is_infinite(img in image_strategy(64)) {
let psnr = PsnrMetric::default();
let result = psnr.compare(&img, &img);
prop_assert!(result.psnr_db.is_infinite() || result.psnr_db > 100.0,
"PSNR of identical images should be infinite, got {}", result.psnr_db);
}
#[test]
fn prop_psnr_02_symmetric(
img1 in image_strategy(64),
img2 in image_strategy(64)
) {
let psnr = PsnrMetric::default();
let result1 = psnr.compare(&img1, &img2);
let result2 = psnr.compare(&img2, &img1);
let diff = if result1.psnr_db.is_infinite() && result2.psnr_db.is_infinite() {
0.0
} else {
(result1.psnr_db - result2.psnr_db).abs()
};
prop_assert!(diff < 0.001, "PSNR should be symmetric, diff={}", diff);
}
#[test]
fn prop_psnr_03_non_negative(
img1 in image_strategy(64),
img2 in image_strategy(64)
) {
let psnr = PsnrMetric::default();
let result = psnr.compare(&img1, &img2);
prop_assert!(result.psnr_db >= 0.0 || result.psnr_db.is_infinite(),
"PSNR must be non-negative, got {}", result.psnr_db);
}
}
proptest! {
#[test]
fn prop_lab_01_lightness_bounded(rgb in rgb_strategy()) {
let lab = Lab::from_rgb(&rgb);
prop_assert!(lab.l >= -1.0 && lab.l <= 101.0,
"Lightness should be ~[0, 100], got {}", lab.l);
}
#[test]
fn prop_lab_02_grayscale_neutral(v in 0u8..=255) {
let rgb = Rgb::new(v, v, v);
let lab = Lab::from_rgb(&rgb);
prop_assert!(lab.a.abs() < 2.0, "Grayscale a should be ~0, got {}", lab.a);
prop_assert!(lab.b.abs() < 2.0, "Grayscale b should be ~0, got {}", lab.b);
}
}
proptest! {
#[test]
fn prop_de_01_identical_is_zero(rgb in rgb_strategy()) {
let metric = CieDe2000Metric::default();
let lab = Lab::from_rgb(&rgb);
let de = metric.delta_e(&lab, &lab);
prop_assert!(de < 0.001, "ΔE of identical colors should be 0, got {}", de);
}
#[test]
fn prop_de_02_symmetric(
rgb1 in rgb_strategy(),
rgb2 in rgb_strategy()
) {
let metric = CieDe2000Metric::default();
let lab1 = Lab::from_rgb(&rgb1);
let lab2 = Lab::from_rgb(&rgb2);
let de1 = metric.delta_e(&lab1, &lab2);
let de2 = metric.delta_e(&lab2, &lab1);
let diff = (de1 - de2).abs();
prop_assert!(diff < 0.001, "ΔE should be symmetric, diff={}", diff);
}
#[test]
fn prop_de_03_non_negative(
rgb1 in rgb_strategy(),
rgb2 in rgb_strategy()
) {
let metric = CieDe2000Metric::default();
let lab1 = Lab::from_rgb(&rgb1);
let lab2 = Lab::from_rgb(&rgb2);
let de = metric.delta_e(&lab1, &lab2);
prop_assert!(de >= 0.0, "ΔE must be non-negative, got {}", de);
}
#[test]
fn prop_de_04_bounded(
rgb1 in rgb_strategy(),
rgb2 in rgb_strategy()
) {
let metric = CieDe2000Metric::default();
let lab1 = Lab::from_rgb(&rgb1);
let lab2 = Lab::from_rgb(&rgb2);
let de = metric.delta_e(&lab1, &lab2);
prop_assert!(de <= 150.0, "ΔE should be bounded, got {}", de);
}
}
proptest! {
#[test]
fn prop_phash_01_identical_distance_zero(img in image_strategy(64)) {
let hasher = PerceptualHash::default();
let hash1 = hasher.compute(&img, 8, 8);
let hash2 = hasher.compute(&img, 8, 8);
prop_assert_eq!(hash1, hash2, "Hash of identical images should be equal");
prop_assert_eq!(PerceptualHash::distance(hash1, hash2), 0);
}
#[test]
fn prop_phash_02_distance_symmetric(h1: u64, h2: u64) {
let d1 = PerceptualHash::distance(h1, h2);
let d2 = PerceptualHash::distance(h2, h1);
prop_assert_eq!(d1, d2, "Hamming distance should be symmetric");
}
#[test]
fn prop_phash_03_distance_bounded(h1: u64, h2: u64) {
let d = PerceptualHash::distance(h1, h2);
prop_assert!(d <= 64, "Hamming distance should be <= 64, got {}", d);
}
#[test]
fn prop_phash_04_self_distance_zero(h: u64) {
prop_assert_eq!(PerceptualHash::distance(h, h), 0);
}
}
proptest! {
#[test]
fn prop_suite_01_identical_passes(img in image_strategy(64)) {
let suite = PixelVerificationSuite::default();
let result = suite.verify(&img, &img, 8, 8);
prop_assert!(result.passes, "Identical images should pass verification");
prop_assert!(result.ssim.is_acceptable);
}
}
}