chromashift/
distance.rs

1use crate::Lab;
2
3/// Trait for calculating perceptual color distance between colors.
4///
5/// This trait provides methods to determine if two colors are perceptually close using the CIE Delta E distance
6/// calculation in Lab color space.
7pub trait ColorDistance<T>: Sized + Copy
8where
9	Lab: From<T> + From<Self>,
10{
11	/// Compares `self` to `other` via [delta_e][ColorDistance::delta_e], checking that the result is less than or equal
12	/// to `tolerance`.
13	///
14	/// `other` must implement [Into]&lt;[Lab]>
15	///
16	/// Tolerance values:
17	///   - 0.0-1.0: Not perceptible by human eye
18	///   - 1.0-2.0: Perceptible through close observation
19	///   - 2.0-10.0: Perceptible at a glance
20	///   - 11.0-49.0: Colors are more similar than opposite
21	///   - 100.0: Colors are exact opposites
22	///
23	/// # Example
24	///
25	/// ```
26	/// use chromashift::{Srgb, ColorDistance};
27	///
28	/// let red1 = Srgb::new(255, 0, 0, 100.0);
29	/// let red2 = Srgb::new(254, 1, 1, 100.0);
30	///
31	/// assert!(red1.close_to(red2, 2.0));
32	/// ```
33	fn close_to(&self, other: T, tolerance: f64) -> bool {
34		self.delta_e(other) <= tolerance
35	}
36
37	/// Compares `self` to `other` via [delta_e][ColorDistance::delta_e], checking that the result is greater than or
38	/// equal to `tolerance`.
39	///
40	/// `other` must implement [Into]&lt;[Lab]>
41	///
42	/// Tolerance values:
43	///   - 0.0-1.0: Not perceptible by human eye
44	///   - 1.0-2.0: Perceptible through close observation
45	///   - 2.0-10.0: Perceptible at a glance
46	///   - 11.0-49.0: Colors are more similar than opposite
47	///   - 100.0: Colors are exact opposites
48	///
49	/// # Example
50	///
51	/// ```
52	/// use chromashift::{Srgb, ColorDistance};
53	///
54	/// let red = Srgb::new(255, 0, 0, 100.0);
55	/// let blue = Srgb::new(0, 0, 255, 100.0);
56	///
57	/// assert!(red.far_from(blue, 10.0));
58	/// ```
59	fn far_from(&self, other: T, tolerance: f64) -> bool {
60		self.delta_e(other) >= tolerance
61	}
62
63	/// This uses the CIEDE2000 Delta E formula difference between `self` and `other`.
64	///
65	/// `other` must implement [Into]&lt;[Lab]>
66	///
67	/// Tolerance values:
68	///   - 0.0-1.0: Not perceptible by human eye
69	///   - 1.0-2.0: Perceptible through close observation
70	///   - 2.0-10.0: Perceptible at a glance
71	///   - 11.0-49.0: Colors are more similar than opposite
72	///   - 100.0: Colors are exact opposites
73	///
74	/// # Example
75	///
76	/// ```
77	/// use chromashift::{Srgb, Named, ColorDistance};
78	///
79	/// let red = Srgb::new(255, 0, 0, 100.0);
80	/// let green = Srgb::new(0, 255, 0, 100.0);
81	///
82	/// assert_eq!(red.delta_e(green).round(), 84.0);
83	///
84	/// assert_eq!(Named::Black.delta_e(Named::White).round(), 100.0);
85	/// ```
86	fn delta_e(&self, other: T) -> f64 {
87		let lab1 = Lab::from(*self);
88		let lab2 = Lab::from(other);
89		// Extract Lab values
90		let (l1, a1, b1) = (lab1.lightness, lab1.a, lab1.b);
91		let (l2, a2, b2) = (lab2.lightness, lab2.a, lab2.b);
92
93		// Initial chroma and mean chroma
94		let c1 = (a1 * a1 + b1 * b1).sqrt();
95		let c2 = (a2 * a2 + b2 * b2).sqrt();
96		let c_bar = (c1 + c2) / 2.0;
97
98		// G compensation for a* non-uniformity
99		let c_bar_7 = c_bar.powf(7.0);
100		let g = 0.5 * (1.0 - (c_bar_7 / (c_bar_7 + 25.0_f64.powf(7.0))).sqrt());
101
102		// Corrected a* values and chroma
103		let a1_prime = (1.0 + g) * a1;
104		let a2_prime = (1.0 + g) * a2;
105		let c1_prime = (a1_prime * a1_prime + b1 * b1).sqrt();
106		let c2_prime = (a2_prime * a2_prime + b2 * b2).sqrt();
107
108		// Hue angles (in degrees)
109		let h1_prime =
110			if a1_prime == 0.0 && b1 == 0.0 { 0.0 } else { b1.atan2(a1_prime).to_degrees().rem_euclid(360.0) };
111		let h2_prime =
112			if a2_prime == 0.0 && b2 == 0.0 { 0.0 } else { b2.atan2(a2_prime).to_degrees().rem_euclid(360.0) };
113
114		// Differences
115		let delta_l = l2 - l1;
116		let delta_c = c2_prime - c1_prime;
117		let delta_h = if c1_prime * c2_prime == 0.0 {
118			0.0
119		} else {
120			let diff = h2_prime - h1_prime;
121			if diff.abs() <= 180.0 {
122				diff
123			} else if diff > 180.0 {
124				diff - 360.0
125			} else {
126				diff + 360.0
127			}
128		};
129		let delta_h_big = 2.0 * (c1_prime * c2_prime).sqrt() * (delta_h.to_radians() / 2.0).sin();
130
131		// Mean values
132		let l_bar = (l1 + l2) / 2.0;
133		let c_prime_bar = (c1_prime + c2_prime) / 2.0;
134		let h_prime_bar = if c1_prime * c2_prime == 0.0 {
135			h1_prime + h2_prime
136		} else {
137			let sum = h1_prime + h2_prime;
138			let diff = (h1_prime - h2_prime).abs();
139			if diff <= 180.0 {
140				sum / 2.0
141			} else if sum < 360.0 {
142				(sum + 360.0) / 2.0
143			} else {
144				(sum - 360.0) / 2.0
145			}
146		};
147
148		// Weighting functions (T factor for hue)
149		let t = 1.0 - 0.17 * (h_prime_bar - 30.0).to_radians().cos()
150			+ 0.24 * (2.0 * h_prime_bar).to_radians().cos()
151			+ 0.32 * (3.0 * h_prime_bar + 6.0).to_radians().cos()
152			- 0.20 * (4.0 * h_prime_bar - 63.0).to_radians().cos();
153
154		let l_offset = l_bar - 50.0;
155		let sl = 1.0 + (0.015 * l_offset * l_offset) / (20.0 + l_offset * l_offset).sqrt();
156		let sc = 1.0 + 0.045 * c_prime_bar;
157		let sh = 1.0 + 0.015 * c_prime_bar * t;
158
159		// Rotation term for blue region
160		let delta_theta = 30.0 * (-((h_prime_bar - 275.0) / 25.0).powf(2.0)).exp();
161		let c_prime_bar_7 = c_prime_bar.powf(7.0);
162		let rc = 2.0 * (c_prime_bar_7 / (c_prime_bar_7 + 25.0_f64.powf(7.0))).sqrt();
163		let rt = -rc * (2.0 * delta_theta).to_radians().sin();
164
165		// Final calculation (kL = kC = kH = 1.0, so omitted)
166		let l_term = delta_l / sl;
167		let c_term = delta_c / sc;
168		let h_term = delta_h_big / sh;
169
170		(l_term * l_term + c_term * c_term + h_term * h_term + rt * c_term * h_term).sqrt()
171	}
172}
173
174impl<C, T> ColorDistance<T> for C
175where
176	C: Copy,
177	Lab: From<C> + From<T>,
178{
179}
180
181#[cfg(test)]
182mod tests {
183	use super::*;
184	use crate::{Hsl, Srgb};
185
186	#[test]
187	fn test_identical_colors() {
188		let red = Srgb::new(255, 0, 0, 100.0);
189		assert!(red.close_to(red, 0.0));
190		assert_eq!(red.delta_e(red), 0.0);
191	}
192
193	#[test]
194	fn test_very_similar_colors() {
195		let red1 = Srgb::new(255, 0, 0, 100.0);
196		let red2 = Srgb::new(254, 1, 1, 100.0);
197
198		assert!(red1.close_to(red2, 2.0));
199		assert!(red1.delta_e(red2) < 2.0);
200	}
201
202	#[test]
203	fn test_different_colors() {
204		let red = Srgb::new(255, 0, 0, 100.0);
205		let blue = Srgb::new(0, 0, 255, 100.0);
206
207		assert!(!red.close_to(blue, 10.0));
208		assert!(red.delta_e(blue) > 40.0);
209	}
210
211	#[test]
212	fn test_cross_color_space_comparison() {
213		let red_srgb = Srgb::new(255, 0, 0, 100.0);
214		let red_hsl = Hsl::new(0.0, 100.0, 50.0, 100.0);
215
216		// These should be very close (same red color in different spaces)
217		assert!(red_srgb.close_to(red_hsl, 2.0));
218	}
219
220	#[test]
221	fn test_tolerance_levels() {
222		let color1 = Srgb::new(100, 100, 100, 100.0);
223		let color2 = Srgb::new(110, 105, 95, 100.0);
224
225		let distance = color1.delta_e(color2);
226
227		// Test that tolerance works correctly
228		assert!(!color1.close_to(color2, distance - 0.1));
229		assert!(color1.close_to(color2, distance + 0.1));
230	}
231
232	#[test]
233	fn test_alpha_ignored_in_distance() {
234		let color1 = Srgb::new(255, 0, 0, 100.0);
235		let color2 = Srgb::new(255, 0, 0, 50.0);
236
237		// Alpha should not affect color distance calculation
238		assert!(color1.close_to(color2, 0.1));
239	}
240}