Files
Graphite/node-graph/nodes/raster/src/adjustments.rs

1668 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![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.]);
}
}