Replace the 'Luminance' node with a 'Desaturate' node with a better selection of desaturation methods (#4529)

* Remove the 'Threshold' node's luminance calculation dropdown so it always compares the Rec. 601 luma

* Find the HSL lightness extremes before encoding and drop a stale luminance TODO
This commit is contained in:
Keavon Chambers
2026-09-14 14:34:55 -07:00
committed by GitHub
parent eaadea6593
commit 1940e430dc
7 changed files with 103 additions and 42 deletions
@@ -16,8 +16,8 @@ use graphene_std::list::{Item, List, NodeIdPath};
use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::math::float_noise::round_away_float_noise;
use graphene_std::memo::IORecord; use graphene_std::memo::IORecord;
use graphene_std::raster::{ use graphene_std::raster::{
AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice,
SelectiveColorChoice, TonalRange,
}; };
use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::raster_types::{CPU, GPU, Raster};
use graphene_std::text::TextAlign; use graphene_std::text::TextAlign;
@@ -238,11 +238,12 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
List<ExtrudeJoiningAlgorithm>, List<ExtrudeJoiningAlgorithm>,
List<PointSpacingType>, List<PointSpacingType>,
List<StringCapitalization>, List<StringCapitalization>,
List<LuminanceCalculation>, List<DesaturateMethod>,
List<RedGreenBlue>, List<RedGreenBlue>,
List<RedGreenBlueAlpha>, List<RedGreenBlueAlpha>,
List<RelativeAbsolute>, List<RelativeAbsolute>,
List<SelectiveColorChoice>, List<SelectiveColorChoice>,
List<TonalRange>,
List<AdjustmentChannel>, List<AdjustmentChannel>,
List<XY>, List<XY>,
List<ScaleType>, List<ScaleType>,
@@ -295,11 +296,12 @@ fn generate_layout(introspected_data: &Arc<dyn std::any::Any + Send + Sync + 'st
Item<ExtrudeJoiningAlgorithm>, Item<ExtrudeJoiningAlgorithm>,
Item<PointSpacingType>, Item<PointSpacingType>,
Item<StringCapitalization>, Item<StringCapitalization>,
Item<LuminanceCalculation>, Item<DesaturateMethod>,
Item<RedGreenBlue>, Item<RedGreenBlue>,
Item<RedGreenBlueAlpha>, Item<RedGreenBlueAlpha>,
Item<RelativeAbsolute>, Item<RelativeAbsolute>,
Item<SelectiveColorChoice>, Item<SelectiveColorChoice>,
Item<TonalRange>,
Item<AdjustmentChannel>, Item<AdjustmentChannel>,
Item<XY>, Item<XY>,
Item<ScaleType>, Item<ScaleType>,
@@ -1065,11 +1067,12 @@ impl_table_item_layout_for_choice_enum!(
ExtrudeJoiningAlgorithm, ExtrudeJoiningAlgorithm,
PointSpacingType, PointSpacingType,
StringCapitalization, StringCapitalization,
LuminanceCalculation, DesaturateMethod,
RedGreenBlue, RedGreenBlue,
RedGreenBlueAlpha, RedGreenBlueAlpha,
RelativeAbsolute, RelativeAbsolute,
SelectiveColorChoice, SelectiveColorChoice,
TonalRange,
AdjustmentChannel, AdjustmentChannel,
XY, XY,
ScaleType, ScaleType,
@@ -1284,11 +1287,12 @@ macro_rules! known_item_types {
ExtrudeJoiningAlgorithm, ExtrudeJoiningAlgorithm,
PointSpacingType, PointSpacingType,
StringCapitalization, StringCapitalization,
LuminanceCalculation, DesaturateMethod,
RedGreenBlue, RedGreenBlue,
RedGreenBlueAlpha, RedGreenBlueAlpha,
RelativeAbsolute, RelativeAbsolute,
SelectiveColorChoice, SelectiveColorChoice,
TonalRange,
AdjustmentChannel, AdjustmentChannel,
XY, XY,
ScaleType, ScaleType,
@@ -20,7 +20,7 @@ use graphene_std::animation::RealTimeMode;
use graphene_std::color::SRGBA8; 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, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice, TonalRange, SelectiveColorChoice, TonalRange,
}; };
use graphene_std::raster_types::Image; use graphene_std::raster_types::Image;
@@ -332,7 +332,7 @@ pub(crate) fn property_from_type(
Some(x) if id_is::<PointSpacingType>(x) => enum_choice::<PointSpacingType>().for_socket(default_info).property_row(), Some(x) if id_is::<PointSpacingType>(x) => enum_choice::<PointSpacingType>().for_socket(default_info).property_row(),
Some(x) if id_is::<BooleanOperation>(x) => enum_choice::<BooleanOperation>().for_socket(default_info).property_row(), Some(x) if id_is::<BooleanOperation>(x) => enum_choice::<BooleanOperation>().for_socket(default_info).property_row(),
Some(x) if id_is::<CentroidType>(x) => enum_choice::<CentroidType>().for_socket(default_info).property_row(), Some(x) if id_is::<CentroidType>(x) => enum_choice::<CentroidType>().for_socket(default_info).property_row(),
Some(x) if id_is::<LuminanceCalculation>(x) => enum_choice::<LuminanceCalculation>().for_socket(default_info).property_row(), Some(x) if id_is::<DesaturateMethod>(x) => enum_choice::<DesaturateMethod>().for_socket(default_info).property_row(),
Some(x) if id_is::<QRCodeErrorCorrectionLevel>(x) => enum_choice::<QRCodeErrorCorrectionLevel>().for_socket(default_info).property_row(), Some(x) if id_is::<QRCodeErrorCorrectionLevel>(x) => enum_choice::<QRCodeErrorCorrectionLevel>().for_socket(default_info).property_row(),
Some(x) if id_is::<ScaleType>(x) => enum_choice::<ScaleType>().for_socket(default_info).property_row(), Some(x) if id_is::<ScaleType>(x) => enum_choice::<ScaleType>().for_socket(default_info).property_row(),
Some(x) if id_is::<InterpolationDistribution>(x) => enum_choice::<InterpolationDistribution>().for_socket(default_info).property_row(), Some(x) if id_is::<InterpolationDistribution>(x) => enum_choice::<InterpolationDistribution>().for_socket(default_info).property_row(),
@@ -591,8 +591,9 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[
], ],
}, },
NodeReplacement { NodeReplacement {
node: graphene_std::raster_nodes::adjustments::luminance::IDENTIFIER, node: graphene_std::raster_nodes::adjustments::desaturate::IDENTIFIER,
aliases: &[ aliases: &[
"raster_nodes::adjustments::LuminanceNode",
"graphene_raster_nodes::adjustments::LuminanceNode", "graphene_raster_nodes::adjustments::LuminanceNode",
"graphene_core::raster::adjustments::LuminanceNode", "graphene_core::raster::adjustments::LuminanceNode",
"graphene_core::raster::LuminanceNode", "graphene_core::raster::LuminanceNode",
+2 -1
View File
@@ -548,7 +548,8 @@ tagged_value! {
// ENUM TYPES // ENUM TYPES
// ========== // ==========
BlendMode(core_types::blending::BlendMode), BlendMode(core_types::blending::BlendMode),
LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation), #[serde(alias = "LuminanceCalculation")]
DesaturateMethod(raster_nodes::adjustments::DesaturateMethod),
QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel),
XY(graphene_core::extract_xy::XY), XY(graphene_core::extract_xy::XY),
StringCapitalization(text_nodes::StringCapitalization), StringCapitalization(text_nodes::StringCapitalization),
@@ -351,7 +351,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
ExtrudeJoiningAlgorithm, ExtrudeJoiningAlgorithm,
PointSpacingType, PointSpacingType,
StringCapitalization, StringCapitalization,
LuminanceCalculation, DesaturateMethod,
RedGreenBlue, RedGreenBlue,
RedGreenBlueAlpha, RedGreenBlueAlpha,
RelativeAbsolute, RelativeAbsolute,
@@ -45,7 +45,6 @@ impl Luminance for RGBA16F {
type LuminanceChannel = f32; type LuminanceChannel = f32;
#[inline(always)] #[inline(always)]
fn luminance(&self) -> f32 { fn luminance(&self) -> f32 {
// TODO: verify this is correct for sRGB
0.2126 * self.red() + 0.7152 * self.green() + 0.0722 * self.blue() 0.2126 * self.red() + 0.7152 * self.green() + 0.0722 * self.blue()
} }
} }
@@ -541,37 +540,36 @@ impl Color {
} }
/// Relative luminance using Rec.709 / sRGB-primary weights, computed on linear-light RGB. /// Relative luminance using Rec.709 / sRGB-primary weights, computed on linear-light RGB.
// From https://stackoverflow.com/a/56678483/775283
#[inline(always)] #[inline(always)]
pub fn luminance_rec_709(&self) -> f32 { pub fn luminance_rec_709(&self) -> f32 {
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue 0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
} }
/// Luma using Rec.601 SDTV coefficients. /// Luma using Rec.601 SDTV coefficients.
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
#[inline(always)] #[inline(always)]
pub fn luminance_rec_601(&self) -> f32 { pub fn luminance_rec_601(&self) -> f32 {
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
0.299 * self.red + 0.587 * self.green + 0.114 * self.blue 0.299 * self.red + 0.587 * self.green + 0.114 * self.blue
} }
/// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing. /// Luma using rounded Rec.601 coefficients (`0.3 / 0.59 / 0.11`), as used by some legacy image processing.
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
#[inline(always)] #[inline(always)]
pub fn luminance_rec_601_rounded(&self) -> f32 { pub fn luminance_rec_601_rounded(&self) -> f32 {
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
0.3 * self.red + 0.59 * self.green + 0.11 * self.blue 0.3 * self.red + 0.59 * self.green + 0.11 * self.blue
} }
/// Perceptual lightness (CIE L*) of the Rec.709 luminance, normalized to 0..1. /// Perceptual lightness (OkLab L) of the linear-light RGB, 0..1.
// From https://stackoverflow.com/a/56678483/775283
#[inline(always)] #[inline(always)]
pub fn luminance_perceptual(&self) -> f32 { pub fn lightness_oklab(&self) -> f32 {
let luminance = self.luminance_rec_709(); // From https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab
if luminance <= 0.008856 { let long = 0.41222147 * self.red + 0.53633254 * self.green + 0.05144599 * self.blue;
(luminance * 903.3) / 100. let medium = 0.2119035 * self.red + 0.6806995 * self.green + 0.10739696 * self.blue;
} else { let short = 0.08830246 * self.red + 0.28171884 * self.green + 0.6299787 * self.blue;
(luminance.cbrt() * 116. - 16.) / 100.
} 0.21045426 * long.cbrt() + 0.7936178 * medium.cbrt() - 0.004072047 * short.cbrt()
} }
/// Construct an opaque grayscale color where R = G = B = `luminance`. /// Construct an opaque grayscale color where R = G = B = `luminance`.
@@ -1062,6 +1060,15 @@ impl Color {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn oklab_lightness_spans_black_to_white() {
assert!(Color::BLACK.lightness_oklab().abs() < 1e-4);
assert!((Color::WHITE.lightness_oklab() - 1.).abs() < 1e-4);
// A gray keeps L at the cube root of its linear value, since the three cone responses sum to it
assert!((Color::from_luminance(0.18).lightness_oklab() - 0.18_f32.cbrt()).abs() < 1e-3);
}
#[test] #[test]
fn hsl_roundtrip() { fn hsl_roundtrip() {
for (red, green, blue) in [ for (red, green, blue) in [
+66 -18
View File
@@ -35,24 +35,52 @@ use vector_types::Gradient;
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=%27clrL%27%20%3D%20Color%20Lookup // 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 // 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 = "wasm", derive(tsify::Tsify))]
#[cfg_attr(feature = "std", derive(dyn_any::DynAny))] #[cfg_attr(feature = "std", derive(dyn_any::DynAny))]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] #[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)] #[derive(Debug, Default, Clone, Copy, Eq, PartialEq, Hash, node_macro::ChoiceType, bytemuck::NoUninit, BufferStruct, FromPrimitive, IntoPrimitive)]
#[widget(Dropdown)] #[widget(Dropdown)]
#[repr(u32)] #[repr(u32)]
pub enum LuminanceCalculation { 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] #[default]
#[label("sRGB")] #[label("Luminance (Rec. 709)")]
SRGB, #[cfg_attr(feature = "serde", serde(alias = "SRGB"))]
Perceptual, LuminanceRec709,
AverageChannels, /// 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`.
MinimumChannels, ///
MaximumChannels, /// 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))] #[node_macro::node(category("Raster: Adjustment"), shader_node(PerPixelAdjust))]
fn luminance<T: Adjust<Color>>( fn desaturate<T: Adjust<Color>>(
_: impl Ctx, _: impl Ctx,
#[implementations( #[implementations(
Raster<CPU>, Raster<CPU>,
@@ -61,18 +89,38 @@ fn luminance<T: Adjust<Color>>(
)] )]
#[gpu_image] #[gpu_image]
input: Item<T>, input: Item<T>,
luminance_calc: Item<LuminanceCalculation>, method: Item<DesaturateMethod>,
) -> Item<T> { ) -> Item<T> {
let mut input = input; let mut input = input;
let luminance_calc = luminance_calc.into_element(); let method = method.into_element();
input.element_mut().adjust(|color| { input.element_mut().adjust(|color| {
let luminance = match luminance_calc { // Gamma-encoded formulas are decoded as if they were a gray
LuminanceCalculation::SRGB => color.luminance_rec_709(), let gamma = || color.to_gamma_srgb_channels();
LuminanceCalculation::Perceptual => color.luminance_perceptual(), let luminance = match method {
LuminanceCalculation::AverageChannels => color.average_rgb_channels(), DesaturateMethod::LuminanceRec709 => color.luminance_rec_709(),
LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), DesaturateMethod::LumaRec709 => {
LuminanceCalculation::MaximumChannels => color.maximum_rgb_channels(), 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) color.map_rgb(|_| luminance)
}); });
@@ -1401,11 +1449,11 @@ fn color_balance<T: Adjust<Color>>(
#[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, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute,
SelectiveColorChoice, TonalRange, SelectiveColorChoice, TonalRange,
}; };
graphene_hash::impl_via_hash!( graphene_hash::impl_via_hash!(
LuminanceCalculation, DesaturateMethod,
RedGreenBlue, RedGreenBlue,
RedGreenBlueAlpha, RedGreenBlueAlpha,
NoiseType, NoiseType,