mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-09-15 14:18:04 +08:00
1668 lines
62 KiB
Rust
1668 lines
62 KiB
Rust
#![allow(clippy::too_many_arguments)]
|
||
|
||
use crate::adjust::Adjust;
|
||
use crate::cubic_spline::CubicSplines;
|
||
use core::fmt::Debug;
|
||
#[cfg(feature = "std")]
|
||
use core_types::list::{Item, List};
|
||
#[cfg(feature = "std")]
|
||
use core_types::transfer_curve::{TransferCurve, TransferCurveEvaluator};
|
||
#[cfg(feature = "std")]
|
||
use glam::DVec2;
|
||
use glam::Vec3;
|
||
use no_std_types::color::{Color, linear_to_srgb, srgb_to_linear};
|
||
use no_std_types::context::Ctx;
|
||
#[cfg(not(feature = "std"))]
|
||
use no_std_types::list::ShaderItem as Item;
|
||
use no_std_types::registry::types::{AngleF32, PercentageF32, SignedPercentageF32};
|
||
use node_macro::BufferStruct;
|
||
use num_enum::{FromPrimitive, IntoPrimitive};
|
||
#[cfg(not(feature = "std"))]
|
||
use num_traits::float::Float;
|
||
#[cfg(feature = "std")]
|
||
use raster_types::{CPU, Raster};
|
||
#[cfg(feature = "std")]
|
||
use vector_types::Gradient;
|
||
|
||
// TODO: Implement the following:
|
||
// Photo Filter
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27phfl%27%20%3D%20Photo%20Filter
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=of%20the%20file.-,Photo%20Filter,-Key%20is%20%27phfl
|
||
//
|
||
// Color Lookup
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Color%20Lookup%20(Photoshop%20CS6
|
||
|
||
/// Conversion from a color to grayscale.
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)]
|
||
#[widget(Dropdown)]
|
||
#[repr(u32)]
|
||
pub enum DesaturateMethod {
|
||
/// Light level of the color, the Y (luminance) of Rec. 709, which weights the linear-light RGB channels by `0.2126, 0.7152, 0.0722`.
|
||
///
|
||
/// Accessibility contrast ratios and SVG luminance masks use this.
|
||
#[default]
|
||
#[label("Luminance (Rec. 709)")]
|
||
#[cfg_attr(feature = "serde", serde(alias = "SRGB"))]
|
||
LuminanceRec709,
|
||
/// Light level approximation for the color, the Y′ (luma) of Rec. 709, which weights the gamma-encoded RGB channels by `0.2126, 0.7152, 0.0722`.
|
||
///
|
||
/// CSS filter functions such as `grayscale()` use this.
|
||
#[label("Luma (Rec. 709)")]
|
||
LumaRec709,
|
||
/// Light level approximation for the color, the Y′ (luma) of Rec. 601, which weights the gamma-encoded RGB channels by `0.299, 0.587, 0.114`.
|
||
#[label("Luma (Rec. 601)")]
|
||
LumaRec601,
|
||
/// Perceptually uniform scale from black to white, the L (lightness) of OkLab.
|
||
#[label("Lightness (OkLab)")]
|
||
#[cfg_attr(feature = "serde", serde(alias = "Perceptual"))]
|
||
LightnessOkLab,
|
||
/// Mean of the three linear-light RGB channels.
|
||
#[menu_separator]
|
||
#[cfg_attr(feature = "serde", serde(alias = "AverageChannels"))]
|
||
ChannelsAverage,
|
||
/// Smallest of the three linear-light RGB channels.
|
||
#[cfg_attr(feature = "serde", serde(alias = "MinimumChannels"))]
|
||
ChannelsMinimum,
|
||
/// Largest of the three linear-light RGB channels, the V (value) of HSV.
|
||
#[cfg_attr(feature = "serde", serde(alias = "MaximumChannels"))]
|
||
ChannelsMaximum,
|
||
/// Midpoint of the largest and smallest gamma-encoded RGB channels, the L (lightness) of HSL.
|
||
///
|
||
/// The classic "Desaturate" command of many image editors uses this.
|
||
#[label("Lightness (HSL)")]
|
||
LightnessHsl,
|
||
}
|
||
|
||
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
|
||
fn desaturate<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
method: Item<DesaturateMethod>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
let method = method.into_element();
|
||
|
||
input.element_mut().adjust(|color| {
|
||
// Gamma-encoded formulas are decoded as if they were a gray
|
||
let gamma = || color.to_gamma_srgb_channels();
|
||
let luminance = match method {
|
||
DesaturateMethod::LuminanceRec709 => color.luminance_rec_709(),
|
||
DesaturateMethod::LumaRec709 => {
|
||
let [r, g, b, _] = gamma();
|
||
srgb_to_linear(0.2126 * r + 0.7152 * g + 0.0722 * b)
|
||
}
|
||
DesaturateMethod::LumaRec601 => {
|
||
let [r, g, b, _] = gamma();
|
||
srgb_to_linear(0.299 * r + 0.587 * g + 0.114 * b)
|
||
}
|
||
DesaturateMethod::LightnessOkLab => {
|
||
// A gray's OkLab lightness is the cube root of its linear value, so cubing gives the gray of equal lightness
|
||
let lightness = color.lightness_oklab();
|
||
lightness * lightness * lightness
|
||
}
|
||
DesaturateMethod::ChannelsAverage => color.average_rgb_channels(),
|
||
DesaturateMethod::ChannelsMinimum => color.minimum_rgb_channels(),
|
||
DesaturateMethod::ChannelsMaximum => color.maximum_rgb_channels(),
|
||
DesaturateMethod::LightnessHsl => {
|
||
// The transfer curve is monotonic, so the extremes are found first and only they are encoded
|
||
let max = linear_to_srgb(color.maximum_rgb_channels());
|
||
let min = linear_to_srgb(color.minimum_rgb_channels());
|
||
srgb_to_linear((max + min) / 2.)
|
||
}
|
||
};
|
||
color.map_rgb(|_| luminance)
|
||
});
|
||
input
|
||
}
|
||
|
||
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
|
||
fn gamma_correction<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
#[default(2.2)]
|
||
#[range]
|
||
#[hard(0.0001..)]
|
||
#[soft(0.01..10)]
|
||
gamma: Item<f32>,
|
||
inverse: Item<bool>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
let gamma = gamma.into_element();
|
||
let inverse = inverse.into_element();
|
||
|
||
let exponent = if inverse { 1. / gamma } else { gamma };
|
||
input.element_mut().adjust(|color| color.apply_gamma_exponent(exponent));
|
||
input
|
||
}
|
||
|
||
#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))]
|
||
fn extract_channel<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
channel: Item<RedGreenBlueAlpha>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
let channel = channel.into_element();
|
||
|
||
input.element_mut().adjust(|color| {
|
||
let extracted_value = match channel {
|
||
RedGreenBlueAlpha::Red => color.r(),
|
||
RedGreenBlueAlpha::Green => color.g(),
|
||
RedGreenBlueAlpha::Blue => color.b(),
|
||
RedGreenBlueAlpha::Alpha => color.a(),
|
||
};
|
||
color.map_rgb(|_| extracted_value).with_alpha(1.)
|
||
});
|
||
input
|
||
}
|
||
|
||
#[node_macro::node(category("Raster: Channels"), shader_node(PerPixelAdjust))]
|
||
fn make_opaque<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
input.element_mut().adjust(|color| color.with_alpha(1.));
|
||
input
|
||
}
|
||
|
||
// TODO: Remove this once GPU shader nodes are able to support the non-classic algorithm
|
||
// TODO: Maybe re-add the "Raster: Adjustment" category to make this user-facing if we care to make this not just for testing
|
||
#[node_macro::node(name("Brightness/Contrast Classic"), category(""), properties("brightness_contrast_properties"), shader_node(PerPixelAdjust))]
|
||
fn brightness_contrast_classic<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
brightness: Item<SignedPercentageF32>,
|
||
contrast: Item<SignedPercentageF32>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
let brightness = brightness.into_element();
|
||
let contrast = contrast.into_element();
|
||
|
||
let brightness = brightness / 255.;
|
||
|
||
let contrast = contrast / 100.;
|
||
let contrast = if contrast > 0. { (contrast * core::f32::consts::FRAC_PI_2 - 0.01).tan() } else { contrast };
|
||
|
||
let offset = brightness * contrast + brightness - contrast / 2.;
|
||
|
||
input.element_mut().adjust(|color| color.map_gamma_rgb(|c| (c + c * contrast + offset).clamp(0., 1.)));
|
||
|
||
input
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27brit%27%20%3D%20Brightness/Contrast
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Padding-,Brightness%20and%20Contrast,-Key%20is%20%27brit
|
||
//
|
||
// Some further analysis available at:
|
||
// https://geraldbakker.nl/psnumbers/brightness-contrast.html
|
||
#[node_macro::node(name("Brightness/Contrast"), category("Raster: Adjustment"), properties("brightness_contrast_properties"), cfg(feature = "std"))]
|
||
fn brightness_contrast<T: Adjust<Color>>(
|
||
_ctx: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
brightness: Item<SignedPercentageF32>,
|
||
contrast: Item<SignedPercentageF32>,
|
||
use_classic: Item<bool>,
|
||
) -> Item<T> {
|
||
let use_classic = use_classic.into_element();
|
||
if use_classic {
|
||
return brightness_contrast_classic(_ctx, input, brightness, contrast);
|
||
}
|
||
|
||
let mut input = input;
|
||
let brightness = brightness.into_element();
|
||
let contrast = contrast.into_element();
|
||
|
||
const WINDOW_SIZE: usize = 1024;
|
||
|
||
// Brightness LUT
|
||
let brightness_is_negative = brightness < 0.;
|
||
// We clamp the brightness before the two curve X-axis points `130 - brightness * 26` and `233 - brightness * 48` intersect.
|
||
// Beyond the point of intersection, the cubic spline fitting becomes invalid and fails an assertion, which we need to avoid.
|
||
// See the intersection of the red lines at x = 103/22*100 = 468.18182 in the graph: https://www.desmos.com/calculator/ekvz4zyd9c
|
||
let brightness = (brightness.abs() / 100.).min(103. / 22. - 0.00001);
|
||
let brightness_curve_points = CubicSplines {
|
||
x: [0., 130. - brightness * 26., 233. - brightness * 48., 255.].map(|x| x / 255.),
|
||
y: [0., 130. + brightness * 51., 233. + brightness * 10., 255.].map(|x| x / 255.),
|
||
};
|
||
let brightness_curve_solutions = brightness_curve_points.solve();
|
||
let mut brightness_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| {
|
||
let x = i as f32 / (WINDOW_SIZE as f32 - 1.);
|
||
brightness_curve_points.interpolate(x, &brightness_curve_solutions)
|
||
});
|
||
// Special handling for when brightness is negative
|
||
if brightness_is_negative {
|
||
brightness_lut = core::array::from_fn(|i| {
|
||
let mut x = i;
|
||
while x > 1 && brightness_lut[x] > i as f32 / WINDOW_SIZE as f32 {
|
||
x -= 1;
|
||
}
|
||
x as f32 / WINDOW_SIZE as f32
|
||
});
|
||
}
|
||
|
||
// Contrast LUT
|
||
// Unlike with brightness, the X-axis points `64` and `192` don't intersect at any contrast value, because they are constants.
|
||
// So we don't have to worry about clamping the contrast value to avoid invalid cubic spline fitting.
|
||
// See the graph: https://www.desmos.com/calculator/iql9vsca56
|
||
let contrast = contrast / 100.;
|
||
let contrast_curve_points = CubicSplines {
|
||
x: [0., 64., 192., 255.].map(|x| x / 255.),
|
||
y: [0., 64. - contrast * 30., 192. + contrast * 30., 255.].map(|x| x / 255.),
|
||
};
|
||
let contrast_curve_solutions = contrast_curve_points.solve();
|
||
let contrast_lut: [f32; WINDOW_SIZE] = core::array::from_fn(|i| {
|
||
let x = i as f32 / (WINDOW_SIZE as f32 - 1.);
|
||
contrast_curve_points.interpolate(x, &contrast_curve_solutions)
|
||
});
|
||
|
||
// Composed brightness and contrast LUTs
|
||
let combined_lut = brightness_lut.map(|brightness| {
|
||
let index_in_contrast_lut = (brightness * (contrast_lut.len() - 1) as f32).round() as usize;
|
||
contrast_lut[index_in_contrast_lut]
|
||
});
|
||
let lut_max = (combined_lut.len() - 1) as f32;
|
||
|
||
input.element_mut().adjust(|color| color.map_gamma_rgb(|c| combined_lut[(c * lut_max).round() as usize]));
|
||
|
||
input
|
||
}
|
||
|
||
#[repr(u32)]
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
|
||
#[widget(Dropdown)]
|
||
/// The channel whose settings are shown, with RGB adjusting all three color channels together.
|
||
pub enum AdjustmentChannel {
|
||
#[default]
|
||
#[label("RGB")]
|
||
Rgb,
|
||
Red,
|
||
Green,
|
||
Blue,
|
||
Alpha,
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels
|
||
//
|
||
// Algorithm from:
|
||
// https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels
|
||
//
|
||
// Some further analysis available at:
|
||
// https://geraldbakker.nl/psnumbers/levels.html
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))]
|
||
fn levels<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
image: Item<T>,
|
||
#[default(0.)] shadows: Item<PercentageF32>,
|
||
#[default(50.)] midtones: Item<PercentageF32>,
|
||
#[default(100.)] highlights: Item<PercentageF32>,
|
||
#[default(0.)] output_minimums: Item<PercentageF32>,
|
||
#[default(100.)] output_maximums: Item<PercentageF32>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let shadows = shadows.into_element();
|
||
let midtones = midtones.into_element();
|
||
let highlights = highlights.into_element();
|
||
let output_minimums = output_minimums.into_element();
|
||
let output_maximums = output_maximums.into_element();
|
||
|
||
image.element_mut().adjust(|color| {
|
||
// 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.;
|
||
let input_midtones = midtones / 100.;
|
||
let input_highlights = highlights / 100.;
|
||
|
||
// Output Range (Range: 0-1)
|
||
let output_minimums = output_minimums / 100.;
|
||
let output_maximums = output_maximums / 100.;
|
||
|
||
// Midtones interpolation factor between minimums and maximums (Range: 0-1)
|
||
let midtones = output_minimums + (output_maximums - output_minimums) * input_midtones;
|
||
|
||
// Gamma correction (Range: 0.01-10)
|
||
let gamma = if midtones < 0.5 {
|
||
// Range: 0-1
|
||
let x = 1. - midtones * 2.;
|
||
// Range: 1-10
|
||
1. + 9. * x
|
||
} else {
|
||
// Range: 0-0.5
|
||
let x = 1. - midtones;
|
||
// Range: 0-1
|
||
let x = x * 2.;
|
||
// Range: 0.01-1
|
||
x.max(0.01)
|
||
};
|
||
|
||
// Input levels (Range: 0-1)
|
||
let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 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 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 output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums;
|
||
r = output_map(r);
|
||
g = output_map(g);
|
||
b = output_map(b);
|
||
|
||
Color::from_gamma_srgb_channels(r, g, b, a)
|
||
});
|
||
image
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27curv%27%20%3D%20Curves
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Curves%20file%20format
|
||
//
|
||
// Each curve is any number of (x, y) points on 0..1 joined by a natural cubic spline held flat beyond the outermost
|
||
// points, and the per-channel curves apply before the composite one, like Levels. The value between those two stages
|
||
// stays exact rather than rounding through an 8-bit table, which can leave results a level away from 8-bit pipelines.
|
||
// Needs the heap for its curves, so it stays off the shader build for now.
|
||
#[cfg(feature = "std")]
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("transfer_curves_properties"))]
|
||
async fn curves<T: Adjust<Color> + Send>(
|
||
_: impl Ctx,
|
||
#[implementations(Raster<CPU>, Color, Gradient)] image: Item<T>,
|
||
curve: Item<TransferCurve>,
|
||
#[name("(Red) Curve")] red_curve: Item<TransferCurve>,
|
||
#[name("(Green) Curve")] green_curve: Item<TransferCurve>,
|
||
#[name("(Blue) Curve")] blue_curve: Item<TransferCurve>,
|
||
#[name("(Alpha) Curve")] alpha_curve: Item<TransferCurve>,
|
||
_channel: Item<AdjustmentChannel>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let composite = curve.into_element().evaluator();
|
||
let red = red_curve.into_element().evaluator();
|
||
let green = green_curve.into_element().evaluator();
|
||
let blue = blue_curve.into_element().evaluator();
|
||
let alpha = alpha_curve.into_element().evaluator();
|
||
let map = |channel: &TransferCurveEvaluator, value: f32| composite.evaluate(channel.evaluate(value as f64).clamp(0., 1.)).clamp(0., 1.) as f32;
|
||
|
||
image.element_mut().adjust(|color| {
|
||
// Curves math operates in gamma space
|
||
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||
|
||
// Alpha stands apart from the composite curve that the three color channels pass through
|
||
let a = alpha.evaluate(a as f64).clamp(0., 1.) as f32;
|
||
|
||
Color::from_gamma_srgb_channels(map(&red, r), map(&green, g), map(&blue, b), a)
|
||
});
|
||
|
||
image
|
||
}
|
||
|
||
/// Builds a transfer curve from a `Vec2[]` of control points, each mapping the input value at its x to the output value at its y. A smooth spline runs through them, holding the outermost points' values beyond them.
|
||
#[cfg(feature = "std")]
|
||
#[node_macro::node(category("Raster: Adjustment"), name("Points to Transfer Curve"))]
|
||
fn points_to_transfer_curve(
|
||
_: impl Ctx,
|
||
/// The control points, in any order, with both coordinates on the 0 to 1 range.
|
||
points: List<DVec2>,
|
||
) -> Item<TransferCurve> {
|
||
let points: Vec<DVec2> = points.iter_element_values().copied().collect();
|
||
Item::new_from_element(TransferCurve::new(points))
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blwh%27%20%3D%20Black%20and%20White
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Black%20White%20(Photoshop%20CS3)
|
||
//
|
||
// Algorithm from:
|
||
// https://stackoverflow.com/a/55233732/775283
|
||
// Works the same for gamma and linear color
|
||
#[node_macro::node(name("Black & White"), category("Raster: Adjustment"), properties("black_and_white_properties"), shader_node(PerPixelAdjust))]
|
||
fn black_and_white<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
image: Item<T>,
|
||
#[default(Color::BLACK)] tint: Item<Color>,
|
||
#[default(40.)]
|
||
#[range]
|
||
#[soft(-200..300)]
|
||
reds: Item<PercentageF32>,
|
||
#[default(60.)]
|
||
#[range]
|
||
#[soft(-200..300)]
|
||
yellows: Item<PercentageF32>,
|
||
#[default(40.)]
|
||
#[range]
|
||
#[soft(-200..300)]
|
||
greens: Item<PercentageF32>,
|
||
#[default(60.)]
|
||
#[range]
|
||
#[soft(-200..300)]
|
||
cyans: Item<PercentageF32>,
|
||
#[default(20.)]
|
||
#[range]
|
||
#[soft(-200..300)]
|
||
blues: Item<PercentageF32>,
|
||
#[default(80.)]
|
||
#[range]
|
||
#[soft(-200..300)]
|
||
magentas: Item<PercentageF32>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let tint = tint.into_element();
|
||
let reds = reds.into_element();
|
||
let yellows = yellows.into_element();
|
||
let greens = greens.into_element();
|
||
let cyans = cyans.into_element();
|
||
let blues = blues.into_element();
|
||
let magentas = magentas.into_element();
|
||
|
||
image.element_mut().adjust(|color| {
|
||
// 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.;
|
||
let greens = greens / 100.;
|
||
let cyans = cyans / 100.;
|
||
let blues = blues / 100.;
|
||
let magentas = magentas / 100.;
|
||
|
||
let gray_base = r.min(g).min(b);
|
||
|
||
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);
|
||
cyan_part * cyans + (green_part - cyan_part) * greens + (blue_part - cyan_part) * blues
|
||
} else if green_part == 0. {
|
||
let magenta_part = red_part.min(blue_part);
|
||
magenta_part * magentas + (red_part - magenta_part) * reds + (blue_part - magenta_part) * blues
|
||
} else {
|
||
let yellow_part = red_part.min(green_part);
|
||
yellow_part * yellows + (red_part - yellow_part) * reds + (green_part - yellow_part) * greens
|
||
};
|
||
|
||
let luminance = gray_base + additional;
|
||
|
||
// TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close)
|
||
// 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.);
|
||
|
||
Color::from_gamma_srgb_channels(result_r, result_g, result_b, alpha_part)
|
||
});
|
||
image
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27hue%20%27%20%3D%20Old,saturation%2C%20Photoshop%205.0
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=0%20%3D%20Use%20other.-,Hue/Saturation,-Hue/Saturation%20settings
|
||
#[node_macro::node(name("Hue/Saturation"), category("Raster: Adjustment"), properties("hue_saturation_properties"), shader_node(PerPixelAdjust))]
|
||
fn hue_saturation<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
hue_shift: Item<AngleF32>,
|
||
saturation_shift: Item<SignedPercentageF32>,
|
||
lightness_shift: Item<SignedPercentageF32>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
let hue_shift = hue_shift.into_element();
|
||
let saturation_shift = saturation_shift.into_element();
|
||
let lightness_shift = lightness_shift.into_element();
|
||
|
||
input.element_mut().adjust(|color| {
|
||
// HSL operates on gamma-space channels
|
||
let [hue, saturation, lightness, alpha] = color.to_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,
|
||
)
|
||
});
|
||
input
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27%20%3D%20Color%20Lookup-,%27nvrt%27%20%3D%20Invert,-%27post%27%20%3D%20Posterize
|
||
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
|
||
fn invert<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
input.element_mut().adjust(|color| color.map_gamma_rgb(|channel| 1. - channel));
|
||
input
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=post%27%20%3D%20Posterize-,%27thrs%27%20%3D%20Threshold,-%27grdm%27%20%3D%20Gradient
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("threshold_properties"), shader_node(PerPixelAdjust))]
|
||
fn threshold<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(Raster<CPU>, Color, Gradient)]
|
||
#[gpu_image]
|
||
image: Item<T>,
|
||
#[default(50.)] min_luminance: Item<PercentageF32>,
|
||
#[default(100.)] max_luminance: Item<PercentageF32>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let min_luminance = min_luminance.into_element() / 100.;
|
||
let max_luminance = max_luminance.into_element() / 100.;
|
||
|
||
image.element_mut().adjust(|color| {
|
||
// For PSD interop, we compare this 14-bit fixed-point Rec. 601 luma against the level unrounded
|
||
let [r, g, b, _] = color.to_gamma_srgb_channels();
|
||
let luminance = (4915. * r + 9667. * g + 1802. * b) / 16384.;
|
||
|
||
let output = if luminance >= min_luminance && luminance <= max_luminance { Color::WHITE } else { Color::BLACK };
|
||
output.with_alpha(color.a())
|
||
});
|
||
image
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27grdm%27%20%3D%20Gradient%20Map
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Gradient%20settings%20(Photoshop%206.0)
|
||
//
|
||
// TODO: Full PSD interop needs a compatibility variant of `GradientInterpolation` with its own midpoint semantics, position warp,
|
||
// TODO: and smoothing (a `gradient_smoothness` attribute), plus noise gradients, which we don't yet support.
|
||
// TODO: Its axes differ from ours: its midpoint is always a knee in the position warp and its smoothness blends the curve over
|
||
// TODO: that fixed warp, while each variant here picks warp and curve together, so neither end of the blend is Linear or Smooth.
|
||
// TODO: Per channel in the gradient space (measured on gamma RGB):
|
||
// TODO: - Position t maps to a parameter p by a piecewise-linear knee through (stop position, index) and (midpoint, index - 0.5).
|
||
// TODO: - Linear lerps the interval's stop colors by the fraction of p. Smooth is a cubic Hermite over the stop index with tangent
|
||
// TODO: `(c[i + 1] - c[i - 1]) / 2`, the end stops repeated past the ends, so two stops give `0.5 p + 1.5 p^2 - p^3`.
|
||
// TODO: - The ramp is `(1 - s) * linear + s * smooth` for smoothness s, clamped per interval to its two stop colors.
|
||
#[cfg(feature = "std")]
|
||
#[node_macro::node(category("Raster: Adjustment"))]
|
||
async fn gradient_map<T: Adjust<Color> + Send>(
|
||
_: impl Ctx,
|
||
#[implementations(Raster<CPU>, Color, Gradient)] image: Item<T>,
|
||
#[default(Color::BLACK, Color::WHITE)] gradient: Item<Gradient>,
|
||
reverse: Item<bool>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let settings = vector_types::GradientSettings::from(&gradient);
|
||
let evaluator = gradient.into_element().evaluator(settings);
|
||
let reverse = reverse.into_element();
|
||
|
||
image.element_mut().adjust(|color| {
|
||
// The classic 0.3/0.59/0.11 luma of the gamma-encoded channels picks the position along the gradient
|
||
let [r, g, b, alpha] = color.to_gamma_srgb_channels();
|
||
let intensity = 0.3 * r + 0.59 * g + 0.11 * b;
|
||
let intensity = if reverse { 1. - intensity } else { intensity };
|
||
|
||
// The source alpha is kept and the gradient's own alpha stops are ignored
|
||
evaluator.evaluate(intensity as f64).with_alpha(alpha)
|
||
});
|
||
|
||
image
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27-,vibA%27%20%3D%20Vibrance,-%27hue%20%27%20%3D%20Old
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Vibrance%20(Photoshop%20CS3)
|
||
//
|
||
// Algorithm based on:
|
||
// https://stackoverflow.com/questions/33966121/what-is-the-algorithm-for-vibrance-filters
|
||
// The results of this implementation are very close to correct, but not quite perfect.
|
||
//
|
||
// Some further analysis available at:
|
||
// https://www.photo-mark.com/notes/analyzing-photoshop-vibrance-and-saturation/
|
||
//
|
||
// This algorithm is currently lacking a "Saturation" parameter which is needed for interoperability.
|
||
// It's not the same as the saturation component of Hue/Saturation/Value. Vibrance and Saturation are both separable.
|
||
// When both parameters are set, it is equivalent to running this adjustment twice, with only vibrance set and then only saturation set.
|
||
// (Except for some noise probably due to rounding error.)
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("vibrance_properties"), shader_node(PerPixelAdjust))]
|
||
fn vibrance<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
image: Item<T>,
|
||
vibrance: Item<SignedPercentageF32>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let vibrance = vibrance.into_element();
|
||
|
||
image.element_mut().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 = 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 == 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 {
|
||
1.
|
||
};
|
||
let scale = slowed_vibrance * scale_multiplier * (2. - channel_difference);
|
||
let channel_reduction = channel_min * scale;
|
||
let scale = 1. + scale * (1. - channel_difference);
|
||
|
||
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 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);
|
||
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. {
|
||
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.
|
||
// 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;
|
||
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
|
||
}
|
||
|
||
#[repr(u32)]
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
|
||
#[widget(Radio)]
|
||
pub enum RedGreenBlue {
|
||
#[default]
|
||
Red,
|
||
Green,
|
||
Blue,
|
||
}
|
||
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)]
|
||
#[widget(Radio)]
|
||
#[repr(u32)]
|
||
pub enum RedGreenBlueAlpha {
|
||
#[default]
|
||
Red,
|
||
Green,
|
||
Blue,
|
||
Alpha,
|
||
}
|
||
|
||
/// Style of noise pattern.
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
|
||
#[widget(Dropdown)]
|
||
pub enum NoiseType {
|
||
#[default]
|
||
Perlin,
|
||
#[label("OpenSimplex2")]
|
||
OpenSimplex2,
|
||
#[label("OpenSimplex2S")]
|
||
OpenSimplex2S,
|
||
Cellular,
|
||
ValueCubic,
|
||
Value,
|
||
WhiteNoise,
|
||
}
|
||
|
||
/// Style of layered levels of the noise pattern.
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
|
||
pub enum FractalType {
|
||
#[default]
|
||
None,
|
||
#[label("Fractional Brownian Motion")]
|
||
FBm,
|
||
Ridged,
|
||
PingPong,
|
||
#[label("Progressive (Domain Warp Only)")]
|
||
DomainWarpProgressive,
|
||
#[label("Independent (Domain Warp Only)")]
|
||
DomainWarpIndependent,
|
||
}
|
||
|
||
/// Distance function used by the cellular noise.
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
|
||
pub enum CellularDistanceFunction {
|
||
#[default]
|
||
Euclidean,
|
||
#[label("Euclidean Squared (Faster)")]
|
||
EuclideanSq,
|
||
Manhattan,
|
||
Hybrid,
|
||
}
|
||
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
|
||
pub enum CellularReturnType {
|
||
CellValue,
|
||
#[default]
|
||
#[label("Nearest (F1)")]
|
||
Nearest,
|
||
#[label("Next Nearest (F2)")]
|
||
NextNearest,
|
||
#[label("Average (F1 / 2 + F2 / 2)")]
|
||
Average,
|
||
#[label("Difference (F2 - F1)")]
|
||
Difference,
|
||
#[label("Product (F2 * F1 / 2)")]
|
||
Product,
|
||
#[label("Division (F1 / F2)")]
|
||
Division,
|
||
}
|
||
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType)]
|
||
#[widget(Dropdown)]
|
||
pub enum DomainWarpType {
|
||
#[default]
|
||
None,
|
||
#[label("OpenSimplex2")]
|
||
OpenSimplex2,
|
||
#[label("OpenSimplex2 Reduced")]
|
||
OpenSimplex2Reduced,
|
||
BasicGrid,
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27mixr%27%20%3D%20Channel%20Mixer
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Lab%20color%20only-,Channel%20Mixer,-Key%20is%20%27mixr
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("channel_mixer_properties"), shader_node(PerPixelAdjust))]
|
||
fn channel_mixer<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
image: Item<T>,
|
||
|
||
monochrome: Item<bool>,
|
||
|
||
#[default(40.)]
|
||
#[name("Red")]
|
||
monochrome_r: Item<f32>,
|
||
#[default(40.)]
|
||
#[name("Green")]
|
||
monochrome_g: Item<f32>,
|
||
#[default(20.)]
|
||
#[name("Blue")]
|
||
monochrome_b: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("Constant")]
|
||
monochrome_c: Item<f32>,
|
||
|
||
#[default(100.)]
|
||
#[name("(Red) Red")]
|
||
red_r: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("(Red) Green")]
|
||
red_g: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("(Red) Blue")]
|
||
red_b: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("(Red) Constant")]
|
||
red_c: Item<f32>,
|
||
|
||
#[default(0.)]
|
||
#[name("(Green) Red")]
|
||
green_r: Item<f32>,
|
||
#[default(100.)]
|
||
#[name("(Green) Green")]
|
||
green_g: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("(Green) Blue")]
|
||
green_b: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("(Green) Constant")]
|
||
green_c: Item<f32>,
|
||
|
||
#[default(0.)]
|
||
#[name("(Blue) Red")]
|
||
blue_r: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("(Blue) Green")]
|
||
blue_g: Item<f32>,
|
||
#[default(100.)]
|
||
#[name("(Blue) Blue")]
|
||
blue_b: Item<f32>,
|
||
#[default(0.)]
|
||
#[name("(Blue) Constant")]
|
||
blue_c: Item<f32>,
|
||
|
||
// Display-only properties (not used within the node)
|
||
_output_channel: Item<RedGreenBlue>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let monochrome = monochrome.into_element();
|
||
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (monochrome_r.into_element(), monochrome_g.into_element(), monochrome_b.into_element(), monochrome_c.into_element());
|
||
let (red_r, red_g, red_b, red_c) = (red_r.into_element(), red_g.into_element(), red_b.into_element(), red_c.into_element());
|
||
let (green_r, green_g, green_b, green_c) = (green_r.into_element(), green_g.into_element(), green_b.into_element(), green_c.into_element());
|
||
let (blue_r, blue_g, blue_b, blue_c) = (blue_r.into_element(), blue_g.into_element(), blue_b.into_element(), blue_c.into_element());
|
||
|
||
image.element_mut().adjust(|color| {
|
||
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||
|
||
// Weights and constants are 10-bit fixed point truncated toward zero, which PSD interop depends on
|
||
let weight = |percent: f32| (percent * 1024. / 100.).trunc() / 1024.;
|
||
|
||
let (out_r, out_g, out_b) = if monochrome {
|
||
let (monochrome_r, monochrome_g, monochrome_b, monochrome_c) = (weight(monochrome_r), weight(monochrome_g), weight(monochrome_b), weight(monochrome_c));
|
||
|
||
let gray = (r * monochrome_r + g * monochrome_g + b * monochrome_b + monochrome_c).clamp(0., 1.);
|
||
|
||
(gray, gray, gray)
|
||
} else {
|
||
let (red_r, red_g, red_b, red_c) = (weight(red_r), weight(red_g), weight(red_b), weight(red_c));
|
||
let (green_r, green_g, green_b, green_c) = (weight(green_r), weight(green_g), weight(green_b), weight(green_c));
|
||
let (blue_r, blue_g, blue_b, blue_c) = (weight(blue_r), weight(blue_g), weight(blue_b), weight(blue_c));
|
||
|
||
let red = (r * red_r + g * red_g + b * red_b + red_c).clamp(0., 1.);
|
||
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.);
|
||
|
||
(red, green, blue)
|
||
};
|
||
|
||
Color::from_gamma_srgb_channels(out_r, out_g, out_b, a)
|
||
});
|
||
image
|
||
}
|
||
|
||
#[repr(u32)]
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
|
||
#[widget(Radio)]
|
||
pub enum RelativeAbsolute {
|
||
#[default]
|
||
Relative,
|
||
Absolute,
|
||
}
|
||
|
||
#[repr(u32)]
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
|
||
pub enum SelectiveColorChoice {
|
||
#[default]
|
||
Reds,
|
||
Yellows,
|
||
Greens,
|
||
Cyans,
|
||
Blues,
|
||
Magentas,
|
||
|
||
#[menu_separator]
|
||
Whites,
|
||
Neutrals,
|
||
Blacks,
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27selc%27%20%3D%20Selective%20color
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=from%20%2D100...100.%20.-,Selective%20Color,-Selective%20Color%20settings
|
||
//
|
||
// Algorithm based on:
|
||
// https://blog.pkh.me/p/22-understanding-selective-coloring-in-adobe-photoshop.html
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("selective_color_properties"), shader_node(PerPixelAdjust))]
|
||
fn selective_color<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
image: Item<T>,
|
||
|
||
mode: Item<RelativeAbsolute>,
|
||
|
||
#[name("(Reds) Cyan")] r_c: Item<f32>,
|
||
#[name("(Reds) Magenta")] r_m: Item<f32>,
|
||
#[name("(Reds) Yellow")] r_y: Item<f32>,
|
||
#[name("(Reds) Black")] r_k: Item<f32>,
|
||
|
||
#[name("(Yellows) Cyan")] y_c: Item<f32>,
|
||
#[name("(Yellows) Magenta")] y_m: Item<f32>,
|
||
#[name("(Yellows) Yellow")] y_y: Item<f32>,
|
||
#[name("(Yellows) Black")] y_k: Item<f32>,
|
||
|
||
#[name("(Greens) Cyan")] g_c: Item<f32>,
|
||
#[name("(Greens) Magenta")] g_m: Item<f32>,
|
||
#[name("(Greens) Yellow")] g_y: Item<f32>,
|
||
#[name("(Greens) Black")] g_k: Item<f32>,
|
||
|
||
#[name("(Cyans) Cyan")] c_c: Item<f32>,
|
||
#[name("(Cyans) Magenta")] c_m: Item<f32>,
|
||
#[name("(Cyans) Yellow")] c_y: Item<f32>,
|
||
#[name("(Cyans) Black")] c_k: Item<f32>,
|
||
|
||
#[name("(Blues) Cyan")] b_c: Item<f32>,
|
||
#[name("(Blues) Magenta")] b_m: Item<f32>,
|
||
#[name("(Blues) Yellow")] b_y: Item<f32>,
|
||
#[name("(Blues) Black")] b_k: Item<f32>,
|
||
|
||
#[name("(Magentas) Cyan")] m_c: Item<f32>,
|
||
#[name("(Magentas) Magenta")] m_m: Item<f32>,
|
||
#[name("(Magentas) Yellow")] m_y: Item<f32>,
|
||
#[name("(Magentas) Black")] m_k: Item<f32>,
|
||
|
||
#[name("(Whites) Cyan")] w_c: Item<f32>,
|
||
#[name("(Whites) Magenta")] w_m: Item<f32>,
|
||
#[name("(Whites) Yellow")] w_y: Item<f32>,
|
||
#[name("(Whites) Black")] w_k: Item<f32>,
|
||
|
||
#[name("(Neutrals) Cyan")] n_c: Item<f32>,
|
||
#[name("(Neutrals) Magenta")] n_m: Item<f32>,
|
||
#[name("(Neutrals) Yellow")] n_y: Item<f32>,
|
||
#[name("(Neutrals) Black")] n_k: Item<f32>,
|
||
|
||
#[name("(Blacks) Cyan")] k_c: Item<f32>,
|
||
#[name("(Blacks) Magenta")] k_m: Item<f32>,
|
||
#[name("(Blacks) Yellow")] k_y: Item<f32>,
|
||
#[name("(Blacks) Black")] k_k: Item<f32>,
|
||
|
||
_colors: Item<SelectiveColorChoice>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let mode = mode.into_element();
|
||
let (r_c, r_m, r_y, r_k) = (r_c.into_element(), r_m.into_element(), r_y.into_element(), r_k.into_element());
|
||
let (y_c, y_m, y_y, y_k) = (y_c.into_element(), y_m.into_element(), y_y.into_element(), y_k.into_element());
|
||
let (g_c, g_m, g_y, g_k) = (g_c.into_element(), g_m.into_element(), g_y.into_element(), g_k.into_element());
|
||
let (c_c, c_m, c_y, c_k) = (c_c.into_element(), c_m.into_element(), c_y.into_element(), c_k.into_element());
|
||
let (b_c, b_m, b_y, b_k) = (b_c.into_element(), b_m.into_element(), b_y.into_element(), b_k.into_element());
|
||
let (m_c, m_m, m_y, m_k) = (m_c.into_element(), m_m.into_element(), m_y.into_element(), m_k.into_element());
|
||
let (w_c, w_m, w_y, w_k) = (w_c.into_element(), w_m.into_element(), w_y.into_element(), w_k.into_element());
|
||
let (n_c, n_m, n_y, n_k) = (n_c.into_element(), n_m.into_element(), n_y.into_element(), n_k.into_element());
|
||
let (k_c, k_m, k_y, k_k) = (k_c.into_element(), k_m.into_element(), k_y.into_element(), k_k.into_element());
|
||
|
||
image.element_mut().adjust(|color| {
|
||
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);
|
||
let med = |a: f32, b: f32, c: f32| a + b + c - min(a, b, c) - max(a, b, c);
|
||
|
||
let max_channel = max(r, g, b);
|
||
let min_channel = min(r, g, b);
|
||
|
||
let pixel_color_range = |choice| match choice {
|
||
SelectiveColorChoice::Reds => max_channel == r,
|
||
SelectiveColorChoice::Yellows => min_channel == b,
|
||
SelectiveColorChoice::Greens => max_channel == g,
|
||
SelectiveColorChoice::Cyans => min_channel == r,
|
||
SelectiveColorChoice::Blues => max_channel == b,
|
||
SelectiveColorChoice::Magentas => min_channel == g,
|
||
SelectiveColorChoice::Whites => r > 0.5 && g > 0.5 && b > 0.5,
|
||
// Every pixel, since the neutrals scale factor already vanishes at black, white, and fully saturated colors
|
||
SelectiveColorChoice::Neutrals => true,
|
||
SelectiveColorChoice::Blacks => r < 0.5 && g < 0.5 && b < 0.5,
|
||
};
|
||
|
||
let color_parameter_group_scale_factor_rgb = max(r, g, b) - med(r, g, b);
|
||
let color_parameter_group_scale_factor_cmy = med(r, g, b) - min(r, g, b);
|
||
|
||
// Used to apply the r, g, or b channel slope (by multiplying it by 1) in relative mode, or no slope (by multiplying it by 0) in absolute mode
|
||
let (slope_r, slope_g, slope_b) = match mode {
|
||
RelativeAbsolute::Relative => (r - 1., g - 1., b - 1.),
|
||
RelativeAbsolute::Absolute => (-1., -1., -1.),
|
||
};
|
||
|
||
let array = [
|
||
(SelectiveColorChoice::Reds, (r_c, r_m, r_y, r_k)),
|
||
(SelectiveColorChoice::Yellows, (y_c, y_m, y_y, y_k)),
|
||
(SelectiveColorChoice::Greens, (g_c, g_m, g_y, g_k)),
|
||
(SelectiveColorChoice::Cyans, (c_c, c_m, c_y, c_k)),
|
||
(SelectiveColorChoice::Blues, (b_c, b_m, b_y, b_k)),
|
||
(SelectiveColorChoice::Magentas, (m_c, m_m, m_y, m_k)),
|
||
(SelectiveColorChoice::Whites, (w_c, w_m, w_y, w_k)),
|
||
(SelectiveColorChoice::Neutrals, (n_c, n_m, n_y, n_k)),
|
||
(SelectiveColorChoice::Blacks, (k_c, k_m, k_y, k_k)),
|
||
];
|
||
let mut sum = Vec3::ZERO;
|
||
// Indexed because the shader compiler cannot lower array iterators
|
||
#[allow(clippy::needless_range_loop)]
|
||
for i in 0..array.len() {
|
||
let (color_parameter_group, (c, m, y, k)) = array[i];
|
||
|
||
// Skip this color parameter group...
|
||
// ...if it's unchanged from the default of zero offset on all CMYK parameters, or...
|
||
// ...if this pixel's color isn't in the range affected by this color parameter group
|
||
if (c == 0. && m == 0. && y == 0. && k == 0.) || !pixel_color_range(color_parameter_group) {
|
||
continue;
|
||
}
|
||
|
||
let color_parameter_group_scale_factor = match color_parameter_group {
|
||
SelectiveColorChoice::Reds | SelectiveColorChoice::Greens | SelectiveColorChoice::Blues => color_parameter_group_scale_factor_rgb,
|
||
SelectiveColorChoice::Cyans | SelectiveColorChoice::Magentas | SelectiveColorChoice::Yellows => color_parameter_group_scale_factor_cmy,
|
||
SelectiveColorChoice::Whites => min(r, g, b) * 2. - 1.,
|
||
SelectiveColorChoice::Neutrals => 1. - ((max(r, g, b) - 0.5).abs() + (min(r, g, b) - 0.5).abs()),
|
||
SelectiveColorChoice::Blacks => 1. - max(r, g, b) * 2.,
|
||
};
|
||
|
||
// For PSD interop, the combined percent (c + k + c k / 100) rounds half up to an integer
|
||
let ink = |color: f32| {
|
||
let percent = ((2. * (100. * (color + k) + color * k) + 100.) / 200.).floor();
|
||
match mode {
|
||
// The multiplier is stored as one byte, 255 / b above 1 and b / 255 below, so 99% and 100% both act as 127/128
|
||
RelativeAbsolute::Relative => {
|
||
let multiplier = 1. + percent / 100.;
|
||
if multiplier >= 1. {
|
||
255. / (255. / multiplier).round() - 1.
|
||
} else {
|
||
(255. * multiplier).round() / 255. - 1.
|
||
}
|
||
}
|
||
RelativeAbsolute::Absolute => percent / 100.,
|
||
}
|
||
};
|
||
|
||
let offset_r = f32::clamp(ink(c) * slope_r, -r, -r + 1.) * color_parameter_group_scale_factor;
|
||
let offset_g = f32::clamp(ink(m) * slope_g, -g, -g + 1.) * color_parameter_group_scale_factor;
|
||
let offset_b = f32::clamp(ink(y) * slope_b, -b, -b + 1.) * color_parameter_group_scale_factor;
|
||
|
||
// An 8-bit PSD document sums the groups' 8-bit offsets, which this float node does not currently attempt to reproduce
|
||
sum += Vec3::new(offset_r, offset_g, offset_b);
|
||
}
|
||
|
||
let rgb = Vec3::new(r, g, b);
|
||
let out = (sum + rgb).clamp(Vec3::ZERO, Vec3::ONE);
|
||
|
||
Color::from_gamma_srgb_channels(out.x, out.y, out.z, a)
|
||
});
|
||
image
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=nvrt%27%20%3D%20Invert-,%27post%27%20%3D%20Posterize,-%27thrs%27%20%3D%20Threshold
|
||
#[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
|
||
fn posterize<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
#[default(4)]
|
||
#[hard(2..)]
|
||
levels: Item<u32>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
let levels = levels.into_element() as f32;
|
||
|
||
input.element_mut().adjust(|color| {
|
||
color.map_gamma_rgb(|c| {
|
||
// Bins as floor(c * levels) with the outputs spread evenly to white.
|
||
// The sliver of slack keeps an input exactly on an edge in the upper bin despite float ties.
|
||
let bin = ((c + 2e-7) * levels).floor().min(levels - 1.);
|
||
bin / (levels - 1.)
|
||
})
|
||
});
|
||
input
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=curv%27%20%3D%20Curves-,%27expA%27%20%3D%20Exposure,-%27vibA%27%20%3D%20Vibrance
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=Flag%20(%20%3D%20128%20)-,Exposure,-Key%20is%20%27expA
|
||
//
|
||
// The exposure, offset, and gamma operations follow:
|
||
// https://geraldbakker.nl/psnumbers/exposure.html
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("exposure_properties"), shader_node(PerPixelAdjust))]
|
||
fn exposure<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(
|
||
Raster<CPU>,
|
||
Color,
|
||
Gradient,
|
||
)]
|
||
#[gpu_image]
|
||
input: Item<T>,
|
||
exposure: Item<f32>,
|
||
offset: Item<f32>,
|
||
#[default(1.)]
|
||
#[range]
|
||
#[hard(0.0001..)]
|
||
#[soft(0.01..10)]
|
||
gamma_correction: Item<f32>,
|
||
) -> Item<T> {
|
||
let mut input = input;
|
||
let exposure = exposure.into_element();
|
||
let offset = offset.into_element();
|
||
let gamma_correction = gamma_correction.into_element();
|
||
|
||
// Linearizes with a 2.2 power above a straight toe of slope 1/32, the two meeting at this constant
|
||
const TOE_END: f32 = 0.05568117; // 32^(-1. / 1.2)
|
||
|
||
let decode = |value: f32| if value < TOE_END { value / 32. } else { value.powf(2.2) };
|
||
let encode = |linear: f32| if linear < TOE_END / 32. { linear * 32. } else { linear.powf(1. / 2.2) };
|
||
let adjust = |c: f32| {
|
||
let linear = decode(c) * 2_f32.powf(exposure) + offset;
|
||
encode(linear.max(0.).powf(1. / gamma_correction).min(1.))
|
||
};
|
||
|
||
input.element_mut().adjust(|color| {
|
||
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||
Color::from_gamma_srgb_channels(adjust(r), adjust(g), adjust(b), a)
|
||
});
|
||
input
|
||
}
|
||
|
||
#[repr(u32)]
|
||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
|
||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, node_macro::ChoiceType, BufferStruct, FromPrimitive, IntoPrimitive)]
|
||
#[widget(Dropdown)]
|
||
pub enum TonalRange {
|
||
Shadows,
|
||
#[default]
|
||
Midtones,
|
||
Highlights,
|
||
}
|
||
|
||
/// A Levels-style tone curve: input black and white points on a 0..255 scale and a gamma exponent. For gamma above 1 the
|
||
/// power curve's slope is unbounded at black, so a cubic toe holds it to 2^gamma until past where that line meets the curve.
|
||
#[derive(Debug, Clone, Copy)]
|
||
struct LevelsCurve {
|
||
black: f32,
|
||
white: f32,
|
||
exponent: f32,
|
||
toe_end: f32,
|
||
toe_value: f32,
|
||
toe_slope_start: f32,
|
||
toe_slope_end: f32,
|
||
}
|
||
|
||
impl LevelsCurve {
|
||
fn new(black: i32, white: i32, gamma: f32) -> Self {
|
||
Self::from_points(black as f32, white as f32, gamma)
|
||
}
|
||
|
||
/// `gamma` is the Levels dialog value (pixel exponent 1/gamma).
|
||
fn from_points(black: f32, white: f32, gamma: f32) -> Self {
|
||
let gamma = gamma.max(0.01);
|
||
let black = black.min(white - 1.);
|
||
let exponent = 1. / gamma;
|
||
|
||
let mut curve = Self {
|
||
black,
|
||
white,
|
||
exponent,
|
||
toe_end: 0.,
|
||
toe_value: 0.,
|
||
toe_slope_start: 0.,
|
||
toe_slope_end: 0.,
|
||
};
|
||
if gamma > 1. {
|
||
let slope = 2_f32.powf(gamma);
|
||
let intersection = 255. * slope.powf(-1. / (1. - exponent));
|
||
// The cubic toe joins the power curve at twice the intersection
|
||
if intersection > 1e-3 {
|
||
let toe_end = 2. * intersection;
|
||
curve.toe_end = toe_end;
|
||
curve.toe_value = 255. * (toe_end / 255.).powf(exponent);
|
||
curve.toe_slope_start = slope;
|
||
curve.toe_slope_end = exponent * (toe_end / 255.).powf(exponent - 1.);
|
||
}
|
||
}
|
||
curve
|
||
}
|
||
|
||
/// Maps one gamma-space channel value in 0..1.
|
||
fn apply(&self, value: f32) -> f32 {
|
||
let t = ((value * 255. - self.black) * 255. / (self.white - self.black)).clamp(0., 255.);
|
||
let y = if t < self.toe_end {
|
||
let u = t / self.toe_end;
|
||
let hermite_start = u * u * u - 2. * u * u + u;
|
||
let hermite_end_value = 3. * u * u - 2. * u * u * u;
|
||
let hermite_end_slope = u * u * u - u * u;
|
||
hermite_start * self.toe_end * self.toe_slope_start + hermite_end_value * self.toe_value + hermite_end_slope * self.toe_end * self.toe_slope_end
|
||
} else {
|
||
255. * (t / 255.).powf(self.exponent)
|
||
};
|
||
y / 255.
|
||
}
|
||
}
|
||
|
||
/// One channel's Levels parameters from its own slider values, and (with preserve_luminosity) the slider
|
||
/// extremes across all three channels. The halvings truncate toward zero, as PSD interop requires.
|
||
fn color_balance_curve(s: i32, m: i32, h: i32, s_max: i32, m_max: i32, m_min: i32, h_min: i32, preserve_luminosity: bool) -> LevelsCurve {
|
||
let (black, white, tone) = if preserve_luminosity {
|
||
(s_max - s, 255 - (h - h_min), m - (m_max + m_min) / 2)
|
||
} else {
|
||
(0.max(-s), 255 - 0.max(h), (s + h) / 2 + m)
|
||
};
|
||
|
||
// Rounding the derived gamma to hundredths, the precision of a PSD Levels record, is needed for compatible results
|
||
let gamma = (2_f32.powf(tone as f32 / 100.) * 100.).round() / 100.;
|
||
LevelsCurve::new(black, white, gamma)
|
||
}
|
||
|
||
// Aims for interoperable compatibility with:
|
||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blnc%27%20%3D%20Color%20Balance
|
||
//
|
||
// Every channel is a Levels curve whose black point, white point, and two-decimal gamma are derived from
|
||
// the nine sliders, see `color_balance_curve`.
|
||
#[node_macro::node(category("Raster: Adjustment"), properties("color_balance_properties"), shader_node(PerPixelAdjust))]
|
||
fn color_balance<T: Adjust<Color>>(
|
||
_: impl Ctx,
|
||
#[implementations(Raster<CPU>, Color, Gradient)]
|
||
#[gpu_image]
|
||
image: Item<T>,
|
||
|
||
#[name("(Shadows) Cyan-Red")] shadows_cyan_red: Item<SignedPercentageF32>,
|
||
#[name("(Shadows) Magenta-Green")] shadows_magenta_green: Item<SignedPercentageF32>,
|
||
#[name("(Shadows) Yellow-Blue")] shadows_yellow_blue: Item<SignedPercentageF32>,
|
||
|
||
#[name("(Midtones) Cyan-Red")] midtones_cyan_red: Item<SignedPercentageF32>,
|
||
#[name("(Midtones) Magenta-Green")] midtones_magenta_green: Item<SignedPercentageF32>,
|
||
#[name("(Midtones) Yellow-Blue")] midtones_yellow_blue: Item<SignedPercentageF32>,
|
||
|
||
#[name("(Highlights) Cyan-Red")] highlights_cyan_red: Item<SignedPercentageF32>,
|
||
#[name("(Highlights) Magenta-Green")] highlights_magenta_green: Item<SignedPercentageF32>,
|
||
#[name("(Highlights) Yellow-Blue")] highlights_yellow_blue: Item<SignedPercentageF32>,
|
||
|
||
#[default(true)] preserve_luminosity: Item<bool>,
|
||
|
||
// Display-only property (not used within the node)
|
||
_tone: Item<TonalRange>,
|
||
) -> Item<T> {
|
||
let mut image = image;
|
||
let preserve_luminosity = preserve_luminosity.into_element();
|
||
|
||
// The derivation below is integer arithmetic, so the sliders round to whole percentages first
|
||
let slider = |value: Item<SignedPercentageF32>| value.into_element().clamp(-100., 100.).round() as i32;
|
||
let (s_r, s_g, s_b) = (slider(shadows_cyan_red), slider(shadows_magenta_green), slider(shadows_yellow_blue));
|
||
let (m_r, m_g, m_b) = (slider(midtones_cyan_red), slider(midtones_magenta_green), slider(midtones_yellow_blue));
|
||
let (h_r, h_g, h_b) = (slider(highlights_cyan_red), slider(highlights_magenta_green), slider(highlights_yellow_blue));
|
||
|
||
let s_max = s_r.max(s_g).max(s_b);
|
||
let m_max = m_r.max(m_g).max(m_b);
|
||
let m_min = m_r.min(m_g).min(m_b);
|
||
let h_min = h_r.min(h_g).min(h_b);
|
||
let red = color_balance_curve(s_r, m_r, h_r, s_max, m_max, m_min, h_min, preserve_luminosity);
|
||
let green = color_balance_curve(s_g, m_g, h_g, s_max, m_max, m_min, h_min, preserve_luminosity);
|
||
let blue = color_balance_curve(s_b, m_b, h_b, s_max, m_max, m_min, h_min, preserve_luminosity);
|
||
|
||
image.element_mut().adjust(|color| {
|
||
// The curves operate on gamma-space channel values
|
||
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||
Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), a)
|
||
});
|
||
image
|
||
}
|
||
|
||
#[cfg(feature = "std")]
|
||
mod _graphene_hash_impls {
|
||
use super::{
|
||
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
|
||
SelectiveColorChoice, TonalRange,
|
||
};
|
||
graphene_hash::impl_via_hash!(
|
||
DesaturateMethod,
|
||
RedGreenBlue,
|
||
RedGreenBlueAlpha,
|
||
NoiseType,
|
||
FractalType,
|
||
CellularDistanceFunction,
|
||
CellularReturnType,
|
||
DomainWarpType,
|
||
RelativeAbsolute,
|
||
SelectiveColorChoice,
|
||
AdjustmentChannel,
|
||
TonalRange,
|
||
);
|
||
}
|
||
|
||
#[cfg(all(feature = "std", test))]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// Matched to within one 8-bit level.
|
||
fn assert_close(actual: [f32; 3], expected: [f32; 3]) {
|
||
for (actual, expected) in actual.iter().zip(expected) {
|
||
assert!((actual - expected).abs() <= 1., "expected {expected}, got {actual}");
|
||
}
|
||
}
|
||
|
||
#[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}");
|
||
}
|
||
|
||
/// Whether one gamma-space RGB value (0..255) ends up white at the given threshold level (0..255).
|
||
fn threshold_is_white(input: [f32; 3], level: f32) -> bool {
|
||
let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.);
|
||
let result = threshold((), Item::new_from_element(pixel), (level / 255. * 100.).into(), 100_f32.into());
|
||
result.into_element().r() == 1.
|
||
}
|
||
|
||
#[test]
|
||
fn threshold_compares_rec_601_luma_as_an_8_bit_level() {
|
||
assert!(!threshold_is_white([200., 100., 40.], 128.));
|
||
assert!(!threshold_is_white([125., 130., 120.], 128.));
|
||
assert!(threshold_is_white([0., 255., 0.], 128.));
|
||
assert!(!threshold_is_white([255., 0., 0.], 128.));
|
||
assert!(threshold_is_white([128., 128., 128.], 128.));
|
||
assert!(!threshold_is_white([127., 127., 127.], 128.));
|
||
assert!(threshold_is_white([200., 100., 40.], 123.));
|
||
assert!(!threshold_is_white([200., 100., 40.], 124.));
|
||
}
|
||
|
||
#[test]
|
||
fn threshold_ties_follow_the_unrounded_fixed_point_luma() {
|
||
// Half-level lumas in 0.3/0.59/0.11 stay below the level either way, and the 14-bit weights pull a whole-level red or blue luma just under it
|
||
assert!(!threshold_is_white([189., 120., 0.], 128.));
|
||
assert!(!threshold_is_white([248., 90., 0.], 128.));
|
||
assert!(!threshold_is_white([135., 100., 0.], 100.));
|
||
assert!(!threshold_is_white([255., 0., 50.], 82.));
|
||
assert!(threshold_is_white([0., 200., 0.], 118.));
|
||
}
|
||
|
||
/// Runs Selective Color on one gamma-space RGB value (0..255) with the given group values
|
||
/// (Reds through Blacks, each cyan, magenta, yellow, black) and returns the gamma-space result on the same scale.
|
||
fn run_selective_color(input: [f32; 3], mode: RelativeAbsolute, groups: [[f32; 4]; 9]) -> [f32; 3] {
|
||
let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.);
|
||
let g = |group: usize, component: usize| Item::new_from_element(groups[group][component]);
|
||
#[rustfmt::skip]
|
||
let result = selective_color(
|
||
(), Item::new_from_element(pixel), mode.into(),
|
||
g(0, 0), g(0, 1), g(0, 2), g(0, 3), g(1, 0), g(1, 1), g(1, 2), g(1, 3), g(2, 0), g(2, 1), g(2, 2), g(2, 3),
|
||
g(3, 0), g(3, 1), g(3, 2), g(3, 3), g(4, 0), g(4, 1), g(4, 2), g(4, 3), g(5, 0), g(5, 1), g(5, 2), g(5, 3),
|
||
g(6, 0), g(6, 1), g(6, 2), g(6, 3), g(7, 0), g(7, 1), g(7, 2), g(7, 3), g(8, 0), g(8, 1), g(8, 2), g(8, 3),
|
||
SelectiveColorChoice::Reds.into(),
|
||
);
|
||
let [r, g, b, _] = result.into_element().to_gamma_srgb_channels();
|
||
[r * 255., g * 255., b * 255.]
|
||
}
|
||
|
||
#[test]
|
||
fn selective_color_applies_negative_values() {
|
||
let mut groups = [[0.; 4]; 9];
|
||
groups[0] = [-100., 0., 0., 0.];
|
||
assert_close(run_selective_color([125., 0., 0.], RelativeAbsolute::Relative, groups), [189., 0., 0.]);
|
||
assert_close(run_selective_color([120., 100., 5.], RelativeAbsolute::Relative, groups), [131., 100., 5.]);
|
||
|
||
let mut groups = [[0.; 4]; 9];
|
||
groups[0] = [0., 0., 0., -100.];
|
||
assert_close(run_selective_color([110., 65., 25.], RelativeAbsolute::Absolute, groups), [136., 99., 66.]);
|
||
}
|
||
|
||
#[test]
|
||
fn selective_color_neutrals_include_pixels_with_an_empty_channel() {
|
||
let mut groups = [[0.; 4]; 9];
|
||
groups[7] = [0., -100., 0., 0.];
|
||
assert_close(run_selective_color([100., 0., 130.], RelativeAbsolute::Relative, groups), [100., 125., 130.]);
|
||
assert_close(run_selective_color([100., 50., 130.], RelativeAbsolute::Relative, groups), [100., 191., 130.]);
|
||
}
|
||
|
||
/// Runs Posterize on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale.
|
||
fn run_posterize(value: f32, levels: u32) -> f32 {
|
||
let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.);
|
||
posterize((), Item::new_from_element(pixel), levels.into()).into_element().to_gamma_srgb_channels()[0] * 255.
|
||
}
|
||
|
||
#[test]
|
||
fn posterize_bins_by_floor_with_levels_spread_to_white() {
|
||
for (value, levels, expected) in [
|
||
(84., 3, 0.),
|
||
(85., 3, 127.5),
|
||
(169., 3, 127.5),
|
||
(170., 3, 255.),
|
||
(36., 7, 0.),
|
||
(37., 7, 42.5),
|
||
(110., 7, 127.5),
|
||
(255., 7, 255.),
|
||
] {
|
||
let actual = run_posterize(value, levels);
|
||
assert!((actual - expected).abs() <= 0.01, "{value} at {levels} levels: expected {expected}, got {actual}");
|
||
}
|
||
}
|
||
|
||
/// Runs Exposure on one gamma-space gray value (0..255) and returns the gamma-space result on the same scale.
|
||
fn run_exposure(value: f32, exposure: f32, offset: f32, gamma_correction: f32) -> f32 {
|
||
let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.);
|
||
let result = super::exposure((), Item::new_from_element(pixel), exposure.into(), offset.into(), gamma_correction.into());
|
||
result.into_element().to_gamma_srgb_channels()[0] * 255.
|
||
}
|
||
|
||
#[test]
|
||
fn exposure_linearizes_through_the_toe_and_power_curve() {
|
||
for (value, exposure, offset, gamma_correction, expected) in [
|
||
(1., 1., 0., 1., 2.),
|
||
(8., 1., 0., 1., 15.),
|
||
(16., 1., 0., 1., 22.),
|
||
(128., 1., 0., 1., 175.),
|
||
(200., 1., 0., 1., 255.),
|
||
(1., 0., 0., 2., 33.),
|
||
(16., 0., 0., 2., 64.),
|
||
(128., 0., 0., 2., 181.),
|
||
(200., 0., 0., 2., 226.),
|
||
(0., -2., 0.2, 1.5, 157.),
|
||
(100., -2., 0.2, 1.5, 164.),
|
||
(200., -2., 0.2, 1.5, 185.),
|
||
(100., 0., -0.25, 1., 0.),
|
||
(200., 0., -0.25, 1., 155.),
|
||
] {
|
||
let actual = run_exposure(value, exposure, offset, gamma_correction);
|
||
assert!(
|
||
(actual - expected).abs() <= 1.,
|
||
"{value} at exposure {exposure}, offset {offset}, gamma {gamma_correction}: expected {expected}, got {actual}"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn exposure_clamps_negative_offsets_before_the_gamma_power() {
|
||
assert_eq!(run_exposure(50., 0., -0.5, 2.), 0.);
|
||
}
|
||
|
||
/// Runs Color Balance on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale.
|
||
fn run_color_balance(input: [f32; 3], shadows: [f32; 3], midtones: [f32; 3], highlights: [f32; 3], preserve_luminosity: bool) -> [f32; 3] {
|
||
let color = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.);
|
||
let result = color_balance(
|
||
(),
|
||
Item::new_from_element(color),
|
||
shadows[0].into(),
|
||
shadows[1].into(),
|
||
shadows[2].into(),
|
||
midtones[0].into(),
|
||
midtones[1].into(),
|
||
midtones[2].into(),
|
||
highlights[0].into(),
|
||
highlights[1].into(),
|
||
highlights[2].into(),
|
||
preserve_luminosity.into(),
|
||
TonalRange::Midtones.into(),
|
||
);
|
||
let [r, g, b, _] = result.into_element().to_gamma_srgb_channels();
|
||
[r * 255., g * 255., b * 255.]
|
||
}
|
||
|
||
#[test]
|
||
fn color_balance_midtones_are_a_gamma_with_a_toe() {
|
||
let none = [0., 0., 0.];
|
||
assert_close(run_color_balance([100., 100., 100.], none, [100., 0., 0.], none, false), [160., 100., 100.]);
|
||
assert_close(run_color_balance([200., 200., 200.], none, [100., 0., 0.], none, false), [226., 200., 200.]);
|
||
assert_close(run_color_balance([4., 4., 4.], none, [100., 0., 0.], none, false), [16., 4., 4.]);
|
||
assert_close(run_color_balance([1., 1., 1.], none, [100., 0., 0.], none, false), [4., 1., 1.]);
|
||
assert_close(run_color_balance([100., 100., 100.], none, [-100., 0., 0.], none, false), [39., 100., 100.]);
|
||
}
|
||
|
||
#[test]
|
||
fn color_balance_shadows_and_highlights_move_the_end_points() {
|
||
let none = [0., 0., 0.];
|
||
assert_close(run_color_balance([100., 100., 100.], [-100., 0., 0.], none, none, false), [0., 100., 100.]);
|
||
assert_close(run_color_balance([150., 150., 150.], [-100., 0., 0.], none, none, false), [52., 150., 150.]);
|
||
assert_close(run_color_balance([200., 200., 200.], [-100., 0., 0.], none, none, false), [138., 200., 200.]);
|
||
assert_close(run_color_balance([100., 100., 100.], none, none, [100., 0., 0.], false), [187., 100., 100.]);
|
||
assert_close(run_color_balance([155., 155., 155.], none, none, [100., 0., 0.], false), [255., 155., 155.]);
|
||
}
|
||
|
||
#[test]
|
||
fn color_balance_preserve_luminosity_makes_sliders_relative() {
|
||
let none = [0., 0., 0.];
|
||
assert_close(run_color_balance([90., 90., 90.], none, [-100., 0., 0.], none, true), [59., 122., 122.]);
|
||
assert_close(run_color_balance([90., 90., 90.], [50., 0., 0.], none, none, true), [90., 50., 50.]);
|
||
assert_close(run_color_balance([120., 120., 120.], none, none, [-100., 0., 0.], true), [120., 197., 197.]);
|
||
assert_close(run_color_balance([90., 90., 90.], [100., 100., 100.], [100., 100., 100.], [100., 100., 100.], true), [90., 90., 90.]);
|
||
}
|
||
|
||
#[test]
|
||
fn color_balance_combined_tones_use_integer_arithmetic() {
|
||
// Red: black 39, white 235, gamma 1.09; green: gamma 0.91; blue: white 210, gamma 1.13
|
||
let result = run_color_balance([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false);
|
||
assert_close(result, [124., 120., 164.]);
|
||
}
|
||
}
|