@@ -2,11 +2,42 @@ use image::Pixel;
22
33#[ derive( Debug , PartialEq ) ]
44pub struct Yiq {
5- y : f32 , // luminance
5+ pub y : f32 , // luminance
66 i : f32 , // hue of color
77 q : f32 , // saturation of color
88}
99
10+ /// Calculate background color components for blending transparent pixels.
11+ /// Uses position-dependent colors (like pixelmatch) to ensure transparent
12+ /// and opaque versions of the same color compare as different.
13+ ///
14+ /// Based on: https://github.com/mapbox/pixelmatch/pull/142
15+ ///
16+ /// # Design Principles (from pixelmatch)
17+ /// 1. Background should NOT be a uniform color
18+ /// 2. Background should NOT contain large areas of uniform color
19+ /// 3. Background should have perceptual variability
20+ /// 4. Background should be deterministic
21+ /// 5. Background color should be easy to compute
22+ /// 6. Background should NOT contain "common colors" (white/black)
23+ /// 7. Background should NOT contain lines
24+ /// 8. Background should not contain anything we'd expect in test images
25+ ///
26+ /// # Magic Numbers Explained
27+ /// - **48.0**: Base value - ensures minimum color value, avoiding pure black
28+ /// - **159.0**: Range multiplier (48 + 159 = 207, staying below 255)
29+ /// - Provides large color variability while avoiding white/black/saturated colors
30+ /// - **1.616, 2.612**: Irrational coefficients for color pattern periods
31+ /// - Carefully chosen to prevent pattern from degenerating into lines
32+ /// - Extra digits preserve irrationality for very large images
33+ /// - These values create a checkerboard-like pattern that repeats
34+ fn background_color ( k : usize ) -> ( f32 , f32 , f32 ) {
35+ let r = 48.0 + 159.0 * ( ( k % 2 ) as f32 ) ;
36+ let g = 48.0 + 159.0 * ( ( k as f32 / 1.616 ) . floor ( ) as u32 % 2 ) as f32 ;
37+ let b = 48.0 + 159.0 * ( ( k as f32 / 2.612 ) . floor ( ) as u32 % 2 ) as f32 ;
38+ ( r, g, b)
39+ }
40+
1041impl Yiq {
1142 #[ allow( clippy:: many_single_char_names, clippy:: excessive_precision) ]
1243 pub fn rgb2y ( rgb : & image:: Rgb < u8 > ) -> f32 {
@@ -18,31 +49,48 @@ impl Yiq {
1849 0.298_895_31 * r + 0.586_622_47 * g + 0.114_482_23 * b
1950 }
2051
21- #[ allow( clippy:: many_single_char_names, clippy:: excessive_precision) ]
22- fn rgb2i ( rgb : & image:: Rgb < u8 > ) -> f32 {
23- let rgb = rgb. channels ( ) ;
24- let r = f32:: from ( rgb[ 0 ] ) ;
25- let g = f32:: from ( rgb[ 1 ] ) ;
26- let b = f32:: from ( rgb[ 2 ] ) ;
27-
28- 0.595_977_99 * r - 0.274_171_6 * g - 0.321_801_89 * b
52+ /// Convert RGBA to YIQ (legacy method - uses position 0 for backward compatibility).
53+ /// For comparing images with transparency, use `from_rgba_with_pos` instead.
54+ #[ allow( dead_code) ]
55+ pub fn from_rgba ( rgba : & image:: Rgba < u8 > ) -> Self {
56+ Self :: from_rgba_with_pos ( rgba, 0 )
2957 }
3058
31- #[ allow( clippy:: many_single_char_names, clippy:: excessive_precision) ]
32- fn rgb2q ( rgb : & image:: Rgb < u8 > ) -> f32 {
33- let rgb = rgb. channels ( ) ;
34- let r = f32:: from ( rgb[ 0 ] ) ;
35- let g = f32:: from ( rgb[ 1 ] ) ;
36- let b = f32:: from ( rgb[ 2 ] ) ;
59+ /// Convert RGBA to YIQ with position-dependent background blending for transparent pixels.
60+ /// This ensures transparent and opaque versions of the same color compare as different.
61+ ///
62+ /// # Arguments
63+ /// * `rgba` - The RGBA pixel to convert
64+ /// * `pos` - Linear position (pixel index) for background color calculation
65+ pub fn from_rgba_with_pos ( rgba : & image:: Rgba < u8 > , pos : usize ) -> Self {
66+ let rgba_channels = rgba. channels ( ) ;
67+ let r = f32:: from ( rgba_channels[ 0 ] ) ;
68+ let g = f32:: from ( rgba_channels[ 1 ] ) ;
69+ let b = f32:: from ( rgba_channels[ 2 ] ) ;
70+ let a = f32:: from ( rgba_channels[ 3 ] ) ;
3771
38- 0.211_470_19 * r - 0.522_617_11 * g + 0.311_146_94 * b
39- }
72+ let ( r_final, g_final, b_final) = if a < 255.0 {
73+ // Blend with position-dependent background for transparent/semi-transparent pixels
74+ let alpha = a / 255.0 ;
75+ let ( bg_r, bg_g, bg_b) = background_color ( pos) ;
76+ (
77+ bg_r + ( r - bg_r) * alpha,
78+ bg_g + ( g - bg_g) * alpha,
79+ bg_b + ( b - bg_b) * alpha,
80+ )
81+ } else {
82+ // Fully opaque - use RGB values as-is
83+ ( r, g, b)
84+ } ;
4085
41- pub fn from_rgba ( rgba : & image:: Rgba < u8 > ) -> Self {
42- let rgb = rgba. to_rgb ( ) ;
43- let y = Self :: rgb2y ( & rgb) ;
44- let i = Self :: rgb2i ( & rgb) ;
45- let q = Self :: rgb2q ( & rgb) ;
86+ // Convert the blended RGB to YIQ
87+ // Standard YIQ conversion coefficients - precision is intentional
88+ #[ expect( clippy:: excessive_precision) ]
89+ let y = 0.298_895_31 * r_final + 0.586_622_47 * g_final + 0.114_482_23 * b_final;
90+ #[ expect( clippy:: excessive_precision) ]
91+ let i = 0.595_977_99 * r_final - 0.274_171_6 * g_final - 0.321_801_89 * b_final;
92+ #[ expect( clippy:: excessive_precision) ]
93+ let q = 0.211_470_19 * r_final - 0.522_617_11 * g_final + 0.311_146_94 * b_final;
4694
4795 Self { y, i, q }
4896 }
@@ -73,8 +121,13 @@ mod tests {
73121 i : 0.0 ,
74122 q : 0.0 ,
75123 } ;
76- let actual = Yiq :: from_rgba ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) ) ;
124+ // Fully opaque black should have zero YIQ
125+ let actual = Yiq :: from_rgba ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) ) ;
77126 assert_eq ! ( expected, actual) ;
127+
128+ // Transparent black should blend with background, NOT equal to opaque black
129+ let transparent_black = Yiq :: from_rgba ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) ) ;
130+ assert_ne ! ( expected, transparent_black, "Transparent black should not equal opaque black" ) ;
78131 }
79132
80133 #[ test]
@@ -91,4 +144,82 @@ mod tests {
91144 } ;
92145 assert_eq ! ( a. squared_distance( & b) , 0.0 ) ;
93146 }
147+
148+ #[ test]
149+ fn test_issue_32_transparent_vs_opaque_black ( ) {
150+ // Issue #32: Transparent black (#00000000) and opaque black (#000000FF)
151+ // should NOT compare as equal since they appear different visually.
152+ let opaque_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) , 0 ) ;
153+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , 0 ) ;
154+
155+ // These should NOT have a squared_distance of 0.0 (they should be different)
156+ // This test will FAIL before the fix and PASS after
157+ assert_ne ! (
158+ opaque_black. squared_distance( & transparent_black) ,
159+ 0.0 ,
160+ "Transparent black and opaque black should have different YIQ values"
161+ ) ;
162+ }
163+
164+ #[ test]
165+ fn test_semi_transparent_pixels ( ) {
166+ // Semi-transparent pixels (alpha between 0 and 255) should be handled
167+ // by blending with the background color
168+ let opaque = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 255 ] ) , 0 ) ;
169+ let semi_transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 128 ] ) , 0 ) ;
170+ let transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 0 ] ) , 0 ) ;
171+
172+ // All three should have different YIQ values due to different blending
173+ assert_ne ! ( opaque. y, semi_transparent. y) ;
174+ assert_ne ! ( opaque. y, transparent. y) ;
175+ assert_ne ! ( semi_transparent. y, transparent. y) ;
176+ }
177+
178+ #[ test]
179+ fn test_position_dependent_background ( ) {
180+ // Same transparent color at different positions should have different
181+ // YIQ values due to position-dependent background blending
182+ let transparent_red_pos0 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 0 ] ) , 0 ) ;
183+ let transparent_red_pos1 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 0 ] ) , 1 ) ;
184+
185+ assert_ne ! (
186+ transparent_red_pos0, transparent_red_pos1,
187+ "Same transparent color at different positions should differ"
188+ ) ;
189+ }
190+
191+ #[ test]
192+ fn test_opaque_pixels_position_independent ( ) {
193+ // Opaque pixels should NOT be affected by position
194+ let opaque_red_pos0 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 255 ] ) , 0 ) ;
195+ let opaque_red_pos1 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 255 ] ) , 100 ) ;
196+
197+ assert_eq ! (
198+ opaque_red_pos0, opaque_red_pos1,
199+ "Opaque pixels should be position-independent"
200+ ) ;
201+ }
202+
203+ #[ test]
204+ fn test_various_colors_with_transparency ( ) {
205+ // Test that transparency handling works for different colors
206+ let color_rgb = [
207+ [ 255 , 0 , 0 ] , // red
208+ [ 0 , 255 , 0 ] , // green
209+ [ 0 , 0 , 255 ] , // blue
210+ [ 255 , 255 , 255 ] , // white
211+ ] ;
212+
213+ // All transparent colors should differ from their opaque equivalents
214+ for rgb in color_rgb {
215+ let transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ rgb[ 0 ] , rgb[ 1 ] , rgb[ 2 ] , 0 ] ) , 0 ) ;
216+ let opaque = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ rgb[ 0 ] , rgb[ 1 ] , rgb[ 2 ] , 255 ] ) , 0 ) ;
217+
218+ assert_ne ! (
219+ transparent, opaque,
220+ "Transparent {:?} should differ from opaque" ,
221+ rgb
222+ ) ;
223+ }
224+ }
94225}
0 commit comments