@@ -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,41 @@ 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
35+ /// transparent pixels. This ensures transparent and opaque versions of the
36+ /// same color compare as different.
37+ pub fn from_rgba_with_pos ( rgba : & image:: Rgba < u8 > , pos : usize ) -> Self {
38+ let rgba_channels = rgba. channels ( ) ;
39+ let r = f32:: from ( rgba_channels[ 0 ] ) ;
40+ let g = f32:: from ( rgba_channels[ 1 ] ) ;
41+ let b = f32:: from ( rgba_channels[ 2 ] ) ;
42+ let a = f32:: from ( rgba_channels[ 3 ] ) ;
3743
38- 0.211_470_19 * r - 0.522_617_11 * g + 0.311_146_94 * b
39- }
44+ let ( r_final, g_final, b_final) = if a < 255.0 {
45+ // Blend with position-dependent background for transparent/semi-transparent
46+ // pixels
47+ let alpha = a / 255.0 ;
48+ let ( bg_r, bg_g, bg_b) = background_color ( pos) ;
49+ // Alpha blending: result = background + (foreground - background) * alpha
50+ // When alpha=0: pure background; when alpha=1: pure foreground
51+ (
52+ bg_r + ( r - bg_r) * alpha,
53+ bg_g + ( g - bg_g) * alpha,
54+ bg_b + ( b - bg_b) * alpha,
55+ )
56+ } else {
57+ // Fully opaque - use RGB values as-is
58+ ( r, g, b)
59+ } ;
4060
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) ;
61+ // Convert the blended RGB to YIQ
62+ // Standard YIQ conversion coefficients - precision is intentional
63+ #[ expect( clippy:: excessive_precision) ]
64+ let y = 0.298_895_31 * r_final + 0.586_622_47 * g_final + 0.114_482_23 * b_final;
65+ #[ expect( clippy:: excessive_precision) ]
66+ let i = 0.595_977_99 * r_final - 0.274_171_6 * g_final - 0.321_801_89 * b_final;
67+ #[ expect( clippy:: excessive_precision) ]
68+ let q = 0.211_470_19 * r_final - 0.522_617_11 * g_final + 0.311_146_94 * b_final;
4669
4770 Self { y, i, q }
4871 }
@@ -73,8 +96,16 @@ mod tests {
7396 i : 0.0 ,
7497 q : 0.0 ,
7598 } ;
76- let actual = Yiq :: from_rgba ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) ) ;
99+ // Fully opaque black should have zero YIQ
100+ let actual = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) , 0 ) ;
77101 assert_eq ! ( expected, actual) ;
102+
103+ // Transparent black should blend with background, NOT equal to opaque black
104+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , 0 ) ;
105+ assert_ne ! (
106+ expected, transparent_black,
107+ "Transparent black should not equal opaque black"
108+ ) ;
78109 }
79110
80111 #[ test]
@@ -91,4 +122,80 @@ mod tests {
91122 } ;
92123 assert_eq ! ( a. squared_distance( & b) , 0.0 ) ;
93124 }
125+
126+ #[ test]
127+ fn test_issue_32_transparent_vs_opaque_black ( ) {
128+ // Issue #32: Transparent black (#00000000) and opaque black (#000000FF)
129+ // should NOT compare as equal since they appear different visually.
130+ let opaque_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 255 ] ) , 0 ) ;
131+ let transparent_black = Yiq :: from_rgba_with_pos ( & image:: Rgba ( [ 0 , 0 , 0 , 0 ] ) , 0 ) ;
132+
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