Switch the Color struct back to storing unassociated alpha (#4518)

* Switch Color struct back to storing unassociated alpha

* Address review feedback

* Update the Invert node and legacy image migration for straight alpha and add round-trip tests

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Dennis Kobert
2026-09-12 08:22:54 +00:00
committed by GitHub
parent e725f55043
commit 944d00cac5
13 changed files with 103 additions and 139 deletions

View File

@@ -201,7 +201,7 @@ pub fn blend_colors(foreground: Color, background: Color, blend_mode: BlendMode,
blend_mode => apply_blend_mode(foreground, background, blend_mode),
};
background.alpha_blend(target_color.apply_opacity(opacity))
background.alpha_blend(target_color.with_alpha(target_color.a() * opacity))
}
/// Mixes the two colors by the blend mode's own formula, leaving the alpha compositing to the caller.
@@ -283,10 +283,10 @@ mod tests {
}
#[test]
fn darker_color_compares_unassociated_channels() {
// The premultiplied backdrop reads as 0.1 gray but is really 0.5 gray, so the 0.4 gray foreground is the darker color
fn darker_color_ignores_backdrop_alpha() {
// The backdrop's low alpha doesn't darken its color, so the 0.4 gray foreground is the darker color
let foreground = Color::from_rgbaf32_unchecked(0.4, 0.4, 0.4, 1.);
let background = Color::from_rgbaf32_unchecked(0.1, 0.1, 0.1, 0.2);
let background = Color::from_rgbaf32_unchecked(0.5, 0.5, 0.5, 0.2);
let blended = apply_blend_mode(foreground, background, BlendMode::DarkerColor);
@@ -294,6 +294,18 @@ mod tests {
assert!((blended.a() - 1.).abs() < 1e-5, "alpha was {}", blended.a());
}
#[test]
fn source_over_weights_straight_colors_by_alpha() {
let over = Color::from_rgbaf32_unchecked(1., 0., 0., 0.5);
let under = Color::from_rgbaf32_unchecked(0., 0., 1., 1.);
let blended = under.alpha_blend(over);
assert!((blended.r() - 0.5).abs() < 1e-5, "red was {}", blended.r());
assert!((blended.b() - 0.5).abs() < 1e-5, "blue was {}", blended.b());
assert!((blended.a() - 1.).abs() < 1e-5, "alpha was {}", blended.a());
}
#[test]
fn alpha_only_modes_fade_with_opacity() {
let foreground = Color::from_rgbaf32_unchecked(0.9, 0.9, 0.9, 1.);

View File

@@ -123,14 +123,6 @@ pub trait RGBMut: RGB {
fn set_blue(&mut self, blue: Self::ColorChannel);
}
pub trait AssociatedAlpha: RGB + Alpha {
fn to_unassociated<Out: UnassociatedAlpha>(&self) -> Out;
}
pub trait UnassociatedAlpha: RGB + Alpha {
fn to_associated<Out: AssociatedAlpha>(&self) -> Out;
}
pub trait Alpha {
type AlphaChannel: LinearChannel;
const TRANSPARENT: Self;

View File

@@ -1,4 +1,4 @@
use super::color_traits::{Alpha, AlphaMut, AssociatedAlpha, Luminance, Pixel, RGB, RGBMut, Rec709Primaries, SRGB};
use super::color_traits::{Alpha, AlphaMut, Luminance, Pixel, RGB, RGBMut, Rec709Primaries, SRGB};
use super::discrete_srgb::{float_to_srgb_u8, srgb_u8_to_float};
use bytemuck::{Pod, Zeroable};
use core::fmt::Debug;
@@ -72,7 +72,7 @@ impl Alpha for RGBA16F {
type AlphaChannel = f32;
#[inline(always)]
fn alpha(&self) -> f32 {
self.alpha.to_f32() / 255.
self.alpha.to_f32()
}
const TRANSPARENT: Self = RGBA16F {
@@ -83,9 +83,8 @@ impl Alpha for RGBA16F {
};
fn multiplied_alpha(&self, alpha: Self::AlphaChannel) -> Self {
let alpha = alpha * 255.;
let mut result = *self;
result.alpha = f16::from_f32(alpha * self.alpha());
result.alpha = f16::from_f32(self.alpha() * alpha);
result
}
}
@@ -254,7 +253,7 @@ impl RGB for Luma {
impl Pixel for Luma {}
/// Linear-light sRGB color with `f32` channels (alpha unassociated for swatch/UI colors, associated/premultiplied for pixel data inside [`Image<Color>`]).
/// Linear-light sRGB color with `f32` channels and unassociated (straight) alpha.
///
/// Channels range from `0.` to `f32::MAX`, encoding brightness proportional to light intensity (cd/m² nits in HDR, or `0..=1` mapped to white for SDR).
///
@@ -359,9 +358,7 @@ impl Pixel for Color {
}
fn from_bytes(bytes: &[u8]) -> Self {
// `Image<Color>` pixel convention is linear-light with associated (premultiplied) alpha.
let srgba = SRGBA8::new(bytes[0], bytes[1], bytes[2], bytes[3]);
Color::from(srgba).apply_opacity(bytes[3] as f32 / 255.)
SRGBA8::new(bytes[0], bytes[1], bytes[2], bytes[3]).into()
}
fn byte_size() -> usize {
4
@@ -378,18 +375,7 @@ impl Alpha for Color {
}
#[inline(always)]
fn multiplied_alpha(&self, alpha: Self::AlphaChannel) -> Self {
Self {
red: self.red * alpha,
green: self.green * alpha,
blue: self.blue * alpha,
alpha: self.alpha * alpha,
}
}
}
impl AssociatedAlpha for Color {
fn to_unassociated<Out: super::UnassociatedAlpha>(&self) -> Out {
todo!()
Self { alpha: self.alpha * alpha, ..*self }
}
}
@@ -443,12 +429,6 @@ impl Color {
Color { red, green, blue, alpha }
}
/// Construct a `Color` from unassociated (straight) RGBA channels, premultiplying the RGB channels by alpha.
#[inline(always)]
pub fn new_from_unassociated_rgba(red: f32, green: f32, blue: f32, alpha: f32) -> Color {
Color::from_rgbaf32_unchecked(red * alpha, green * alpha, blue * alpha, alpha)
}
/// Create a linear-light `Color` from HSL coordinates (all between 0 and 1).
/// HSL is defined on sRGB display values, so the RGB produced by the HSL math is gamma-encoded and decoded to linear before being wrapped in `Color`.
///
@@ -707,8 +687,7 @@ impl Color {
/// Whole-color "Darker Color" blend: keeps whichever color has the lower mean RGB, with `other`'s alpha.
#[inline(always)]
pub fn blend_darker_color(&self, other: Color) -> Color {
let background = self.to_unassociated_alpha();
let darker = if background.average_rgb_channels() <= other.average_rgb_channels() { background } else { other };
let darker = if self.average_rgb_channels() <= other.average_rgb_channels() { *self } else { other };
darker.with_alpha(other.alpha)
}
@@ -740,8 +719,7 @@ impl Color {
/// Whole-color "Lighter Color" blend: keeps whichever color has the higher mean RGB, with `other`'s alpha.
#[inline(always)]
pub fn blend_lighter_color(&self, other: Color) -> Color {
let background = self.to_unassociated_alpha();
let lighter = if background.average_rgb_channels() >= other.average_rgb_channels() { background } else { other };
let lighter = if self.average_rgb_channels() >= other.average_rgb_channels() { *self } else { other };
lighter.with_alpha(other.alpha)
}
@@ -824,25 +802,23 @@ impl Color {
/// Whole-color "Hue" blend: source hue with this color's saturation and Rec.601 luma, with `c_s`'s alpha.
pub fn blend_hue(&self, c_s: Color) -> Color {
let background = self.to_unassociated_alpha();
let sat_b = background.chroma_range();
let lum_b = background.luminance_rec_601();
let sat_b = self.chroma_range();
let lum_b = self.luminance_rec_601();
c_s.with_saturation(sat_b).with_luminance(lum_b).with_alpha(c_s.alpha)
}
/// Whole-color "Saturation" blend: this color's hue/luma with source saturation, with `c_s`'s alpha.
pub fn blend_saturation(&self, c_s: Color) -> Color {
let background = self.to_unassociated_alpha();
let sat_s = c_s.chroma_range();
let lum_b = background.luminance_rec_601();
let lum_b = self.luminance_rec_601();
background.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha)
self.with_saturation(sat_s).with_luminance(lum_b).with_alpha(c_s.alpha)
}
/// Whole-color "Color" blend: source hue/saturation with this color's luma, with `c_s`'s alpha.
pub fn blend_color(&self, c_s: Color) -> Color {
let lum_b = self.to_unassociated_alpha().luminance_rec_601();
let lum_b = self.luminance_rec_601();
c_s.with_luminance(lum_b).with_alpha(c_s.alpha)
}
@@ -851,7 +827,7 @@ impl Color {
pub fn blend_luminosity(&self, c_s: Color) -> Color {
let lum_s = c_s.luminance_rec_601();
self.to_unassociated_alpha().with_luminance(lum_s).with_alpha(c_s.alpha)
self.with_luminance(lum_s).with_alpha(c_s.alpha)
}
/// All four channels as `(red, green, blue, alpha)`.
@@ -990,13 +966,13 @@ impl Color {
Self::from_rgbaf32_unchecked(f(self.r()), f(self.g()), f(self.b()), self.a())
}
/// Multiply all four channels (including alpha) by `opacity`, applying an additional premultiplication factor to this Color.
/// Multiply RGB by alpha, giving the associated (premultiplied) form for compositing and filtering.
#[inline(always)]
pub fn apply_opacity(&self, opacity: f32) -> Self {
Self::from_rgbaf32_unchecked(self.r() * opacity, self.g() * opacity, self.b() * opacity, self.a() * opacity)
pub fn to_associated_alpha(&self) -> Self {
self.map_rgb(|channel| channel * self.alpha)
}
/// Divide RGB by alpha to recover unassociated (straight-alpha) channels; no-op if alpha is zero.
/// Divide RGB by alpha, undoing [`Self::to_associated_alpha`]; no-op if alpha is zero.
#[inline(always)]
pub fn to_unassociated_alpha(&self) -> Self {
if self.alpha == 0. {
@@ -1011,27 +987,30 @@ impl Color {
}
}
/// Apply a per-channel blend function to this color (unmultiplied) and `other`, returning a color with `other`'s alpha; channels are clamped to 0..1.
/// Apply a per-channel blend function to this color and `other`, returning a color with `other`'s alpha; channels are clamped to 0..1.
#[inline(always)]
pub fn blend_rgb<F: Fn(f32, f32) -> f32>(&self, other: Color, f: F) -> Self {
let background = self.to_unassociated_alpha();
Color {
red: f(background.red, other.red).clamp(0., 1.),
green: f(background.green, other.green).clamp(0., 1.),
blue: f(background.blue, other.blue).clamp(0., 1.),
red: f(self.red, other.red).clamp(0., 1.),
green: f(self.green, other.green).clamp(0., 1.),
blue: f(self.blue, other.blue).clamp(0., 1.),
alpha: other.alpha,
}
}
/// Porter-Duff "source over" composite of `other` over `self`. Both colors must use associated (premultiplied) alpha.
/// Porter-Duff "source over" composite of `other` over `self`.
#[inline(always)]
pub fn alpha_blend(&self, other: Color) -> Self {
let inv_alpha = 1. - other.alpha;
let under_weight = self.alpha * (1. - other.alpha);
let alpha = other.alpha + under_weight;
if alpha == 0. {
return Self::TRANSPARENT;
}
Self {
red: self.red * inv_alpha + other.red,
green: self.green * inv_alpha + other.green,
blue: self.blue * inv_alpha + other.blue,
alpha: self.alpha * inv_alpha + other.alpha,
red: (other.red * other.alpha + self.red * under_weight) / alpha,
green: (other.green * other.alpha + self.green * under_weight) / alpha,
blue: (other.blue * other.alpha + self.blue * under_weight) / alpha,
alpha,
}
}

View File

@@ -144,14 +144,7 @@ impl<P: Pixel> Image<P> {
impl Image<Color> {
/// Generate Image from some frontend image data (the canvas pixels as u8s in a flat array)
pub fn from_image_data(image_data: &[u8], width: u32, height: u32) -> Self {
let data = image_data
.chunks_exact(4)
.map(|v| {
// `Image<Color>` pixels are stored linear-light with premultiplied alpha
let srgba = SRGBA8::new(v[0], v[1], v[2], v[3]);
Color::from(srgba).apply_opacity(v[3] as f32 / 255.)
})
.collect();
let data = image_data.chunks_exact(4).map(|v| SRGBA8::new(v[0], v[1], v[2], v[3]).into()).collect();
Image {
width,
height,
@@ -171,7 +164,7 @@ impl Image<Color> {
}
use super::*;
impl<P: Alpha + RGB + AssociatedAlpha> Image<P>
impl<P: Alpha + RGB> Image<P>
where
P::ColorChannel: Linear,
<P as Alpha>::AlphaChannel: Linear,
@@ -195,10 +188,9 @@ where
// Smaller alpha values than this would map to fully transparent
// anyway, avoid expensive encoding.
if a >= 0.5 / 255. {
let undo_premultiply = 1. / a;
let r = color.r().to_f32() * undo_premultiply;
let g = color.g().to_f32() * undo_premultiply;
let b = color.b().to_f32() * undo_premultiply;
let r = color.r().to_f32();
let g = color.g().to_f32();
let b = color.b().to_f32();
// Compute new sRGB value if necessary.
if r != last_r {
@@ -287,4 +279,14 @@ mod test {
assert_eq!(image, deserialized);
}
#[test]
fn image_data_round_trips_translucent_pixels() {
use super::*;
let bytes = [255, 0, 0, 128, 0, 255, 0, 1, 255, 255, 255, 41, 10, 20, 30, 255];
let image = Image::from_image_data(&bytes, 4, 1);
assert_eq!(image.to_flat_u8().0, bytes);
}
}

View File

@@ -22,7 +22,7 @@ use dyn_any::DynAny;
use glam::{DAffine2, DMat2, DVec2};
use graphene_hash::CacheHashWrapper;
use graphene_resource::Resource;
use graphic_types::raster_types::{BitmapMut, CPU, GPU, Image, Raster, Texture};
use graphic_types::raster_types::{CPU, GPU, Image, Raster, Texture};
use graphic_types::vector_types::gradient::{Gradient, GradientForm};
use graphic_types::vector_types::vector::click_target::{ClickTarget, FreePoint};
use graphic_types::vector_types::vector::misc::dvec2_to_point;
@@ -130,9 +130,7 @@ fn composite_paint_over(over: Color, under: Color, blend_mode: BlendMode) -> Col
return Color::TRANSPARENT;
}
// The blend formulas read their backdrop premultiplied
let premultiplied_under = Color::from_rgbaf32_unchecked(under.r() * under_alpha, under.g() * under_alpha, under.b() * under_alpha, under_alpha);
let mixed = apply_blend_mode(over, premultiplied_under, blend_mode);
let mixed = apply_blend_mode(over, under, blend_mode);
// The mode only mixes where the backdrop has coverage, so its alpha interpolates each source channel from the raw color to the mixed color
let source_channel = |over_channel: f32, mixed_channel: f32| over_channel * (1. - under_alpha) + mixed_channel * under_alpha;
@@ -428,17 +426,8 @@ fn singular_values(transform: DAffine2) -> (f64, f64) {
pub fn black_or_white_for_best_contrast(background: Option<Color>) -> Color {
let Some(bg) = background else { return core_types::consts::LAYER_OUTLINE_STROKE_COLOR };
let alpha = bg.a();
// Un-premultiply, then encode to gamma sRGB to do the composite in display space.
let (gamma_r, gamma_g, gamma_b) = if alpha > f32::EPSILON {
let [r, g, b, _] = Color::from_rgbaf32_unchecked(bg.r() / alpha, bg.g() / alpha, bg.b() / alpha, alpha).to_gamma_srgb_channels();
(r, g, b)
} else {
(0., 0., 0.)
};
// Composite over black in sRGB space (premultiplied by alpha), then decode to linear for the luminance test.
// Composite over black in gamma sRGB space, then decode to linear for the luminance test.
let [gamma_r, gamma_g, gamma_b, alpha] = bg.to_gamma_srgb_channels();
let composited = Color::from_gamma_srgb_channels(gamma_r * alpha, gamma_g * alpha, gamma_b * alpha, 1.);
let threshold = (1.05 * 0.05f32).sqrt() - 0.05;
@@ -2295,9 +2284,7 @@ fn render_raster_cpu_item_svg(item: ItemRef<'_, Raster<CPU>>, render: &mut SvgRe
}
if render_params.to_canvas() {
let mut image_copy = image.clone();
image_copy.data_mut().map_pixels(|p| p.to_unassociated_alpha());
let id = *render.image_data.entry(CacheHashWrapper(image_copy.into_data())).or_insert_with(generate_uuid);
let id = *render.image_data.entry(CacheHashWrapper(image.clone().into_data())).or_insert_with(generate_uuid);
render.parent_tag(
"foreignObject",

View File

@@ -115,9 +115,7 @@ impl RasterGpuToRasterCpuConverter {
let start = row * row_stride;
let row_slice = &view[start..start + row_bytes];
for px in row_slice.chunks_exact(4) {
// `Image<Color>` pixels are stored linear-light with associated (premultiplied) alpha
let srgba = SRGBA8::new(px[0], px[1], px[2], px[3]);
cpu_data.push(Color::from(srgba).apply_opacity(px[3] as f32 / 255.));
cpu_data.push(SRGBA8::new(px[0], px[1], px[2], px[3]).into());
}
}

View File

@@ -171,14 +171,7 @@ fn decode_image(_: impl Ctx, data: Item<Resource>) -> Item<Raster<CPU>> {
};
let image = image.to_rgba32f();
let image = Image {
data: image
.chunks(4)
.map(|pixel| {
// Decoded bytes are unassociated gamma sRGB; premultiply in gamma then lift to linear
let a = pixel[3];
Color::from_gamma_srgb_channels(pixel[0] * a, pixel[1] * a, pixel[2] * a, a)
})
.collect(),
data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(),
width: image.width(),
height: image.height(),
..Default::default()

View File

@@ -144,12 +144,7 @@ fn make_opaque<T: Adjust<Color>>(
input: Item<T>,
) -> Item<T> {
let mut input = input;
input.element_mut().adjust(|color| {
if color.a() == 0. {
return color.with_alpha(1.);
}
Color::from_rgbaf32_unchecked(color.r() / color.a(), color.g() / color.a(), color.b() / color.a(), 1.)
});
input.element_mut().adjust(|color| color.with_alpha(1.));
input
}
@@ -502,11 +497,7 @@ fn invert<T: Adjust<Color>>(
input: Item<T>,
) -> Item<T> {
let mut input = input;
input.element_mut().adjust(|color| {
// Invert in gamma space relative to alpha
let [r, g, b, a] = color.to_gamma_srgb_channels();
Color::from_gamma_srgb_channels(a - r, a - g, a - b, a)
});
input.element_mut().adjust(|color| color.map_gamma_rgb(|channel| 1. - channel));
input
}
@@ -1147,3 +1138,19 @@ mod _graphene_hash_impls {
SelectiveColorChoice
);
}
#[cfg(all(feature = "std", test))]
mod test {
use super::*;
#[test]
fn invert_flips_straight_channels_and_keeps_alpha() {
let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5);
let inverted = invert((), Item::new_from_element(color)).into_element();
let [r, g, b, a] = inverted.to_gamma_srgb_channels();
assert!((r - 0.).abs() < 1e-5 && (g - 0.75).abs() < 1e-5 && (b - 1.).abs() < 1e-5, "inverted channels were {r} {g} {b}");
assert!((a - 0.5).abs() < 1e-5, "alpha was {a}");
}
}

View File

@@ -115,13 +115,10 @@ fn color_overlay<T: Adjust<Color>>(
let opacity = (opacity / 100.).clamp(0., 1.);
image.element_mut().adjust(|pixel| {
let image = pixel.map_rgb(|channel| channel * (1. - opacity));
let overlay = apply_blend_mode(color, *pixel, blend_mode);
let mix = |image: f32, overlay: f32| image + (overlay - image) * opacity;
// The apply blend mode function divides rgb by the alpha channel for the background. This undoes that.
let associated_pixel = Color::from_rgbaf32_unchecked(pixel.r() * pixel.a(), pixel.g() * pixel.a(), pixel.b() * pixel.a(), pixel.a());
let overlay = apply_blend_mode(color, associated_pixel, blend_mode).map_rgb(|channel| channel * opacity);
Color::from_rgbaf32_unchecked(image.r() + overlay.r(), image.g() + overlay.g(), image.b() + overlay.b(), pixel.a())
Color::from_rgbaf32_unchecked(mix(pixel.r(), overlay.r()), mix(pixel.g(), overlay.g()), mix(pixel.b(), overlay.b()), pixel.a())
});
image
}

View File

@@ -177,7 +177,7 @@ fn gaussian_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Im
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
working.map_pixels(|px| px.to_associated_alpha());
let mut blurred = gaussian_separable(working, &kernel, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred
@@ -191,7 +191,7 @@ fn box_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<C
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
working.map_pixels(|px| px.to_associated_alpha());
let mut blurred = box_separable(working, radius, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred

View File

@@ -48,8 +48,10 @@ pub fn sample_image(ctx: impl ExtractFootprint + Clone + Send, image_frame: Item
let (image, mut attributes) = image_frame.into_parts();
let (width, height) = (image.width, image.height);
// Resize the image using the image crate
let data = bytemuck::cast_vec(image.into_data().data);
// Resize the image using the image crate, which filters each channel independently, so premultiply to keep transparent texels from bleeding into edges
let mut image = image.into_data();
image.map_pixels(|px| px.to_associated_alpha());
let data = bytemuck::cast_vec(image.data);
let image_size = DAffine2::from_scale(DVec2::new(width as f64, height as f64));
let size_px = image_size.transform_vector2(size).as_uvec2();
@@ -77,12 +79,13 @@ pub fn sample_image(ctx: impl ExtractFootprint + Clone + Send, image_frame: Item
let buffer = resized.to_rgba32f();
let buffer = buffer.into_raw();
let vec = bytemuck::cast_vec(buffer);
let image = Image {
let mut image = Image {
width: new_width,
height: new_height,
data: vec,
base64_string: None,
};
image.map_pixels(|px: Color| px.to_unassociated_alpha());
// we need to adjust the offset if we truncate the offset calculation
let new_transform = image_frame_transform * DAffine2::from_translation(offset) * DAffine2::from_scale(size);
@@ -257,13 +260,7 @@ pub fn image<'a: 'n>(_: impl Ctx, resource: Item<Resource>) -> Item<Raster<CPU>>
};
let image = image.to_rgba32f();
let image = Image {
data: image
.chunks(4)
.map(|pixel| {
let alpha = pixel[3];
Color::from_gamma_srgb_channels(pixel[0] * alpha, pixel[1] * alpha, pixel[2] * alpha, alpha)
})
.collect(),
data: image.chunks(4).map(|pixel| Color::from_gamma_srgb_channels(pixel[0], pixel[1], pixel[2], pixel[3])).collect(),
width: image.width(),
height: image.height(),
..Default::default()