Skip to content

Commit af4eefa

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

File tree

6 files changed

+183
-43
lines changed

6 files changed

+183
-43
lines changed

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::{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,
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: 134 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,22 @@ use image::Pixel;
22

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

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

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_blank.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)