Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs (#4149)

* Deprecate all usages of the Color struct representing gamma space values, fixing round-trip precision bugs

* Code review fixes
This commit is contained in:
Keavon Chambers
2026-05-14 22:48:33 -07:00
committed by GitHub
parent 456a7c868d
commit a56746c6bf
67 changed files with 1210 additions and 941 deletions

View File

@@ -2,6 +2,7 @@
use base64::Engine;
#[cfg(target_family = "wasm")]
use canvas_utils::{Canvas, CanvasHandle};
use core_types::color::SRGBA8;
use core_types::list::{Item, List};
#[cfg(target_family = "wasm")]
use core_types::math::bbox::Bbox;
@@ -123,7 +124,15 @@ fn string_to_bytes(_: impl Ctx, string: String) -> List<u8> {
#[node_macro::node(category("Web Request"), name("Image to Bytes"))]
fn image_to_bytes(_: impl Ctx, image: List<Raster<CPU>>) -> List<u8> {
let Some(image) = image.element(0) else { return List::new() };
image.data.iter().flat_map(|color| color.to_rgba8_srgb()).map(Item::new_from_element).collect()
image
.data
.iter()
.flat_map(|color| {
let SRGBA8 { red, green, blue, alpha } = (*color).into();
[red, green, blue, alpha]
})
.map(Item::new_from_element)
.collect()
}
/// Loads binary from URLs and local asset paths. Returns a transparent placeholder if the resource fails to load, allowing rendering to continue.
@@ -154,7 +163,11 @@ fn decode_image(_: impl Ctx, data: Arc<[u8]>) -> List<Raster<CPU>> {
let image = Image {
data: image
.chunks(4)
.map(|pixel| Color::from_unassociated_alpha(pixel[0], pixel[1], pixel[2], pixel[3]).to_linear_srgb())
.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(),
width: image.width(),
height: image.height(),

View File

@@ -823,7 +823,8 @@ fn rgba_to_color(_: impl Ctx, _primary: (), red: Fraction, green: Fraction, blue
let blue = (blue as f32).clamp(0., 1.);
let alpha = (alpha as f32).clamp(0., 1.);
List::new_from_element(Color::from_rgbaf32_unchecked(red, green, blue, alpha))
// RGB user inputs are interpreted as sRGB display values; lift to linear-light for the internal `Color`
List::new_from_element(Color::from_gamma_srgb_channels(red, green, blue, alpha))
}
/// Constructs a color value from hue, saturation, value, and alpha components given as numbers from 0 to 1.
@@ -848,11 +849,11 @@ fn hsla_to_color(_: impl Ctx, _primary: (), hue: Fraction, #[default(1.)] satura
List::new_from_element(Color::from_hsla(hue, saturation, lightness, alpha))
}
/// Constructs a color value from an sRGB color code string, such as `#RRGGBB` or `#RRGGBBAA`. Invalid hex code strings produce no color.
/// Constructs a color value from a CSS color string. Accepts hex (`#RRGGBB`, `#RRGGBBAA`, plus bare and shorthand variants), CSS named colors (like `red`), and functional notations (`rgb(...)`, `hsl(...)`, etc.). Invalid inputs produce no color.
#[node_macro::node(category("Color"), name("Hex to Color"))]
fn hex_to_color(_: impl Ctx, hex_code: String) -> List<Color> {
match Color::from_hex_str(&hex_code) {
Some(c) => List::new_from_element(c),
match core_types::misc::parse_css_color(&hex_code) {
Some(color) => List::new_from_element(color),
None => List::new(),
}
}

View File

@@ -5,8 +5,8 @@ use crate::cubic_spline::CubicSplines;
use core::fmt::Debug;
#[cfg(feature = "std")]
use core_types::list::List;
use glam::{Vec3, Vec4};
use no_std_types::color::Color;
use glam::Vec3;
use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear};
use no_std_types::context::Ctx;
use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32};
use node_macro::BufferStruct;
@@ -63,7 +63,7 @@ fn luminance<T: Adjust<Color>>(
) -> T {
input.adjust(|color| {
let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_srgb(),
LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
@@ -91,7 +91,7 @@ fn gamma_correction<T: Adjust<Color>>(
inverse: bool,
) -> T {
let exponent = if inverse { 1. / gamma } else { gamma };
input.adjust(|color| color.gamma(exponent));
input.adjust(|color| color.apply_gamma_exponent(exponent));
input
}
@@ -161,7 +161,7 @@ fn brightness_contrast_classic<T: Adjust<Color>>(
let offset = brightness * contrast + brightness - contrast / 2.;
input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)).to_linear_srgb());
input.adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)));
input
}
@@ -240,7 +240,7 @@ fn brightness_contrast<T: Adjust<Color>>(
});
let lut_max = (combined_lut.len() - 1) as f32;
input.adjust(|color| color.to_gamma_srgb().map_rgb(|c| combined_lut[(c * lut_max).round() as usize]).to_linear_srgb());
input.adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize]));
input
}
@@ -270,7 +270,8 @@ fn levels<T: Adjust<Color>>(
#[default(100.)] output_maximums: PercentageF32,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
// Levels math operates in gamma space
let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels();
// Input Range (Range: 0-1)
let input_shadows = shadows / 100.;
@@ -301,15 +302,24 @@ fn levels<T: Adjust<Color>>(
// Input levels (Range: 0-1)
let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.);
let color = color.map_rgb(|c| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.));
let input_map = |c: f32| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.);
r = input_map(r);
g = input_map(g);
b = input_map(b);
// Midtones (Range: 0-1)
let color = color.gamma(gamma);
// Midtones gamma curve (Range: 0-1)
let inverse_gamma = 1. / gamma.max(0.0001);
r = r.powf(inverse_gamma);
g = g.powf(inverse_gamma);
b = b.powf(inverse_gamma);
// Output levels (Range: 0-1)
let color = color.map_rgb(|c| c * (output_maximums - output_minimums) + output_minimums);
let output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums;
r = output_map(r);
g = output_map(g);
b = output_map(b);
color.to_linear_srgb()
Color::from_gamma_srgb_channels(r, g, b, a)
});
image
}
@@ -353,7 +363,8 @@ fn black_and_white<T: Adjust<Color>>(
magentas: PercentageF32,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
// Black & White channel weights are tuned for gamma-space values
let [r, g, b, alpha_part] = color.to_gamma_srgb_channels();
let reds = reds / 100.;
let yellows = yellows / 100.;
@@ -362,12 +373,11 @@ fn black_and_white<T: Adjust<Color>>(
let blues = blues / 100.;
let magentas = magentas / 100.;
let gray_base = color.r().min(color.g()).min(color.b());
let gray_base = r.min(g).min(b);
let red_part = color.r() - gray_base;
let green_part = color.g() - gray_base;
let blue_part = color.b() - gray_base;
let alpha_part = color.a();
let red_part = r - gray_base;
let green_part = g - gray_base;
let blue_part = b - gray_base;
let additional = if red_part == 0. {
let cyan_part = green_part.min(blue_part);
@@ -383,11 +393,15 @@ fn black_and_white<T: Adjust<Color>>(
let luminance = gray_base + additional;
// TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close)
let color = tint.with_luminance(luminance);
// Apply luminance substitution in gamma space
let [tr, tg, tb, _] = tint.to_gamma_srgb_channels();
let tint_luma_rec_601 = 0.3 * tr + 0.59 * tg + 0.11 * tb;
let delta = luminance - tint_luma_rec_601;
let result_r = (tr + delta).clamp(0., 1.);
let result_g = (tg + delta).clamp(0., 1.);
let result_b = (tb + delta).clamp(0., 1.);
let color = Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), alpha_part);
color.to_linear_srgb()
Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part)
});
image
}
@@ -410,20 +424,17 @@ fn hue_saturation<T: Adjust<Color>>(
lightness_shift: SignedPercentageF32,
) -> T {
input.adjust(|color| {
let color = color.to_gamma_srgb();
// HSL operates on gamma-space channels
let [hue, saturation, lightness, alpha] = color.to_hsla();
let color = Color::from_hsla(
Color::from_hsla(
(hue + hue_shift / 360.) % 1.,
// TODO: Improve the way saturation works (it's slightly off)
(saturation + saturation_shift / 100.).clamp(0., 1.),
// TODO: Fix the way lightness works (it's very off)
(lightness + lightness_shift / 100.).clamp(0., 1.),
alpha,
);
color.to_linear_srgb()
)
});
input
}
@@ -442,11 +453,9 @@ fn invert<T: Adjust<Color>>(
mut input: T,
) -> T {
input.adjust(|color| {
let color = color.to_gamma_srgb();
let color = color.map_rgb(|c| color.a() - c);
color.to_linear_srgb()
// 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
}
@@ -468,11 +477,11 @@ fn threshold<T: Adjust<Color>>(
luminance_calc: LuminanceCalculation,
) -> T {
image.adjust(|color| {
let min_luminance = Color::srgb_to_linear(min_luminance / 100.);
let max_luminance = Color::srgb_to_linear(max_luminance / 100.);
let min_luminance = srgb_to_linear(min_luminance / 100.);
let max_luminance = srgb_to_linear(max_luminance / 100.);
let luminance = match luminance_calc {
LuminanceCalculation::SRGB => color.luminance_srgb(),
LuminanceCalculation::SRGB => color.luminance_rec_709(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(),
@@ -512,17 +521,22 @@ fn vibrance<T: Adjust<Color>>(
vibrance: SignedPercentageF32,
) -> T {
image.adjust(|color| {
let r_raw = color.r();
let g_raw = color.g();
let b_raw = color.b();
let alpha_in = color.a();
let vibrance = vibrance / 100.;
// Slow the effect down by half when it's negative, since artifacts begin appearing past -50%.
// So this scales the 0% to -50% range to 0% to -100%.
let slowed_vibrance = if vibrance >= 0. { vibrance } else { vibrance * 0.5 };
let channel_max = color.r().max(color.g()).max(color.b());
let channel_min = color.r().min(color.g()).min(color.b());
let channel_max = r_raw.max(g_raw).max(b_raw);
let channel_min = r_raw.min(g_raw).min(b_raw);
let channel_difference = channel_max - channel_min;
let scale_multiplier = if channel_max == color.r() {
let green_blue_difference = (color.g() - color.b()).abs();
let scale_multiplier = if channel_max == r_raw {
let green_blue_difference = (g_raw - b_raw).abs();
let t = (green_blue_difference / channel_difference).min(1.);
t * 0.5 + 0.5
} else {
@@ -532,30 +546,48 @@ fn vibrance<T: Adjust<Color>>(
let channel_reduction = channel_min * scale;
let scale = 1. + scale * (1. - channel_difference);
let luminance_initial = color.to_linear_srgb().luminance_srgb();
let altered_color = color.map_rgb(|c| c * scale - channel_reduction).to_linear_srgb();
let luminance = altered_color.luminance_srgb();
let altered_color = altered_color.map_rgb(|c| c * luminance_initial / luminance);
let r_lin0 = srgb_to_linear(r_raw);
let g_lin0 = srgb_to_linear(g_raw);
let b_lin0 = srgb_to_linear(b_raw);
let luminance_initial = 0.2126 * r_lin0 + 0.7152 * g_lin0 + 0.0722 * b_lin0;
let channel_max = altered_color.r().max(altered_color.g()).max(altered_color.b());
let altered_color = if Color::linear_to_srgb(channel_max) > 1. {
let mut alt_r = srgb_to_linear(r_raw * scale - channel_reduction);
let mut alt_g = srgb_to_linear(g_raw * scale - channel_reduction);
let mut alt_b = srgb_to_linear(b_raw * scale - channel_reduction);
let luminance = 0.2126 * alt_r + 0.7152 * alt_g + 0.0722 * alt_b;
// Skip the luminance-preservation scaling when the result is black (e.g. black input pixel), avoiding division by zero.
if luminance > 0. {
alt_r *= luminance_initial / luminance;
alt_g *= luminance_initial / luminance;
alt_b *= luminance_initial / luminance;
}
let channel_max = alt_r.max(alt_g).max(alt_b);
if linear_to_srgb(channel_max) > 1. {
let scale = (1. - luminance) / (channel_max - luminance);
altered_color.map_rgb(|c| (c - luminance) * scale + luminance)
} else {
altered_color
};
let altered_color = altered_color.to_gamma_srgb();
alt_r = (alt_r - luminance) * scale + luminance;
alt_g = (alt_g - luminance) * scale + luminance;
alt_b = (alt_b - luminance) * scale + luminance;
}
alt_r = linear_to_srgb(alt_r);
alt_g = linear_to_srgb(alt_g);
alt_b = linear_to_srgb(alt_b);
if vibrance >= 0. {
altered_color
Color::from_rgbaf32_unchecked(alt_r, alt_g, alt_b, alpha_in)
} else {
// TODO: The result ends up a bit darker than it should be, further investigation is needed
let luminance = color.luminance_rec_601();
// Near -0% vibrance we mostly use `altered_color`.
// Near -100% vibrance, we mostly use half the desaturated luminance color and half `altered_color`.
// TODO: The result ends up a bit darker than it should be, further investigation is needed.
// Mix in gamma space (matching `alt_*`), so the luminance is computed from gamma channels too.
let [gr, gg, gb, _] = color.to_gamma_srgb_channels();
let luminance = 0.299 * gr + 0.587 * gg + 0.114 * gb;
let factor = -slowed_vibrance;
altered_color.map_rgb(|c| c * (1. - factor) + luminance * factor)
Color::from_rgbaf32_unchecked(
alt_r * (1. - factor) + luminance * factor,
alt_g * (1. - factor) + luminance * factor,
alt_b * (1. - factor) + luminance * factor,
alpha_in,
)
}
});
image
@@ -747,16 +779,14 @@ fn channel_mixer<T: Adjust<Color>>(
_output_channel: RedGreenBlue,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
let [r, g, b, a] = color.to_gamma_srgb_channels();
let (r, g, b, a) = color.components();
let color = if monochrome {
let (out_r, out_g, out_b) = if monochrome {
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r / 100., monochrome_g / 100., monochrome_b / 100., monochrome_c / 100.);
let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.);
Color::from_rgbaf32_unchecked(gray, gray, gray, a)
(gray, gray, gray)
} else {
let (red_r, red_g, red_b, red_c) = (red_r / 100., red_g / 100., red_b / 100., red_c / 100.);
let (green_r, green_g, green_b, green_c) = (green_r / 100., green_g / 100., green_b / 100., green_c / 100.);
@@ -766,10 +796,10 @@ fn channel_mixer<T: Adjust<Color>>(
let green = (r * green_r + g * green_g + b * green_b + green_c).clamp(0., 1.);
let blue = (r * blue_r + g * blue_g + b * blue_b + blue_c).clamp(0., 1.);
Color::from_rgbaf32_unchecked(red, green, blue, a)
(red, green, blue)
};
color.to_linear_srgb()
Color::from_gamma_srgb_channels(out_r, out_g, out_b, a)
});
image
}
@@ -873,9 +903,7 @@ fn selective_color<T: Adjust<Color>>(
_colors: SelectiveColorChoice,
) -> T {
image.adjust(|color| {
let color = color.to_gamma_srgb();
let (r, g, b, a) = color.components();
let [r, g, b, a] = color.to_gamma_srgb_channels();
let min = |a: f32, b: f32, c: f32| a.min(b).min(c);
let max = |a: f32, b: f32, c: f32| a.max(b).max(c);
@@ -945,9 +973,9 @@ fn selective_color<T: Adjust<Color>>(
}
let rgb = Vec3::new(r, g, b);
let color = Color::from_vec4(Vec4::from(((sum + rgb).clamp(Vec3::ZERO, Vec3::ONE), a)));
let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE);
color.to_linear_srgb()
Color::from_gamma_srgb_channels(out.x, out.y, out.z, a)
});
image
}
@@ -973,15 +1001,11 @@ fn posterize<T: Adjust<Color>>(
levels: u32,
) -> T {
input.adjust(|color| {
let color = color.to_gamma_srgb();
let levels = levels as f32;
// `hard_min(2)` constrains the widget but doesn't bind the data-flow input (a saved doc or upstream node could still feed 0 or 1, producing inf/NaN below).
let levels = (levels as f32).max(2.);
let number_of_areas = levels.recip();
let size_of_areas = (levels - 1.).recip();
let channel = |channel: f32| (channel / number_of_areas).floor() * size_of_areas;
let color = color.map_rgb(channel);
color.to_linear_srgb()
color.map_gamma_rgb(|c| (c / number_of_areas).floor() * size_of_areas)
});
input
}
@@ -1016,7 +1040,7 @@ fn exposure<T: Adjust<Color>>(
// Offset
.map_rgb(|c: f32| c + offset)
// Gamma correction
.gamma(gamma_correction);
.apply_gamma_exponent(gamma_correction);
adjusted.map_rgb(|c: f32| c.clamp(0., 1.))
});

View File

@@ -99,7 +99,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.to_associated_alpha(opacity))
background.alpha_blend(target_color.apply_opacity(opacity))
}
pub fn apply_blend_mode(foreground: Color, background: Color, blend_mode: BlendMode) -> Color {

View File

@@ -1,4 +1,5 @@
use core_types::color::Color;
use bytemuck::{Pod, Zeroable};
use core_types::color::{Alpha, Color, Pixel, RGB};
use core_types::context::Ctx;
use core_types::list::List;
use core_types::registry::types::PixelLength;
@@ -6,6 +7,84 @@ use raster_types::Image;
use raster_types::{Bitmap, BitmapMut};
use raster_types::{CPU, Raster};
/// Working-buffer pixel for the blur algorithms' `gamma` mode: premultiplied sRGB-gamma `f32` channels.
/// Only used internally so the working buffer's color space is reflected in the type instead of stuffed into `Color` (which is linear-light by invariant).
#[repr(C)]
#[derive(Debug, Default, Clone, Copy, PartialEq, Pod, Zeroable)]
struct PremultipliedGammaPixel {
r: f32,
g: f32,
b: f32,
a: f32,
}
impl Pixel for PremultipliedGammaPixel {}
impl RGB for PremultipliedGammaPixel {
type ColorChannel = f32;
fn red(&self) -> f32 {
self.r
}
fn green(&self) -> f32 {
self.g
}
fn blue(&self) -> f32 {
self.b
}
}
impl Alpha for PremultipliedGammaPixel {
type AlphaChannel = f32;
const TRANSPARENT: Self = Self { r: 0., g: 0., b: 0., a: 0. };
fn alpha(&self) -> f32 {
self.a
}
fn multiplied_alpha(&self, mult: f32) -> Self {
Self {
r: self.r * mult,
g: self.g * mult,
b: self.b * mult,
a: self.a * mult,
}
}
}
fn premultiply_gamma(buffer: Image<Color>) -> Image<PremultipliedGammaPixel> {
Image {
width: buffer.width,
height: buffer.height,
data: buffer
.data
.into_iter()
.map(|px| {
let [r, g, b, a] = px.to_gamma_srgb_channels();
PremultipliedGammaPixel { r: r * a, g: g * a, b: b * a, a }
})
.collect(),
base64_string: None,
}
}
fn unpremultiply_gamma_to_linear(buffer: Image<PremultipliedGammaPixel>) -> Image<Color> {
Image {
width: buffer.width,
height: buffer.height,
data: buffer
.data
.into_iter()
.map(|px| {
if px.a > 0. {
let inv_a = 1. / px.a;
Color::from_gamma_srgb_channels(px.r * inv_a, px.g * inv_a, px.b * inv_a, px.a)
} else {
Color::TRANSPARENT
}
})
.collect(),
base64_string: None,
}
}
/// Blurs the image with a Gaussian or box blur kernel filter.
#[node_macro::node(category("Raster: Filter"))]
async fn blur(
@@ -98,26 +177,49 @@ fn gaussian_kernel(radius: f64) -> Vec<f64> {
gaussian_kernel
}
fn gaussian_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a()));
} else {
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a()));
}
let (width, height) = original_buffer.dimensions();
// Create 1D gaussian kernel
fn gaussian_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
let kernel = gaussian_kernel(radius);
if gamma {
let working = premultiply_gamma(buffer);
let blurred = gaussian_separable(working, &kernel, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a });
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
let mut blurred = gaussian_separable(working, &kernel, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred
}
}
fn box_blur_algorithm(buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
let working = premultiply_gamma(buffer);
let blurred = box_separable(working, radius, |r, g, b, a| PremultipliedGammaPixel { r, g, b, a });
unpremultiply_gamma_to_linear(blurred)
} else {
let mut working = buffer;
working.map_pixels(|px| px.apply_opacity(px.a()));
let mut blurred = box_separable(working, radius, Color::from_rgbaf32_unchecked);
blurred.map_pixels(|px| px.to_unassociated_alpha());
blurred
}
}
fn gaussian_separable<P, F>(buffer: Image<P>, kernel: &[f64], construct: F) -> Image<P>
where
P: Pixel + Copy + RGB<ColorChannel = f32> + Alpha<AlphaChannel = f32>,
F: Fn(f32, f32, f32, f32) -> P,
{
let (width, height) = buffer.dimensions();
let half_kernel = kernel.len() / 2;
// Intermediate buffer for horizontal and vertical passes
let mut x_axis = Image::new(width, height, Color::TRANSPARENT);
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
let mut x_axis = Image::new(width, height, P::default());
let mut y_axis = Image::new(width, height, P::default());
for pass in [false, true] {
let (max, old_buffer, current_buffer) = match pass {
false => (width, &original_buffer, &mut x_axis),
false => (width, &buffer, &mut x_axis),
true => (height, &x_axis, &mut y_axis),
};
let pass = pass as usize;
@@ -140,41 +242,32 @@ fn gaussian_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma
}
}
// Normalize
let (r, g, b, a) = if weight_sum > 0. {
((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32)
} else {
let px = old_buffer.get_pixel(x, y).unwrap();
(px.r(), px.g(), px.b(), px.a())
};
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a));
current_buffer.set_pixel(x, y, construct(r, g, b, a));
}
}
}
if gamma {
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
} else {
y_axis.map_pixels(|px| px.to_unassociated_alpha());
}
y_axis
}
fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: bool) -> Image<Color> {
if gamma {
original_buffer.map_pixels(|px| px.to_gamma_srgb().to_associated_alpha(px.a()));
} else {
original_buffer.map_pixels(|px| px.to_associated_alpha(px.a()));
}
let (width, height) = original_buffer.dimensions();
let mut x_axis = Image::new(width, height, Color::TRANSPARENT);
let mut y_axis = Image::new(width, height, Color::TRANSPARENT);
fn box_separable<P, F>(buffer: Image<P>, radius: f64, construct: F) -> Image<P>
where
P: Pixel + Copy + RGB<ColorChannel = f32> + Alpha<AlphaChannel = f32>,
F: Fn(f32, f32, f32, f32) -> P,
{
let (width, height) = buffer.dimensions();
let mut x_axis = Image::new(width, height, P::default());
let mut y_axis = Image::new(width, height, P::default());
for pass in [false, true] {
let (max, old_buffer, current_buffer) = match pass {
false => (width, &original_buffer, &mut x_axis),
false => (width, &buffer, &mut x_axis),
true => (height, &x_axis, &mut y_axis),
};
let pass = pass as usize;
@@ -196,17 +289,11 @@ fn box_blur_algorithm(mut original_buffer: Image<Color>, radius: f64, gamma: boo
}
let (r, g, b, a) = ((r_sum / weight_sum) as f32, (g_sum / weight_sum) as f32, (b_sum / weight_sum) as f32, (a_sum / weight_sum) as f32);
current_buffer.set_pixel(x, y, Color::from_rgbaf32_unchecked(r, g, b, a));
current_buffer.set_pixel(x, y, construct(r, g, b, a));
}
}
}
if gamma {
y_axis.map_pixels(|px| px.to_linear_srgb().to_unassociated_alpha());
} else {
y_axis.map_pixels(|px| px.to_unassociated_alpha());
}
y_axis
}

View File

@@ -24,9 +24,9 @@ async fn gradient_map<T: Adjust<Color>>(
let Some(gradient) = gradient.element(0) else { return image };
image.adjust(|color| {
let intensity = color.luminance_srgb();
let intensity = color.luminance_rec_709();
let intensity = if reverse { 1. - intensity } else { intensity };
gradient.evaluate(intensity as f64).to_linear_srgb()
gradient.evaluate(intensity as f64)
});
image

View File

@@ -16,7 +16,8 @@ async fn image_color_palette(
let bins = GRID * GRID * GRID;
let mut histogram = vec![0; (bins + 1.) as usize];
let mut color_bins = vec![Vec::new(); (bins + 1.) as usize];
// Each bin stores `(red, green, blue, alpha)` tuples in sRGB gamma space; averaging in gamma space gives perceptually-uniform binning.
let mut color_bins: Vec<Vec<[f32; 4]>> = vec![Vec::new(); (bins + 1.) as usize];
for element in image.iter_element_values() {
for pixel in element.data.iter() {
@@ -27,7 +28,7 @@ async fn image_color_palette(
let bin = (r * GRID + g * GRID + b * GRID) as usize;
histogram[bin] += 1;
color_bins[bin].push(pixel.to_gamma_srgb());
color_bins[bin].push(pixel.to_gamma_srgb_channels());
}
}
@@ -39,24 +40,21 @@ async fn image_color_palette(
.flat_map(|&i| {
let list = &color_bins[i];
let mut r = 0.;
let mut g = 0.;
let mut b = 0.;
let mut a = 0.;
let [mut r, mut g, mut b, mut a] = [0.; 4];
for color in list.iter() {
r += color.r();
g += color.g();
b += color.b();
a += color.a();
for &[cr, cg, cb, ca] in list.iter() {
r += cr;
g += cg;
b += cb;
a += ca;
}
r /= list.len() as f32;
g /= list.len() as f32;
b /= list.len() as f32;
a /= list.len() as f32;
let len = list.len() as f32;
let [r, g, b, a] = [r / len, g / len, b / len, a / len];
Color::from_rgbaf32(r, g, b, a).map(Item::new_from_element).into_iter()
// Reject NaN/out-of-range averages, then lift the gamma-space bin centroid to linear-light
let in_gamut = a <= 1. && ![r, g, b, a].iter().any(|c| c.is_sign_negative() || !c.is_finite());
in_gamut.then(|| Color::from_gamma_srgb_channels(r, g, b, a)).map(Item::new_from_element).into_iter()
})
.collect()
}