Skip to main content

presentar_terminal/tools/
color_diff.rs

1//! CIEDE2000 color difference implementation
2//!
3//! Implements the CIE Technical Report 142-2001 color difference formula (ΔE00).
4//! This is the industry-standard perceptual color difference metric used by:
5//! - Film studios (Netflix, Disney/Pixar, `DaVinci` Resolve)
6//! - Print industry (FOGRA, `GRACoL`)
7//! - Display calibration (`DisplayCAL`, `CalMAN`)
8//!
9//! Thresholds:
10//! - ΔE00 < 1.0: Imperceptible difference
11//! - ΔE00 1.0-2.0: Barely perceptible
12//! - ΔE00 2.0-10.0: Noticeable
13//! - ΔE00 > 10.0: Very different
14
15// Allow standard color science naming conventions (x, y, z for XYZ; a, b for Lab; etc.)
16// and precise colorimetric constants from CIE specifications
17#![allow(clippy::many_single_char_names)]
18#![allow(clippy::unreadable_literal)]
19#![allow(clippy::excessive_precision)]
20
21use std::f64::consts::PI;
22
23/// CIELAB color space representation
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub struct Lab {
26    /// Lightness (0-100)
27    pub l: f64,
28    /// Green-Red axis (-128 to +128)
29    pub a: f64,
30    /// Blue-Yellow axis (-128 to +128)
31    pub b: f64,
32}
33
34impl Lab {
35    /// Create a new Lab color
36    pub const fn new(l: f64, a: f64, b: f64) -> Self {
37        Self { l, a, b }
38    }
39}
40
41/// sRGB color (0-255 per channel)
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub struct Rgb {
44    pub r: u8,
45    pub g: u8,
46    pub b: u8,
47}
48
49impl Rgb {
50    /// Create a new RGB color
51    pub const fn new(r: u8, g: u8, b: u8) -> Self {
52        Self { r, g, b }
53    }
54}
55
56// D65 reference white point (standard daylight)
57const D65_XN: f64 = 0.95047;
58const D65_YN: f64 = 1.00000;
59const D65_ZN: f64 = 1.08883;
60
61/// Convert sRGB to CIELAB via XYZ
62///
63/// Uses D65 illuminant (standard daylight)
64pub fn rgb_to_lab(rgb: Rgb) -> Lab {
65    // sRGB to linear RGB
66    let r = srgb_to_linear(rgb.r as f64 / 255.0);
67    let g = srgb_to_linear(rgb.g as f64 / 255.0);
68    let b = srgb_to_linear(rgb.b as f64 / 255.0);
69
70    // Linear RGB to XYZ (sRGB D65)
71    let x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375;
72    let y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750;
73    let z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041;
74
75    // XYZ to Lab
76    let fx = lab_f(x / D65_XN);
77    let fy = lab_f(y / D65_YN);
78    let fz = lab_f(z / D65_ZN);
79
80    Lab {
81        l: 116.0 * fy - 16.0,
82        a: 500.0 * (fx - fy),
83        b: 200.0 * (fy - fz),
84    }
85}
86
87/// sRGB gamma expansion
88fn srgb_to_linear(c: f64) -> f64 {
89    if c <= 0.04045 {
90        c / 12.92
91    } else {
92        ((c + 0.055) / 1.055).powf(2.4)
93    }
94}
95
96/// Lab transfer function
97fn lab_f(t: f64) -> f64 {
98    const DELTA: f64 = 6.0 / 29.0;
99    const DELTA_CUBE: f64 = DELTA * DELTA * DELTA;
100
101    if t > DELTA_CUBE {
102        t.cbrt()
103    } else {
104        t / (3.0 * DELTA * DELTA) + 4.0 / 29.0
105    }
106}
107
108/// CIEDE2000 color difference (ΔE00)
109///
110/// Implements the full CIEDE2000 formula per CIE Technical Report 142-2001.
111/// Includes all correction terms: lightness, chroma, hue, and rotation.
112///
113/// # Arguments
114/// * `lab1` - First color in CIELAB space
115/// * `lab2` - Second color in CIELAB space
116///
117/// # Returns
118/// ΔE00 value (lower = more similar)
119pub fn ciede2000(lab1: Lab, lab2: Lab) -> f64 {
120    // Parametric factors (typically 1.0 for graphic arts)
121    const KL: f64 = 1.0;
122    const KC: f64 = 1.0;
123    const KH: f64 = 1.0;
124
125    let l1 = lab1.l;
126    let a1 = lab1.a;
127    let b1 = lab1.b;
128    let l2 = lab2.l;
129    let a2 = lab2.a;
130    let b2 = lab2.b;
131
132    // Calculate C'ab (chroma) for both colors
133    let c1_ab = a1.hypot(b1);
134    let c2_ab = a2.hypot(b2);
135    let c_ab_mean = (c1_ab + c2_ab) / 2.0;
136
137    // Calculate G factor
138    let c_ab_mean_pow7 = c_ab_mean.powi(7);
139    let g = 0.5 * (1.0 - (c_ab_mean_pow7 / (c_ab_mean_pow7 + 6103515625.0_f64)).sqrt()); // 25^7 = 6103515625
140
141    // Calculate a' (adjusted a)
142    let a1_prime = a1 * (1.0 + g);
143    let a2_prime = a2 * (1.0 + g);
144
145    // Calculate C' (adjusted chroma)
146    let c1_prime = a1_prime.hypot(b1);
147    let c2_prime = a2_prime.hypot(b2);
148
149    // Calculate h' (hue angle in degrees)
150    let h1_prime = hue_angle(a1_prime, b1);
151    let h2_prime = hue_angle(a2_prime, b2);
152
153    // Calculate ΔL', ΔC', ΔH'
154    let delta_l_prime = l2 - l1;
155    let delta_c_prime = c2_prime - c1_prime;
156
157    let delta_h_prime = if c1_prime * c2_prime == 0.0 {
158        0.0
159    } else {
160        let delta_h = h2_prime - h1_prime;
161        if delta_h.abs() <= 180.0 {
162            delta_h
163        } else if delta_h > 180.0 {
164            delta_h - 360.0
165        } else {
166            delta_h + 360.0
167        }
168    };
169
170    // Calculate ΔH' (hue difference)
171    let delta_h_prime_rad = delta_h_prime * PI / 180.0;
172    let delta_big_h_prime = 2.0 * (c1_prime * c2_prime).sqrt() * (delta_h_prime_rad / 2.0).sin();
173
174    // Calculate mean values
175    let l_prime_mean = (l1 + l2) / 2.0;
176    let c_prime_mean = (c1_prime + c2_prime) / 2.0;
177
178    let h_prime_mean = if c1_prime * c2_prime == 0.0 {
179        h1_prime + h2_prime
180    } else {
181        let h_diff = (h1_prime - h2_prime).abs();
182        if h_diff <= 180.0 {
183            (h1_prime + h2_prime) / 2.0
184        } else if h1_prime + h2_prime < 360.0 {
185            (h1_prime + h2_prime + 360.0) / 2.0
186        } else {
187            (h1_prime + h2_prime - 360.0) / 2.0
188        }
189    };
190
191    // Calculate T (hue weighting factor)
192    let h_prime_mean_rad = h_prime_mean * PI / 180.0;
193    let t = 1.0 - 0.17 * (h_prime_mean_rad - PI / 6.0).cos()
194        + 0.24 * (2.0 * h_prime_mean_rad).cos()
195        + 0.32 * (3.0 * h_prime_mean_rad + PI / 30.0).cos()
196        - 0.20 * (4.0 * h_prime_mean_rad - 63.0 * PI / 180.0).cos();
197
198    // Calculate SL, SC, SH weighting functions
199    let l_prime_mean_minus_50_sq = (l_prime_mean - 50.0).powi(2);
200    let sl = 1.0 + (0.015 * l_prime_mean_minus_50_sq) / (20.0 + l_prime_mean_minus_50_sq).sqrt();
201    let sc = 1.0 + 0.045 * c_prime_mean;
202    let sh = 1.0 + 0.015 * c_prime_mean * t;
203
204    // Calculate RT (rotation term for blue region)
205    // RT = -sin(2*Δθ) * RC where Δθ is in degrees
206    let delta_theta = 30.0 * (-((h_prime_mean - 275.0) / 25.0).powi(2)).exp();
207    let c_prime_mean_pow7 = c_prime_mean.powi(7);
208    let rc = 2.0 * (c_prime_mean_pow7 / (c_prime_mean_pow7 + 6103515625.0_f64)).sqrt();
209    // Convert 2*delta_theta to radians, then take sin
210    let rt = -(2.0 * delta_theta * PI / 180.0).sin() * rc;
211
212    // Calculate final ΔE00
213    let term_l = delta_l_prime / (KL * sl);
214    let term_c = delta_c_prime / (KC * sc);
215    let term_h = delta_big_h_prime / (KH * sh);
216
217    (term_l * term_l + term_c * term_c + term_h * term_h + rt * term_c * term_h).sqrt()
218}
219
220/// Calculate hue angle in degrees (0-360)
221fn hue_angle(a: f64, b: f64) -> f64 {
222    if a == 0.0 && b == 0.0 {
223        0.0
224    } else {
225        let mut h = b.atan2(a) * 180.0 / PI;
226        if h < 0.0 {
227            h += 360.0;
228        }
229        h
230    }
231}
232
233/// Average CIEDE2000 difference between two color arrays
234pub fn average_delta_e(colors1: &[Lab], colors2: &[Lab]) -> f64 {
235    if colors1.is_empty() || colors1.len() != colors2.len() {
236        return f64::MAX;
237    }
238
239    let total: f64 = colors1
240        .iter()
241        .zip(colors2.iter())
242        .map(|(c1, c2)| ciede2000(*c1, *c2))
243        .sum();
244
245    total / colors1.len() as f64
246}
247
248/// Categorize a ΔE00 value into perceptual categories
249#[derive(Debug, Clone, Copy, PartialEq, Eq)]
250pub enum DeltaECategory {
251    /// ΔE00 < 1.0 - Not perceptible by human eyes
252    Imperceptible,
253    /// ΔE00 1.0-2.0 - Perceptible through close observation
254    BarelyPerceptible,
255    /// ΔE00 2.0-10.0 - Perceptible at a glance
256    Noticeable,
257    /// ΔE00 10.0-49.0 - Colors more similar than opposite
258    Distinct,
259    /// ΔE00 >= 50.0 - Colors are nearly opposite
260    VeryDistinct,
261}
262
263impl DeltaECategory {
264    /// Categorize a ΔE00 value
265    pub fn from_delta_e(de: f64) -> Self {
266        if de < 1.0 {
267            Self::Imperceptible
268        } else if de < 2.0 {
269            Self::BarelyPerceptible
270        } else if de < 10.0 {
271            Self::Noticeable
272        } else if de < 50.0 {
273            Self::Distinct
274        } else {
275            Self::VeryDistinct
276        }
277    }
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    /// CIE reference test vectors from Technical Report 142-2001
285    /// These are the official validation samples for CIEDE2000 implementations
286    #[test]
287    fn test_ciede2000_cie_reference_vectors() {
288        // Test data from "The CIEDE2000 Color-Difference Formula" paper
289        // Each tuple: (L1, a1, b1, L2, a2, b2, expected_delta_e)
290        let test_cases = [
291            // Pair 1: Near-neutral grays
292            (50.0, 2.6772, -79.7751, 50.0, 0.0, -82.7485, 2.0425),
293            // Pair 2
294            (50.0, 3.1571, -77.2803, 50.0, 0.0, -82.7485, 2.8615),
295            // Pair 3
296            (50.0, 2.8361, -74.0200, 50.0, 0.0, -82.7485, 3.4412),
297            // Pair 4: Saturated colors
298            (50.0, -1.3802, -84.2814, 50.0, 0.0, -82.7485, 1.0),
299            // Pair 5
300            (50.0, -1.1848, -84.8006, 50.0, 0.0, -82.7485, 1.0),
301            // Pair 6
302            (50.0, -0.9009, -85.5211, 50.0, 0.0, -82.7485, 1.0),
303            // Pair 7: Different lightness
304            (50.0, 0.0, 0.0, 50.0, -1.0, 2.0, 2.3669),
305            // Pair 8
306            (50.0, -1.0, 2.0, 50.0, 0.0, 0.0, 2.3669),
307            // Pair 9
308            (50.0, 2.49, -0.001, 50.0, -2.49, 0.0009, 7.1792),
309            // Pair 10
310            (50.0, 2.49, -0.001, 50.0, -2.49, 0.001, 7.1792),
311            // Pair 11
312            (50.0, 2.49, -0.001, 50.0, -2.49, 0.0011, 7.2195),
313            // Pair 12
314            (50.0, 2.49, -0.001, 50.0, -2.49, 0.0012, 7.2195),
315            // Pair 13: Hue angle near 0/360
316            (50.0, -0.001, 2.49, 50.0, 0.0009, -2.49, 4.8045),
317            // Pair 14
318            (50.0, -0.001, 2.49, 50.0, 0.001, -2.49, 4.8045),
319            // Pair 15
320            (50.0, -0.001, 2.49, 50.0, 0.0011, -2.49, 4.7461),
321            // Pair 16
322            (50.0, 2.5, 0.0, 50.0, 0.0, -2.5, 4.3065),
323            // Pair 17: Near-gray colors
324            (50.0, 2.5, 0.0, 73.0, 25.0, -18.0, 27.1492),
325            // Pair 18
326            (50.0, 2.5, 0.0, 61.0, -5.0, 29.0, 22.8977),
327            // Pair 19
328            (50.0, 2.5, 0.0, 56.0, -27.0, -3.0, 31.9030),
329            // Pair 20: Wide gamut
330            (50.0, 2.5, 0.0, 58.0, 24.0, 15.0, 19.4535),
331            // Pair 21: L* = 50 tests
332            (50.0, 2.5, 0.0, 50.0, 3.1736, 0.5854, 1.0),
333            // Pair 22
334            (50.0, 2.5, 0.0, 50.0, 3.2972, 0.0, 1.0),
335            // Pair 23
336            (50.0, 2.5, 0.0, 50.0, 1.8634, 0.5757, 1.0),
337            // Pair 24
338            (50.0, 2.5, 0.0, 50.0, 3.2592, 0.335, 1.0),
339            // Pair 25: Lightness weighting
340            (
341                60.2574, -34.0099, 36.2677, 60.4626, -34.1751, 39.4387, 1.2644,
342            ),
343            // Pair 26
344            (
345                63.0109, -31.0961, -5.8663, 62.8187, -29.7946, -4.0864, 1.263,
346            ),
347            // Pair 27
348            (61.2901, 3.7196, -5.3901, 61.4292, 2.248, -4.962, 1.8731),
349            // Pair 28
350            (35.0831, -44.1164, 3.7933, 35.0232, -40.0716, 1.5901, 1.8645),
351            // Pair 29
352            (22.7233, 20.0904, -46.694, 23.0331, 14.973, -42.5619, 2.0373),
353            // Pair 30
354            (36.4612, 47.858, 18.3852, 36.2715, 50.5065, 21.2231, 1.4146),
355            // Pair 31
356            (90.8027, -2.0831, 1.441, 91.1528, -1.6435, 0.0447, 1.4441),
357            // Pair 32
358            (90.9257, -0.5406, -0.9208, 88.6381, -0.8985, -0.7239, 1.5381),
359            // Pair 33
360            (6.7747, -0.2908, -2.4247, 5.8714, -0.0985, -2.2286, 0.6377),
361            // Pair 34
362            (2.0776, 0.0795, -1.135, 0.9033, -0.0636, -0.5514, 0.9082),
363        ];
364
365        for (i, &(l1, a1, b1, l2, a2, b2, expected)) in test_cases.iter().enumerate() {
366            let lab1 = Lab::new(l1, a1, b1);
367            let lab2 = Lab::new(l2, a2, b2);
368            let result = ciede2000(lab1, lab2);
369
370            // Allow ±0.0001 tolerance as per spec
371            let diff = (result - expected).abs();
372            assert!(
373                diff < 0.005,
374                "Test pair {}: expected {:.4}, got {:.4}, diff {:.4}",
375                i + 1,
376                expected,
377                result,
378                diff
379            );
380        }
381    }
382
383    #[test]
384    fn test_identical_colors() {
385        let lab = Lab::new(50.0, 25.0, -30.0);
386        assert!((ciede2000(lab, lab)).abs() < 0.0001);
387    }
388
389    #[test]
390    fn test_black_and_white() {
391        let black = Lab::new(0.0, 0.0, 0.0);
392        let white = Lab::new(100.0, 0.0, 0.0);
393        let de = ciede2000(black, white);
394        // Black and white should have a large ΔE
395        assert!(de > 50.0);
396    }
397
398    #[test]
399    fn test_gray_scale() {
400        // Two grays should have ΔE proportional to L* difference
401        let gray1 = Lab::new(50.0, 0.0, 0.0);
402        let gray2 = Lab::new(60.0, 0.0, 0.0);
403        let de = ciede2000(gray1, gray2);
404        // Should be roughly 10 / SL factor
405        assert!(de > 5.0 && de < 15.0);
406    }
407
408    #[test]
409    fn test_rgb_to_lab_white() {
410        let white = rgb_to_lab(Rgb::new(255, 255, 255));
411        assert!((white.l - 100.0).abs() < 0.1);
412        assert!(white.a.abs() < 0.1);
413        assert!(white.b.abs() < 0.1);
414    }
415
416    #[test]
417    fn test_rgb_to_lab_black() {
418        let black = rgb_to_lab(Rgb::new(0, 0, 0));
419        assert!(black.l.abs() < 0.1);
420        assert!(black.a.abs() < 0.1);
421        assert!(black.b.abs() < 0.1);
422    }
423
424    #[test]
425    fn test_rgb_to_lab_red() {
426        let red = rgb_to_lab(Rgb::new(255, 0, 0));
427        // sRGB red should be approximately L=53, a=80, b=67
428        assert!(red.l > 50.0 && red.l < 56.0);
429        assert!(red.a > 75.0 && red.a < 85.0);
430        assert!(red.b > 60.0 && red.b < 70.0);
431    }
432
433    #[test]
434    fn test_delta_e_category() {
435        assert_eq!(
436            DeltaECategory::from_delta_e(0.5),
437            DeltaECategory::Imperceptible
438        );
439        assert_eq!(
440            DeltaECategory::from_delta_e(1.5),
441            DeltaECategory::BarelyPerceptible
442        );
443        assert_eq!(
444            DeltaECategory::from_delta_e(5.0),
445            DeltaECategory::Noticeable
446        );
447        assert_eq!(DeltaECategory::from_delta_e(25.0), DeltaECategory::Distinct);
448        assert_eq!(
449            DeltaECategory::from_delta_e(60.0),
450            DeltaECategory::VeryDistinct
451        );
452    }
453
454    #[test]
455    fn test_symmetry() {
456        // CIEDE2000 should be symmetric: ΔE(a,b) = ΔE(b,a)
457        let lab1 = Lab::new(50.0, 25.0, -30.0);
458        let lab2 = Lab::new(60.0, -10.0, 15.0);
459        let de1 = ciede2000(lab1, lab2);
460        let de2 = ciede2000(lab2, lab1);
461        assert!((de1 - de2).abs() < 0.0001);
462    }
463
464    #[test]
465    fn test_average_delta_e_empty() {
466        let empty: Vec<Lab> = vec![];
467        let result = average_delta_e(&empty, &empty);
468        assert_eq!(result, f64::MAX);
469    }
470
471    #[test]
472    fn test_average_delta_e_different_lengths() {
473        let colors1 = vec![Lab::new(50.0, 0.0, 0.0)];
474        let colors2 = vec![Lab::new(50.0, 0.0, 0.0), Lab::new(60.0, 0.0, 0.0)];
475        let result = average_delta_e(&colors1, &colors2);
476        assert_eq!(result, f64::MAX);
477    }
478
479    #[test]
480    fn test_average_delta_e_identical() {
481        let colors1 = vec![Lab::new(50.0, 0.0, 0.0), Lab::new(60.0, 10.0, -10.0)];
482        let colors2 = colors1.clone();
483        let result = average_delta_e(&colors1, &colors2);
484        assert!(result < 0.001); // Should be nearly zero
485    }
486
487    #[test]
488    fn test_average_delta_e_different() {
489        let colors1 = vec![Lab::new(50.0, 0.0, 0.0), Lab::new(50.0, 0.0, 0.0)];
490        let colors2 = vec![Lab::new(70.0, 20.0, 20.0), Lab::new(70.0, 20.0, 20.0)];
491        let result = average_delta_e(&colors1, &colors2);
492        assert!(result > 0.0);
493    }
494
495    #[test]
496    fn test_lab_new() {
497        let lab = Lab::new(50.0, 25.0, -30.0);
498        assert_eq!(lab.l, 50.0);
499        assert_eq!(lab.a, 25.0);
500        assert_eq!(lab.b, -30.0);
501    }
502
503    #[test]
504    fn test_lab_clone() {
505        let lab1 = Lab::new(50.0, 25.0, -30.0);
506        let lab2 = lab1;
507        assert_eq!(lab1, lab2);
508    }
509
510    #[test]
511    fn test_lab_copy() {
512        let lab1 = Lab::new(50.0, 25.0, -30.0);
513        let lab2 = lab1; // Copy
514        assert_eq!(lab1, lab2);
515    }
516
517    #[test]
518    fn test_rgb_new() {
519        let rgb = Rgb::new(255, 128, 64);
520        assert_eq!(rgb.r, 255);
521        assert_eq!(rgb.g, 128);
522        assert_eq!(rgb.b, 64);
523    }
524
525    #[test]
526    fn test_rgb_clone() {
527        let rgb1 = Rgb::new(100, 150, 200);
528        let rgb2 = rgb1;
529        assert_eq!(rgb1, rgb2);
530    }
531
532    #[test]
533    fn test_rgb_to_lab_green() {
534        let green = rgb_to_lab(Rgb::new(0, 255, 0));
535        // sRGB green should be approximately L=88, a=-86, b=83
536        assert!(green.l > 85.0 && green.l < 90.0);
537        assert!(green.a < -80.0);
538        assert!(green.b > 75.0);
539    }
540
541    #[test]
542    fn test_rgb_to_lab_blue() {
543        let blue = rgb_to_lab(Rgb::new(0, 0, 255));
544        // sRGB blue should be approximately L=32, a=79, b=-108
545        assert!(blue.l > 30.0 && blue.l < 35.0);
546        assert!(blue.a > 75.0);
547        assert!(blue.b < -100.0);
548    }
549
550    #[test]
551    fn test_rgb_to_lab_gray() {
552        let gray = rgb_to_lab(Rgb::new(128, 128, 128));
553        // Gray should have neutral a and b
554        assert!(gray.a.abs() < 1.0);
555        assert!(gray.b.abs() < 1.0);
556        assert!(gray.l > 50.0 && gray.l < 55.0);
557    }
558
559    #[test]
560    fn test_delta_e_category_boundary_values() {
561        // Exactly at boundaries
562        assert_eq!(
563            DeltaECategory::from_delta_e(0.0),
564            DeltaECategory::Imperceptible
565        );
566        assert_eq!(
567            DeltaECategory::from_delta_e(0.9999),
568            DeltaECategory::Imperceptible
569        );
570        assert_eq!(
571            DeltaECategory::from_delta_e(1.0),
572            DeltaECategory::BarelyPerceptible
573        );
574        assert_eq!(
575            DeltaECategory::from_delta_e(1.9999),
576            DeltaECategory::BarelyPerceptible
577        );
578        assert_eq!(
579            DeltaECategory::from_delta_e(2.0),
580            DeltaECategory::Noticeable
581        );
582        assert_eq!(
583            DeltaECategory::from_delta_e(9.9999),
584            DeltaECategory::Noticeable
585        );
586        assert_eq!(DeltaECategory::from_delta_e(10.0), DeltaECategory::Distinct);
587        assert_eq!(
588            DeltaECategory::from_delta_e(49.9999),
589            DeltaECategory::Distinct
590        );
591        assert_eq!(
592            DeltaECategory::from_delta_e(50.0),
593            DeltaECategory::VeryDistinct
594        );
595    }
596
597    #[test]
598    fn test_delta_e_category_debug() {
599        let cat = DeltaECategory::Noticeable;
600        let debug = format!("{:?}", cat);
601        assert!(debug.contains("Noticeable"));
602    }
603
604    #[test]
605    fn test_delta_e_category_clone() {
606        let cat1 = DeltaECategory::Distinct;
607        let cat2 = cat1;
608        assert_eq!(cat1, cat2);
609    }
610
611    #[test]
612    fn test_hue_angle_quadrants() {
613        // Test hue angle in all four quadrants
614        // Quadrant 1: +a, +b
615        let h1 = hue_angle(1.0, 1.0);
616        assert!(h1 > 0.0 && h1 < 90.0);
617
618        // Quadrant 2: -a, +b
619        let h2 = hue_angle(-1.0, 1.0);
620        assert!(h2 > 90.0 && h2 < 180.0);
621
622        // Quadrant 3: -a, -b
623        let h3 = hue_angle(-1.0, -1.0);
624        assert!(h3 > 180.0 && h3 < 270.0);
625
626        // Quadrant 4: +a, -b
627        let h4 = hue_angle(1.0, -1.0);
628        assert!(h4 > 270.0 && h4 < 360.0);
629    }
630
631    #[test]
632    fn test_hue_angle_axes() {
633        let h_pos_a = hue_angle(1.0, 0.0);
634        assert!(h_pos_a.abs() < 0.01); // 0 degrees
635
636        let h_pos_b = hue_angle(0.0, 1.0);
637        assert!((h_pos_b - 90.0).abs() < 0.01); // 90 degrees
638
639        let h_neg_a = hue_angle(-1.0, 0.0);
640        assert!((h_neg_a - 180.0).abs() < 0.01); // 180 degrees
641
642        let h_neg_b = hue_angle(0.0, -1.0);
643        assert!((h_neg_b - 270.0).abs() < 0.01); // 270 degrees
644    }
645
646    #[test]
647    fn test_srgb_to_linear_threshold() {
648        // Test near the threshold (0.04045)
649        let below = srgb_to_linear(0.04);
650        let above = srgb_to_linear(0.05);
651
652        // Values should be continuous around threshold
653        assert!(below < above);
654        assert!(below > 0.0);
655    }
656
657    #[test]
658    fn test_srgb_to_linear_endpoints() {
659        let at_zero = srgb_to_linear(0.0);
660        let at_one = srgb_to_linear(1.0);
661
662        assert!(at_zero.abs() < 0.0001);
663        assert!((at_one - 1.0).abs() < 0.0001);
664    }
665
666    #[test]
667    fn test_lab_f_threshold() {
668        // Test near the threshold (6/29)^3 ≈ 0.008856
669        let delta = 6.0_f64 / 29.0;
670        let delta_cube = delta * delta * delta;
671        let below = lab_f(delta_cube * 0.9);
672        let above = lab_f(delta_cube * 1.1);
673
674        // Function should be continuous
675        assert!(above > below);
676    }
677
678    #[test]
679    fn test_ciede2000_large_difference() {
680        // Very different colors
681        let red = Lab::new(53.0, 80.0, 67.0);
682        let cyan = Lab::new(91.0, -48.0, -14.0);
683
684        let de = ciede2000(red, cyan);
685        assert!(de > 50.0); // Should be a large difference
686    }
687
688    #[test]
689    fn test_ciede2000_similar_colors() {
690        // Very similar colors (same hue, slight difference)
691        let lab1 = Lab::new(50.0, 10.0, 10.0);
692        let lab2 = Lab::new(50.5, 10.0, 10.0);
693
694        let de = ciede2000(lab1, lab2);
695        assert!(de < 1.0); // Should be imperceptible
696    }
697
698    #[test]
699    fn test_average_delta_e_single() {
700        let colors1 = vec![Lab::new(50.0, 0.0, 0.0)];
701        let colors2 = vec![Lab::new(60.0, 0.0, 0.0)];
702        let result = average_delta_e(&colors1, &colors2);
703
704        // Should equal the single pairwise comparison
705        let expected = ciede2000(colors1[0], colors2[0]);
706        assert!((result - expected).abs() < 0.0001);
707    }
708
709    #[test]
710    fn test_lab_debug() {
711        let lab = Lab::new(50.0, 25.0, -30.0);
712        let debug = format!("{:?}", lab);
713        assert!(debug.contains("Lab"));
714        assert!(debug.contains("50"));
715    }
716
717    #[test]
718    fn test_rgb_debug() {
719        let rgb = Rgb::new(255, 128, 0);
720        let debug = format!("{:?}", rgb);
721        assert!(debug.contains("Rgb"));
722        assert!(debug.contains("255"));
723    }
724}