@@ -2,9 +2,22 @@ use image::Pixel;
22
33#[ derive( Debug , PartialEq ) ]
44pub struct Yiq {
5- y : f32 , // luminance
6- i : f32 , // hue of color
7- q : f32 , // saturation of color
5+ pub y : f32 , // luminance
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+ /// Based on: https://github.com/mapbox/pixelmatch/pull/142
15+ #[ allow( clippy:: excessive_precision) ]
16+ fn background_color ( k : usize ) -> ( f32 , f32 , f32 ) {
17+ let r = 48.0 + 159.0 * ( ( k % 2 ) as f32 ) ;
18+ let g = 48.0 + 159.0 * ( ( k as f32 / 1.618033988749895 ) . floor ( ) as u32 % 2 ) as f32 ;
19+ let b = 48.0 + 159.0 * ( ( k as f32 / 2.618033988749895 ) . floor ( ) as u32 % 2 ) as f32 ;
20+ ( r, g, b)
821}
922
1023impl Yiq {
@@ -18,31 +31,39 @@ impl Yiq {
1831 0.298_895_31 * r + 0.586_622_47 * g + 0.114_482_23 * b
1932 }
2033
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
29- }
30-
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 ] ) ;
34+ /// Convert RGBA to YIQ with position-dependent background blending for transparent pixels.
35+ /// This ensures transparent and opaque versions of the same color compare as different.
36+ pub fn from_rgba_with_pos ( rgba : & image:: Rgba < u8 > , pos : usize ) -> Self {
37+ let rgba_channels = rgba. channels ( ) ;
38+ let r = f32:: from ( rgba_channels[ 0 ] ) ;
39+ let g = f32:: from ( rgba_channels[ 1 ] ) ;
40+ let b = f32:: from ( rgba_channels[ 2 ] ) ;
41+ let a = f32:: from ( rgba_channels[ 3 ] ) ;
3742
38- 0.211_470_19 * r - 0.522_617_11 * g + 0.311_146_94 * b
39- }
43+ let ( r_final, g_final, b_final) = if a < 255.0 {
44+ // Blend with position-dependent background for transparent/semi-transparent pixels
45+ let alpha = a / 255.0 ;
46+ let ( bg_r, bg_g, bg_b) = background_color ( pos) ;
47+ // Alpha blending: result = background + (foreground - background) * alpha
48+ // When alpha=0: pure background; when alpha=1: pure foreground
49+ (
50+ bg_r + ( r - bg_r) * alpha,
51+ bg_g + ( g - bg_g) * alpha,
52+ bg_b + ( b - bg_b) * alpha,
53+ )
54+ } else {
55+ // Fully opaque - use RGB values as-is
56+ ( r, g, b)
57+ } ;
4058
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) ;
59+ // Convert the blended RGB to YIQ
60+ // Standard YIQ conversion coefficients - precision is intentional
61+ #[ expect( clippy:: excessive_precision) ]
62+ let y = 0.298_895_31 * r_final + 0.586_622_47 * g_final + 0.114_482_23 * b_final;
63+ #[ expect( clippy:: excessive_precision) ]
64+ let i = 0.595_977_99 * r_final - 0.274_171_6 * g_final - 0.321_801_89 * b_final;
65+ #[ expect( clippy:: excessive_precision) ]
66+ let q = 0.211_470_19 * r_final - 0.522_617_11 * g_final + 0.311_146_94 * b_final;
4667
4768 Self { y, i, q }
4869 }
@@ -73,8 +94,16 @@ mod tests {
7394 i : 0.0 ,
7495 q : 0.0 ,
7596 } ;
76- let actual = Yiq :: from_rgba ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) ) ;
97+ // Fully opaque black should have zero YIQ
98+ let actual = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) , 0 ) ;
7799 assert_eq ! ( expected, actual) ;
100+
101+ // Transparent black should blend with background, NOT equal to opaque black
102+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , 0 ) ;
103+ assert_ne ! (
104+ expected, transparent_black,
105+ "Transparent black should not equal opaque black"
106+ ) ;
78107 }
79108
80109 #[ test]
@@ -91,4 +120,82 @@ mod tests {
91120 } ;
92121 assert_eq ! ( a. squared_distance( & b) , 0.0 ) ;
93122 }
123+
124+ #[ test]
125+ fn test_issue_32_transparent_vs_opaque_black ( ) {
126+ // Issue #32: Transparent black (#00000000) and opaque black (#000000FF)
127+ // should NOT compare as equal since they appear different visually.
128+ let opaque_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) , 0 ) ;
129+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , 0 ) ;
130+
131+ // These should NOT have a squared_distance of 0.0 (they should be different)
132+ // This test will FAIL before the fix and PASS after
133+ assert_ne ! (
134+ opaque_black. squared_distance( & transparent_black) ,
135+ 0.0 ,
136+ "Transparent black and opaque black should have different YIQ values"
137+ ) ;
138+ }
139+
140+ #[ test]
141+ fn test_semi_transparent_pixels ( ) {
142+ // Semi-transparent pixels (alpha between 0 and 255) should be handled
143+ // by blending with the background color
144+ let opaque = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 255 ] ) , 0 ) ;
145+ let semi_transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 128 ] ) , 0 ) ;
146+ let transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 100 , 50 , 25 , 0 ] ) , 0 ) ;
147+
148+ // All three should have different YIQ values due to different blending
149+ assert_ne ! ( opaque. y, semi_transparent. y) ;
150+ assert_ne ! ( opaque. y, transparent. y) ;
151+ assert_ne ! ( semi_transparent. y, transparent. y) ;
152+ }
153+
154+ #[ test]
155+ fn test_position_dependent_background ( ) {
156+ // Same transparent color at different positions should have different
157+ // YIQ values due to position-dependent background blending
158+ let transparent_red_pos0 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 0 ] ) , 0 ) ;
159+ let transparent_red_pos1 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 0 ] ) , 1 ) ;
160+
161+ assert_ne ! (
162+ transparent_red_pos0, transparent_red_pos1,
163+ "Same transparent color at different positions should differ"
164+ ) ;
165+ }
166+
167+ #[ test]
168+ fn test_opaque_pixels_position_independent ( ) {
169+ // Opaque pixels should NOT be affected by position
170+ let opaque_red_pos0 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 255 ] ) , 0 ) ;
171+ let opaque_red_pos1 = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 255 , 0 , 0 , 255 ] ) , 100 ) ;
172+
173+ assert_eq ! (
174+ opaque_red_pos0, opaque_red_pos1,
175+ "Opaque pixels should be position-independent"
176+ ) ;
177+ }
178+
179+ #[ test]
180+ fn test_various_colors_with_transparency ( ) {
181+ // Test that transparency handling works for different colors
182+ let color_rgb = [
183+ [ 255 , 0 , 0 ] , // red
184+ [ 0 , 255 , 0 ] , // green
185+ [ 0 , 0 , 255 ] , // blue
186+ [ 255 , 255 , 255 ] , // white
187+ ] ;
188+
189+ // All transparent colors should differ from their opaque equivalents
190+ for rgb in color_rgb {
191+ let transparent = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ rgb[ 0 ] , rgb[ 1 ] , rgb[ 2 ] , 0 ] ) , 0 ) ;
192+ let opaque = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ rgb[ 0 ] , rgb[ 1 ] , rgb[ 2 ] , 255 ] ) , 0 ) ;
193+
194+ assert_ne ! (
195+ transparent, opaque,
196+ "Transparent {:?} should differ from opaque" ,
197+ rgb
198+ ) ;
199+ }
200+ }
94201}
0 commit comments