New node: Color Balance (#4527)

This commit is contained in:
Keavon Chambers
2026-09-12 22:11:51 -07:00
committed by GitHub
parent 98550f6f25
commit fea741cdef
5 changed files with 268 additions and 7 deletions

View File

@@ -919,6 +919,7 @@ fn static_node_properties() -> NodeProperties {
map.insert("black_and_white_properties".to_string(), Box::new(node_properties::black_and_white_properties)); map.insert("black_and_white_properties".to_string(), Box::new(node_properties::black_and_white_properties));
map.insert("threshold_properties".to_string(), Box::new(node_properties::threshold_properties)); map.insert("threshold_properties".to_string(), Box::new(node_properties::threshold_properties));
map.insert("vibrance_properties".to_string(), Box::new(node_properties::vibrance_properties)); map.insert("vibrance_properties".to_string(), Box::new(node_properties::vibrance_properties));
map.insert("color_balance_properties".to_string(), Box::new(node_properties::color_balance_properties));
map.insert("fill_properties".to_string(), Box::new(node_properties::fill_properties)); map.insert("fill_properties".to_string(), Box::new(node_properties::fill_properties));
map.insert("stroke_properties".to_string(), Box::new(node_properties::stroke_properties)); map.insert("stroke_properties".to_string(), Box::new(node_properties::stroke_properties));
map.insert("offset_path_properties".to_string(), Box::new(node_properties::offset_path_properties)); map.insert("offset_path_properties".to_string(), Box::new(node_properties::offset_path_properties));

View File

@@ -21,7 +21,7 @@ use graphene_std::color::SRGBA8;
use graphene_std::extract_xy::XY; use graphene_std::extract_xy::XY;
use graphene_std::raster::{ use graphene_std::raster::{
AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, AdjustmentChannel, BlendMode, CellularDistanceFunction, CellularReturnType, Color, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice, SelectiveColorChoice, TonalRange,
}; };
use graphene_std::raster_types::Image; use graphene_std::raster_types::Image;
use graphene_std::text::{Font, TextAlign}; use graphene_std::text::{Font, TextAlign};
@@ -318,6 +318,7 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<CellularReturnType>(x) => enum_choice::<CellularReturnType>().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::<CellularReturnType>(x) => enum_choice::<CellularReturnType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<DomainWarpType>(x) => enum_choice::<DomainWarpType>().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::<DomainWarpType>(x) => enum_choice::<DomainWarpType>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<RelativeAbsolute>(x) => enum_choice::<RelativeAbsolute>().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::<RelativeAbsolute>(x) => enum_choice::<RelativeAbsolute>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<TonalRange>(x) => enum_choice::<TonalRange>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<AdjustmentChannel>(x) => enum_choice::<AdjustmentChannel>().for_socket(default_info).disabled(false).property_row(), Some(x) if id_is::<AdjustmentChannel>(x) => enum_choice::<AdjustmentChannel>().for_socket(default_info).disabled(false).property_row(),
Some(x) if id_is::<GridType>(x) => enum_choice::<GridType>().for_socket(default_info).property_row(), Some(x) if id_is::<GridType>(x) => enum_choice::<GridType>().for_socket(default_info).property_row(),
Some(x) if id_is::<StrokeCap>(x) => enum_choice::<StrokeCap>().for_socket(default_info).property_row(), Some(x) if id_is::<StrokeCap>(x) => enum_choice::<StrokeCap>().for_socket(default_info).property_row(),
@@ -1676,6 +1677,51 @@ pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesC
)] )]
} }
pub(crate) fn color_balance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::color_balance::*;
let mut tone_info = ParameterWidgetsInfo::new(node_id, ToneInput, true, context);
tone_info.exposable = false;
let tone = enum_choice::<TonalRange>().for_socket(tone_info).property_row();
let preserve_luminosity = bool_widget(ParameterWidgetsInfo::new(node_id, PreserveLuminosityInput, true, context), CheckboxInput::default());
let document_node = match get_document_node(node_id, context) {
Ok(document_node) => document_node,
Err(err) => {
log::error!("Could not get document node in color_balance_properties: {err}");
return Vec::new();
}
};
let tone_choice = match document_node.input_value(ToneInput) {
Some(TaggedValue::TonalRange(choice)) => *choice,
_ => {
warn!("Color Balance node properties panel could not be displayed.");
return vec![];
}
};
// Only the selected tone's three sliders are shown
let parameters: [ParameterRef; 3] = match tone_choice {
TonalRange::Shadows => [ShadowsCyanRedInput.into(), ShadowsMagentaGreenInput.into(), ShadowsYellowBlueInput.into()],
TonalRange::Midtones => [MidtonesCyanRedInput.into(), MidtonesMagentaGreenInput.into(), MidtonesYellowBlueInput.into()],
TonalRange::Highlights => [HighlightsCyanRedInput.into(), HighlightsMagentaGreenInput.into(), HighlightsYellowBlueInput.into()],
};
let tracks = [
Gradient::from(vec![Color::CYAN, Color::RED]),
Gradient::from(vec![Color::MAGENTA, Color::GREEN]),
Gradient::from(vec![Color::YELLOW, Color::BLUE]),
];
let number_input = NumberInput::default().mode_increment().unit("%").min(-100.).max(100.);
let mut layout = vec![tone];
for (parameter, track) in parameters.into_iter().zip(tracks) {
layout.push(spectrum_slider_row(node_id, context, parameter, track, Color::WHITE, -100., 100., 0., number_input.clone()));
}
layout.push(LayoutGroup::row(preserve_luminosity));
layout
}
pub(crate) fn black_and_white_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> { pub(crate) fn black_and_white_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::black_and_white::*; use graphene_std::raster::black_and_white::*;

View File

@@ -562,6 +562,7 @@ tagged_value! {
DomainWarpType(raster_nodes::adjustments::DomainWarpType), DomainWarpType(raster_nodes::adjustments::DomainWarpType),
RelativeAbsolute(raster_nodes::adjustments::RelativeAbsolute), RelativeAbsolute(raster_nodes::adjustments::RelativeAbsolute),
SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice), SelectiveColorChoice(raster_nodes::adjustments::SelectiveColorChoice),
TonalRange(raster_nodes::adjustments::TonalRange),
AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel), AdjustmentChannel(raster_nodes::adjustments::AdjustmentChannel),
GridType(vector::misc::GridType), GridType(vector::misc::GridType),
ArcType(vector::misc::ArcType), ArcType(vector::misc::ArcType),

View File

@@ -356,6 +356,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
RedGreenBlueAlpha, RedGreenBlueAlpha,
RelativeAbsolute, RelativeAbsolute,
SelectiveColorChoice, SelectiveColorChoice,
TonalRange,
AdjustmentChannel, AdjustmentChannel,
Stroke, Stroke,
XY, XY,

View File

@@ -25,10 +25,6 @@ use raster_types::{CPU, Raster};
use vector_types::Gradient; use vector_types::Gradient;
// TODO: Implement the following: // TODO: Implement the following:
// Color Balance
// Aims for interoperable compatibility with:
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27blnc%27%20%3D%20Color%20Balance
//
// Photo Filter // Photo Filter
// Aims for interoperable compatibility with: // 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=%27phfl%27%20%3D%20Photo%20Filter
@@ -1235,11 +1231,156 @@ fn exposure<T: Adjust<Color>>(
input 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")] #[cfg(feature = "std")]
mod _graphene_hash_impls { mod _graphene_hash_impls {
use super::{ use super::{
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice, SelectiveColorChoice, TonalRange,
}; };
graphene_hash::impl_via_hash!( graphene_hash::impl_via_hash!(
LuminanceCalculation, LuminanceCalculation,
@@ -1252,7 +1393,8 @@ mod _graphene_hash_impls {
DomainWarpType, DomainWarpType,
RelativeAbsolute, RelativeAbsolute,
SelectiveColorChoice, SelectiveColorChoice,
AdjustmentChannel AdjustmentChannel,
TonalRange,
); );
} }
@@ -1271,3 +1413,73 @@ mod test {
assert!((a - 0.5).abs() < 1e-5, "alpha was {a}"); assert!((a - 0.5).abs() < 1e-5, "alpha was {a}");
} }
} }
#[cfg(all(feature = "std", test))]
mod color_balance_tests {
use super::*;
/// Runs Color Balance on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale.
fn run(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.]
}
/// 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 midtones_are_a_gamma_with_a_toe() {
let none = [0., 0., 0.];
assert_close(run([100., 100., 100.], none, [100., 0., 0.], none, false), [160., 100., 100.]);
assert_close(run([200., 200., 200.], none, [100., 0., 0.], none, false), [226., 200., 200.]);
assert_close(run([4., 4., 4.], none, [100., 0., 0.], none, false), [16., 4., 4.]);
assert_close(run([1., 1., 1.], none, [100., 0., 0.], none, false), [4., 1., 1.]);
assert_close(run([100., 100., 100.], none, [-100., 0., 0.], none, false), [39., 100., 100.]);
}
#[test]
fn shadows_and_highlights_move_the_end_points() {
let none = [0., 0., 0.];
assert_close(run([100., 100., 100.], [-100., 0., 0.], none, none, false), [0., 100., 100.]);
assert_close(run([150., 150., 150.], [-100., 0., 0.], none, none, false), [52., 150., 150.]);
assert_close(run([200., 200., 200.], [-100., 0., 0.], none, none, false), [138., 200., 200.]);
assert_close(run([100., 100., 100.], none, none, [100., 0., 0.], false), [187., 100., 100.]);
assert_close(run([155., 155., 155.], none, none, [100., 0., 0.], false), [255., 155., 155.]);
}
#[test]
fn preserve_luminosity_makes_sliders_relative() {
let none = [0., 0., 0.];
assert_close(run([90., 90., 90.], none, [-100., 0., 0.], none, true), [59., 122., 122.]);
assert_close(run([90., 90., 90.], [50., 0., 0.], none, none, true), [90., 50., 50.]);
assert_close(run([120., 120., 120.], none, none, [-100., 0., 0.], true), [120., 197., 197.]);
assert_close(run([90., 90., 90.], [100., 100., 100.], [100., 100., 100.], [100., 100., 100.], true), [90., 90., 90.]);
}
#[test]
fn 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([128., 128., 128.], [-39., 6., 42.], [21., 0., -25.], [20., -35., 45.], false);
assert_close(result, [124., 120., 164.]);
}
}