Skip to content

Commit 1c82f21

Browse files
committed
Fix "Transparent black and black aren't the same color" issue
Reference: - #32 - mapbox/pixelmatch#142
1 parent b27ef9d commit 1c82f21

File tree

6 files changed

+241
-46
lines changed

6 files changed

+241
-46
lines changed

benches/benchmark.rs

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use criterion::{Criterion, criterion_group, criterion_main};
22
use dify::diff;
3-
use image::{RgbaImage, io::Reader as ImageReader};
3+
use image::{ImageReader, RgbaImage};
44

55
fn get_image(path: &str) -> RgbaImage {
66
ImageReader::open(path)
@@ -29,12 +29,13 @@ fn criterion_benchmark(c: &mut Criterion) {
2929

3030
b.iter(|| {
3131
diff::get_results(
32-
&left_image,
33-
&right_image,
32+
left_image.clone(),
33+
right_image.clone(),
3434
default_run_params.threshold,
35-
default_run_params.do_not_check_dimensions,
35+
default_run_params.detect_anti_aliased_pixels,
3636
default_run_params.blend_factor_of_unchanged_pixels,
3737
&default_run_params.output_image_base,
38+
&default_run_params.block_out_areas,
3839
)
3940
})
4041
});
@@ -45,12 +46,13 @@ fn criterion_benchmark(c: &mut Criterion) {
4546

4647
b.iter(|| {
4748
diff::get_results(
48-
&left_image,
49-
&right_image,
49+
left_image.clone(),
50+
right_image.clone(),
5051
default_run_params.threshold,
51-
default_run_params.do_not_check_dimensions,
52+
default_run_params.detect_anti_aliased_pixels,
5253
default_run_params.blend_factor_of_unchanged_pixels,
5354
&default_run_params.output_image_base,
55+
&default_run_params.block_out_areas,
5456
)
5557
})
5658
});
@@ -61,12 +63,13 @@ fn criterion_benchmark(c: &mut Criterion) {
6163

6264
b.iter(|| {
6365
diff::get_results(
64-
&left_image,
65-
&right_image,
66+
left_image.clone(),
67+
right_image.clone(),
6668
default_run_params.threshold,
67-
default_run_params.do_not_check_dimensions,
69+
default_run_params.detect_anti_aliased_pixels,
6870
default_run_params.blend_factor_of_unchanged_pixels,
6971
&default_run_params.output_image_base,
72+
&default_run_params.block_out_areas,
7073
)
7174
})
7275
});

src/diff.rs

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,9 @@ pub fn get_results(
6464
{
6565
DiffResult::BlockedOut(x, y)
6666
} else {
67-
let left_pixel = Yiq::from_rgba(left_pixel);
68-
let right_pixel = Yiq::from_rgba(right_pixel);
67+
let pos = y.saturating_mul(width).saturating_add(x) as usize;
68+
let left_pixel = Yiq::from_rgba_with_pos(left_pixel, pos);
69+
let right_pixel = Yiq::from_rgba_with_pos(right_pixel, pos);
6970
let delta = left_pixel.squared_distance(&right_pixel);
7071

7172
if delta.abs() > threshold {
@@ -99,10 +100,10 @@ pub fn get_results(
99100
DiffResult::Identical(x, y) | DiffResult::BelowThreshold(x, y) => {
100101
if let Some(alpha) = blend_factor_of_unchanged_pixels {
101102
let left_pixel = left_image.get_pixel(x, y);
102-
let yiq_y = Yiq::rgb2y(&left_pixel.to_rgb());
103+
let pos = y.saturating_mul(width).saturating_add(x) as usize;
104+
let yiq = Yiq::from_rgba_with_pos(left_pixel, pos);
103105
let rgba_a = left_pixel.channels()[3] as f32;
104-
let color =
105-
super::blend_semi_transparent_white(yiq_y, alpha * rgba_a / 255.0) as u8;
106+
let color = yiq.blend_with_white(alpha * rgba_a / 255.0) as u8;
106107

107108
output_image.put_pixel(x, y, Rgba([color, color, color, u8::MAX]));
108109
}

src/yiq.rs

Lines changed: 193 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,48 +7,116 @@ pub struct Yiq {
77
q: f32, // saturation of color
88
}
99

10-
impl Yiq {
11-
#[allow(clippy::many_single_char_names, clippy::excessive_precision)]
12-
pub fn rgb2y(rgb: &image::Rgb<u8>) -> f32 {
13-
let rgb = rgb.channels();
14-
let r = f32::from(rgb[0]);
15-
let g = f32::from(rgb[1]);
16-
let b = f32::from(rgb[2]);
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)
28+
}
1729

30+
impl Yiq {
31+
#[allow(clippy::excessive_precision)]
32+
pub fn rgb2y(r: f32, g: f32, b: f32) -> f32 {
1833
0.298_895_31 * r + 0.586_622_47 * g + 0.114_482_23 * b
1934
}
2035

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-
36+
#[allow(clippy::excessive_precision)]
37+
pub fn rgb2i(r: f32, g: f32, b: f32) -> f32 {
2838
0.595_977_99 * r - 0.274_171_6 * g - 0.321_801_89 * b
2939
}
3040

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]);
37-
41+
#[allow(clippy::excessive_precision)]
42+
pub fn rgb2q(r: f32, g: f32, b: f32) -> f32 {
3843
0.211_470_19 * r - 0.522_617_11 * g + 0.311_146_94 * b
3944
}
4045

46+
/// Convert RGBA to YIQ with position-dependent background blending for
47+
/// transparent pixels. This ensures transparent and opaque versions of the
48+
/// same color compare as different.
49+
pub fn from_rgba_with_pos(rgba: &image::Rgba<u8>, pos: usize) -> Self {
50+
let rgba_channels = rgba.channels();
51+
let r = f32::from(rgba_channels[0]);
52+
let g = f32::from(rgba_channels[1]);
53+
let b = f32::from(rgba_channels[2]);
54+
let a = f32::from(rgba_channels[3]);
55+
56+
let (r, g, b) = if a < 255.0 {
57+
// Blend with position-dependent background for transparent/semi-transparent pixels
58+
let alpha = a / 255.0;
59+
let (bg_r, bg_g, bg_b) = background_color(pos);
60+
// Alpha blending: result = background + (foreground - background) * alpha
61+
// When alpha=0: pure background; when alpha=1: pure foreground
62+
(
63+
bg_r + (r - bg_r) * alpha,
64+
bg_g + (g - bg_g) * alpha,
65+
bg_b + (b - bg_b) * alpha,
66+
)
67+
} else {
68+
// Fully opaque - use RGB values as-is
69+
(r, g, b)
70+
};
71+
72+
let y = Self::rgb2y(r, g, b);
73+
let i = Self::rgb2i(r, g, b);
74+
let q = Self::rgb2q(r, g, b);
75+
76+
Self { y, i, q }
77+
}
78+
79+
/// Convert RGBA to YIQ without transparency handling.
80+
///
81+
/// # Deprecated
82+
///
83+
/// This method does not handle transparency correctly. Use [`from_rgba_with_pos`]
84+
/// instead, which properly handles transparent and semi-transparent pixels
85+
/// by blending with a position-dependent background.
86+
#[deprecated(
87+
since = "0.8.0",
88+
note = "Use from_rgba_with_pos instead for correct transparency handling"
89+
)]
90+
#[allow(dead_code)]
4191
pub fn from_rgba(rgba: &image::Rgba<u8>) -> Self {
4292
let rgb = rgba.to_rgb();
43-
let y = Self::rgb2y(&rgb);
44-
let i = Self::rgb2i(&rgb);
45-
let q = Self::rgb2q(&rgb);
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+
let y = Self::rgb2y(r, g, b);
98+
let i = Self::rgb2i(r, g, b);
99+
let q = Self::rgb2q(r, g, b);
46100

47101
Self { y, i, q }
48102
}
49103

50104
pub fn delta_y(left: &image::Rgb<u8>, right: &image::Rgb<u8>) -> f32 {
51-
Self::rgb2y(left) - Self::rgb2y(right)
105+
let left_channels = left.channels();
106+
let (left_r, left_g, left_b) = (
107+
f32::from(left_channels[0]),
108+
f32::from(left_channels[1]),
109+
f32::from(left_channels[2]),
110+
);
111+
112+
let right_channels = right.channels();
113+
let (right_r, right_g, right_b) = (
114+
f32::from(right_channels[0]),
115+
f32::from(right_channels[1]),
116+
f32::from(right_channels[2]),
117+
);
118+
119+
Self::rgb2y(left_r, left_g, left_b) - Self::rgb2y(right_r, right_g, right_b)
52120
}
53121

54122
// in the performance critical applications, square root can be omiitted
@@ -60,6 +128,10 @@ impl Yiq {
60128

61129
if self.y > other.y { -delta } else { delta }
62130
}
131+
132+
pub fn blend_with_white(&self, alpha: f32) -> f32 {
133+
255.0 + (self.y - 255.0) * alpha
134+
}
63135
}
64136

65137
#[cfg(test)]
@@ -73,7 +145,7 @@ mod tests {
73145
i: 0.0,
74146
q: 0.0,
75147
};
76-
let actual = Yiq::from_rgba(&image::Rgba([0, 0, 0, 0]));
148+
let actual = Yiq::from_rgba_with_pos(&image::Rgba([0, 0, 0, 255]), 0);
77149
assert_eq!(expected, actual);
78150
}
79151

@@ -91,4 +163,99 @@ mod tests {
91163
};
92164
assert_eq!(a.squared_distance(&b), 0.0);
93165
}
166+
167+
#[test]
168+
fn test_issue_32_transparent_vs_opaque_black() {
169+
// https://github.com/jihchi/dify/issues/32
170+
// Transparent black (#00000000) and opaque black (#000000FF)
171+
// should NOT compare as equal since they appear different visually.
172+
let opaque_black = Yiq::from_rgba_with_pos(&image::Rgba([0, 0, 0, 255]), 0);
173+
let transparent_black = Yiq::from_rgba_with_pos(&image::Rgba([0, 0, 0, 0]), 0);
174+
175+
assert_ne!(
176+
opaque_black.squared_distance(&transparent_black),
177+
0.0,
178+
"Transparent black and opaque black should have different YIQ values"
179+
);
180+
}
181+
182+
#[test]
183+
fn test_semi_transparent_pixels() {
184+
// Semi-transparent pixels (alpha between 0 and 255) should be handled
185+
// by blending with the background color
186+
let opaque = Yiq::from_rgba_with_pos(&image::Rgba([100, 50, 25, 255]), 0);
187+
let semi_transparent = Yiq::from_rgba_with_pos(&image::Rgba([100, 50, 25, 128]), 0);
188+
let transparent = Yiq::from_rgba_with_pos(&image::Rgba([100, 50, 25, 0]), 0);
189+
190+
// All three should have different YIQ values due to different blending
191+
assert_ne!(opaque.y, semi_transparent.y);
192+
assert_ne!(opaque.y, transparent.y);
193+
assert_ne!(semi_transparent.y, transparent.y);
194+
}
195+
196+
#[test]
197+
fn test_position_dependent_background() {
198+
// Same transparent color at different positions should have different
199+
// YIQ values due to position-dependent background blending
200+
let transparent_red_pos0 = Yiq::from_rgba_with_pos(&image::Rgba([255, 0, 0, 0]), 0);
201+
let transparent_red_pos1 = Yiq::from_rgba_with_pos(&image::Rgba([255, 0, 0, 0]), 1);
202+
203+
assert_ne!(
204+
transparent_red_pos0, transparent_red_pos1,
205+
"Same transparent color at different positions should differ"
206+
);
207+
}
208+
209+
#[test]
210+
fn test_fully_transparent_pixels_with_different_rgb_compare_equal() {
211+
// Fully transparent pixels (alpha=0) should compare equal regardless of RGB values
212+
// because they are visually identical (completely invisible)
213+
let pos = 42;
214+
let transparent_black = Yiq::from_rgba_with_pos(&image::Rgba([0, 0, 0, 0]), pos);
215+
let transparent_red = Yiq::from_rgba_with_pos(&image::Rgba([255, 0, 0, 0]), pos);
216+
let transparent_white = Yiq::from_rgba_with_pos(&image::Rgba([255, 255, 255, 0]), pos);
217+
218+
assert_eq!(transparent_black, transparent_red);
219+
assert_eq!(transparent_black, transparent_white);
220+
assert_eq!(
221+
transparent_black.squared_distance(&transparent_red),
222+
0.0,
223+
"Fully transparent pixels should have zero distance regardless of RGB"
224+
);
225+
}
226+
227+
#[test]
228+
fn test_opaque_pixels_position_independent() {
229+
// Opaque pixels should NOT be affected by position
230+
let opaque_red_pos0 = Yiq::from_rgba_with_pos(&image::Rgba([255, 0, 0, 255]), 0);
231+
let opaque_red_pos1 = Yiq::from_rgba_with_pos(&image::Rgba([255, 0, 0, 255]), 100);
232+
233+
assert_eq!(
234+
opaque_red_pos0, opaque_red_pos1,
235+
"Opaque pixels should be position-independent"
236+
);
237+
}
238+
239+
#[test]
240+
fn test_various_colors_with_transparency() {
241+
// Test that transparency handling works for different colors
242+
let color_rgb = [
243+
[255, 0, 0], // red
244+
[0, 255, 0], // green
245+
[0, 0, 255], // blue
246+
[255, 255, 255], // white
247+
];
248+
249+
// All transparent colors should differ from their opaque equivalents
250+
for rgb in color_rgb {
251+
let transparent = Yiq::from_rgba_with_pos(&image::Rgba([rgb[0], rgb[1], rgb[2], 0]), 0);
252+
let opaque = Yiq::from_rgba_with_pos(&image::Rgba([rgb[0], rgb[1], rgb[2], 255]), 0);
253+
254+
assert_ne!(
255+
transparent, opaque,
256+
"Transparent {:?} should differ from opaque",
257+
rgb
258+
);
259+
}
260+
}
94261
}

0 commit comments

Comments
 (0)