Skip to content

Commit bbcc125

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

6 files changed

Lines changed: 232 additions & 40 deletions

File tree

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: 11 additions & 5 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,12 @@ 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 properly
107+
let pos = y.saturating_mul(width).saturating_add(x) as usize;
108+
let yiq = Yiq::from_rgba_with_pos(left_pixel, pos);
103109
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;
110+
// Blend the YIQ Y value with white for output visualization
111+
let color = yiq.blend_with_white(alpha * rgba_a / 255.0) as u8;
106112

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

src/yiq.rs

Lines changed: 181 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,28 @@ use image::Pixel;
33
#[derive(Debug, PartialEq)]
44
pub 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

1030
impl 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
}

tests/e2e.rs

Lines changed: 27 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();
@@ -192,3 +195,22 @@ fn test_block_out_area() {
192195

193196
output.close().unwrap();
194197
}
198+
199+
#[test]
200+
fn test_transparent_black() {
201+
let output = NamedTempFile::new("test_transparent_black.png").unwrap();
202+
let mut cmd = Command::new(cargo_bin!("dify"));
203+
let assert = cmd
204+
.arg(fs::canonicalize("./tests/fixtures/black_transparent.png").unwrap())
205+
.arg(fs::canonicalize("./tests/fixtures/black_opaque.png").unwrap())
206+
.arg("--output")
207+
.arg(output.path().display().to_string());
208+
209+
assert.assert().code(match consts::OS {
210+
"windows" => 7787,
211+
"linux" | "macos" => 212,
212+
_ => 212,
213+
});
214+
215+
output.close().unwrap();
216+
}

tests/fixtures/black_opaque.png

1.58 KB
Loading
1.48 KB
Loading

0 commit comments

Comments
 (0)