Add a saturation input to the 'Vibrance' node and rework both axes to work in linear light (#4537)

* Add a saturation input to the 'Vibrance' node and rework both axes to work in linear light

* Refine the sliders

* Clarify some comments
This commit is contained in:
Keavon Chambers
2026-09-15 17:58:25 -07:00
committed by GitHub
parent 8d111e37f5
commit fac555ab3b
3 changed files with 129 additions and 92 deletions

View File

@@ -2095,18 +2095,16 @@ pub(crate) fn threshold_properties(node_id: NodeId, context: &mut NodeProperties
pub(crate) fn vibrance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
use graphene_std::raster::vibrance::*;
let track = Gradient::from(vec![Color::MIDDLE_GRAY, Color::RED]);
vec![spectrum_slider_row(
node_id,
context,
VibranceInput,
track,
Color::WHITE,
-100.,
100.,
0.,
NumberInput::default().mode_increment().unit("%").min(-100.).max(100.),
)]
let number_input = NumberInput::default().mode_increment().unit("%").min(-100.).max(100.);
let slider = SliderRange {
min: -100.,
max: 100.,
default: Some(0.),
};
let vibrance = range_slider_widget(ParameterWidgetsInfo::new(node_id, VibranceInput, true, context), number_input.clone(), slider);
let saturation = range_slider_widget(ParameterWidgetsInfo::new(node_id, SaturationInput, true, context), number_input, slider);
vec![LayoutGroup::row(vibrance), LayoutGroup::row(saturation)]
}
pub(crate) fn color_balance_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {

View File

@@ -2196,6 +2196,17 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
inputs_count = 3;
}
// Vibrance gained a Saturation input after its Vibrance input, whose default of 0 leaves old documents unchanged
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::vibrance::IDENTIFIER) && inputs_count == 2 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
let old_inputs = document.network_interface.replace_inputs(node_id, network_path, &mut node_template)?;
for (index, input) in old_inputs.iter().take(2).enumerate() {
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input.clone(), network_path);
}
inputs_count = 3;
}
// Levels' Midtones became the gamma value it encoded, and each channel gained its own record after the composite one
if reference == DefinitionIdentifier::ProtoNode(graphene_std::raster::levels::IDENTIFIER) && inputs_count == 6 {
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();

View File

@@ -1194,18 +1194,6 @@ async fn gradient_map<T: Adjust<Color> + Send>(
// 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,
@@ -1217,83 +1205,76 @@ fn vibrance<T: Adjust<Color>>(
#[gpu_image]
image: Item<T>,
vibrance: Item<SignedPercentageF32>,
saturation: Item<SignedPercentageF32>,
) -> Item<T> {
let mut image = image;
let vibrance = vibrance.into_element();
let vibrance = vibrance.into_element().clamp(-100., 100.) / 100.;
let saturation_scale = 1. + saturation.into_element().clamp(-100., 100.) / 100.;
// Vibrance then saturation, both in linear light, which equals applying each alone in turn
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 (r, g, b) = (color.r(), color.g(), color.b());
let maximum = r.max(g).max(b);
let [chroma_factor, brightness_factor] = vibrance_factors(r, g, b, vibrance);
let after_vibrance = Color::from_rgbaf32_unchecked(
scale_about_maximum(r, maximum, chroma_factor, brightness_factor),
scale_about_maximum(g, maximum, chroma_factor, brightness_factor),
scale_about_maximum(b, maximum, chroma_factor, brightness_factor),
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,
)
}
// For PSD interop, saturation scales each channel's distance from a gray weighted by ProPhoto's luminance coefficients
let gray = 0.288040 * after_vibrance.r() + 0.711874 * after_vibrance.g() + 0.000086 * after_vibrance.b();
after_vibrance.map_rgb(|c| (gray + (c - gray) * saturation_scale).clamp(0., 1.))
});
image
}
fn scale_about_maximum(channel: f32, maximum: f32, chroma_factor: f32, brightness_factor: f32) -> f32 {
(brightness_factor * (maximum + chroma_factor * (channel - maximum))).clamp(0., 1.)
}
/// Share of the vibrance boost the skin-tone protection removes at full weight, a fitted constant.
const VIBRANCE_PROTECTION_LOSS: f32 = 0.4857;
/// Vibrance on linear SDR channels as `[chroma factor about the max, brightness multiply]` for an amount in -1..1, both fading out toward black.
/// Negative desaturates and darkens low-chroma colors most. Positive boosts them, brightens a little, and spares reds.
fn vibrance_factors(r: f32, g: f32, b: f32, amount: f32) -> [f32; 2] {
let maximum = r.max(g).max(b);
let minimum = r.min(g).min(b);
if maximum <= 0. {
return [1., 1.];
}
let ratio = minimum / maximum;
let saturation = 1. - ratio;
let q = ratio * saturation;
let toe = 1. - (16. * maximum).min(1.);
let rolloff = 1. - toe * toe;
let brightness = (2. * q - q * q) * (1. - maximum) * rolloff;
if amount < 0. {
let amount = -amount;
let chroma_factor = (1. - amount / 4.) * (1. - amount * (1. - rolloff * saturation * (1. + saturation) / 2.));
return [chroma_factor, 1. - amount * brightness];
}
let protection = skin_tone_window(hexagon_hue_degrees(r, g, b)) * (1. - saturation * saturation);
let amount = amount * (1. - protection * (1. - amount));
let boost = (5. / 6.) * (1. - VIBRANCE_PROTECTION_LOSS * protection) * amount * ratio * (1. - minimum) * rolloff;
[1. / (1. - boost), 1. + amount * brightness / 4.]
}
/// How fully a hue falls under the skin-tone protection: all of it from red to 30 degrees, fading out by 45, and back in from 300.
fn skin_tone_window(hue: f32) -> f32 {
if hue < 45. {
((45. - hue) / 15.).min(1.)
} else if hue >= 300. {
(hue - 300.) / 60.
} else {
0.
}
}
#[repr(u32)]
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
@@ -2243,6 +2224,53 @@ mod tests {
assert!(threshold_is_white([0., 200., 0.], 118.));
}
/// Runs the node on one gamma-space RGB value (0..255) and returns the gamma-space result on the same scale.
fn run_vibrance(input: [f32; 3], vibrance_amount: f32, saturation: f32) -> [f32; 3] {
let pixel = Color::from_gamma_srgb_channels(input[0] / 255., input[1] / 255., input[2] / 255., 1.);
let result = vibrance((), Item::new_from_element(pixel), vibrance_amount.into(), saturation.into());
let [r, g, b, _] = result.into_element().to_gamma_srgb_channels();
[r * 255., g * 255., b * 255.]
}
#[test]
fn vibrance_saturation_scales_chroma_around_a_prophoto_weighted_gray() {
for (input, saturation, expected) in [
([255., 0., 0.], -100., [146., 146., 146.]),
([0., 255., 0.], -100., [219., 219., 219.]),
([0., 0., 255.], -100., [0., 0., 0.]),
([200., 100., 50.], -100., [139., 139., 139.]),
([0., 255., 0.], -50., [161., 238., 161.]),
([200., 100., 50.], 50., [223., 71., 0.]),
([100., 150., 200.], 100., [3., 161., 244.]),
([200., 180., 170.], 100., [213., 174., 152.]),
] {
let actual = run_vibrance(input, 0., saturation);
for (actual, expected) in actual.iter().zip(expected) {
assert!((actual - expected).abs() <= 1., "{input:?} at {saturation}: expected {expected}, got {actual}");
}
}
}
#[test]
fn vibrance_boosts_low_chroma_colors_most_and_spares_reds() {
for (input, amount, expected) in [
([200., 100., 100.], -100., [188., 148., 148.]),
([200., 160., 160.], -50., [192., 172., 172.]),
([50., 0., 0.], -100., [50., 31., 31.]),
([100., 50., 0.], -100., [100., 67., 50.]),
([200., 100., 100.], 100., [203., 71., 71.]),
([255., 125., 125.], 100., [255., 91., 91.]),
([125., 255., 125.], 100., [80., 255., 80.]),
([255., 200., 205.], 100., [255., 190., 196.]),
([60., 120., 200.], 75., [38., 115., 201.]),
] {
let actual = run_vibrance(input, amount, 0.);
for (actual, expected) in actual.iter().zip(expected) {
assert!((actual - expected).abs() <= 1., "{input:?} at {amount}: expected {expected}, got {actual}");
}
}
}
/// 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] {