Skip to content

Commit f0f1e60

Browse files
committed
Transparent is not white
Reference: - #32 - mapbox/pixelmatch#142
1 parent b27ef9d commit f0f1e60

4 files changed

Lines changed: 184 additions & 39 deletions

File tree

benches/benchmark.rs

Lines changed: 10 additions & 7 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::{RgbaImage, ImageReader};
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,
3535
default_run_params.do_not_check_dimensions,
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,
5152
default_run_params.do_not_check_dimensions,
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,
6769
default_run_params.do_not_check_dimensions,
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: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,12 @@ 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+
// Calculate linear position for position-dependent background blending
68+
// This ensures transparent and opaque versions of the same color compare as different
69+
// Use saturating arithmetic to handle theoretically very large images
70+
let pos = y.saturating_mul(width).saturating_add(x) as usize;
71+
let left_pixel = Yiq::from_rgba_with_pos(left_pixel, pos);
72+
let right_pixel = Yiq::from_rgba_with_pos(right_pixel, pos);
6973
let delta = left_pixel.squared_distance(&right_pixel);
7074

7175
if delta.abs() > threshold {
@@ -99,10 +103,14 @@ pub fn get_results(
99103
DiffResult::Identical(x, y) | DiffResult::BelowThreshold(x, y) => {
100104
if let Some(alpha) = blend_factor_of_unchanged_pixels {
101105
let left_pixel = left_image.get_pixel(x, y);
102-
let yiq_y = Yiq::rgb2y(&left_pixel.to_rgb());
106+
// Use position-aware YIQ conversion to handle transparency correctly
107+
// Use saturating arithmetic to handle theoretically very large images
108+
let pos = y.saturating_mul(width).saturating_add(x) as usize;
109+
let yiq = Yiq::from_rgba_with_pos(left_pixel, pos);
103110
let rgba_a = left_pixel.channels()[3] as f32;
111+
// Blend the YIQ Y value with white for output visualization
104112
let color =
105-
super::blend_semi_transparent_white(yiq_y, alpha * rgba_a / 255.0) as u8;
113+
super::blend_semi_transparent_white(yiq.y, alpha * rgba_a / 255.0) as u8;
106114

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

src/yiq.rs

Lines changed: 154 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,42 @@ use image::Pixel;
22

33
#[derive(Debug, PartialEq)]
44
pub 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+
1041
impl 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
}

tests/e2e.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,11 @@ fn test_left_does_not_exist() {
4242
Caused by:
4343
{} (os error 2)
4444
"#,
45-
left.display().to_string(),
45+
left.display(),
4646
match consts::OS {
4747
"windows" => "The system cannot find the file specified.",
48-
"linux" | "macos" | _ => "No such file or directory",
48+
"linux" | "macos" => "No such file or directory",
49+
_ => "Unknown error",
4950
}
5051
));
5152
}
@@ -64,10 +65,11 @@ fn test_right_does_not_exist() {
6465
Caused by:
6566
{} (os error 2)
6667
"#,
67-
right.display().to_string(),
68+
right.display(),
6869
match consts::OS {
6970
"windows" => "The system cannot find the file specified.",
70-
"linux" | "macos" | _ => "No such file or directory",
71+
"linux" | "macos" => "No such file or directory",
72+
_ => "Unknown error",
7173
}
7274
));
7375
}
@@ -101,7 +103,8 @@ fn test_different_image() {
101103

102104
assert.assert().code(match consts::OS {
103105
"windows" => 7787,
104-
"linux" | "macos" | _ => 106,
106+
"linux" | "macos" => 106,
107+
_ => 106,
105108
});
106109

107110
output.close().unwrap();

0 commit comments

Comments
 (0)