Give the tone curve a curve for each colour channel
The declaration in ops/tone_curve.yaml has claimed per-channel curves since it was written — it is the justification for the operation carrying both `tone` and `colour`. Only the master curve existed. This is the other three. The master runs first and the channels grade its result. Both orders are real images and they differ visibly, so the choice is made and written down rather than left to the loop: a point placed on the blue curve should act on the tone the photographer can see, which is what the master has already produced. The other order anchors the grade to tones the master is about to move, so adjusting contrast slides a warm shadow up into the midtones. Every id that existed before today is spelled exactly as it was. The master curve keeps `p2_y` and the new curves take `r_`, `g_` and `b_` prefixes, so a sidecar written when there was one curve loads, means what it meant, and renders the same shader — asserted on the generated source, not on the parameter values. Nothing needed a version check because nothing was renamed. Each curve reaches the shader only when it has been moved off the diagonal, so an S-curve and no colour work generates what it generated when this operation held ten parameters instead of forty, down to the uniform names. The monotonicity guarantee is enforced per curve: a coincident pair on blue divides by zero exactly as thoroughly as one on the master. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
//! TRACES: FR-DEV-3
|
||||
//! The tone curve's four curves, seen from outside the crate.
|
||||
//!
|
||||
//! The unit tests beside the operation assert what one `ToneCurve` does. These
|
||||
//! assert the two properties that only show up once it is a node in a chain
|
||||
//! with a sidecar under it: that a file written before the per-channel curves
|
||||
//! existed still describes the edit it described, and that the three new
|
||||
//! curves cost a photograph that does not use them precisely nothing.
|
||||
|
||||
use dr_pipeline::ops::curve::{self, Axis, Channel};
|
||||
use dr_pipeline::{EditGraph, Sidecar};
|
||||
|
||||
/// A version block carrying `params`, in the on-disk spelling.
|
||||
fn sidecar_with(params: &[(&str, f32)]) -> String {
|
||||
let mut text = String::from("drsc 1\n\n[version u1]\nname = Default\nrevision = 2\nmodified = 0\n");
|
||||
for (key, value) in params {
|
||||
text.push_str(&format!("{key} = {value}\n"));
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
fn apply(text: &str) -> EditGraph {
|
||||
let sidecar = Sidecar::parse(text).expect("a valid sidecar");
|
||||
let mut graph = EditGraph::default_chain();
|
||||
sidecar
|
||||
.default_version()
|
||||
.expect("a default version")
|
||||
.apply(&mut graph);
|
||||
graph
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-8
|
||||
/// **The compatibility guarantee, end to end.**
|
||||
///
|
||||
/// An edit made before this build existed has to produce the same image now.
|
||||
/// Asserted on the generated shader rather than on the parameter values,
|
||||
/// because that is what the photograph is actually made of: same source, same
|
||||
/// uniforms, same picture.
|
||||
#[test]
|
||||
fn an_edit_written_before_the_channel_curves_renders_as_it_did() {
|
||||
let from_file = apply(&sidecar_with(&[
|
||||
("tone_curve.p1_y", 0.15),
|
||||
("tone_curve.p3_y", 0.85),
|
||||
]));
|
||||
|
||||
let mut by_hand = EditGraph::default_chain();
|
||||
by_hand.set_param(curve::ID, curve::P1_Y, 0.15);
|
||||
by_hand.set_param(curve::ID, curve::P3_Y, 0.85);
|
||||
|
||||
let restored = from_file.compose();
|
||||
let expected = by_hand.compose();
|
||||
assert_eq!(restored.source, expected.source);
|
||||
assert_eq!(restored.uniforms, expected.uniforms);
|
||||
assert_eq!(restored.structure_hash, expected.structure_hash);
|
||||
}
|
||||
|
||||
/// **Neutral means absent, and stays absent with four times as much to be
|
||||
/// neutral about.**
|
||||
///
|
||||
/// The curve carries forty parameters now. An image edited with an S-curve and
|
||||
/// nothing else must generate the shader it generated when it carried ten:
|
||||
/// three untouched curves are not three identity evaluations, they are nothing
|
||||
/// at all.
|
||||
#[test]
|
||||
fn three_untouched_curves_cost_nothing() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(curve::ID, curve::P2_Y, 0.62);
|
||||
let shader = graph.compose();
|
||||
|
||||
assert!(
|
||||
shader.source.contains("---- tone_curve ----"),
|
||||
"the master curve must reach the shader"
|
||||
);
|
||||
assert!(
|
||||
!shader.source.contains("channel_curve"),
|
||||
"an untouched channel curve reached the shader:\n{}",
|
||||
shader.source
|
||||
);
|
||||
for prefix in ["r_", "g_", "b_"] {
|
||||
assert!(
|
||||
!shader.source.contains(&format!("tone_curve_{prefix}")),
|
||||
"an untouched channel curve declared uniforms:\n{}",
|
||||
shader.source
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// And with none of them touched, the operation is not there at all — the
|
||||
/// property `a_fresh_graph_is_neutral` asserts for the chain, restated for the
|
||||
/// node that just quadrupled in size.
|
||||
#[test]
|
||||
fn a_curve_at_its_defaults_is_absent_from_the_shader() {
|
||||
let graph = EditGraph::default_chain();
|
||||
assert!(graph.is_neutral());
|
||||
assert!(!graph.compose().source.contains("tone_curve"));
|
||||
|
||||
// Including when every one of the forty parameters has been explicitly
|
||||
// written to its own default, which is what a sidecar round trip through
|
||||
// a build with a different idea of "default" would produce.
|
||||
let mut written = EditGraph::default_chain();
|
||||
for cap in written.capabilities() {
|
||||
if cap.id != curve::ID {
|
||||
continue;
|
||||
}
|
||||
for p in &cap.params {
|
||||
written.set_param(curve::ID, p.id, p.default);
|
||||
}
|
||||
}
|
||||
assert!(written.is_neutral());
|
||||
}
|
||||
|
||||
/// A grade with no tonal work is a real edit, and it must not drag the
|
||||
/// luminance path in behind it.
|
||||
#[test]
|
||||
fn a_channel_curve_reaches_the_shader_on_its_own() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(
|
||||
curve::ID,
|
||||
curve::coordinate(Channel::Blue, 0, Axis::Y),
|
||||
0.08,
|
||||
);
|
||||
|
||||
let shader = graph.compose();
|
||||
assert!(shader.source.contains("c.b = channel_curve(c.b,"));
|
||||
assert!(
|
||||
!shader.source.contains("apply_tone_gain"),
|
||||
"the identity master curve reached the shader:\n{}",
|
||||
shader.source
|
||||
);
|
||||
// Blue's ten points, and nothing else: the other two curves are at the
|
||||
// identity and contribute no slot to the uniform block.
|
||||
for prefix in ["r_", "g_"] {
|
||||
assert!(
|
||||
!shader.source.contains(&format!("tone_curve_{prefix}")),
|
||||
"an untouched channel curve declared uniforms:\n{}",
|
||||
shader.source
|
||||
);
|
||||
}
|
||||
for point in 0..curve::POINTS {
|
||||
assert!(
|
||||
shader.source.contains(&format!("tone_curve_b_x{point}")),
|
||||
"blue's point {point} is missing from the uniform block"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Each curve is its own shader, so the pipeline cache cannot hand the red
|
||||
/// curve's compiled program to an edit that moved the green one.
|
||||
#[test]
|
||||
fn every_curve_generates_a_distinct_shader() {
|
||||
let mut hashes: Vec<u64> = Vec::new();
|
||||
for channel in Channel::ALL {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
graph.set_param(curve::ID, curve::coordinate(channel, 2, Axis::Y), 0.62);
|
||||
hashes.push(graph.compose().structure_hash);
|
||||
}
|
||||
let before = hashes.len();
|
||||
hashes.sort_unstable();
|
||||
hashes.dedup();
|
||||
assert_eq!(before, hashes.len(), "two curves share a compiled shader");
|
||||
}
|
||||
|
||||
/// The forty parameters all persist, and the file names each one once.
|
||||
#[test]
|
||||
fn every_point_of_every_curve_round_trips_through_a_sidecar() {
|
||||
let mut graph = EditGraph::default_chain();
|
||||
// A different y per coordinate, so a point wired to the wrong channel
|
||||
// cannot pass by holding the value it was supposed to hold anyway.
|
||||
// Thirty-secondths because they survive both the decimal the file is
|
||||
// written in and the binary32 it is read back into exactly — this test is
|
||||
// about which parameter a value lands in, and a rounding difference here
|
||||
// would fail it for an unrelated reason.
|
||||
let mut step = 1;
|
||||
for channel in Channel::ALL {
|
||||
for point in 0..curve::POINTS {
|
||||
graph.set_param(
|
||||
curve::ID,
|
||||
curve::coordinate(channel, point, Axis::Y),
|
||||
step as f32 / 32.0,
|
||||
);
|
||||
step += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let mut sidecar = Sidecar::new();
|
||||
sidecar.put(dr_pipeline::sidecar::Version::from_graph(
|
||||
"u1", "Default", &graph,
|
||||
));
|
||||
let restored = apply(&sidecar.to_text());
|
||||
|
||||
for channel in Channel::ALL {
|
||||
for point in 0..curve::POINTS {
|
||||
let id = curve::coordinate(channel, point, Axis::Y);
|
||||
assert_eq!(
|
||||
restored.param(curve::ID, id),
|
||||
graph.param(curve::ID, id),
|
||||
"{id} did not survive the sidecar"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user