diff --git a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs index 7c4d6c1a6d..7c0da3eb64 100644 --- a/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs +++ b/editor/src/messages/portfolio/document/data_panel/data_panel_message_handler.rs @@ -16,8 +16,8 @@ use graphene_std::list::{Item, List, NodeIdPath}; use graphene_std::math::float_noise::round_away_float_noise; use graphene_std::memo::IORecord; use graphene_std::raster::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, - SelectiveColorChoice, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + TonalRange, }; use graphene_std::raster_types::{CPU, GPU, Raster}; use graphene_std::text::TextAlign; @@ -238,11 +238,12 @@ fn generate_layout(introspected_data: &Arc, List, List, - List, + List, List, List, List, List, + List, List, List, List, @@ -295,11 +296,12 @@ fn generate_layout(introspected_data: &Arc, Item, Item, - Item, + Item, Item, Item, Item, Item, + Item, Item, Item, Item, @@ -1065,11 +1067,12 @@ impl_table_item_layout_for_choice_enum!( ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, - LuminanceCalculation, + DesaturateMethod, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + TonalRange, AdjustmentChannel, XY, ScaleType, @@ -1284,11 +1287,12 @@ macro_rules! known_item_types { ExtrudeJoiningAlgorithm, PointSpacingType, StringCapitalization, - LuminanceCalculation, + DesaturateMethod, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, + TonalRange, AdjustmentChannel, XY, ScaleType, diff --git a/editor/src/messages/portfolio/document/node_graph/node_properties.rs b/editor/src/messages/portfolio/document/node_graph/node_properties.rs index d1f120b163..ab4f0cac6c 100644 --- a/editor/src/messages/portfolio/document/node_graph/node_properties.rs +++ b/editor/src/messages/portfolio/document/node_graph/node_properties.rs @@ -20,7 +20,7 @@ use graphene_std::animation::RealTimeMode; use graphene_std::color::SRGBA8; use graphene_std::extract_xy::XY; 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, }; use graphene_std::raster_types::Image; @@ -332,7 +332,7 @@ pub(crate) fn property_from_type( Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), - Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), + Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), Some(x) if id_is::(x) => enum_choice::().for_socket(default_info).property_row(), diff --git a/editor/src/messages/portfolio/document_migration.rs b/editor/src/messages/portfolio/document_migration.rs index d3b1ebf01d..00975c54d6 100644 --- a/editor/src/messages/portfolio/document_migration.rs +++ b/editor/src/messages/portfolio/document_migration.rs @@ -591,8 +591,9 @@ const NODE_REPLACEMENTS: &[NodeReplacement<'static>] = &[ ], }, NodeReplacement { - node: graphene_std::raster_nodes::adjustments::luminance::IDENTIFIER, + node: graphene_std::raster_nodes::adjustments::desaturate::IDENTIFIER, aliases: &[ + "raster_nodes::adjustments::LuminanceNode", "graphene_raster_nodes::adjustments::LuminanceNode", "graphene_core::raster::adjustments::LuminanceNode", "graphene_core::raster::LuminanceNode", diff --git a/node-graph/graph-craft/src/document/value.rs b/node-graph/graph-craft/src/document/value.rs index 198cc9affa..7e5db6c200 100644 --- a/node-graph/graph-craft/src/document/value.rs +++ b/node-graph/graph-craft/src/document/value.rs @@ -548,7 +548,8 @@ tagged_value! { // ENUM TYPES // ========== BlendMode(core_types::blending::BlendMode), - LuminanceCalculation(raster_nodes::adjustments::LuminanceCalculation), + #[serde(alias = "LuminanceCalculation")] + DesaturateMethod(raster_nodes::adjustments::DesaturateMethod), QRCodeErrorCorrectionLevel(vector_nodes::generator_nodes::QRCodeErrorCorrectionLevel), XY(graphene_core::extract_xy::XY), StringCapitalization(text_nodes::StringCapitalization), diff --git a/node-graph/interpreted-executor/src/node_registry.rs b/node-graph/interpreted-executor/src/node_registry.rs index cc20a0832c..14ee46e2d5 100644 --- a/node-graph/interpreted-executor/src/node_registry.rs +++ b/node-graph/interpreted-executor/src/node_registry.rs @@ -351,7 +351,7 @@ fn node_registry() -> HashMap f32 { - // TODO: verify this is correct for sRGB 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. - // From https://stackoverflow.com/a/56678483/775283 #[inline(always)] 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 } /// Luma using Rec.601 SDTV coefficients. - // From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients #[inline(always)] 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 } /// 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)] 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 } - /// Perceptual lightness (CIE L*) of the Rec.709 luminance, normalized to 0..1. - // From https://stackoverflow.com/a/56678483/775283 + /// Perceptual lightness (OkLab L) of the linear-light RGB, 0..1. #[inline(always)] - pub fn luminance_perceptual(&self) -> f32 { - let luminance = self.luminance_rec_709(); + pub fn lightness_oklab(&self) -> f32 { + // From https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab - if luminance <= 0.008856 { - (luminance * 903.3) / 100. - } else { - (luminance.cbrt() * 116. - 16.) / 100. - } + let long = 0.41222147 * self.red + 0.53633254 * self.green + 0.05144599 * self.blue; + let medium = 0.2119035 * self.red + 0.6806995 * self.green + 0.10739696 * self.blue; + let short = 0.08830246 * self.red + 0.28171884 * self.green + 0.6299787 * self.blue; + + 0.21045426 * long.cbrt() + 0.7936178 * medium.cbrt() - 0.004072047 * short.cbrt() } /// Construct an opaque grayscale color where R = G = B = `luminance`. @@ -1062,6 +1060,15 @@ impl Color { #[cfg(test)] mod tests { 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] fn hsl_roundtrip() { for (red, green, blue) in [ diff --git a/node-graph/nodes/raster/src/adjustments.rs b/node-graph/nodes/raster/src/adjustments.rs index 0460cc3062..ed2cc8e637 100644 --- a/node-graph/nodes/raster/src/adjustments.rs +++ b/node-graph/nodes/raster/src/adjustments.rs @@ -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=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 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] - #[label("sRGB")] - SRGB, - Perceptual, - AverageChannels, - MinimumChannels, - MaximumChannels, + #[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 luminance>( +fn desaturate>( _: impl Ctx, #[implementations( Raster, @@ -61,18 +89,38 @@ fn luminance>( )] #[gpu_image] input: Item, - luminance_calc: Item, + method: Item, ) -> Item { let mut input = input; - let luminance_calc = luminance_calc.into_element(); + let method = method.into_element(); input.element_mut().adjust(|color| { - let luminance = match luminance_calc { - LuminanceCalculation::SRGB => color.luminance_rec_709(), - LuminanceCalculation::Perceptual => color.luminance_perceptual(), - LuminanceCalculation::AverageChannels => color.average_rgb_channels(), - LuminanceCalculation::MinimumChannels => color.minimum_rgb_channels(), - LuminanceCalculation::MaximumChannels => color.maximum_rgb_channels(), + // 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) }); @@ -1401,11 +1449,11 @@ fn color_balance>( #[cfg(feature = "std")] mod _graphene_hash_impls { use super::{ - AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DomainWarpType, FractalType, LuminanceCalculation, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, + AdjustmentChannel, CellularDistanceFunction, CellularReturnType, DesaturateMethod, DomainWarpType, FractalType, NoiseType, RedGreenBlue, RedGreenBlueAlpha, RelativeAbsolute, SelectiveColorChoice, TonalRange, }; graphene_hash::impl_via_hash!( - LuminanceCalculation, + DesaturateMethod, RedGreenBlue, RedGreenBlueAlpha, NoiseType,