Make the view transform an operation the photographer can set
FR-DEV-3j gives the view transform two controls, contrast and the white point in stops above middle grey, persisted and held per mask layer like any other setting. A scene-referred pipeline whose white point cannot be moved hands the photographer a shoulder they cannot place. `view_transform` is a hand-written node in `Stage::View`, a new stage the composer emits last and emits whatever the node's state: a neutral operation is otherwise left out of the shader, but a photograph with no view transform is a scan. "Active" keeps meaning "moved from the defaults", so an untouched photograph writes nothing for it and `every_node_starts_neutral` still holds. A caller whose chain holds no view operation gets a default one. The composer's loop becomes an ordered list of steps — camera nodes, the matrix, scene nodes, layer-only nodes, the view — so each is emitted in exactly one place. The base curve could not be a node because it belonged to the camera; the ops README records why that argument went with it (D19). The panel shows it in the Light group as "Tone Mapping". Tests that counted the blocks of a neutral graph now count one, the view transform, and the two chain-wide tests that load a film expect the view transform to be absent, since a stock replaces it.
This commit is contained in:
@@ -2371,6 +2371,12 @@ mod tests {
|
|||||||
let mut fused_blocks = 0;
|
let mut fused_blocks = 0;
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
let id = desc.id.0;
|
let id = desc.id.0;
|
||||||
|
// A stock is loaded here, and a stock is a rendering: the view
|
||||||
|
// transform it replaces is correctly in neither half (FR-DEV-3j).
|
||||||
|
if id == dr_pipeline::ops::view_transform::ID.0 {
|
||||||
|
assert!(!shader.source.contains("---- view_transform ----"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
let point = shader.source.contains(&format!("---- {id} ----"));
|
||||||
let neighbourhood = detail
|
let neighbourhood = detail
|
||||||
.passes
|
.passes
|
||||||
|
|||||||
@@ -293,19 +293,25 @@ in raw pixels is a different photograph on screen and in the exported file.
|
|||||||
|
|
||||||
## What is not a node, and why
|
## What is not a node, and why
|
||||||
|
|
||||||
Three things act on every pixel and are deliberately not in this directory:
|
Two things act on every pixel and are deliberately not in this directory: the
|
||||||
the as-shot white balance, the camera matrix, and the **view transform**
|
as-shot white balance and the camera matrix. They are emitted by
|
||||||
(FR-DEV-3j). They are emitted by [`../src/operation.rs`](../src/operation.rs)
|
[`../src/operation.rs`](../src/operation.rs) into the composed shader around the
|
||||||
into the composed shader around the block of nodes.
|
block of nodes. They are properties of the *file*, at the same standing as the
|
||||||
|
|
||||||
The first two are properties of the *file*, at the same standing as the
|
|
||||||
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
|
masked-photosite crop (FR-RAW-3) and the stored orientation (FR-DEV-3h): nobody
|
||||||
chose the sensor's green sensitivity, and reading the file correctly means
|
chose the sensor's green sensitivity, and reading the file correctly means
|
||||||
undoing it. The view transform is different in kind — it is the one stage that
|
undoing it.
|
||||||
maps scene-linear colour to a display range (D19, ARCH §6.14) — and it runs
|
|
||||||
after every node, because corrections to capture are only meaningful on linear
|
The **view transform** (FR-DEV-3j) *is* a node — `view_transform.yaml`, a
|
||||||
values. It replaced the per-body **base curve**, which was looked up by camera
|
`rust:` one — and that is a change of mind worth knowing about. It replaced the
|
||||||
model and flat past 1.0, so it clipped every recovered highlight.
|
per-body base curve, which was kept out of this directory because it belonged
|
||||||
|
to the camera: as a node it would have carried one body's rendering onto
|
||||||
|
another body's file through a shared sidecar. D19 retired the per-body curves,
|
||||||
|
and with them the argument. One view transform serves every body, so its
|
||||||
|
settings are a decision about the picture like any other. What is still
|
||||||
|
special about it is `Stage::View`: the composer emits it at the end of the
|
||||||
|
chain *whatever its state*, because a photograph with no view transform is a
|
||||||
|
scan and not a picture. Its neutral is its defaults, like every other node's,
|
||||||
|
so an untouched photograph writes nothing for it.
|
||||||
|
|
||||||
## Stages
|
## Stages
|
||||||
|
|
||||||
@@ -315,7 +321,10 @@ primaries, scene-referred and unbounded. White balance is the only camera
|
|||||||
node, because its multipliers scale the sensor's own channels. Everything else
|
node, because its multipliers scale the sensor's own channels. Everything else
|
||||||
belongs in the scene, where a hue or a luminance weight means the same thing
|
belongs in the scene, where a hue or a luminance weight means the same thing
|
||||||
whichever body took the frame (D19). The composer emits the camera nodes, then
|
whichever body took the frame (D19). The composer emits the camera nodes, then
|
||||||
the matrix, then the scene nodes, each group in `order:`.
|
the matrix, then the scene nodes, each group in `order:`, and the view
|
||||||
|
transform last. `stage: view` is not offered to a declaration: a node that
|
||||||
|
maps into a display range is exactly what ARCH §6.14 forbids of everything
|
||||||
|
before the end, and the one that is allowed to is hand-written.
|
||||||
|
|
||||||
## Errors
|
## Errors
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
id: view_transform
|
||||||
|
order: 200
|
||||||
|
# A `rust:` node publishes its own descriptor; its attributes are on the type
|
||||||
|
# in `../src/ops/view_transform.rs`.
|
||||||
|
rust: ViewTransform
|
||||||
|
|
||||||
|
why_rust: |
|
||||||
|
It is composed at its defaults — a photograph with no view transform is a
|
||||||
|
scan, not a picture — which is `Stage::View`, and a declaration has no way to
|
||||||
|
say it. Its three uniforms are also the solution of two equations rather than
|
||||||
|
expressions over its parameters (`dr_pipeline::view::Sigmoid::new`).
|
||||||
|
|
||||||
|
placement: |
|
||||||
|
Last, after every scene operation and, when there is one, after the detail
|
||||||
|
stage (D19, FR-DEV-3j). It is the one stage allowed to map scene-linear colour
|
||||||
|
to a display range, so anything after it would be working on a rendering.
|
||||||
|
The order here only places it in the panel; the composer puts every
|
||||||
|
`Stage::View` node at the end whatever its number says.
|
||||||
@@ -1122,13 +1122,21 @@ mod tests {
|
|||||||
fn a_fresh_graph_is_neutral() {
|
fn a_fresh_graph_is_neutral() {
|
||||||
// Opening an unedited image must produce the image, not an
|
// Opening an unedited image must produce the image, not an
|
||||||
// interpretation of it.
|
// interpretation of it.
|
||||||
|
//
|
||||||
|
// One block, and it is the view transform: a view operation is
|
||||||
|
// composed at its defaults, because a photograph with no view
|
||||||
|
// transform is a scan rather than a picture (FR-DEV-3j). It is still
|
||||||
|
// neutral in the sense that matters here — nothing moved, nothing is
|
||||||
|
// written — and every adjustment is absent.
|
||||||
let g = EditGraph::default_chain();
|
let g = EditGraph::default_chain();
|
||||||
assert!(g.is_neutral());
|
assert!(g.is_neutral());
|
||||||
|
let source = g.compose().source;
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
g.compose().source.matches("---- ").count(),
|
source.matches("---- ").count(),
|
||||||
0,
|
1,
|
||||||
"a neutral graph must generate no operation blocks"
|
"a neutral graph must generate no adjustment blocks"
|
||||||
);
|
);
|
||||||
|
assert!(source.contains("---- view_transform ----"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1207,13 +1215,15 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn only_active_operations_reach_the_shader() {
|
fn only_active_operations_reach_the_shader() {
|
||||||
// The composition property, end to end: two adjustments out of seven
|
// The composition property, end to end: two adjustments out of seven
|
||||||
// available must generate a shader doing exactly two things.
|
// available must generate a shader doing exactly two things — and
|
||||||
|
// the view transform, which every render has (FR-DEV-3j).
|
||||||
let mut g = EditGraph::default_chain();
|
let mut g = EditGraph::default_chain();
|
||||||
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
g.set_param(exposure::ID, exposure::EXPOSURE, 1.0);
|
||||||
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
g.set_param(white_balance::ID, white_balance::TINT, 25.0);
|
||||||
|
|
||||||
let shader = g.compose();
|
let shader = g.compose();
|
||||||
assert_eq!(shader.source.matches("---- ").count(), 2);
|
assert_eq!(shader.source.matches("---- ").count(), 3);
|
||||||
|
assert!(shader.source.contains("---- view_transform ----"));
|
||||||
assert!(shader.source.contains("---- exposure ----"));
|
assert!(shader.source.contains("---- exposure ----"));
|
||||||
assert!(shader.source.contains("---- white_balance ----"));
|
assert!(shader.source.contains("---- white_balance ----"));
|
||||||
assert!(!shader.source.contains("---- saturation ----"));
|
assert!(!shader.source.contains("---- saturation ----"));
|
||||||
|
|||||||
@@ -169,6 +169,13 @@ mod tests {
|
|||||||
let mut fused_blocks = 0;
|
let mut fused_blocks = 0;
|
||||||
for desc in g.descriptors() {
|
for desc in g.descriptors() {
|
||||||
let id = desc.id.0;
|
let id = desc.id.0;
|
||||||
|
// The film is loaded here, and a stock is a rendering: the view
|
||||||
|
// transform it replaces is correctly in neither stage (FR-DEV-3f,
|
||||||
|
// FR-DEV-3j).
|
||||||
|
if id == crate::ops::view_transform::ID.0 {
|
||||||
|
assert!(!shader.source.contains("---- view_transform ----"));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let point = shader.source.contains(&format!("---- {id} ----"));
|
let point = shader.source.contains(&format!("---- {id} ----"));
|
||||||
let neighbourhood = detail
|
let neighbourhood = detail
|
||||||
.passes
|
.passes
|
||||||
|
|||||||
@@ -468,6 +468,16 @@ pub enum Stage {
|
|||||||
/// Working-space colour: linear Rec.709 primaries, scene-referred,
|
/// Working-space colour: linear Rec.709 primaries, scene-referred,
|
||||||
/// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14).
|
/// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14).
|
||||||
Scene,
|
Scene,
|
||||||
|
/// The view transform (FR-DEV-3j): after every scene operation, and the
|
||||||
|
/// one stage allowed to map the scene to a display range.
|
||||||
|
///
|
||||||
|
/// Composed **whatever its state**. A neutral operation is otherwise left
|
||||||
|
/// out, but a photograph with no view transform is a scan rather than a
|
||||||
|
/// picture, so a view operation at its defaults still renders — with its
|
||||||
|
/// defaults. "Active" keeps its meaning of "moved from the defaults",
|
||||||
|
/// which is what the sidecar and the panel read. When the chain holds no
|
||||||
|
/// view operation at all the composer supplies a default one.
|
||||||
|
View,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A named WGSL helper function, deduplicated across operations.
|
/// A named WGSL helper function, deduplicated across operations.
|
||||||
@@ -849,16 +859,9 @@ fn compose_inner(
|
|||||||
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
|
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
|
||||||
|
|
||||||
// TRACES: FR-DEV-3j
|
// TRACES: FR-DEV-3j
|
||||||
// The view transform's function, whenever the composer emits the view
|
// Whether the composer emits a view transform — which is every render but
|
||||||
// transform — which is every render but the camera-space tap and one a
|
// the camera-space tap and one a rendering operation has taken over.
|
||||||
// rendering operation has taken over. See `rendering_tail` below.
|
|
||||||
let views = !op_renders && output_mode != OutputMode::CameraLinear;
|
let views = !op_renders && output_mode != OutputMode::CameraLinear;
|
||||||
if views {
|
|
||||||
helpers.push(Helper {
|
|
||||||
name: "view_sigmoid",
|
|
||||||
source: crate::view::VIEW_SIGMOID_WGSL,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Framing's block follows the base one at a fixed offset, for the same
|
// Framing's block follows the base one at a fixed offset, for the same
|
||||||
// reason: the prologue is emitted whether or not any operation is active,
|
// reason: the prologue is emitted whether or not any operation is active,
|
||||||
@@ -910,22 +913,71 @@ fn compose_inner(
|
|||||||
.map(|o| o.as_ref())
|
.map(|o| o.as_ref())
|
||||||
.filter(move |o| o.detail().is_none() && o.stage() == stage)
|
.filter(move |o| o.detail().is_none() && o.stage() == stage)
|
||||||
};
|
};
|
||||||
let mut in_working_space = false;
|
// The view operation, or a default one when the caller's chain holds
|
||||||
for op in point(Stage::Camera).chain(point(Stage::Scene)) {
|
// none — `compose(&[])` in a test, or a probe. Absent altogether where
|
||||||
if !in_working_space && op.stage() == Stage::Scene {
|
// nothing is to be rendered: see `views`.
|
||||||
body.push_str(CAMERA_MATRIX);
|
let default_view = crate::ops::ViewTransform::new();
|
||||||
in_working_space = true;
|
let view: Option<&dyn Operation> = views.then(|| {
|
||||||
}
|
point(Stage::View)
|
||||||
|
.next()
|
||||||
|
.unwrap_or(&default_view as &dyn Operation)
|
||||||
|
});
|
||||||
|
|
||||||
|
// What is emitted, in order: camera RGB, the matrix out of it, the scene,
|
||||||
|
// any layer-only operations, and the view transform last (D19).
|
||||||
|
enum Step<'a> {
|
||||||
|
Op(&'a dyn Operation),
|
||||||
|
Matrix,
|
||||||
|
Orphans,
|
||||||
|
}
|
||||||
|
let steps = point(Stage::Camera)
|
||||||
|
.map(Step::Op)
|
||||||
|
.chain(std::iter::once(Step::Matrix))
|
||||||
|
.chain(point(Stage::Scene).map(Step::Op))
|
||||||
|
.chain(std::iter::once(Step::Orphans))
|
||||||
|
.chain(view.map(Step::Op));
|
||||||
|
|
||||||
|
for step in steps {
|
||||||
|
let op = match step {
|
||||||
|
Step::Op(op) => op,
|
||||||
|
Step::Matrix => {
|
||||||
|
body.push_str(CAMERA_MATRIX);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Step::Orphans => {
|
||||||
|
// A layer's operation the global chain does not hold at
|
||||||
|
// all. Not a case any editor produces — both chains come from
|
||||||
|
// `ops::chain` — but a layer must not lose an edit because a
|
||||||
|
// caller composed a shorter chain.
|
||||||
|
let mut orphans: Vec<&'static str> = Vec::new();
|
||||||
|
for l in &layers.ops {
|
||||||
|
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op)
|
||||||
|
{
|
||||||
|
orphans.push(l.op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for id in orphans {
|
||||||
|
let local: Vec<&crate::mask::LocalOp> =
|
||||||
|
layers.ops.iter().filter(|l| l.op == id).collect();
|
||||||
|
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
|
||||||
|
body.push_str(&local_block("", &local));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let id = op.descriptor().id.0;
|
let id = op.descriptor().id.0;
|
||||||
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
||||||
if !op.is_active() && local.is_empty() {
|
// The global side of the blend. A view operation always has one; see
|
||||||
|
// `Stage::View`.
|
||||||
|
let global = op.is_active() || op.stage() == Stage::View;
|
||||||
|
if !global && local.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let prefix = sanitise(id);
|
let prefix = sanitise(id);
|
||||||
|
|
||||||
let mut fragment = String::new();
|
let mut fragment = String::new();
|
||||||
let mut op_uniforms = Vec::new();
|
let mut op_uniforms = Vec::new();
|
||||||
if op.is_active() {
|
if global {
|
||||||
// Each op's uniforms are prefixed, so two operations may both
|
// Each op's uniforms are prefixed, so two operations may both
|
||||||
// declare a field called `amount` without colliding.
|
// declare a field called `amount` without colliding.
|
||||||
op_uniforms = op.uniforms();
|
op_uniforms = op.uniforms();
|
||||||
@@ -964,27 +1016,6 @@ fn compose_inner(
|
|||||||
body.push_str(&local_block(&fragment, &local));
|
body.push_str(&local_block(&fragment, &local));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emitted here when no scene operation was listed at all, which is what
|
|
||||||
// makes an empty chain still convert out of camera space.
|
|
||||||
if !in_working_space {
|
|
||||||
body.push_str(CAMERA_MATRIX);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A layer's operation the global chain does not hold at all. Not a case
|
|
||||||
// any editor produces — both chains come from `ops::chain` — but a layer
|
|
||||||
// must not lose an edit because a caller composed a shorter chain.
|
|
||||||
let mut orphans: Vec<&'static str> = Vec::new();
|
|
||||||
for l in &layers.ops {
|
|
||||||
if !orphans.contains(&l.op) && !ops.iter().any(|o| o.descriptor().id.0 == l.op) {
|
|
||||||
orphans.push(l.op);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for id in orphans {
|
|
||||||
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
|
||||||
let _ = writeln!(body, "\n // ---- {id} (local only) ----");
|
|
||||||
body.push_str(&local_block("", &local));
|
|
||||||
}
|
|
||||||
|
|
||||||
// TRACES: FR-DEV-19c
|
// TRACES: FR-DEV-19c
|
||||||
// Held apart from the body, because it belongs after the output transform
|
// Held apart from the body, because it belongs after the output transform
|
||||||
// rather than among the operations — see `mask::LayerShader::reveal`.
|
// rather than among the operations — see `mask::LayerShader::reveal`.
|
||||||
@@ -1114,32 +1145,13 @@ fn compose_inner(
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// The view transform, which an operation may have taken over.
|
// Where the view transform is not, say why, for whoever reads the
|
||||||
let rendering_tail = if !views {
|
// generated source. Where it is, it is the last operation block above.
|
||||||
|
let rendering_tail = if views {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
" // No view transform: an operation declaring `Operation::renders` has\n // mapped the scene to a display range itself, or this is the\n // camera-space tap, which stores the sensor's own numbers.\n"
|
" // No view transform: an operation declaring `Operation::renders` has\n // mapped the scene to a display range itself, or this is the\n // camera-space tap, which stores the sensor's own numbers.\n"
|
||||||
.to_string()
|
.to_string()
|
||||||
} else {
|
|
||||||
let curve = crate::view::Sigmoid::default_curve();
|
|
||||||
format!(
|
|
||||||
" // ==== the view transform (FR-DEV-3j) ====
|
|
||||||
//
|
|
||||||
// Marked with `====` and not the `----` an operation block carries: this
|
|
||||||
// is not one, and the difference is what several tests count on to tell
|
|
||||||
// an edit apart from the rendering of one.
|
|
||||||
//
|
|
||||||
// The one stage allowed to map scene-linear colour to a display range
|
|
||||||
// (D19, ARCH §6.14), after every operation. Everything above it is
|
|
||||||
// unbounded; everything the shoulder has not brought under 1.0 is
|
|
||||||
// clipped by the output transform, at the last moment.
|
|
||||||
//
|
|
||||||
// Skipped for an already-rendered source — a JPEG is a display rendering
|
|
||||||
// already, and rendering it again would compress it twice.
|
|
||||||
if (!non_linear) {{
|
|
||||||
c = view_sigmoid(c, {:?}, {:?}, {:?});
|
|
||||||
}}
|
|
||||||
",
|
|
||||||
curve.n, curve.inv_k, curve.w
|
|
||||||
)
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Formatted with Rust's `Display` so the shader reads the same threshold
|
// Formatted with Rust's `Display` so the shader reads the same threshold
|
||||||
@@ -1833,6 +1845,8 @@ mod tests {
|
|||||||
fn an_inactive_operation_contributes_nothing() {
|
fn an_inactive_operation_contributes_nothing() {
|
||||||
// The point of composing rather than branching: an op at neutral
|
// The point of composing rather than branching: an op at neutral
|
||||||
// must not appear in the source at all.
|
// must not appear in the source at all.
|
||||||
|
// Measured against an empty chain rather than the preamble alone,
|
||||||
|
// since every render carries the view transform's uniforms too.
|
||||||
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
|
let ops = vec![fake(DESC_A.clone(), 0.0, false)];
|
||||||
let shader = compose(&ops);
|
let shader = compose(&ops);
|
||||||
assert!(
|
assert!(
|
||||||
@@ -1841,7 +1855,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
shader.uniforms.len(),
|
shader.uniforms.len(),
|
||||||
PREAMBLE_FIELDS,
|
compose(&[]).uniforms.len(),
|
||||||
"it must contribute no uniforms either"
|
"it must contribute no uniforms either"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ pub mod distortion;
|
|||||||
pub mod film_sim;
|
pub mod film_sim;
|
||||||
pub mod local_contrast;
|
pub mod local_contrast;
|
||||||
pub mod noise_reduction;
|
pub mod noise_reduction;
|
||||||
|
pub mod view_transform;
|
||||||
pub mod vignetting;
|
pub mod vignetting;
|
||||||
|
|
||||||
pub use aberration::Aberration;
|
pub use aberration::Aberration;
|
||||||
@@ -87,6 +88,7 @@ pub use film_sim::{FilmSim, FilmTables, PaperTables};
|
|||||||
// documentation for why that is two nodes and not one.
|
// documentation for why that is two nodes and not one.
|
||||||
pub use local_contrast::{Clarity, Texture};
|
pub use local_contrast::{Clarity, Texture};
|
||||||
pub use noise_reduction::NoiseReduction;
|
pub use noise_reduction::NoiseReduction;
|
||||||
|
pub use view_transform::ViewTransform;
|
||||||
pub use vignetting::Vignetting;
|
pub use vignetting::Vignetting;
|
||||||
|
|
||||||
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
// The declared nodes, plus `helpers` and `chain`. Generated into OUT_DIR by
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
//! TRACES: FR-DEV-3j
|
||||||
|
//! The view transform as an operation: the photographer's two numbers for
|
||||||
|
//! the curve [`crate::view`] defines.
|
||||||
|
//!
|
||||||
|
//! # Why it is a node now, when the base curve could not be
|
||||||
|
//!
|
||||||
|
//! The base curve was kept out of the chain for reasons that were all about
|
||||||
|
//! the *body*: it was looked up by camera model, so as a node it would have
|
||||||
|
//! carried one camera's rendering onto another camera's file through a shared
|
||||||
|
//! sidecar, shown a dead slider on an unprofiled body, and opened a profiled
|
||||||
|
//! one reporting itself modified. D19 removed the premise. There is one view
|
||||||
|
//! transform for every body, so its settings are a decision about the picture
|
||||||
|
//! like any other, and they belong in the sidecar, the history and a mask
|
||||||
|
//! layer.
|
||||||
|
//!
|
||||||
|
//! # Why it is composed at its defaults
|
||||||
|
//!
|
||||||
|
//! A neutral operation is normally left out of the shader, and "active" means
|
||||||
|
//! "moved from its defaults". Both stay true here — an untouched photograph
|
||||||
|
//! writes no view transform parameters, and `every_node_starts_neutral` still
|
||||||
|
//! holds — but the composer emits this node whatever its state, because a
|
||||||
|
//! photograph with no view transform is a scan, not a picture. See
|
||||||
|
//! [`crate::operation::Stage::View`].
|
||||||
|
|
||||||
|
use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
|
use crate::descriptor::{
|
||||||
|
Attribute, LocalizedKey, OpDescriptor, OpId, ParamDescriptor, ParamId, Scale, Unit,
|
||||||
|
};
|
||||||
|
use crate::operation::{Helper, Operation, Stage, Uniform};
|
||||||
|
use crate::view::{Sigmoid, CONTRAST_RANGE, DEFAULT_CONTRAST, DEFAULT_WHITE, WHITE_RANGE};
|
||||||
|
|
||||||
|
pub const ID: OpId = OpId("view_transform");
|
||||||
|
pub const CONTRAST: ParamId = ParamId("contrast");
|
||||||
|
pub const WHITE: ParamId = ParamId("white");
|
||||||
|
|
||||||
|
static HELPERS: [Helper; 1] = [Helper {
|
||||||
|
name: "view_sigmoid",
|
||||||
|
source: crate::view::VIEW_SIGMOID_WGSL,
|
||||||
|
}];
|
||||||
|
|
||||||
|
static DESCRIPTOR: LazyLock<Arc<OpDescriptor>> = LazyLock::new(|| {
|
||||||
|
Arc::new(OpDescriptor {
|
||||||
|
// Tone: it is the tone response of the whole picture, and the panel's
|
||||||
|
// Light group is where a photographer looks for the white point.
|
||||||
|
attributes: vec![Attribute::Tone],
|
||||||
|
id: ID,
|
||||||
|
label: LocalizedKey("op.view_transform"),
|
||||||
|
params: vec![
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"contrast",
|
||||||
|
"param.view_transform.contrast",
|
||||||
|
CONTRAST_RANGE.0,
|
||||||
|
CONTRAST_RANGE.1,
|
||||||
|
DEFAULT_CONTRAST,
|
||||||
|
Unit::None,
|
||||||
|
Scale::Linear,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
ParamDescriptor::scalar(
|
||||||
|
"white",
|
||||||
|
"param.view_transform.white",
|
||||||
|
WHITE_RANGE.0,
|
||||||
|
WHITE_RANGE.1,
|
||||||
|
DEFAULT_WHITE,
|
||||||
|
Unit::Stops,
|
||||||
|
Scale::Linear,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ViewTransform {
|
||||||
|
contrast: f32,
|
||||||
|
white: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ViewTransform {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
contrast: DEFAULT_CONTRAST,
|
||||||
|
white: DEFAULT_WHITE,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ViewTransform {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve these settings solve to.
|
||||||
|
pub fn sigmoid(&self) -> Sigmoid {
|
||||||
|
Sigmoid::new(self.contrast, self.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Operation for ViewTransform {
|
||||||
|
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||||
|
DESCRIPTOR.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_param(&mut self, id: ParamId, value: f32) {
|
||||||
|
match id {
|
||||||
|
CONTRAST => self.contrast = value,
|
||||||
|
WHITE => self.white = value,
|
||||||
|
_ => log::warn!("view_transform: unknown parameter {id}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn param(&self, id: ParamId) -> f32 {
|
||||||
|
match id {
|
||||||
|
CONTRAST => self.contrast,
|
||||||
|
WHITE => self.white,
|
||||||
|
_ => 0.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_active(&self) -> bool {
|
||||||
|
self.contrast != DEFAULT_CONTRAST || self.white != DEFAULT_WHITE
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stage(&self) -> Stage {
|
||||||
|
Stage::View
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wgsl_body(&self) -> String {
|
||||||
|
"\
|
||||||
|
// Skipped for an already-rendered source: a JPEG is a display rendering
|
||||||
|
// already, and rendering it again would compress it twice.
|
||||||
|
if (!non_linear) {
|
||||||
|
c = view_sigmoid(c, slope, inv_k, peak);
|
||||||
|
}"
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uniforms(&self) -> Vec<Uniform> {
|
||||||
|
let s = self.sigmoid();
|
||||||
|
vec![
|
||||||
|
Uniform {
|
||||||
|
name: "slope",
|
||||||
|
value: s.n,
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "inv_k",
|
||||||
|
value: s.inv_k,
|
||||||
|
},
|
||||||
|
Uniform {
|
||||||
|
name: "peak",
|
||||||
|
value: s.w,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn helpers(&self) -> &[Helper] {
|
||||||
|
&HELPERS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn it_starts_neutral_and_says_so() {
|
||||||
|
// TRACES: FR-DEV-3j
|
||||||
|
// Neutral in the sense every other node is: nothing moved, so nothing
|
||||||
|
// is written. It is still composed — see the module documentation.
|
||||||
|
let op = ViewTransform::new();
|
||||||
|
assert!(!op.is_active());
|
||||||
|
assert_eq!(op.sigmoid(), Sigmoid::default_curve());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn moving_either_slider_makes_it_active() {
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(WHITE, 6.0);
|
||||||
|
assert!(op.is_active());
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(CONTRAST, 2.0);
|
||||||
|
assert!(op.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_uniforms_are_the_solved_curve() {
|
||||||
|
let mut op = ViewTransform::new();
|
||||||
|
op.set_param(CONTRAST, 2.0);
|
||||||
|
op.set_param(WHITE, 6.0);
|
||||||
|
let s = Sigmoid::new(2.0, 6.0);
|
||||||
|
let u = op.uniforms();
|
||||||
|
assert_eq!(
|
||||||
|
u.iter().map(|u| u.value).collect::<Vec<_>>(),
|
||||||
|
vec![s.n, s.inv_k, s.w]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_descriptor_defaults_are_the_curve_defaults() {
|
||||||
|
// The sidecar treats a value equal to the descriptor's default as
|
||||||
|
// unedited; the two disagreeing would make every photograph open
|
||||||
|
// reporting a view transform edit it never had.
|
||||||
|
let d = ViewTransform::new().descriptor();
|
||||||
|
assert_eq!(
|
||||||
|
d.param(CONTRAST).expect("contrast").default,
|
||||||
|
DEFAULT_CONTRAST
|
||||||
|
);
|
||||||
|
assert_eq!(d.param(WHITE).expect("white").default, DEFAULT_WHITE);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,7 +32,8 @@
|
|||||||
//! # What is not covered, and why that is honest
|
//! # What is not covered, and why that is honest
|
||||||
//!
|
//!
|
||||||
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
//! A `rust:` node — `tone_curve`, `colour_mixer`, `film_sim`,
|
||||||
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze` —
|
//! `capture_sharpen`, `noise_reduction`, `clarity`, `texture`, `dehaze`,
|
||||||
|
//! `view_transform` —
|
||||||
//! names a hand-written type and has no declaration to interpret. It is not skipped
|
//! names a hand-written type and has no declaration to interpret. It is not skipped
|
||||||
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
//! silently: [`every_declared_node_is_checked`] asserts the two sets partition
|
||||||
//! `ops/` between them, so a node that stops being declared cannot quietly
|
//! `ops/` between them, so a node that stops being declared cannot quietly
|
||||||
@@ -403,6 +404,7 @@ fn every_declared_node_is_checked() {
|
|||||||
"noise_reduction",
|
"noise_reduction",
|
||||||
"texture",
|
"texture",
|
||||||
"tone_curve",
|
"tone_curve",
|
||||||
|
"view_transform",
|
||||||
"vignetting",
|
"vignetting",
|
||||||
],
|
],
|
||||||
"the set of hand-written nodes changed; if that is deliberate, update \
|
"the set of hand-written nodes changed; if that is deliberate, update \
|
||||||
|
|||||||
+18
-18
File diff suppressed because one or more lines are too long
@@ -203,12 +203,20 @@ fn catalogued(key: &str) -> Option<&'static str> {
|
|||||||
// it is a tick box, and a checkbox labelled with a verb reads as a
|
// it is a tick box, and a checkbox labelled with a verb reads as a
|
||||||
// button that does something once rather than a state that is on.
|
// button that does something once rather than a state that is on.
|
||||||
"op.lens_profile" => "Lens Profile",
|
"op.lens_profile" => "Lens Profile",
|
||||||
|
// The view transform (FR-DEV-3j). "Tone Mapping" rather than the
|
||||||
|
// "View Transform" `derive` would give: the id names where it sits in
|
||||||
|
// the pipeline, and the photographer is choosing how the scene's range
|
||||||
|
// is fitted onto the screen.
|
||||||
|
"op.view_transform" => "Tone Mapping",
|
||||||
|
|
||||||
// Parameters
|
// Parameters
|
||||||
// Named for what it does rather than what it is, since a lone
|
// Named for what it does rather than what it is, since a lone
|
||||||
// parameter is titled by its operation and this one never reaches the
|
// parameter is titled by its operation and this one never reaches the
|
||||||
// panel under its own name — see `rows_filtered`.
|
// panel under its own name — see `rows_filtered`.
|
||||||
"param.lens_profile.apply" => "Apply",
|
"param.lens_profile.apply" => "Apply",
|
||||||
|
"param.view_transform.contrast" => "Contrast",
|
||||||
|
// In stops above middle grey: where the scene reaches display white.
|
||||||
|
"param.view_transform.white" => "White Point",
|
||||||
"param.temperature" => "Temperature",
|
"param.temperature" => "Temperature",
|
||||||
"param.tint" => "Tint",
|
"param.tint" => "Tint",
|
||||||
"param.exposure" => "Exposure",
|
"param.exposure" => "Exposure",
|
||||||
|
|||||||
Reference in New Issue
Block a user