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

View File

@@ -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),

View File

@@ -351,7 +351,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
ExtrudeJoiningAlgorithm,
PointSpacingType,
StringCapitalization,
LuminanceCalculation,
DesaturateMethod,
RedGreenBlue,
RedGreenBlueAlpha,
RelativeAbsolute,

View File

@@ -45,7 +45,6 @@ impl Luminance for RGBA16F {
type LuminanceChannel = f32;
#[inline(always)]
fn luminance(&self) -> 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 [

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=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<T: Adjust<Color>>(
fn desaturate<T: Adjust<Color>>(
_: impl Ctx,
#[implementations(
Raster<CPU>,
@@ -61,18 +89,38 @@ fn luminance<T: Adjust<Color>>(
)]
#[gpu_image]
input: Item<T>,
luminance_calc: Item<LuminanceCalculation>,
method: Item<DesaturateMethod>,
) -> Item<T> {
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<T: Adjust<Color>>(
#[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,