Add color weights to Grayscale node and improve luminance handling (#1015)

* Add weighted grayscale node

* Rename nodes, fix grayscale weighting, add luma calc options

* Fix tests

* Add Tint Option

* Improve (but not full fix) tint

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
isiko 2023-02-10 21:55:01 +01:00 committed by Keavon Chambers
parent d456640bb8
commit a709a772d5
7 changed files with 285 additions and 34 deletions

View file

@ -6,7 +6,7 @@ use graph_craft::document::*;
use graph_craft::imaginate_input::ImaginateSamplingMethod;
use graph_craft::proto::{NodeIdentifier, Type};
use graph_craft::{concrete, generic};
use graphene_core::raster::Image;
use graphene_core::raster::{Color, Image, LuminanceCalculation};
use std::collections::VecDeque;
@ -162,10 +162,74 @@ static STATIC_NODES: &[DocumentNodeType] = &[
DocumentNodeType {
name: "Grayscale",
category: "Image Adjustments",
identifier: NodeImplementation::proto("graphene_core::raster::GrayscaleNode", &[concrete!("Image")]),
inputs: &[DocumentInputType::new("Image", TaggedValue::Image(Image::empty()), true)],
identifier: NodeImplementation::proto(
"graphene_core::raster::GrayscaleNode<_, _, _, _, _, _, _>",
&[
concrete!("Image"),
concrete!("Color"),
concrete!("f64"),
concrete!("f64"),
concrete!("f64"),
concrete!("f64"),
concrete!("f64"),
concrete!("f64"),
],
),
inputs: &[
DocumentInputType {
name: "Image",
data_type: FrontendGraphDataType::Raster,
default: NodeInput::value(TaggedValue::Image(Image::empty()), true),
},
DocumentInputType {
name: "Tint",
data_type: FrontendGraphDataType::Number,
default: NodeInput::value(TaggedValue::Color(Color::BLACK), false),
},
DocumentInputType {
name: "Reds",
data_type: FrontendGraphDataType::Number,
default: NodeInput::value(TaggedValue::F64(50.), false),
},
DocumentInputType {
name: "Yellows",
data_type: FrontendGraphDataType::Number,
default: NodeInput::value(TaggedValue::F64(50.), false),
},
DocumentInputType {
name: "Greens",
data_type: FrontendGraphDataType::Number,
default: NodeInput::value(TaggedValue::F64(50.), false),
},
DocumentInputType {
name: "Cyans",
data_type: FrontendGraphDataType::Number,
default: NodeInput::value(TaggedValue::F64(50.), false),
},
DocumentInputType {
name: "Blues",
data_type: FrontendGraphDataType::Number,
default: NodeInput::value(TaggedValue::F64(50.), false),
},
DocumentInputType {
name: "Magentas",
data_type: FrontendGraphDataType::Number,
default: NodeInput::value(TaggedValue::F64(50.), false),
},
],
outputs: &[FrontendGraphDataType::Raster],
properties: node_properties::no_properties,
properties: node_properties::grayscale_properties,
},
DocumentNodeType {
name: "Luminance",
category: "Image Adjustments",
identifier: NodeImplementation::proto("graphene_core::raster::LuminanceNode<_>", &[concrete!("Image"), concrete!("LuminanceCalculation")]),
inputs: &[
DocumentInputType::new("Image", TaggedValue::Image(Image::empty()), true),
DocumentInputType::new("Luma Calculation", TaggedValue::LuminanceCalculation(LuminanceCalculation::SRGB), false),
],
outputs: &[FrontendGraphDataType::Raster],
properties: node_properties::luminance_properties,
},
#[cfg(feature = "gpu")]
DocumentNodeType {
@ -255,10 +319,11 @@ static STATIC_NODES: &[DocumentNodeType] = &[
DocumentNodeType {
name: "Threshold",
category: "Image Adjustments",
identifier: NodeImplementation::proto("graphene_core::raster::ThresholdNode<_>", &[concrete!("Image"), concrete!("f64")]),
identifier: NodeImplementation::proto("graphene_core::raster::ThresholdNode<_, _>", &[concrete!("Image"), concrete!("LuminanceCalculation"), concrete!("f64")]),
inputs: &[
DocumentInputType::new("Image", TaggedValue::Image(Image::empty()), true),
DocumentInputType::new("Threshold", TaggedValue::F64(1.), false),
DocumentInputType::new("Luma Calculation", TaggedValue::LuminanceCalculation(LuminanceCalculation::SRGB), false),
DocumentInputType::new("Threshold", TaggedValue::F64(50.), false),
],
outputs: &[FrontendGraphDataType::Raster],
properties: node_properties::adjust_threshold_properties,
@ -269,7 +334,7 @@ static STATIC_NODES: &[DocumentNodeType] = &[
identifier: NodeImplementation::proto("graphene_core::raster::VibranceNode<_>", &[concrete!("Image"), concrete!("f64")]),
inputs: &[
DocumentInputType::new("Image", TaggedValue::Image(Image::empty()), true),
DocumentInputType::new("Vibrance", TaggedValue::F64(1.), false),
DocumentInputType::new("Vibrance", TaggedValue::F64(0.), false),
],
outputs: &[FrontendGraphDataType::Raster],
properties: node_properties::adjust_vibrance_properties,
@ -280,7 +345,7 @@ static STATIC_NODES: &[DocumentNodeType] = &[
identifier: NodeImplementation::proto("graphene_core::raster::OpacityNode<_>", &[concrete!("Image"), concrete!("f64")]),
inputs: &[
DocumentInputType::new("Image", TaggedValue::Image(Image::empty()), true),
DocumentInputType::new("Factor", TaggedValue::F64(1.), false),
DocumentInputType::new("Factor", TaggedValue::F64(100.), false),
],
outputs: &[FrontendGraphDataType::Raster],
properties: node_properties::multiply_opacity,
@ -291,7 +356,7 @@ static STATIC_NODES: &[DocumentNodeType] = &[
identifier: NodeImplementation::proto("graphene_core::raster::PosterizeNode<_>", &[concrete!("Image"), concrete!("f64")]),
inputs: &[
DocumentInputType::new("Image", TaggedValue::Image(Image::empty()), true),
DocumentInputType::new("Value", TaggedValue::F64(5.), false),
DocumentInputType::new("Value", TaggedValue::F64(4.), false),
],
outputs: &[FrontendGraphDataType::Raster],
properties: node_properties::posterize_properties,

View file

@ -8,6 +8,7 @@ use glam::DVec2;
use graph_craft::document::value::TaggedValue;
use graph_craft::document::{generate_uuid, DocumentNode, NodeId, NodeInput};
use graph_craft::imaginate_input::*;
use graphene_core::raster::{Color, LuminanceCalculation};
use super::document_node_types::NodePropertiesContext;
use super::{FrontendGraphDataType, IMAGINATE_NODE};
@ -147,6 +148,47 @@ fn number_widget(document_node: &DocumentNode, node_id: NodeId, index: usize, na
widgets
}
// TODO: Generalize this for all dropdowns
fn luminance_calculation(document_node: &DocumentNode, node_id: u64, index: usize, name: &str, blank_assist: bool) -> LayoutGroup {
let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::General, blank_assist);
if let &NodeInput::Value {
tagged_value: TaggedValue::LuminanceCalculation(calculation),
exposed: false,
} = &document_node.inputs[index]
{
let calculation_modes = LuminanceCalculation::list();
let mut entries = Vec::with_capacity(calculation_modes.len());
for method in calculation_modes {
entries.push(DropdownEntryData::new(method.to_string()).on_update(update_value(move |_| TaggedValue::LuminanceCalculation(method), node_id, index)));
}
let entries = vec![entries];
widgets.extend_from_slice(&[
WidgetHolder::unrelated_separator(),
DropdownInput::new(entries).selected_index(Some(calculation as u32)).widget_holder(),
]);
}
LayoutGroup::Row { widgets }.with_tooltip("Formula used to calculate the luminance of a pixel")
}
fn color_widget(document_node: &DocumentNode, node_id: u64, index: usize, name: &str, color_props: ColorInput, blank_assist: bool) -> LayoutGroup {
let mut widgets = start_widgets(document_node, node_id, index, name, FrontendGraphDataType::Number, blank_assist);
if let NodeInput::Value {
tagged_value: TaggedValue::Color(x),
exposed: false,
} = document_node.inputs[index]
{
widgets.extend_from_slice(&[
WidgetHolder::unrelated_separator(),
color_props
.value(Some(x as Color))
.on_update(update_value(|x: &ColorInput| TaggedValue::Color(x.value.unwrap()), node_id, index))
.widget_holder(),
])
}
LayoutGroup::Row { widgets }
}
/// Properties for the input node, with information describing how frames work and a refresh button
pub fn input_properties(_document_node: &DocumentNode, _node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let information = WidgetHolder::text_widget("The graph's input is the artwork under the frame layer");
@ -157,6 +199,35 @@ pub fn input_properties(_document_node: &DocumentNode, _node_id: NodeId, _contex
vec![LayoutGroup::Row { widgets: vec![information] }, LayoutGroup::Row { widgets: vec![refresh_button] }]
}
pub fn grayscale_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
const MIN: f64 = -200.;
const MAX: f64 = 300.;
// TODO: Add tint color (blended above using the "Color" blend mode)
let tint = color_widget(document_node, node_id, 1, "Tint", ColorInput::default(), true);
let r_weight = number_widget(document_node, node_id, 2, "Reds", NumberInput::default().min(MIN).max(MAX).unit("%"), true);
let y_weight = number_widget(document_node, node_id, 3, "Yellows", NumberInput::default().min(MIN).max(MAX).unit("%"), true);
let g_weight = number_widget(document_node, node_id, 4, "Greens", NumberInput::default().min(MIN).max(MAX).unit("%"), true);
let c_weight = number_widget(document_node, node_id, 5, "Cyans", NumberInput::default().min(MIN).max(MAX).unit("%"), true);
let b_weight = number_widget(document_node, node_id, 6, "Blues", NumberInput::default().min(MIN).max(MAX).unit("%"), true);
let m_weight = number_widget(document_node, node_id, 7, "Magentas", NumberInput::default().min(MIN).max(MAX).unit("%"), true);
vec![
tint,
LayoutGroup::Row { widgets: r_weight },
LayoutGroup::Row { widgets: y_weight },
LayoutGroup::Row { widgets: g_weight },
LayoutGroup::Row { widgets: c_weight },
LayoutGroup::Row { widgets: b_weight },
LayoutGroup::Row { widgets: m_weight },
]
}
pub fn luminance_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let luma_calculation = luminance_calculation(document_node, node_id, 1, "Luma Calculation", true);
vec![luma_calculation]
}
pub fn adjust_hsl_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let hue_shift = number_widget(document_node, node_id, 1, "Hue Shift", NumberInput::default().min(-180.).max(180.).unit("°"), true);
let saturation_shift = number_widget(document_node, node_id, 2, "Saturation Shift", NumberInput::default().min(-100.).max(100.).unit("%"), true);
@ -184,9 +255,10 @@ pub fn blur_image_properties(document_node: &DocumentNode, node_id: NodeId, _con
}
pub fn adjust_threshold_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let thereshold = number_widget(document_node, node_id, 1, "Threshold", NumberInput::default().min(0.).max(1.), true);
let luma_calculation = luminance_calculation(document_node, node_id, 1, "Luma Calculation", true);
let thereshold = number_widget(document_node, node_id, 2, "Threshold", NumberInput::default().min(0.).max(100.).unit("%"), true);
vec![LayoutGroup::Row { widgets: thereshold }]
vec![luma_calculation, LayoutGroup::Row { widgets: thereshold }]
}
pub fn adjust_vibrance_properties(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
@ -203,7 +275,7 @@ pub fn gpu_map_properties(document_node: &DocumentNode, node_id: NodeId, _contex
}
pub fn multiply_opacity(document_node: &DocumentNode, node_id: NodeId, _context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
let gamma = number_widget(document_node, node_id, 1, "Factor", NumberInput::default().min(0.).max(1.), true);
let gamma = number_widget(document_node, node_id, 1, "Factor", NumberInput::default().min(0.).max(100.).unit("%"), true);
vec![LayoutGroup::Row { widgets: gamma }]
}

View file

@ -371,11 +371,14 @@ mod test {
use super::*;
#[ignore]
#[test]
fn map_node() {
// let array = &mut [Color::from_rgbaf32(1.0, 0.0, 0.0, 1.0).unwrap()];
GrayscaleNode.eval(Color::from_rgbf32_unchecked(1., 0., 0.));
/*let map = ForEachNode(MutWrapper(GrayscaleNode));
// LuminanceNode.eval(Color::from_rgbf32_unchecked(1., 0., 0.));
/*let map = ForEachNode(MutWrapper(LuminanceNode));
(&map).eval(array.iter_mut());
assert_eq!(array[0], Color::from_rgbaf32(0.33333334, 0.33333334, 0.33333334, 1.0).unwrap());*/
}

View file

@ -2,16 +2,48 @@ use super::Color;
use crate::Node;
use core::fmt::Debug;
use dyn_any::{DynAny, StaticType};
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Debug, Default, Clone, Copy, Eq, PartialEq, DynAny, specta::Type, Hash)]
pub enum LuminanceCalculation {
#[default]
SRGB,
Perceptual,
AverageChannels,
}
impl LuminanceCalculation {
pub fn list() -> [LuminanceCalculation; 3] {
[LuminanceCalculation::SRGB, LuminanceCalculation::Perceptual, LuminanceCalculation::AverageChannels]
}
}
impl std::fmt::Display for LuminanceCalculation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
LuminanceCalculation::SRGB => write!(f, "sRGB"),
LuminanceCalculation::Perceptual => write!(f, "Perceptual"),
LuminanceCalculation::AverageChannels => write!(f, "Average Channels"),
}
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct GrayscaleNode;
pub struct LuminanceNode<LuminanceCalculation> {
luma_calculation: LuminanceCalculation,
}
#[node_macro::node_fn(GrayscaleNode)]
fn grayscale_color_node(color: Color) -> Color {
#[node_macro::node_fn(LuminanceNode)]
fn luminance_color_node(color: Color, luma_calculation: LuminanceCalculation) -> Color {
// TODO: Remove conversion to linear when the whole node graph uses linear color
let color = color.to_linear_srgb();
let luminance = color.luminance();
let luminance = match luma_calculation {
LuminanceCalculation::SRGB => color.luminance_srgb(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
};
// TODO: Remove conversion to linear when the whole node graph uses linear color
let luminance = Color::linear_to_srgb(luminance);
@ -19,6 +51,51 @@ fn grayscale_color_node(color: Color) -> Color {
color.map_rgb(|_| luminance)
}
#[derive(Debug, Clone, Copy, Default)]
pub struct GrayscaleNode<Tint, Reds, Yellows, Greens, Cyans, Blues, Magentas> {
tint: Tint,
reds: Reds,
yellows: Yellows,
greens: Greens,
cyans: Cyans,
blues: Blues,
magentas: Magentas,
}
// From <https://stackoverflow.com/a/55233732/775283>
// Works the same for gamma and linear color
#[node_macro::node_fn(GrayscaleNode)]
fn grayscale_color_node(color: Color, tint: Color, reds: f64, yellows: f64, greens: f64, cyans: f64, blues: f64, magentas: f64) -> Color {
let reds = reds as f32 / 100.;
let yellows = yellows as f32 / 100.;
let greens = greens as f32 / 100.;
let cyans = cyans as f32 / 100.;
let blues = blues as f32 / 100.;
let magentas = magentas as f32 / 100.;
let gray_base = color.r().min(color.g()).min(color.b());
let red_part = color.r() - gray_base;
let green_part = color.g() - gray_base;
let blue_part = color.b() - gray_base;
let additional = if red_part == 0. {
let cyan_part = green_part.min(blue_part);
cyan_part * cyans + (green_part - cyan_part) * greens + (blue_part - cyan_part) * blues
} else if green_part == 0. {
let magenta_part = red_part.min(blue_part);
magenta_part * magentas + (red_part - magenta_part) * reds + (blue_part - magenta_part) * blues
} else {
let yellow_part = red_part.min(green_part);
yellow_part * yellows + (red_part - yellow_part) * reds + (green_part - yellow_part) * greens
};
let luminance = gray_base + additional;
// TODO: Fix "Color" blend mode implementation so it matches the expected behavior perfectly (it's currently close)
tint.with_luminance(luminance)
}
#[cfg(not(target_arch = "spirv"))]
pub use hue_shift::HueSaturationNode;
@ -54,18 +131,25 @@ fn invert_image(color: Color) -> Color {
}
#[derive(Debug, Clone, Copy)]
pub struct ThresholdNode<Threshold> {
pub struct ThresholdNode<LuminanceCalculation, Threshold> {
luma_calculation: LuminanceCalculation,
threshold: Threshold,
}
#[node_macro::node_fn(ThresholdNode)]
fn threshold_node(color: Color, threshold: f64) -> Color {
let threshold = Color::srgb_to_linear(threshold as f32);
fn threshold_node(color: Color, luma_calculation: LuminanceCalculation, threshold: f64) -> Color {
let threshold = Color::srgb_to_linear(threshold as f32 / 100.);
// TODO: Remove conversion to linear when the whole node graph uses linear color
let color = color.to_linear_srgb();
if color.luminance() >= threshold {
let luminance = match luma_calculation {
LuminanceCalculation::SRGB => color.luminance_srgb(),
LuminanceCalculation::Perceptual => color.luminance_perceptual(),
LuminanceCalculation::AverageChannels => color.average_rgb_channels(),
};
if luminance >= threshold {
Color::WHITE
} else {
Color::BLACK
@ -108,7 +192,7 @@ pub struct OpacityNode<O> {
#[node_macro::node_fn(OpacityNode)]
fn image_opacity(color: Color, opacity_multiplier: f64) -> Color {
let opacity_multiplier = opacity_multiplier as f32;
let opacity_multiplier = opacity_multiplier as f32 / 100.;
Color::from_rgbaf32_unchecked(color.r(), color.g(), color.b(), color.a() * opacity_multiplier)
}

View file

@ -196,14 +196,28 @@ impl Color {
self.alpha
}
// From https://stackoverflow.com/a/56678483/775283
pub fn luminance(&self) -> f32 {
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
pub fn average_rgb_channels(&self) -> f32 {
(self.red + self.green + self.blue) / 3.
}
// From https://stackoverflow.com/a/56678483/775283
pub fn perceptual_luminance(&self) -> f32 {
let luminance = self.luminance();
pub fn luminance_srgb(&self) -> f32 {
0.2126 * self.red + 0.7152 * self.green + 0.0722 * self.blue
}
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
pub fn luminance_rec_601(&self) -> f32 {
0.299 * self.red + 0.587 * self.green + 0.114 * self.blue
}
// From https://en.wikipedia.org/wiki/Luma_(video)#Rec._601_luma_versus_Rec._709_luma_coefficients
pub fn luminance_rec_601_rounded(&self) -> f32 {
0.3 * self.red + 0.59 * self.green + 0.11 * self.blue
}
// From https://stackoverflow.com/a/56678483/775283
pub fn luminance_perceptual(&self) -> f32 {
let luminance = self.luminance_srgb();
if luminance <= 0.008856 {
(luminance * 903.3) / 100.
@ -212,6 +226,11 @@ impl Color {
}
}
pub fn with_luminance(&self, luminance: f32) -> Color {
let d = luminance - self.luminance_rec_601();
self.map_rgb(|c| (c + d).clamp(0., 1.))
}
/// Return the all components as a tuple, first component is red, followed by green, followed by blue, followed by alpha.
///
/// # Examples

View file

@ -2,6 +2,7 @@ pub use dyn_any::StaticType;
use dyn_any::{DynAny, Upcast};
use dyn_clone::DynClone;
pub use glam::DVec2;
use graphene_core::raster::LuminanceCalculation;
use graphene_core::Node;
use std::hash::Hash;
pub use std::sync::Arc;
@ -26,6 +27,7 @@ pub enum TaggedValue {
Color(graphene_core::raster::color::Color),
Subpath(graphene_core::vector::subpath::Subpath),
RcSubpath(Arc<graphene_core::vector::subpath::Subpath>),
LuminanceCalculation(LuminanceCalculation),
ImaginateSamplingMethod(ImaginateSamplingMethod),
ImaginateMaskStartingFill(ImaginateMaskStartingFill),
ImaginateStatus(ImaginateStatus),
@ -86,20 +88,24 @@ impl Hash for TaggedValue {
13.hash(state);
s.hash(state)
}
Self::ImaginateSamplingMethod(m) => {
Self::LuminanceCalculation(l) => {
14.hash(state);
l.hash(state)
}
Self::ImaginateSamplingMethod(m) => {
15.hash(state);
m.hash(state)
}
Self::ImaginateMaskStartingFill(f) => {
15.hash(state);
16.hash(state);
f.hash(state)
}
Self::ImaginateStatus(s) => {
16.hash(state);
17.hash(state);
s.hash(state)
}
Self::LayerPath(p) => {
17.hash(state);
18.hash(state);
p.hash(state)
}
}
@ -123,6 +129,7 @@ impl<'a> TaggedValue {
TaggedValue::Color(x) => Box::new(x),
TaggedValue::Subpath(x) => Box::new(x),
TaggedValue::RcSubpath(x) => Box::new(x),
TaggedValue::LuminanceCalculation(x) => Box::new(x),
TaggedValue::ImaginateSamplingMethod(x) => Box::new(x),
TaggedValue::ImaginateMaskStartingFill(x) => Box::new(x),
TaggedValue::ImaginateStatus(x) => Box::new(x),

View file

@ -64,10 +64,11 @@ static NODE_REGISTRY: &[(NodeIdentifier, NodeConstructor)] = &[
}),
(NodeIdentifier::new("graphene_core::ops::IdNode", &[generic!("T")]), |_| IdNode::new().into_type_erased()),
// Filters
raster_node!(graphene_core::raster::GrayscaleNode, params: []),
raster_node!(graphene_core::raster::LuminanceNode<_>, params: [LuminanceCalculation]),
raster_node!(graphene_core::raster::GrayscaleNode<_, _, _, _, _, _, _>, params: [Color, f64, f64, f64, f64, f64, f64]),
raster_node!(graphene_core::raster::HueSaturationNode<_, _, _>, params: [f64, f64, f64]),
raster_node!(graphene_core::raster::InvertRGBNode, params: []),
raster_node!(graphene_core::raster::ThresholdNode<_>, params: [f64]),
raster_node!(graphene_core::raster::ThresholdNode<_, _>, params: [LuminanceCalculation, f64]),
raster_node!(graphene_core::raster::VibranceNode<_>, params: [f64]),
raster_node!(graphene_core::raster::BrightnessContrastNode< _, _>, params: [f64, f64]),
raster_node!(graphene_core::raster::OpacityNode<_>, params: [f64]),