@@ -3,8 +3,28 @@ use image::Pixel;
33#[ derive( Debug , PartialEq ) ]
44pub struct Yiq {
55 y : f32 , // luminance
6- i : f32 , // hue of color
7- q : f32 , // saturation of color
6+ i : f32 , // hue of color
7+ q : f32 , // saturation of color
8+ }
9+
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+ /// Design considerations from https://github.com/mapbox/pixelmatch/pull/142:
15+ /// - Non-uniform color (no solid background)
16+ /// - No large areas of uniform color
17+ /// - High perceptual variability
18+ /// - Deterministic computation
19+ /// - Function of pixel index only
20+ /// - Avoids common colors (especially white and black)
21+ /// - Contains no lines or patterns expected in test images
22+ #[ allow( clippy:: excessive_precision) ]
23+ fn background_color ( k : usize ) -> ( f32 , f32 , f32 ) {
24+ let r = 48.0 + 159.0 * ( ( k % 2 ) as f32 ) ;
25+ let g = 48.0 + 159.0 * ( ( k as f32 / 1.618033988749895 ) . floor ( ) as u32 % 2 ) as f32 ;
26+ let b = 48.0 + 159.0 * ( ( k as f32 / 2.618033988749895 ) . floor ( ) as u32 % 2 ) as f32 ;
27+ ( r, g, b)
828}
929
1030impl Yiq {
@@ -18,31 +38,66 @@ impl Yiq {
1838 0.298_895_31 * r + 0.586_622_47 * g + 0.114_482_23 * b
1939 }
2040
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 ] ) ;
41+ /// Convert RGBA to YIQ with position-dependent background blending for
42+ /// transparent pixels. This ensures transparent and opaque versions of the
43+ /// same color compare as different.
44+ pub fn from_rgba_with_pos ( rgba : & image:: Rgba < u8 > , pos : usize ) -> Self {
45+ let rgba_channels = rgba. channels ( ) ;
46+ let r = f32:: from ( rgba_channels[ 0 ] ) ;
47+ let g = f32:: from ( rgba_channels[ 1 ] ) ;
48+ let b = f32:: from ( rgba_channels[ 2 ] ) ;
49+ let a = f32:: from ( rgba_channels[ 3 ] ) ;
2750
28- 0.595_977_99 * r - 0.274_171_6 * g - 0.321_801_89 * b
29- }
51+ let ( r_final, g_final, b_final) = if a < 255.0 {
52+ // Blend with position-dependent background for transparent/semi-transparent
53+ // pixels
54+ let alpha = a / 255.0 ;
55+ let ( bg_r, bg_g, bg_b) = background_color ( pos) ;
56+ // Alpha blending: result = background + (foreground - background) * alpha
57+ // When alpha=0: pure background; when alpha=1: pure foreground
58+ (
59+ bg_r + ( r - bg_r) * alpha,
60+ bg_g + ( g - bg_g) * alpha,
61+ bg_b + ( b - bg_b) * alpha,
62+ )
63+ } else {
64+ // Fully opaque - use RGB values as-is
65+ ( r, g, b)
66+ } ;
3067
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 ] ) ;
68+ // Convert the blended RGB to YIQ
69+ // Standard YIQ conversion coefficients - precision is intentional
70+ #[ expect( clippy:: excessive_precision) ]
71+ let y = 0.298_895_31 * r_final + 0.586_622_47 * g_final + 0.114_482_23 * b_final;
72+ #[ expect( clippy:: excessive_precision) ]
73+ let i = 0.595_977_99 * r_final - 0.274_171_6 * g_final - 0.321_801_89 * b_final;
74+ #[ expect( clippy:: excessive_precision) ]
75+ let q = 0.211_470_19 * r_final - 0.522_617_11 * g_final + 0.311_146_94 * b_final;
3776
38- 0.211_470_19 * r - 0.522_617_11 * g + 0.311_146_94 * b
77+ Self { y , i , q }
3978 }
4079
80+ /// Convert RGBA to YIQ without transparency handling.
81+ ///
82+ /// # Deprecated
83+ ///
84+ /// This method does not handle transparency correctly. Use [`from_rgba_with_pos`] instead,
85+ /// which properly handles transparent and semi-transparent pixels by blending with a
86+ /// position-dependent background.
87+ #[ deprecated( since = "0.7.4" , note = "Use from_rgba_with_pos instead for correct transparency handling" ) ]
88+ #[ allow( dead_code) ]
4189 pub fn from_rgba ( rgba : & image:: Rgba < u8 > ) -> Self {
4290 let rgb = rgba. to_rgb ( ) ;
4391 let y = Self :: rgb2y ( & rgb) ;
44- let i = Self :: rgb2i ( & rgb) ;
45- let q = Self :: rgb2q ( & rgb) ;
92+ // For i and q, just use opaque values (old behavior)
93+ let rgb_channels = rgb. channels ( ) ;
94+ let r = f32:: from ( rgb_channels[ 0 ] ) ;
95+ let g = f32:: from ( rgb_channels[ 1 ] ) ;
96+ let b = f32:: from ( rgb_channels[ 2 ] ) ;
97+ #[ expect( clippy:: excessive_precision) ]
98+ let i = 0.595_977_99 * r - 0.274_171_6 * g - 0.321_801_89 * b;
99+ #[ expect( clippy:: excessive_precision) ]
100+ let q = 0.211_470_19 * r - 0.522_617_11 * g + 0.311_146_94 * b;
46101
47102 Self { y, i, q }
48103 }
@@ -60,6 +115,10 @@ impl Yiq {
60115
61116 if self . y > other. y { -delta } else { delta }
62117 }
118+
119+ pub fn blend_with_white ( & self , alpha : f32 ) -> f32 {
120+ 255.0 + ( self . y - 255.0 ) * alpha
121+ }
63122}
64123
65124#[ cfg( test) ]
@@ -73,8 +132,16 @@ mod tests {
73132 i : 0.0 ,
74133 q : 0.0 ,
75134 } ;
76- let actual = Yiq :: from_rgba ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) ) ;
135+ // Fully opaque black should have zero YIQ
136+ let actual = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) , 0 ) ;
77137 assert_eq ! ( expected, actual) ;
138+
139+ // Transparent black should blend with background, NOT equal to opaque black
140+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , 0 ) ;
141+ assert_ne ! (
142+ expected, transparent_black,
143+ "Transparent black should not equal opaque black"
144+ ) ;
78145 }
79146
80147 #[ test]
@@ -91,4 +158,98 @@ mod tests {
91158 } ;
92159 assert_eq ! ( a. squared_distance( & b) , 0.0 ) ;
93160 }
161+
162+ #[ test]
163+ fn test_issue_32_transparent_vs_opaque_black ( ) {
164+ // Issue #32: Transparent black (#00000000) and opaque black (#000000FF)
165+ // should NOT compare as equal since they appear different visually.
166+ let opaque_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) , 0 ) ;
167+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , 0 ) ;
168+
169+ assert_ne ! (
170+ opaque_black. squared_distance( & transparent_black) ,
171+ 0.0 ,
172+ "Transparent black and opaque black should have different YIQ values"
173+ ) ;
174+ }
175+
176+ #[ test]
177+ fn test_semi_transparent_pixels ( ) {
178+ // Semi-transparent pixels (alpha between 0 and 255) should be handled
179+ // by blending with the background color
180+ let opaque = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 255 ] ) , 0 ) ;
181+ let semi_transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 128 ] ) , 0 ) ;
182+ let transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 0 ] ) , 0 ) ;
183+
184+ // All three should have different YIQ values due to different blending
185+ assert_ne ! ( opaque. y, semi_transparent. y) ;
186+ assert_ne ! ( opaque. y, transparent. y) ;
187+ assert_ne ! ( semi_transparent. y, transparent. y) ;
188+ }
189+
190+ #[ test]
191+ fn test_position_dependent_background ( ) {
192+ // Same transparent color at different positions should have different
193+ // YIQ values due to position-dependent background blending
194+ let transparent_red_pos0 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 0 ] ) , 0 ) ;
195+ let transparent_red_pos1 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 0 ] ) , 1 ) ;
196+
197+ assert_ne ! (
198+ transparent_red_pos0, transparent_red_pos1,
199+ "Same transparent color at different positions should differ"
200+ ) ;
201+ }
202+
203+ #[ test]
204+ fn test_fully_transparent_pixels_with_different_rgb_compare_equal ( ) {
205+ // Fully transparent pixels (alpha=0) should compare equal regardless of RGB values
206+ // because they are visually identical (completely invisible)
207+ let pos = 42 ;
208+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , pos) ;
209+ let transparent_red = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 0 ] ) , pos) ;
210+ let transparent_white = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 255 , 255 , 0 ] ) , pos) ;
211+
212+ assert_eq ! ( transparent_black, transparent_red) ;
213+ assert_eq ! ( transparent_black, transparent_white) ;
214+ assert_eq ! (
215+ transparent_black. squared_distance( & transparent_red) ,
216+ 0.0 ,
217+ "Fully transparent pixels should have zero distance regardless of RGB"
218+ ) ;
219+ }
220+
221+ #[ test]
222+ fn test_opaque_pixels_position_independent ( ) {
223+ // Opaque pixels should NOT be affected by position
224+ let opaque_red_pos0 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 255 ] ) , 0 ) ;
225+ let opaque_red_pos1 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 255 ] ) , 100 ) ;
226+
227+ assert_eq ! (
228+ opaque_red_pos0, opaque_red_pos1,
229+ "Opaque pixels should be position-independent"
230+ ) ;
231+ }
232+
233+ #[ test]
234+ fn test_various_colors_with_transparency ( ) {
235+ // Test that transparency handling works for different colors
236+ let color_rgb = [
237+ [ 255 , 0 , 0 ] , // red
238+ [ 0 , 255 , 0 ] , // green
239+ [ 0 , 0 , 255 ] , // blue
240+ [ 255 , 255 , 255 ] , // white
241+ ] ;
242+
243+ // All transparent colors should differ from their opaque equivalents
244+ for rgb in color_rgb {
245+ let transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ rgb[ 0 ] , rgb[ 1 ] , rgb[ 2 ] , 0 ] ) , 0 ) ;
246+ let opaque = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ rgb[ 0 ] , rgb[ 1 ] , rgb[ 2 ] , 255 ] ) , 0 ) ;
247+
248+ assert_ne ! (
249+ transparent, opaque,
250+ "Transparent {:?} should differ from opaque" ,
251+ rgb
252+ ) ;
253+ }
254+ }
94255}
0 commit comments