Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its last point: every value above 1.0 left it as the same number, per channel. Exposure and highlight recovery put values up there, and the curve threw them away, then handed the result on as though it were still scene-linear. The six per-body curves were also, by their own file's account, hand-tuned shapes rather than measurements, and not enough is known about where they came from to keep them (D19). In their place, one view transform for every body (FR-DEV-3j): a log-logistic sigmoid per channel, with the middle channel put back between the other two so a hue survives the shoulder. Its two free constants are solved from two conditions rather than set: scene grey 0.13, where the retired default curve put it, lands on display 0.18, and the scene white four stops above grey lands on 1.0. So a highlight a stop past sensor saturation still rolls into white, and the midtones stay within 0.26 EV of the retired default between scene 0.03 and 1.0. `dr_pipeline::view` holds the CPU reference and the WGSL, and the tests there are FR-DEV-3j's acceptance criteria. It is still fixed and still in the fused pass's tail, so a detail stage still sees rendered values; the next commits make it an operation and move it after the detail stage. It is skipped for a JPEG, as the base curve was, and absent from the camera-space tap. The base curve's database, its lookup and its twelve uniform slots go. `RawImage` and `DemosaicedImage` lose the field, and the GPU test that proved a curve reached the shader is replaced by one that renders the view transform against the CPU reference and shows two highlights above 1.0 still render apart. The JPEG-and-sensor test now asserts the two differ by exactly the view transform, where before an identity fixture curve had made them match.
This commit is contained in:
@@ -294,40 +294,18 @@ in raw pixels is a different photograph on screen and in the exported file.
|
||||
## What is not a node, and why
|
||||
|
||||
Three things act on every pixel and are deliberately not in this directory:
|
||||
the as-shot white balance, the camera matrix, and the **base curve**
|
||||
(FR-DEV-3e). They are emitted by [`../src/operation.rs`](../src/operation.rs)
|
||||
into the composed shader's fixed preamble, around the block of nodes.
|
||||
the as-shot white balance, the camera matrix, and the **view transform**
|
||||
(FR-DEV-3j). They are emitted by [`../src/operation.rs`](../src/operation.rs)
|
||||
into the composed shader around the block of nodes.
|
||||
|
||||
The test is not "does it transform a colour" — all three do. It is **whose
|
||||
decision is it**. A node is something a photographer chose: it has parameters,
|
||||
it moves off a neutral, it lands in the sidecar, it can be undone. These three
|
||||
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 chose the sensor's
|
||||
green sensitivity or the body's rendering; they are what reading the file
|
||||
correctly means.
|
||||
|
||||
Making the base curve a node would have said the opposite in four places at
|
||||
once. It would have appeared in the develop panel as a control, so an
|
||||
unprofiled body would show a slider that does nothing. Its values would have
|
||||
gone into the sidecar, and sidecars are shared between devices and bodies
|
||||
(FR-NC-9) — one camera's rendering would follow an edit onto another camera's
|
||||
file. Its neutral would have had to be "the identity", so a profiled body would
|
||||
open reporting itself modified. And there is no seam through which a node could
|
||||
learn which camera took the frame: the profile arrives on the decoded image,
|
||||
travels through `DemosaicedImage` beside the matrix it belongs with, and is
|
||||
written into the uniform block by the same three lines in `dr-gpu` — which is
|
||||
exactly the path the matrix already took, because it is exactly the same kind
|
||||
of thing.
|
||||
|
||||
What it *does* share with the tone curve node is the spline. The composer asks
|
||||
`ToneCurve` for its `curve_span`/`curve_eval` helpers rather than emitting a
|
||||
second copy, so a profile author placing a control point and a photographer
|
||||
dragging one mean the same thing by it.
|
||||
|
||||
The order still reads correctly from this directory: the base curve runs after
|
||||
every node in the chain. That is the same reasoning `exposure` records under
|
||||
`placement:` — corrections to capture are only meaningful on linear values, so
|
||||
the rendering goes last.
|
||||
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
|
||||
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
|
||||
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
|
||||
values. It replaced the per-body **base curve**, which was looked up by camera
|
||||
model and flat past 1.0, so it clipped every recovered highlight.
|
||||
|
||||
## Stages
|
||||
|
||||
|
||||
@@ -50,6 +50,7 @@ pub mod preset;
|
||||
pub mod sidecar;
|
||||
pub mod spot;
|
||||
pub mod state;
|
||||
pub mod view;
|
||||
|
||||
pub use coverage::Coverage;
|
||||
pub use declared::{Declaration, DeclaredOp};
|
||||
@@ -66,8 +67,7 @@ pub use history::{Edit, Entry as HistoryEntry, History, Step};
|
||||
pub use lens::{compose_warps, ComposedWarp, LensProfile, Tca, Warp};
|
||||
pub use operation::{
|
||||
compose, compose_with_framing, Affects, ComposedShader, Helper, Invalidation, Operation,
|
||||
OutputMode, Uniform, BASE_CURVE_POINTS, BASE_CURVE_UNIFORM_OFFSET, CLIP_ONSET,
|
||||
RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
||||
OutputMode, Stage, Uniform, CLIP_ONSET, RESERVED_UNIFORM_FIELDS, SAMPLE_CACHE_UNIFORM_OFFSET,
|
||||
};
|
||||
pub use preset::{LibraryParseError, NameError, Preset, PresetLibrary, Reach, Scope};
|
||||
pub use sidecar::{Sidecar, Version};
|
||||
|
||||
@@ -557,12 +557,12 @@ pub struct ComposedShader {
|
||||
/// Fields the generated uniform struct always carries, before op uniforms.
|
||||
///
|
||||
/// WGSL requires a uniform struct to be non-empty and 16-byte aligned; these
|
||||
/// are needed by every generated shader in any case.
|
||||
/// are needed by every generated shader in any case: the matrix, the as-shot
|
||||
/// balance and the sample cache's flags. Framing's block follows.
|
||||
///
|
||||
/// Twelve of the twenty-eight are the camera profile's base curve
|
||||
/// ([`BASE_CURVE_UNIFORM_FIELDS`]); the rest are the matrix, the as-shot
|
||||
/// balance and framing's own block.
|
||||
const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS + BASE_CURVE_UNIFORM_FIELDS;
|
||||
/// Twelve fewer than before D19, which retired the base curve that sat at the
|
||||
/// end of this block.
|
||||
const BASE_UNIFORM_FIELDS: usize = 16 + SAMPLE_CACHE_UNIFORM_FIELDS;
|
||||
|
||||
/// Slots the sample cache's two flags occupy: read, write, and two spare to
|
||||
/// keep the block a whole `vec4`. See [`ComposedShader::sample_key`].
|
||||
@@ -571,36 +571,11 @@ const SAMPLE_CACHE_UNIFORM_FIELDS: usize = 4;
|
||||
/// Where the sample cache's flags sit in the generated uniform block: `x` says
|
||||
/// read the source colour from the cache, `y` says write it there.
|
||||
///
|
||||
/// Exported for the reason [`BASE_CURVE_UNIFORM_OFFSET`] is — `dr-gpu` writes
|
||||
/// Exported for the reason [`RESERVED_UNIFORM_FIELDS`] is — `dr-gpu` writes
|
||||
/// these by index — and zero in every block the composer hands out, so a
|
||||
/// caller that never heard of the cache gets the direct read it always had.
|
||||
pub const SAMPLE_CACHE_UNIFORM_OFFSET: usize = 16;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Slots the base curve occupies: five `(x, y)` points and an active flag.
|
||||
///
|
||||
/// Twelve rather than eleven so the block stays a whole number of `vec4`s,
|
||||
/// which is what std140 requires of a uniform struct's members. The spare
|
||||
/// float is left zero rather than repurposed — a uniform slot that means one
|
||||
/// thing today and two things next year is how a shader comes to read a
|
||||
/// highlight rolloff out of a crop rectangle.
|
||||
const BASE_CURVE_UNIFORM_FIELDS: usize = 12;
|
||||
|
||||
/// TRACES: FR-DEV-3e
|
||||
/// Where the base curve's slots begin in the generated uniform block.
|
||||
///
|
||||
/// Exported for the same reason [`RESERVED_UNIFORM_FIELDS`] is: `dr-gpu`
|
||||
/// writes these by index, and an offset computed independently at both ends is
|
||||
/// an offset that will eventually disagree with itself.
|
||||
pub const BASE_CURVE_UNIFORM_OFFSET: usize =
|
||||
SAMPLE_CACHE_UNIFORM_OFFSET + SAMPLE_CACHE_UNIFORM_FIELDS;
|
||||
|
||||
/// How many control points a base curve carries.
|
||||
///
|
||||
/// The same five the tone curve widget has, deliberately — see the helper
|
||||
/// selection in [`compose_full`].
|
||||
pub const BASE_CURVE_POINTS: usize = 5;
|
||||
|
||||
/// Where the highlight desaturation begins: the fraction of the white level
|
||||
/// above which a photosite is treated as clipped.
|
||||
///
|
||||
@@ -869,42 +844,20 @@ fn compose_inner(
|
||||
\x20 // The sample cache (see `ComposedShader::sample_key`): `.x` reads\n\
|
||||
\x20 // the source colour from `sampled`, `.y` writes it to\n\
|
||||
\x20 // `sample_out`. Zero for both is the direct read.\n\
|
||||
\x20 sample_cache: vec4<f32>,\n\
|
||||
\x20 // The camera profile's base curve (FR-DEV-3e): five points on a\n\
|
||||
\x20 // monotone spline, packed as x0..x3, y0..y3, then (x4, y4, on).\n\
|
||||
\x20 // `.z` of the last is the flag, not padding — it is 0 for a\n\
|
||||
\x20 // body with no profile and for an already-rendered source.\n\
|
||||
\x20 base_curve_x: vec4<f32>,\n\
|
||||
\x20 base_curve_y: vec4<f32>,\n\
|
||||
\x20 base_curve_last: vec4<f32>,\n",
|
||||
\x20 sample_cache: vec4<f32>,\n",
|
||||
);
|
||||
uniform_values.resize(BASE_UNIFORM_FIELDS, 0.0);
|
||||
|
||||
// TRACES: FR-DEV-3e
|
||||
// The spline the base curve is evaluated on is the *tone curve's* spline,
|
||||
// reached through the trait rather than reimplemented here.
|
||||
//
|
||||
// Two reasons, and the second is the one that matters. The obvious one is
|
||||
// that a shader carrying two `curve_eval`s would not compile, and the
|
||||
// composer's helper de-duplication is what makes both stages able to ask
|
||||
// for it. The real one is that a profile author placing a control point
|
||||
// and a photographer dragging one must mean the same thing by it — down to
|
||||
// the Fritsch-Carlson tangent limiting, which is what decides how a
|
||||
// shoulder actually rolls off. Two implementations that agreed today would
|
||||
// be two that could disagree later, and the disagreement would show up as
|
||||
// a body whose profile renders subtly differently from the curve someone
|
||||
// drew to match it.
|
||||
//
|
||||
// Emitted unconditionally, unlike an operation's helpers. The base curve
|
||||
// is active for every RAW frame — an unprofiled body still gets the
|
||||
// database's default rendering — so making the shader's shape depend on it
|
||||
// would split the pipeline cache in two for no benefit. The uniform flag
|
||||
// above turns it off for the cases that are genuinely already rendered,
|
||||
// and a branch on a uniform is coherent across the whole dispatch.
|
||||
for h in crate::ops::ToneCurve::new().helpers() {
|
||||
if matches!(h.name, "curve_span" | "curve_eval") {
|
||||
helpers.push(*h);
|
||||
}
|
||||
// TRACES: FR-DEV-3j
|
||||
// The view transform's function, whenever the composer emits the view
|
||||
// transform — which is every render but the camera-space tap and one a
|
||||
// rendering operation has taken over. See `rendering_tail` below.
|
||||
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
|
||||
@@ -1161,47 +1114,32 @@ fn compose_inner(
|
||||
),
|
||||
};
|
||||
|
||||
// The base curve, which an operation may have taken over.
|
||||
let rendering_tail = if op_renders {
|
||||
" // The base curve is absent: an operation declaring\n // `Operation::renders` has done its job, and doing it again would render\n // the picture twice.\n"
|
||||
// The view transform, which an operation may have taken over.
|
||||
let rendering_tail = if !views {
|
||||
" // 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()
|
||||
} else {
|
||||
" // ==== camera profile: the base curve (FR-DEV-3e) ====
|
||||
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 reading of a file.
|
||||
// an edit apart from the rendering of one.
|
||||
//
|
||||
// After every operation, and — since D19 moved the matrix to the front of
|
||||
// the chain — on working-space colour rather than the camera RGB it was
|
||||
// tuned against. An interim placement: the per-body curve is being
|
||||
// replaced by a view transform after the detail stage (FR-DEV-3j).
|
||||
// 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.
|
||||
//
|
||||
// The branch is on a uniform, so the whole dispatch takes the same path.
|
||||
// It is off for a JPEG and any other already-rendered source, which must
|
||||
// not be rendered twice, and for a body the profile database declines to
|
||||
// offer any curve for at all.
|
||||
if (u.base_curve_last.z > 0.5) {
|
||||
c = vec3<f32>(
|
||||
curve_eval(
|
||||
u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y,
|
||||
u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w,
|
||||
u.base_curve_last.x, u.base_curve_last.y, c.r,
|
||||
),
|
||||
curve_eval(
|
||||
u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y,
|
||||
u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w,
|
||||
u.base_curve_last.x, u.base_curve_last.y, c.g,
|
||||
),
|
||||
curve_eval(
|
||||
u.base_curve_x.x, u.base_curve_y.x, u.base_curve_x.y, u.base_curve_y.y,
|
||||
u.base_curve_x.z, u.base_curve_y.z, u.base_curve_x.w, u.base_curve_y.w,
|
||||
u.base_curve_last.x, u.base_curve_last.y, c.b,
|
||||
),
|
||||
);
|
||||
}
|
||||
"
|
||||
.to_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, {:?}, {:?}, {:?});
|
||||
}}
|
||||
",
|
||||
curve.n, curve.inv_k, curve.w
|
||||
)
|
||||
};
|
||||
|
||||
// Formatted with Rust's `Display` so the shader reads the same threshold
|
||||
@@ -2270,10 +2208,10 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_rendering_operation_takes_over_the_base_curve() {
|
||||
// TRACES: FR-DEV-3e | FR-DEV-3f
|
||||
// A film stock's characteristic curve does the base curve's job.
|
||||
// Emitting the profile's rendering as well would render the scene
|
||||
fn a_rendering_operation_takes_over_the_view_transform() {
|
||||
// TRACES: FR-DEV-3j | FR-DEV-3f
|
||||
// A film stock's characteristic curve does the view transform's job.
|
||||
// Emitting the default rendering as well would render the scene
|
||||
// twice — a picture that comes out looking like neither the camera's
|
||||
// rendering nor the film's, with a colour-management bug's signature
|
||||
// and no colour-management bug to find.
|
||||
@@ -2299,8 +2237,8 @@ mod tests {
|
||||
.find("---- film_sim ----")
|
||||
.expect("the operation itself must still be emitted");
|
||||
assert!(
|
||||
!source.contains("base_curve_last.z > 0.5"),
|
||||
"the base curve is still being applied on top of the film"
|
||||
!source.contains("view_sigmoid"),
|
||||
"the view transform is still being applied on top of the film"
|
||||
);
|
||||
// D19: the film no longer converts out of camera space itself. The
|
||||
// composer does, once, ahead of it — the film is handed working-space
|
||||
@@ -2323,7 +2261,7 @@ mod tests {
|
||||
// suppressed the tail unconditionally renders every ordinary edit
|
||||
// flat and uncorrected, which reads as a broken camera profile.
|
||||
let source = compose(&[fake(DESC_A.clone(), 2.0, false)]).source;
|
||||
assert!(source.contains("base_curve_last.z > 0.5"));
|
||||
assert!(source.contains("c = view_sigmoid("));
|
||||
assert!(source.contains("camera profile: the matrix"));
|
||||
}
|
||||
|
||||
@@ -2335,7 +2273,7 @@ mod tests {
|
||||
// catalogue, and it must not disturb the camera's own rendering.
|
||||
let film: Box<dyn Operation> = Box::new(crate::ops::FilmSim::new());
|
||||
let source = compose(&[film, fake(DESC_A.clone(), 2.0, false)]).source;
|
||||
assert!(source.contains("base_curve_last.z > 0.5"));
|
||||
assert!(source.contains("c = view_sigmoid("));
|
||||
assert!(source.contains("camera profile: the matrix"));
|
||||
}
|
||||
|
||||
@@ -2374,8 +2312,8 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_curve_runs_after_the_operations() {
|
||||
// TRACES: FR-DEV-3e
|
||||
fn the_view_transform_runs_after_the_operations() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// Exposure and the tonal controls are corrections to capture, and
|
||||
// they are only meaningful on linear values. A stop is a doubling; run
|
||||
// exposure after a curve and it is not one any more, and every slider
|
||||
@@ -2383,66 +2321,23 @@ mod tests {
|
||||
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
||||
let source = compose(&ops).source;
|
||||
let op = source.find("---- op_a ----").expect("op present");
|
||||
let curve = source
|
||||
.find("if (u.base_curve_last.z > 0.5)")
|
||||
.expect("base curve applied");
|
||||
assert!(op < curve, "the base curve must come after the operations");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_curve_reaches_a_shader_with_no_operations_at_all() {
|
||||
// TRACES: FR-DEV-3e
|
||||
// The same property as as-shot white balance, and for the same reason:
|
||||
// it is part of interpreting the file, not part of the edit. An
|
||||
// unedited RAW must open looking like a photograph rather than like a
|
||||
// scan of one.
|
||||
let shader = compose(&[]);
|
||||
assert!(shader.source.contains("u.base_curve_x"));
|
||||
let view = source.find("c = view_sigmoid(").expect("view applied");
|
||||
assert!(
|
||||
shader.source.contains("fn curve_eval("),
|
||||
"the spline it is evaluated on must be emitted too"
|
||||
op < view,
|
||||
"the view transform must come after the operations"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_curve_and_the_tone_curve_share_one_spline() {
|
||||
// TRACES: FR-DEV-3e
|
||||
// Two `curve_eval`s in one shader would not compile — but the reason
|
||||
// the helper is *shared* rather than merely renamed is that a profile
|
||||
// author placing a control point and a photographer dragging one must
|
||||
// mean the same thing by it, down to the tangent limiting that decides
|
||||
// how a shoulder rolls off.
|
||||
let mut curve = crate::ops::ToneCurve::new();
|
||||
curve.set_param(crate::ops::curve::P2_Y, 0.7);
|
||||
assert!(
|
||||
curve.is_active(),
|
||||
"the fixture must actually reach the shader"
|
||||
);
|
||||
|
||||
let source = compose(&[Box::new(curve)]).source;
|
||||
assert_eq!(
|
||||
source.matches("fn curve_eval(").count(),
|
||||
1,
|
||||
"the spline must be declared exactly once"
|
||||
);
|
||||
assert_eq!(source.matches("fn curve_span(").count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_base_curve_owns_the_slots_dr_gpu_writes() {
|
||||
// TRACES: FR-DEV-3e
|
||||
// `dr-gpu` fills these by index. The offset is exported rather than
|
||||
// recomputed there, and this asserts the exported number still points
|
||||
// at the block the shader declares — the failure otherwise is a
|
||||
// highlight rolloff read out of a crop rectangle, which renders as
|
||||
// nonsense rather than as an error.
|
||||
assert_eq!(
|
||||
BASE_CURVE_UNIFORM_OFFSET + BASE_CURVE_UNIFORM_FIELDS,
|
||||
BASE_UNIFORM_FIELDS,
|
||||
"the base curve must be the last thing in the base block"
|
||||
);
|
||||
assert_eq!(BASE_CURVE_POINTS * 2 + 1, BASE_CURVE_UNIFORM_FIELDS - 1);
|
||||
assert!(compose(&[]).uniforms.len() >= BASE_UNIFORM_FIELDS);
|
||||
fn the_view_transform_reaches_a_shader_with_no_operations_at_all() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// An unedited RAW must open looking like a photograph rather than
|
||||
// like a scan of one, and it is skipped only for a source that is
|
||||
// already a rendering.
|
||||
let source = compose(&[]).source;
|
||||
assert!(source.contains("c = view_sigmoid("));
|
||||
assert!(source.contains("fn view_sigmoid("));
|
||||
assert!(source.contains("if (!non_linear)"));
|
||||
}
|
||||
|
||||
/// Compose with neutral framing into a chosen output space.
|
||||
@@ -2450,6 +2345,15 @@ mod tests {
|
||||
compose_with_framing(ops, &Framing::new(), output)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_camera_space_tap_has_no_view_transform() {
|
||||
// TRACES: FR-MRG-2
|
||||
// A merge stitches the sensor's own numbers; a rendering in them
|
||||
// would be developed a second time when the composite is opened.
|
||||
let source = compose_camera_probe(&[], &crate::framing::Framing::default()).source;
|
||||
assert!(!source.contains("view_sigmoid"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_srgb_render_is_byte_for_byte_what_it_was_before_output_spaces_existed() {
|
||||
// The display path is the shader compiled on nearly every frame, and
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
//! TRACES: FR-DEV-3j | FR-DEV-2
|
||||
//! The view transform — the one stage that maps scene-linear colour to a
|
||||
//! display range (D19, ARCH §6.14).
|
||||
//!
|
||||
//! # What it is
|
||||
//!
|
||||
//! A log-logistic sigmoid, per channel:
|
||||
//!
|
||||
//! ```text
|
||||
//! f(x) = w · r / (1 + r), r = (x / k)^n
|
||||
//! ```
|
||||
//!
|
||||
//! `n` is the contrast — the slope in log-log terms, before the shoulder
|
||||
//! bends it. `k` and `w` are solved from two conditions rather than set:
|
||||
//! scene middle grey lands on display middle grey, and the scene white the
|
||||
//! photographer chose lands on display white. So the curve has a toe, a
|
||||
//! midtone slope and a shoulder that approaches `w` — a hair above 1.0 —
|
||||
//! without ever reaching it. Everything the shoulder has not reached by the
|
||||
//! white point is clipped by the output transform, which is the last moment
|
||||
//! and the only place a clip belongs.
|
||||
//!
|
||||
//! # Why per channel, and why the middle channel is put back
|
||||
//!
|
||||
//! Per channel is what makes a bright saturated colour desaturate as it
|
||||
//! approaches white — a blown sky rolls toward white rather than toward a
|
||||
//! saturated corner of the gamut, which is what film and every camera JPEG
|
||||
//! do. It also bends hue: the three channels sit at different places on the
|
||||
//! curve, so their ratios change, and an orange flame drifts toward yellow.
|
||||
//! So after the curve the middle channel is moved back to where it sat
|
||||
//! *between the other two* before it — the same fraction of the way from the
|
||||
//! smallest to the largest. The smallest and largest keep what the curve gave
|
||||
//! them, which keeps the desaturation; the hue, which is decided by that
|
||||
//! fraction, survives. It is the "preserve hue" step of darktable's sigmoid,
|
||||
//! at full strength.
|
||||
//!
|
||||
//! # Why these defaults
|
||||
//!
|
||||
//! [`SCENE_GREY`] is where the retired default base curve put middle grey
|
||||
//! (FR-DEV-3e): linear sensor data from a correctly exposed frame has it
|
||||
//! near 13% of saturation, and a camera JPEG shows it at 18%. The contrast
|
||||
//! and white defaults were chosen against that same retired curve: at 1.4 and
|
||||
//! 4 stops the midtones stay within a quarter of a stop of it between scene
|
||||
//! 0.03 and 1.0, while a highlight a stop past sensor saturation still rolls
|
||||
//! into white rather than stopping dead at it. The upper midtones come out a
|
||||
//! little darker than the curve had them, which is the price of that
|
||||
//! headroom and what the white slider is for.
|
||||
|
||||
/// Scene-linear middle grey: where the retired default curve placed it.
|
||||
pub const SCENE_GREY: f32 = 0.13;
|
||||
|
||||
/// Display-linear middle grey — what a camera JPEG shows a grey card as.
|
||||
pub const DISPLAY_GREY: f32 = 0.18;
|
||||
|
||||
/// The default contrast, the sigmoid's log-log slope parameter `n`.
|
||||
pub const DEFAULT_CONTRAST: f32 = 1.4;
|
||||
|
||||
/// The default white point, in stops above [`SCENE_GREY`].
|
||||
pub const DEFAULT_WHITE: f32 = 4.0;
|
||||
|
||||
/// The contrast range a photographer is offered.
|
||||
pub const CONTRAST_RANGE: (f32, f32) = (1.0, 3.0);
|
||||
|
||||
/// The white point range, in stops above middle grey.
|
||||
///
|
||||
/// The floor is not taste. The two conditions `k` and `w` are solved from
|
||||
/// have a solution only while `2^(white · n)` exceeds `1 / DISPLAY_GREY`,
|
||||
/// and at the lowest contrast that needs `white` above about 2.47 stops.
|
||||
pub const WHITE_RANGE: (f32, f32) = (2.5, 10.0);
|
||||
|
||||
/// The curve's three numbers, solved from the photographer's two.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Sigmoid {
|
||||
/// Contrast: the exponent.
|
||||
pub n: f32,
|
||||
/// `1 / k`, so the shader multiplies rather than divides.
|
||||
pub inv_k: f32,
|
||||
/// The asymptote the shoulder approaches, a little above 1.0.
|
||||
pub w: f32,
|
||||
}
|
||||
|
||||
impl Sigmoid {
|
||||
/// Solve the curve for a contrast and a white point in stops.
|
||||
///
|
||||
/// Out-of-range inputs are clamped to [`CONTRAST_RANGE`] and
|
||||
/// [`WHITE_RANGE`] rather than trusted: they arrive from a sidecar, which
|
||||
/// may have been written by a build with other limits, and outside them
|
||||
/// the solution below divides by something that is no longer positive.
|
||||
///
|
||||
/// With `r_g` the value of `r` at scene grey and `q = 2^(white · n)`, the
|
||||
/// two conditions `f(grey) = display grey` and `f(grey · 2^white) = 1`
|
||||
/// are `w·r_g/(1+r_g) = g` and `w·q·r_g/(1+q·r_g) = 1`. Dividing one by
|
||||
/// the other eliminates `w` and leaves `r_g = (g·q − 1) / (q·(1 − g))`.
|
||||
pub fn new(contrast: f32, white: f32) -> Self {
|
||||
let n = contrast.clamp(CONTRAST_RANGE.0, CONTRAST_RANGE.1) as f64;
|
||||
let white = white.clamp(WHITE_RANGE.0, WHITE_RANGE.1) as f64;
|
||||
let g = f64::from(DISPLAY_GREY);
|
||||
let q = (white * n).exp2();
|
||||
let r_grey = (g * q - 1.0) / (q * (1.0 - g));
|
||||
let w = g * (1.0 + r_grey) / r_grey;
|
||||
// r = (x / k)^n, and r at scene grey is r_grey, so
|
||||
// k = grey / r_grey^(1/n).
|
||||
let k = f64::from(SCENE_GREY) / r_grey.powf(1.0 / n);
|
||||
Self {
|
||||
n: n as f32,
|
||||
inv_k: (1.0 / k) as f32,
|
||||
w: w as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// The default curve.
|
||||
pub fn default_curve() -> Self {
|
||||
Self::new(DEFAULT_CONTRAST, DEFAULT_WHITE)
|
||||
}
|
||||
|
||||
/// One channel through the curve. The CPU reference the shader is
|
||||
/// tested against.
|
||||
pub fn channel(&self, x: f32) -> f32 {
|
||||
let r = (x.max(0.0) * self.inv_k).powf(self.n);
|
||||
self.w * r / (1.0 + r)
|
||||
}
|
||||
|
||||
/// A colour through the curve, with the middle channel put back between
|
||||
/// the other two. See the module documentation.
|
||||
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
||||
let x = c.map(|v| v.max(0.0));
|
||||
let y = x.map(|v| self.channel(v));
|
||||
let lo = x[0].min(x[1]).min(x[2]);
|
||||
let hi = x[0].max(x[1]).max(x[2]);
|
||||
if hi - lo <= 1e-9 {
|
||||
return y;
|
||||
}
|
||||
let (y_lo, y_hi) = (self.channel(lo), self.channel(hi));
|
||||
x.map(|v| y_lo + (y_hi - y_lo) * (v - lo) / (hi - lo))
|
||||
}
|
||||
}
|
||||
|
||||
/// The WGSL twin of [`Sigmoid::apply`], as a helper function.
|
||||
pub const VIEW_SIGMOID_WGSL: &str = "\
|
||||
// The view transform (FR-DEV-3j): a log-logistic sigmoid per channel, then the
|
||||
// middle channel put back between the other two so that the hue survives the
|
||||
// shoulder. See `dr_pipeline::view` for the derivation and the defaults.
|
||||
fn view_sigmoid(c: vec3<f32>, n: f32, inv_k: f32, w: f32) -> vec3<f32> {
|
||||
// Negative components are colours outside the working primaries. They are
|
||||
// floored here, at the last stage, which is the one place a gamut clip
|
||||
// belongs.
|
||||
let x = max(c, vec3<f32>(0.0));
|
||||
let lo = min(x.r, min(x.g, x.b));
|
||||
let hi = max(x.r, max(x.g, x.b));
|
||||
let r_lo = pow(lo * inv_k, n);
|
||||
let r_hi = pow(hi * inv_k, n);
|
||||
let y_lo = w * r_lo / (1.0 + r_lo);
|
||||
let y_hi = w * r_hi / (1.0 + r_hi);
|
||||
// Each channel's place between the smallest and the largest. A neutral
|
||||
// has no spread, and every channel then takes the one value there is.
|
||||
let spread = hi - lo;
|
||||
let t = select((x - vec3<f32>(lo)) / max(spread, 1e-9), vec3<f32>(0.0), spread <= 1e-9);
|
||||
return vec3<f32>(y_lo) + (y_hi - y_lo) * t;
|
||||
}
|
||||
";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The retired default base curve, for the acceptance comparison: five
|
||||
/// points through the unit square, sampled here by straight lines between
|
||||
/// them in log-log terms — close enough to the monotone spline that drew
|
||||
/// it for a tolerance measured in quarters of a stop. Below its second
|
||||
/// point it was a straight line from the origin.
|
||||
fn retired_default(x: f32) -> f32 {
|
||||
const P: [(f32, f32); 4] = [(0.04, 0.043), (0.13, 0.175), (0.45, 0.690), (1.0, 1.0)];
|
||||
if x < 0.04 {
|
||||
return x * (0.043 / 0.04);
|
||||
}
|
||||
let x = x.min(1.0);
|
||||
let i = P.windows(2).position(|w| x <= w[1].0).unwrap_or(2);
|
||||
let ((x0, y0), (x1, y1)) = (P[i], P[i + 1]);
|
||||
let t = (x.ln() - x0.ln()) / (x1.ln() - x0.ln());
|
||||
(y0.ln() + t * (y1.ln() - y0.ln())).exp()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn middle_grey_lands_on_display_grey() {
|
||||
// TRACES: FR-DEV-3j
|
||||
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0), (3.0, 10.0)] {
|
||||
let s = Sigmoid::new(contrast, white);
|
||||
let got = s.channel(SCENE_GREY);
|
||||
assert!(
|
||||
(got - DISPLAY_GREY).abs() < 0.01,
|
||||
"contrast {contrast}, white {white}: grey went to {got}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_white_point_reaches_display_white() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// The whole meaning of the slider: the scene value it names is where
|
||||
// the picture reaches white, and not before.
|
||||
for (contrast, white) in [(1.0, 3.0), (1.4, 4.0), (2.5, 8.0)] {
|
||||
let s = Sigmoid::new(contrast, white);
|
||||
let at = SCENE_GREY * white.exp2();
|
||||
assert!((s.channel(at) - 1.0).abs() < 1e-4, "{}", s.channel(at));
|
||||
assert!(s.channel(at * 0.9) < 1.0);
|
||||
assert!(s.w > 1.0, "the shoulder must approach a value above white");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_is_monotone_and_keeps_going_past_one() {
|
||||
// TRACES: FR-DEV-3j | FR-DEV-2
|
||||
// What the base curve got wrong: it was flat past 1.0, so every
|
||||
// recovered highlight left it as the same number.
|
||||
let s = Sigmoid::default_curve();
|
||||
let mut last = -1.0;
|
||||
for i in 0..=2000 {
|
||||
let x = i as f32 * 0.004;
|
||||
let y = s.channel(x);
|
||||
assert!(y > last || (x == 0.0 && y == 0.0), "not increasing at {x}");
|
||||
last = y;
|
||||
}
|
||||
assert!(s.channel(2.0) > s.channel(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_stays_close_to_the_retired_curve() {
|
||||
// TRACES: FR-DEV-3j | FR-DEV-3e
|
||||
// D19's promise to every existing photograph: the midtones do not
|
||||
// move by more than a third of a stop.
|
||||
let s = Sigmoid::default_curve();
|
||||
let mut x = 0.03_f32;
|
||||
while x <= 1.0 {
|
||||
let ev = (s.channel(x) / retired_default(x)).log2();
|
||||
assert!(ev.abs() < 0.3, "at scene {x} the default moved {ev:+.2} EV");
|
||||
x *= 1.1;
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neutral_stays_neutral() {
|
||||
// TRACES: FR-DEV-3j
|
||||
let s = Sigmoid::default_curve();
|
||||
for v in [0.0, 0.01, 0.13, 1.0, 7.0] {
|
||||
let [r, g, b] = s.apply([v, v, v]);
|
||||
assert_eq!(r, g);
|
||||
assert_eq!(g, b);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_hue_survives_the_shoulder() {
|
||||
// TRACES: FR-DEV-3j
|
||||
// The middle channel's place between the other two is what decides
|
||||
// the hue. Without the correction an orange at the shoulder drifts
|
||||
// toward yellow as the red channel saturates first.
|
||||
let s = Sigmoid::default_curve();
|
||||
let orange = [2.0, 0.8, 0.1];
|
||||
let out = s.apply(orange);
|
||||
let before = (orange[1] - orange[2]) / (orange[0] - orange[2]);
|
||||
let after = (out[1] - out[2]) / (out[0] - out[2]);
|
||||
assert!((before - after).abs() < 1e-5, "{before} became {after}");
|
||||
// And the extremes keep what the curve gave them — the desaturation
|
||||
// toward white is the point of working per channel.
|
||||
assert!((out[0] - s.channel(2.0)).abs() < 1e-6);
|
||||
assert!((out[2] - s.channel(0.1)).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_settings_are_clamped_not_trusted() {
|
||||
// A sidecar from another build may carry anything, and outside the
|
||||
// range the solution divides by a value that is no longer positive.
|
||||
let s = Sigmoid::new(0.0, 0.0);
|
||||
assert!(s.n.is_finite() && s.inv_k.is_finite() && s.w.is_finite());
|
||||
assert_eq!(s, Sigmoid::new(CONTRAST_RANGE.0, WHITE_RANGE.0));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user