mirror of
https://github.com/GraphiteEditor/Graphite.git
synced 2026-10-02 11:38:12 +08:00
Add per-channel parameters to the 'Levels' node and make its midtones a gamma value (#4535)
* Add per-channel records and a gamma midtones value to the 'Levels' node, with a channel selector in its Properties panel * Migrate the old 'Levels' midtones through its output range and bound a lone midtone marker by the track edges
This commit is contained in:
@@ -695,11 +695,9 @@ pub struct SpectrumInput {
|
|||||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct SpectrumMarker {
|
pub struct SpectrumMarker {
|
||||||
/// Position of the marker along the spectrum track, normally from 0 to 1. A shifted or stretched non-cyclic ramp can
|
/// Position along the track, normally 0..1. A shifted or stretched non-cyclic ramp can push it outside, where it is not drawn.
|
||||||
/// place it outside that range, where the track draws only the markers falling within its visible span.
|
|
||||||
position: f64,
|
position: f64,
|
||||||
/// Position (0..1) of the midpoint between this marker and the next, used only if `show_midpoints` is true.
|
/// Midpoint (0..1) of the interval to the next marker, used only with `show_midpoints`. The last marker's midpoint spans the wrap of a cyclic track, or is otherwise ignored.
|
||||||
/// The last marker's value controls the wrapped interval when `track_cyclic` is set, and is otherwise ignored.
|
|
||||||
midpoint: f64,
|
midpoint: f64,
|
||||||
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`],
|
/// CSS color string for the marker handle's fill. Set via `SpectrumMarker::new` from a linear [`Color`],
|
||||||
/// discarding any transparency so the handle always shows the RGB that steers the interpolation.
|
/// discarding any transparency so the handle always shows the RGB that steers the interpolation.
|
||||||
@@ -708,6 +706,9 @@ pub struct SpectrumMarker {
|
|||||||
/// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers.
|
/// Whether a dashed line runs from this marker to the next through the lane below the track. Dragging it carries both markers.
|
||||||
#[serde(rename = "dashedToNext")]
|
#[serde(rename = "dashedToNext")]
|
||||||
dashed_to_next: bool,
|
dashed_to_next: bool,
|
||||||
|
/// Whether this marker follows its neighbors instead of bounding them, so they may drag past its drawn position.
|
||||||
|
#[serde(rename = "betweenNeighbors")]
|
||||||
|
between_neighbors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpectrumMarker {
|
impl SpectrumMarker {
|
||||||
@@ -718,9 +719,15 @@ impl SpectrumMarker {
|
|||||||
midpoint,
|
midpoint,
|
||||||
handle_color_css,
|
handle_color_css,
|
||||||
dashed_to_next: false,
|
dashed_to_next: false,
|
||||||
|
between_neighbors: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn between_neighbors(mut self) -> Self {
|
||||||
|
self.between_neighbors = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn dash_to_next(mut self) -> Self {
|
pub fn dash_to_next(mut self) -> Self {
|
||||||
self.dashed_to_next = true;
|
self.dashed_to_next = true;
|
||||||
self
|
self
|
||||||
|
|||||||
@@ -1399,17 +1399,63 @@ pub(crate) fn transfer_curves_properties(node_id: NodeId, context: &mut NodeProp
|
|||||||
pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
pub(crate) fn levels_properties(node_id: NodeId, context: &mut NodePropertiesContext) -> Vec<LayoutGroup> {
|
||||||
use graphene_std::raster::levels::*;
|
use graphene_std::raster::levels::*;
|
||||||
|
|
||||||
|
let mut channel_info = ParameterWidgetsInfo::new(node_id, ChannelInput, true, context);
|
||||||
|
channel_info.exposable = false;
|
||||||
|
let channel = enum_choice::<AdjustmentChannel>().for_socket(channel_info).property_row();
|
||||||
|
|
||||||
|
let channel_value = match get_document_node(node_id, context).ok().and_then(|document_node| document_node.input_value(ChannelInput).cloned()) {
|
||||||
|
Some(TaggedValue::AdjustmentChannel(channel)) => channel,
|
||||||
|
_ => AdjustmentChannel::Rgb,
|
||||||
|
};
|
||||||
|
let [shadows, midtones, highlights, output_minimums, output_maximums]: [ParameterRef; 5] = match channel_value {
|
||||||
|
AdjustmentChannel::Rgb => [
|
||||||
|
ShadowsInput.into(),
|
||||||
|
MidtonesInput.into(),
|
||||||
|
HighlightsInput.into(),
|
||||||
|
OutputMinimumsInput.into(),
|
||||||
|
OutputMaximumsInput.into(),
|
||||||
|
],
|
||||||
|
AdjustmentChannel::Red => [
|
||||||
|
RedShadowsInput.into(),
|
||||||
|
RedMidtonesInput.into(),
|
||||||
|
RedHighlightsInput.into(),
|
||||||
|
RedOutputMinimumsInput.into(),
|
||||||
|
RedOutputMaximumsInput.into(),
|
||||||
|
],
|
||||||
|
AdjustmentChannel::Green => [
|
||||||
|
GreenShadowsInput.into(),
|
||||||
|
GreenMidtonesInput.into(),
|
||||||
|
GreenHighlightsInput.into(),
|
||||||
|
GreenOutputMinimumsInput.into(),
|
||||||
|
GreenOutputMaximumsInput.into(),
|
||||||
|
],
|
||||||
|
AdjustmentChannel::Blue => [
|
||||||
|
BlueShadowsInput.into(),
|
||||||
|
BlueMidtonesInput.into(),
|
||||||
|
BlueHighlightsInput.into(),
|
||||||
|
BlueOutputMinimumsInput.into(),
|
||||||
|
BlueOutputMaximumsInput.into(),
|
||||||
|
],
|
||||||
|
AdjustmentChannel::Alpha => [
|
||||||
|
AlphaShadowsInput.into(),
|
||||||
|
AlphaMidtonesInput.into(),
|
||||||
|
AlphaHighlightsInput.into(),
|
||||||
|
AlphaOutputMinimumsInput.into(),
|
||||||
|
AlphaOutputMaximumsInput.into(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
let input_range_params = [
|
let input_range_params = [
|
||||||
SpectrumSectionParam::new(ShadowsInput, Color::BLACK, 0., MarkerScale::Percent),
|
SpectrumSectionParam::new(shadows, Color::BLACK, 0., MarkerScale::Percent),
|
||||||
SpectrumSectionParam::new(MidtonesInput, Color::MIDDLE_GRAY, 50., MarkerScale::Percent),
|
SpectrumSectionParam::new(midtones, Color::MIDDLE_GRAY, 1., MarkerScale::Gamma).between_neighbors(),
|
||||||
SpectrumSectionParam::new(HighlightsInput, Color::WHITE, 100., MarkerScale::Percent),
|
SpectrumSectionParam::new(highlights, Color::WHITE, 100., MarkerScale::Percent),
|
||||||
];
|
];
|
||||||
let output_range_params = [
|
let output_range_params = [
|
||||||
SpectrumSectionParam::new(OutputMinimumsInput, Color::BLACK, 0., MarkerScale::Percent),
|
SpectrumSectionParam::new(output_minimums, Color::BLACK, 0., MarkerScale::Percent),
|
||||||
SpectrumSectionParam::new(OutputMaximumsInput, Color::WHITE, 100., MarkerScale::Percent),
|
SpectrumSectionParam::new(output_maximums, Color::WHITE, 100., MarkerScale::Percent),
|
||||||
];
|
];
|
||||||
|
|
||||||
let mut layout = Vec::with_capacity(5);
|
let mut layout = vec![channel];
|
||||||
build_shared_spectrum_section(node_id, context, &bw_track(), &input_range_params, &mut layout);
|
build_shared_spectrum_section(node_id, context, &bw_track(), &input_range_params, &mut layout);
|
||||||
build_shared_spectrum_section(node_id, context, &bw_track(), &output_range_params, &mut layout);
|
build_shared_spectrum_section(node_id, context, &bw_track(), &output_range_params, &mut layout);
|
||||||
layout
|
layout
|
||||||
@@ -1459,6 +1505,8 @@ struct SpectrumSectionParam {
|
|||||||
scale: MarkerScale,
|
scale: MarkerScale,
|
||||||
/// Whether a dashed line joins the marker to the next parameter's marker.
|
/// Whether a dashed line joins the marker to the next parameter's marker.
|
||||||
dash_to_next: bool,
|
dash_to_next: bool,
|
||||||
|
/// Whether the marker takes its scale position within the span between its neighbors rather than the whole track, following them as they move.
|
||||||
|
between_neighbors: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SpectrumSectionParam {
|
impl SpectrumSectionParam {
|
||||||
@@ -1469,9 +1517,15 @@ impl SpectrumSectionParam {
|
|||||||
default_value,
|
default_value,
|
||||||
scale,
|
scale,
|
||||||
dash_to_next: false,
|
dash_to_next: false,
|
||||||
|
between_neighbors: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn between_neighbors(mut self) -> Self {
|
||||||
|
self.between_neighbors = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
fn dash_to_next(mut self) -> Self {
|
fn dash_to_next(mut self) -> Self {
|
||||||
self.dash_to_next = true;
|
self.dash_to_next = true;
|
||||||
self
|
self
|
||||||
@@ -1507,6 +1561,7 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
|
|||||||
let mut marker_default_positions = Vec::new();
|
let mut marker_default_positions = Vec::new();
|
||||||
let mut marker_scales = Vec::new();
|
let mut marker_scales = Vec::new();
|
||||||
let mut marker_positions = Vec::new();
|
let mut marker_positions = Vec::new();
|
||||||
|
let mut marker_between = Vec::new();
|
||||||
let mut marker_colors_and_links = Vec::new();
|
let mut marker_colors_and_links = Vec::new();
|
||||||
for (i, param) in params.iter().enumerate() {
|
for (i, param) in params.iter().enumerate() {
|
||||||
let (exposed, value) = exposure_and_value[i];
|
let (exposed, value) = exposure_and_value[i];
|
||||||
@@ -1518,20 +1573,41 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
|
|||||||
marker_input_indices.push(param.parameter.input_index);
|
marker_input_indices.push(param.parameter.input_index);
|
||||||
marker_default_positions.push(param.scale.position(param.default_value));
|
marker_default_positions.push(param.scale.position(param.default_value));
|
||||||
marker_scales.push(param.scale);
|
marker_scales.push(param.scale);
|
||||||
|
marker_between.push(param.between_neighbors);
|
||||||
marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker));
|
marker_colors_and_links.push((param.handle_color, param.dash_to_next && next_has_marker));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence
|
// Enforce non-decreasing order so markers never visually cross, matching the node's algorithm where shadows takes precedence.
|
||||||
for i in 1..marker_positions.len() {
|
// A marker placed between its neighbors bounds nothing here and instead takes its scale position within their settled span.
|
||||||
marker_positions[i] = marker_positions[i].max(marker_positions[i - 1]);
|
let mut floor = 0.;
|
||||||
|
for (position, &between) in marker_positions.iter_mut().zip(&marker_between) {
|
||||||
|
if between {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
*position = position.max(floor);
|
||||||
|
floor = *position;
|
||||||
|
}
|
||||||
|
for i in 0..marker_positions.len() {
|
||||||
|
if marker_between[i] {
|
||||||
|
let left = if i == 0 { 0. } else { marker_positions[i - 1] };
|
||||||
|
let right = marker_positions.get(i + 1).copied().unwrap_or(1.);
|
||||||
|
marker_positions[i] = left + marker_positions[i] * (right - left);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let spectrum_markers: Vec<SpectrumMarker> = marker_positions
|
let spectrum_markers: Vec<SpectrumMarker> = marker_positions
|
||||||
.iter()
|
.iter()
|
||||||
.zip(&marker_colors_and_links)
|
.zip(&marker_colors_and_links)
|
||||||
.map(|(&position, &(handle_color, dashed))| {
|
.zip(&marker_between)
|
||||||
let marker = SpectrumMarker::new(position, 0.5, handle_color);
|
.map(|((&position, &(handle_color, dashed)), &between)| {
|
||||||
if dashed { marker.dash_to_next() } else { marker }
|
let mut marker = SpectrumMarker::new(position, 0.5, handle_color);
|
||||||
|
if dashed {
|
||||||
|
marker = marker.dash_to_next();
|
||||||
|
}
|
||||||
|
if between {
|
||||||
|
marker = marker.between_neighbors();
|
||||||
|
}
|
||||||
|
marker
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
@@ -1550,21 +1626,35 @@ fn build_shared_spectrum_section(node_id: NodeId, context: &mut NodePropertiesCo
|
|||||||
let marker_default_positions = marker_default_positions.clone();
|
let marker_default_positions = marker_default_positions.clone();
|
||||||
let marker_scales = marker_scales.clone();
|
let marker_scales = marker_scales.clone();
|
||||||
let marker_positions = marker_positions.clone();
|
let marker_positions = marker_positions.clone();
|
||||||
|
let marker_between = marker_between.clone();
|
||||||
move |update: &SpectrumInputUpdate| {
|
move |update: &SpectrumInputUpdate| {
|
||||||
let i = match update {
|
let i = match update {
|
||||||
SpectrumInputUpdate::MoveMarker { index, .. } | SpectrumInputUpdate::ResetMarker { index } => *index as usize,
|
SpectrumInputUpdate::MoveMarker { index, .. } | SpectrumInputUpdate::ResetMarker { index } => *index as usize,
|
||||||
_ => return Message::NoOp,
|
_ => return Message::NoOp,
|
||||||
};
|
};
|
||||||
let (Some(&input_index), Some(&scale), Some(&default_position)) = (marker_input_indices.get(i), marker_scales.get(i), marker_default_positions.get(i)) else {
|
let (Some(&input_index), Some(&scale), Some(&between), Some(&default_position)) =
|
||||||
|
(marker_input_indices.get(i), marker_scales.get(i), marker_between.get(i), marker_default_positions.get(i))
|
||||||
|
else {
|
||||||
return Message::NoOp;
|
return Message::NoOp;
|
||||||
};
|
};
|
||||||
let left = if i == 0 { 0. } else { marker_positions[i - 1] };
|
|
||||||
let right = marker_positions.get(i + 1).copied().unwrap_or(1.);
|
// The span the marker's scale maps onto: its neighbors' positions when placed between them, otherwise the track between the
|
||||||
|
// nearest markers that bound it, which a marker placed between its neighbors never does
|
||||||
|
let bounding = |j: usize| between || !marker_between[j];
|
||||||
|
let left = (0..i).rev().find(|&j| bounding(j)).map_or(0., |j| marker_positions[j]);
|
||||||
|
let right = (i + 1..marker_positions.len()).find(|&j| bounding(j)).map_or(1., |j| marker_positions[j]);
|
||||||
|
|
||||||
let scale_position = match update {
|
let scale_position = match update {
|
||||||
|
SpectrumInputUpdate::MoveMarker { position, .. } if between => {
|
||||||
|
let span = right - left;
|
||||||
|
if span <= f64::EPSILON {
|
||||||
|
return Message::NoOp;
|
||||||
|
}
|
||||||
|
((position - left) / span).clamp(0., 1.)
|
||||||
|
}
|
||||||
SpectrumInputUpdate::MoveMarker { position, .. } => *position,
|
SpectrumInputUpdate::MoveMarker { position, .. } => *position,
|
||||||
// A default that would cross a neighbor falls back to the midpoint between them
|
// A default that would cross a neighbor falls back to the midpoint between them
|
||||||
SpectrumInputUpdate::ResetMarker { .. } if (left..=right).contains(&default_position) => default_position,
|
SpectrumInputUpdate::ResetMarker { .. } if between || (left..=right).contains(&default_position) => default_position,
|
||||||
SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2.,
|
SpectrumInputUpdate::ResetMarker { .. } => (left + right) / 2.,
|
||||||
_ => return Message::NoOp,
|
_ => return Message::NoOp,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2196,6 +2196,31 @@ fn migrate_node(node_id: &NodeId, node: &DocumentNode, network_path: &[NodeId],
|
|||||||
inputs_count = 3;
|
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();
|
||||||
|
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)?;
|
||||||
|
let output_level = |index: usize, default: f32| match old_inputs.get(index).and_then(|input| input.as_value()) {
|
||||||
|
Some(TaggedValue::F32(percent)) => percent / 100.,
|
||||||
|
_ => default,
|
||||||
|
};
|
||||||
|
let (output_minimums, output_maximums) = (output_level(4, 0.), output_level(5, 1.));
|
||||||
|
for (index, input) in old_inputs.iter().take(6).enumerate() {
|
||||||
|
let input = match (index, input.as_value()) {
|
||||||
|
(2, Some(TaggedValue::F32(percent))) => {
|
||||||
|
// The old node's midtones-to-gamma mapping, from https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels
|
||||||
|
let midtones = output_minimums + (output_maximums - output_minimums) * percent / 100.;
|
||||||
|
let gamma = if midtones < 0.5 { 1. + 9. * (1. - midtones * 2.) } else { ((1. - midtones) * 2.).max(0.01) };
|
||||||
|
NodeInput::value(TaggedValue::F32(gamma.clamp(0.01, 9.99)), input.is_exposed())
|
||||||
|
}
|
||||||
|
_ => input.clone(),
|
||||||
|
};
|
||||||
|
document.network_interface.set_input(&InputConnector::node_at_index(*node_id, index), input, network_path);
|
||||||
|
}
|
||||||
|
inputs_count = 27;
|
||||||
|
}
|
||||||
|
|
||||||
if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 {
|
if reference == DefinitionIdentifier::ProtoNode(graphene_std::repeat::repeat_on_points::IDENTIFIER) && inputs_count == 2 {
|
||||||
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
|
let mut node_template = resolve_document_node_type(&reference)?.default_node_template();
|
||||||
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
|
document.network_interface.replace_implementation(node_id, network_path, &mut node_template);
|
||||||
|
|||||||
@@ -98,11 +98,19 @@
|
|||||||
function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number {
|
function holdBetweenNeighbors(first: number, last: number, spacing: number, position: number): number {
|
||||||
// Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors
|
// Without selection nothing reports the dragged marker's new index after a reorder, so it stays between its neighbors
|
||||||
if (allowReorder && allowSelect) return position;
|
if (allowReorder && allowSelect) return position;
|
||||||
const lower = markers[first - 1]?.position ?? 0;
|
const lower = neighborBound(first, -1) ?? 0;
|
||||||
const upper = (markers[last + 1]?.position ?? 1) - spacing;
|
const upper = (neighborBound(last, 1) ?? 1) - spacing;
|
||||||
return Math.max(lower, Math.min(upper, position));
|
return Math.max(lower, Math.min(upper, position));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The position of the nearest marker past `index` in the direction of `step` that bounds others, skipping any placed between its neighbors since those follow them instead
|
||||||
|
function neighborBound(index: number, step: -1 | 1): number | undefined {
|
||||||
|
for (let i = index + step; i >= 0 && i < markers.length; i += step) {
|
||||||
|
if (!markers[i].betweenNeighbors) return markers[i].position;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// The spans from each marker passing `linked` to its successor
|
// The spans from each marker passing `linked` to its successor
|
||||||
function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] {
|
function markerSpans(markers: SpectrumMarker[], linked: (marker: SpectrumMarker) => boolean): { index: number; left: number; width: number }[] {
|
||||||
const spans: { index: number; left: number; width: number }[] = [];
|
const spans: { index: number; left: number; width: number }[] = [];
|
||||||
|
|||||||
@@ -328,88 +328,222 @@ pub enum AdjustmentChannel {
|
|||||||
Alpha,
|
Alpha,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One Levels record in the node's units: percentage input and output points and the gamma value.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct LevelsRecord {
|
||||||
|
shadows: f32,
|
||||||
|
midtones: f32,
|
||||||
|
highlights: f32,
|
||||||
|
output_minimums: f32,
|
||||||
|
output_maximums: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A record's input curve followed by its output range.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct LevelsStage {
|
||||||
|
curve: LevelsCurve,
|
||||||
|
output_minimum: f32,
|
||||||
|
output_maximum: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LevelsRecord {
|
||||||
|
fn new(shadows: f32, midtones: f32, highlights: f32, output_minimums: f32, output_maximums: f32) -> Self {
|
||||||
|
Self {
|
||||||
|
shadows,
|
||||||
|
midtones,
|
||||||
|
highlights,
|
||||||
|
output_minimums,
|
||||||
|
output_maximums,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage(&self, gamma: f32) -> LevelsStage {
|
||||||
|
LevelsStage {
|
||||||
|
curve: LevelsCurve::from_points(self.shadows * 2.55, self.highlights * 2.55, gamma),
|
||||||
|
output_minimum: self.output_minimums / 100.,
|
||||||
|
output_maximum: self.output_maximums / 100.,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LevelsStage {
|
||||||
|
fn apply(&self, value: f32) -> f32 {
|
||||||
|
self.curve.apply(value) * (self.output_maximum - self.output_minimum) + self.output_minimum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A channel's record followed by the composite record.
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
struct LevelsChain {
|
||||||
|
first: LevelsStage,
|
||||||
|
second: LevelsStage,
|
||||||
|
two_stages: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl LevelsChain {
|
||||||
|
fn new(channel: LevelsRecord, composite: LevelsRecord) -> Self {
|
||||||
|
// For PSD interop, two power functions with nothing between them (the composite's input points and the
|
||||||
|
// channel's output range at their defaults) merge into one curve with the product of the gammas, toe included
|
||||||
|
let nothing_between = composite.shadows == 0. && composite.highlights == 100. && channel.output_minimums == 0. && channel.output_maximums == 100.;
|
||||||
|
if nothing_between {
|
||||||
|
let merged = LevelsRecord {
|
||||||
|
output_minimums: composite.output_minimums,
|
||||||
|
output_maximums: composite.output_maximums,
|
||||||
|
..channel
|
||||||
|
};
|
||||||
|
let stage = merged.stage(channel.midtones * composite.midtones);
|
||||||
|
Self {
|
||||||
|
first: stage,
|
||||||
|
second: stage,
|
||||||
|
two_stages: false,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self {
|
||||||
|
first: channel.stage(channel.midtones),
|
||||||
|
second: composite.stage(composite.midtones),
|
||||||
|
two_stages: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&self, value: f32) -> f32 {
|
||||||
|
let value = self.first.apply(value);
|
||||||
|
if self.two_stages { self.second.apply(value) } else { value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Aims for interoperable compatibility with:
|
// Aims for interoperable compatibility with:
|
||||||
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels
|
// https://www.adobe.com/devnet-apps/photoshop/fileformatashtml/#:~:text=levl%27%20%3D%20Levels
|
||||||
//
|
//
|
||||||
// Algorithm from:
|
|
||||||
// https://stackoverflow.com/questions/39510072/algorithm-for-adjustment-of-image-levels
|
|
||||||
//
|
|
||||||
// Some further analysis available at:
|
// Some further analysis available at:
|
||||||
// https://geraldbakker.nl/psnumbers/levels.html
|
// https://geraldbakker.nl/psnumbers/levels.html
|
||||||
#[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))]
|
#[node_macro::node(category("Raster: Adjustment"), properties("levels_properties"), shader_node(PerPixelAdjust))]
|
||||||
fn levels<T: Adjust<Color>>(
|
fn levels<T: Adjust<Color>>(
|
||||||
_: impl Ctx,
|
_: impl Ctx,
|
||||||
#[implementations(
|
#[implementations(Raster<CPU>, Color, Gradient)]
|
||||||
Raster<CPU>,
|
|
||||||
Color,
|
|
||||||
Gradient,
|
|
||||||
)]
|
|
||||||
#[gpu_image]
|
#[gpu_image]
|
||||||
image: Item<T>,
|
image: Item<T>,
|
||||||
#[default(0.)] shadows: Item<PercentageF32>,
|
#[default(0.)] shadows: Item<PercentageF32>,
|
||||||
#[default(50.)] midtones: Item<PercentageF32>,
|
#[default(1.)] midtones: Item<f32>,
|
||||||
#[default(100.)] highlights: Item<PercentageF32>,
|
#[default(100.)] highlights: Item<PercentageF32>,
|
||||||
#[default(0.)] output_minimums: Item<PercentageF32>,
|
#[default(0.)] output_minimums: Item<PercentageF32>,
|
||||||
#[default(100.)] output_maximums: Item<PercentageF32>,
|
#[default(100.)] output_maximums: Item<PercentageF32>,
|
||||||
|
#[name("(Red) Shadows")]
|
||||||
|
#[default(0.)]
|
||||||
|
red_shadows: Item<PercentageF32>,
|
||||||
|
#[name("(Red) Midtones")]
|
||||||
|
#[default(1.)]
|
||||||
|
red_midtones: Item<f32>,
|
||||||
|
#[name("(Red) Highlights")]
|
||||||
|
#[default(100.)]
|
||||||
|
red_highlights: Item<PercentageF32>,
|
||||||
|
#[name("(Red) Output Minimums")]
|
||||||
|
#[default(0.)]
|
||||||
|
red_output_minimums: Item<PercentageF32>,
|
||||||
|
#[name("(Red) Output Maximums")]
|
||||||
|
#[default(100.)]
|
||||||
|
red_output_maximums: Item<PercentageF32>,
|
||||||
|
#[name("(Green) Shadows")]
|
||||||
|
#[default(0.)]
|
||||||
|
green_shadows: Item<PercentageF32>,
|
||||||
|
#[name("(Green) Midtones")]
|
||||||
|
#[default(1.)]
|
||||||
|
green_midtones: Item<f32>,
|
||||||
|
#[name("(Green) Highlights")]
|
||||||
|
#[default(100.)]
|
||||||
|
green_highlights: Item<PercentageF32>,
|
||||||
|
#[name("(Green) Output Minimums")]
|
||||||
|
#[default(0.)]
|
||||||
|
green_output_minimums: Item<PercentageF32>,
|
||||||
|
#[name("(Green) Output Maximums")]
|
||||||
|
#[default(100.)]
|
||||||
|
green_output_maximums: Item<PercentageF32>,
|
||||||
|
#[name("(Blue) Shadows")]
|
||||||
|
#[default(0.)]
|
||||||
|
blue_shadows: Item<PercentageF32>,
|
||||||
|
#[name("(Blue) Midtones")]
|
||||||
|
#[default(1.)]
|
||||||
|
blue_midtones: Item<f32>,
|
||||||
|
#[name("(Blue) Highlights")]
|
||||||
|
#[default(100.)]
|
||||||
|
blue_highlights: Item<PercentageF32>,
|
||||||
|
#[name("(Blue) Output Minimums")]
|
||||||
|
#[default(0.)]
|
||||||
|
blue_output_minimums: Item<PercentageF32>,
|
||||||
|
#[name("(Blue) Output Maximums")]
|
||||||
|
#[default(100.)]
|
||||||
|
blue_output_maximums: Item<PercentageF32>,
|
||||||
|
#[name("(Alpha) Shadows")]
|
||||||
|
#[default(0.)]
|
||||||
|
alpha_shadows: Item<PercentageF32>,
|
||||||
|
#[name("(Alpha) Midtones")]
|
||||||
|
#[default(1.)]
|
||||||
|
alpha_midtones: Item<f32>,
|
||||||
|
#[name("(Alpha) Highlights")]
|
||||||
|
#[default(100.)]
|
||||||
|
alpha_highlights: Item<PercentageF32>,
|
||||||
|
#[name("(Alpha) Output Minimums")]
|
||||||
|
#[default(0.)]
|
||||||
|
alpha_output_minimums: Item<PercentageF32>,
|
||||||
|
#[name("(Alpha) Output Maximums")]
|
||||||
|
#[default(100.)]
|
||||||
|
alpha_output_maximums: Item<PercentageF32>,
|
||||||
|
_channel: Item<AdjustmentChannel>,
|
||||||
) -> Item<T> {
|
) -> Item<T> {
|
||||||
let mut image = image;
|
let mut image = image;
|
||||||
let shadows = shadows.into_element();
|
let composite = LevelsRecord::new(
|
||||||
let midtones = midtones.into_element();
|
shadows.into_element(),
|
||||||
let highlights = highlights.into_element();
|
midtones.into_element(),
|
||||||
let output_minimums = output_minimums.into_element();
|
highlights.into_element(),
|
||||||
let output_maximums = output_maximums.into_element();
|
output_minimums.into_element(),
|
||||||
|
output_maximums.into_element(),
|
||||||
|
);
|
||||||
|
let red = LevelsChain::new(
|
||||||
|
LevelsRecord::new(
|
||||||
|
red_shadows.into_element(),
|
||||||
|
red_midtones.into_element(),
|
||||||
|
red_highlights.into_element(),
|
||||||
|
red_output_minimums.into_element(),
|
||||||
|
red_output_maximums.into_element(),
|
||||||
|
),
|
||||||
|
composite,
|
||||||
|
);
|
||||||
|
let green = LevelsChain::new(
|
||||||
|
LevelsRecord::new(
|
||||||
|
green_shadows.into_element(),
|
||||||
|
green_midtones.into_element(),
|
||||||
|
green_highlights.into_element(),
|
||||||
|
green_output_minimums.into_element(),
|
||||||
|
green_output_maximums.into_element(),
|
||||||
|
),
|
||||||
|
composite,
|
||||||
|
);
|
||||||
|
let blue = LevelsChain::new(
|
||||||
|
LevelsRecord::new(
|
||||||
|
blue_shadows.into_element(),
|
||||||
|
blue_midtones.into_element(),
|
||||||
|
blue_highlights.into_element(),
|
||||||
|
blue_output_minimums.into_element(),
|
||||||
|
blue_output_maximums.into_element(),
|
||||||
|
),
|
||||||
|
composite,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alpha stands apart from the composite record that the three color channels pass through
|
||||||
|
let alpha = LevelsRecord::new(
|
||||||
|
alpha_shadows.into_element(),
|
||||||
|
alpha_midtones.into_element(),
|
||||||
|
alpha_highlights.into_element(),
|
||||||
|
alpha_output_minimums.into_element(),
|
||||||
|
alpha_output_maximums.into_element(),
|
||||||
|
);
|
||||||
|
let alpha = alpha.stage(alpha.midtones);
|
||||||
|
|
||||||
image.element_mut().adjust(|color| {
|
image.element_mut().adjust(|color| {
|
||||||
// Levels math operates in gamma space
|
// Levels math operates in gamma space
|
||||||
let [mut r, mut g, mut b, a] = color.to_gamma_srgb_channels();
|
let [r, g, b, a] = color.to_gamma_srgb_channels();
|
||||||
|
|
||||||
// Input Range (Range: 0-1)
|
Color::from_gamma_srgb_channels(red.apply(r), green.apply(g), blue.apply(b), alpha.apply(a))
|
||||||
let input_shadows = shadows / 100.;
|
|
||||||
let input_midtones = midtones / 100.;
|
|
||||||
let input_highlights = highlights / 100.;
|
|
||||||
|
|
||||||
// Output Range (Range: 0-1)
|
|
||||||
let output_minimums = output_minimums / 100.;
|
|
||||||
let output_maximums = output_maximums / 100.;
|
|
||||||
|
|
||||||
// Midtones interpolation factor between minimums and maximums (Range: 0-1)
|
|
||||||
let midtones = output_minimums + (output_maximums - output_minimums) * input_midtones;
|
|
||||||
|
|
||||||
// Gamma correction (Range: 0.01-10)
|
|
||||||
let gamma = if midtones < 0.5 {
|
|
||||||
// Range: 0-1
|
|
||||||
let x = 1. - midtones * 2.;
|
|
||||||
// Range: 1-10
|
|
||||||
1. + 9. * x
|
|
||||||
} else {
|
|
||||||
// Range: 0-0.5
|
|
||||||
let x = 1. - midtones;
|
|
||||||
// Range: 0-1
|
|
||||||
let x = x * 2.;
|
|
||||||
// Range: 0.01-1
|
|
||||||
x.max(0.01)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Input levels (Range: 0-1)
|
|
||||||
let highlights_minus_shadows = (input_highlights - input_shadows).clamp(f32::EPSILON, 1.);
|
|
||||||
let input_map = |c: f32| ((c - input_shadows).max(0.) / highlights_minus_shadows).min(1.);
|
|
||||||
r = input_map(r);
|
|
||||||
g = input_map(g);
|
|
||||||
b = input_map(b);
|
|
||||||
|
|
||||||
// Midtones gamma curve (Range: 0-1)
|
|
||||||
let inverse_gamma = 1. / gamma.max(0.0001);
|
|
||||||
r = r.powf(inverse_gamma);
|
|
||||||
g = g.powf(inverse_gamma);
|
|
||||||
b = b.powf(inverse_gamma);
|
|
||||||
|
|
||||||
// Output levels (Range: 0-1)
|
|
||||||
let output_map = |c: f32| c * (output_maximums - output_minimums) + output_minimums;
|
|
||||||
r = output_map(r);
|
|
||||||
g = output_map(g);
|
|
||||||
b = output_map(b);
|
|
||||||
|
|
||||||
Color::from_gamma_srgb_channels(r, g, b, a)
|
|
||||||
});
|
});
|
||||||
image
|
image
|
||||||
}
|
}
|
||||||
@@ -1469,6 +1603,70 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs Levels with composite and red records given as [black, white, gamma, output black, output white] with 0..255 points
|
||||||
|
/// on one gamma-space gray value (0..255), returning the red and green results on the same scale.
|
||||||
|
fn run_levels(value: f32, composite: [f32; 5], red: [f32; 5]) -> [f32; 2] {
|
||||||
|
let pixel = Color::from_gamma_srgb_channels(value / 255., value / 255., value / 255., 1.);
|
||||||
|
let percent = |level: f32| level / 2.55;
|
||||||
|
let result = levels(
|
||||||
|
(),
|
||||||
|
Item::new_from_element(pixel),
|
||||||
|
percent(composite[0]).into(),
|
||||||
|
composite[2].into(),
|
||||||
|
percent(composite[1]).into(),
|
||||||
|
percent(composite[3]).into(),
|
||||||
|
percent(composite[4]).into(),
|
||||||
|
percent(red[0]).into(),
|
||||||
|
red[2].into(),
|
||||||
|
percent(red[1]).into(),
|
||||||
|
percent(red[3]).into(),
|
||||||
|
percent(red[4]).into(),
|
||||||
|
0_f32.into(),
|
||||||
|
1_f32.into(),
|
||||||
|
100_f32.into(),
|
||||||
|
0_f32.into(),
|
||||||
|
100_f32.into(),
|
||||||
|
0_f32.into(),
|
||||||
|
1_f32.into(),
|
||||||
|
100_f32.into(),
|
||||||
|
0_f32.into(),
|
||||||
|
100_f32.into(),
|
||||||
|
0_f32.into(),
|
||||||
|
1_f32.into(),
|
||||||
|
100_f32.into(),
|
||||||
|
0_f32.into(),
|
||||||
|
100_f32.into(),
|
||||||
|
AdjustmentChannel::Rgb.into(),
|
||||||
|
);
|
||||||
|
let [r, g, _, _] = result.into_element().to_gamma_srgb_channels();
|
||||||
|
[r * 255., g * 255.]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn levels_records_merge_into_one_gamma_only_when_nothing_lies_between() {
|
||||||
|
const DEFAULT: [f32; 5] = [0., 255., 1., 0., 255.];
|
||||||
|
for (value, composite, red, expected_red, expected_green) in [
|
||||||
|
// Two gammas with nothing between them act as one gamma of 2.25, toe included
|
||||||
|
(5., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 23., 14.),
|
||||||
|
(25., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 89., 54.),
|
||||||
|
(100., [0., 255., 1.5, 0., 255.], [0., 255., 1.5, 0., 255.], 168., 137.),
|
||||||
|
// A black point in each record keeps them as two curves
|
||||||
|
(40., [30., 255., 1.5, 0., 255.], [20., 255., 1.5, 0., 255.], 49., 28.),
|
||||||
|
(100., [30., 255., 1.5, 0., 255.], [20., 255., 1.5, 0., 255.], 144., 117.),
|
||||||
|
// Input and output points only
|
||||||
|
(100., [30., 220., 1., 0., 255.], [50., 255., 1., 0., 200.], 26., 94.),
|
||||||
|
(150., [30., 220., 1., 0., 255.], [50., 255., 1., 0., 200.], 91., 161.),
|
||||||
|
// A pure channel gamma under a composite with points stays a separate stage
|
||||||
|
(5., [0., 200., 1.2, 10., 255.], [0., 255., 3., 0., 255.], 70., 21.),
|
||||||
|
(50., [0., 200., 1.2, 10., 255.], [0., 255., 3., 0., 255.], 201., 88.),
|
||||||
|
(128., DEFAULT, DEFAULT, 128., 128.),
|
||||||
|
] {
|
||||||
|
let [red_actual, green_actual] = run_levels(value, composite, red);
|
||||||
|
assert!((red_actual - expected_red).abs() <= 1.5, "{value} red: expected {expected_red}, got {red_actual}");
|
||||||
|
assert!((green_actual - expected_green).abs() <= 1.5, "{value} green: expected {expected_green}, got {green_actual}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn invert_flips_straight_channels_and_keeps_alpha() {
|
fn invert_flips_straight_channels_and_keeps_alpha() {
|
||||||
let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5);
|
let color = Color::from_gamma_srgb_channels(1., 0.25, 0., 0.5);
|
||||||
|
|||||||
Reference in New Issue
Block a user