|
|
|
@@ -338,9 +338,10 @@ pub trait Operation: Send + Sync {
|
|
|
|
|
/// one.
|
|
|
|
|
///
|
|
|
|
|
/// Almost everything returns `false`. An operation that returns `true`
|
|
|
|
|
/// takes camera RGB and hands back linear sRGB, and in exchange the
|
|
|
|
|
/// composer emits neither the camera profile's base curve nor the
|
|
|
|
|
/// conversion out of camera space — because this operation has done both.
|
|
|
|
|
/// hands back display-referred linear sRGB, and in exchange the composer
|
|
|
|
|
/// does not emit the camera profile's base curve — because this operation
|
|
|
|
|
/// has done its job. It is handed working-space colour like any other
|
|
|
|
|
/// scene-stage operation (D19).
|
|
|
|
|
///
|
|
|
|
|
/// The reason it is a trait method and not a flag the caller sets is the
|
|
|
|
|
/// one [`compose_full`] gives for deciding the output mode the same way: a
|
|
|
|
@@ -353,6 +354,14 @@ pub trait Operation: Send + Sync {
|
|
|
|
|
false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// TRACES: FR-DEV-2 | FR-DEV-3e
|
|
|
|
|
/// Which colour this operation is handed. See [`Stage`].
|
|
|
|
|
///
|
|
|
|
|
/// Default [`Stage::Scene`], which is every operation but white balance.
|
|
|
|
|
fn stage(&self) -> Stage {
|
|
|
|
|
Stage::Scene
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// TRACES: FR-DEV-3 | FR-DEV-8
|
|
|
|
|
/// This operation's neighbourhood stage, if it has one.
|
|
|
|
|
///
|
|
|
|
@@ -419,6 +428,48 @@ pub trait Operation: Send + Sync {
|
|
|
|
|
fn set_lens_profile(&mut self, _profile: Option<&crate::lens::LensProfile>) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// TRACES: FR-DEV-2 | FR-DEV-3e
|
|
|
|
|
/// Camera RGB to the working space, emitted between the camera-stage
|
|
|
|
|
/// operations and the scene-stage ones (see [`Stage`]).
|
|
|
|
|
///
|
|
|
|
|
/// Identity for a non-linear source, which is already in sRGB primaries, and
|
|
|
|
|
/// for the camera-space tap, whose caller fills it so (FR-MRG-2).
|
|
|
|
|
const CAMERA_MATRIX: &str = "
|
|
|
|
|
// ==== camera profile: the matrix (FR-DEV-3e) ====
|
|
|
|
|
//
|
|
|
|
|
// Camera RGB -> linear sRGB primaries, unbounded. After white balance,
|
|
|
|
|
// whose multipliers are defined on the sensor's channels, and before every
|
|
|
|
|
// other operation, which is handed working-space colour so that a hue or
|
|
|
|
|
// a luminance weight means the same thing on every body (D19).
|
|
|
|
|
c = vec3<f32>(
|
|
|
|
|
dot(u.cam_to_srgb_0.rgb, c),
|
|
|
|
|
dot(u.cam_to_srgb_1.rgb, c),
|
|
|
|
|
dot(u.cam_to_srgb_2.rgb, c),
|
|
|
|
|
);
|
|
|
|
|
";
|
|
|
|
|
|
|
|
|
|
/// TRACES: FR-DEV-2 | FR-DEV-3e
|
|
|
|
|
/// Which colour a point operation is handed (D19, ARCH §5.2).
|
|
|
|
|
///
|
|
|
|
|
/// The composer emits every [`Stage::Camera`] operation, then the camera
|
|
|
|
|
/// matrix, then every [`Stage::Scene`] one, each group in graph order. Before
|
|
|
|
|
/// D19 there was no such split: every operation ran in camera RGB and the
|
|
|
|
|
/// matrix came after them all, so `luminance()`'s Rec.709 weights were
|
|
|
|
|
/// applied to camera primaries and a band in the colour mixer was a
|
|
|
|
|
/// different hue on each body.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub enum Stage {
|
|
|
|
|
/// Camera RGB, balanced as shot, ahead of the matrix.
|
|
|
|
|
///
|
|
|
|
|
/// For white balance alone. Its multipliers scale the sensor's own
|
|
|
|
|
/// channels, and a matrix that mixes them would make the same numbers a
|
|
|
|
|
/// different correction once it had run.
|
|
|
|
|
Camera,
|
|
|
|
|
/// Working-space colour: linear Rec.709 primaries, scene-referred,
|
|
|
|
|
/// unbounded. Nothing here clamps above 1.0 or encodes (ARCH §6.14).
|
|
|
|
|
Scene,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// A named WGSL helper function, deduplicated across operations.
|
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
|
|
|
pub struct Helper {
|
|
|
|
@@ -897,11 +948,21 @@ fn compose_inner(
|
|
|
|
|
// Every point operation that is active globally *or* in some layer. One
|
|
|
|
|
// that only a layer moved still runs here, at its place in the chain,
|
|
|
|
|
// with nothing on the global side of its blend.
|
|
|
|
|
for op in ops
|
|
|
|
|
.iter()
|
|
|
|
|
.map(|o| o.as_ref())
|
|
|
|
|
.filter(|o| o.detail().is_none())
|
|
|
|
|
{
|
|
|
|
|
//
|
|
|
|
|
// Camera-stage operations first, then the camera matrix, then the rest —
|
|
|
|
|
// each group in graph order (D19). Graph order still decides everything
|
|
|
|
|
// within a group; the stage only decides which colour the group is given.
|
|
|
|
|
let point = |stage: Stage| {
|
|
|
|
|
ops.iter()
|
|
|
|
|
.map(|o| o.as_ref())
|
|
|
|
|
.filter(move |o| o.detail().is_none() && o.stage() == stage)
|
|
|
|
|
};
|
|
|
|
|
let mut in_working_space = false;
|
|
|
|
|
for op in point(Stage::Camera).chain(point(Stage::Scene)) {
|
|
|
|
|
if !in_working_space && op.stage() == Stage::Scene {
|
|
|
|
|
body.push_str(CAMERA_MATRIX);
|
|
|
|
|
in_working_space = true;
|
|
|
|
|
}
|
|
|
|
|
let id = op.descriptor().id.0;
|
|
|
|
|
let local: Vec<&crate::mask::LocalOp> = layers.ops.iter().filter(|l| l.op == id).collect();
|
|
|
|
|
if !op.is_active() && local.is_empty() {
|
|
|
|
@@ -950,6 +1011,12 @@ fn compose_inner(
|
|
|
|
|
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.
|
|
|
|
@@ -1094,14 +1161,9 @@ fn compose_inner(
|
|
|
|
|
),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// The camera profile's rendering, which an operation may have taken over.
|
|
|
|
|
//
|
|
|
|
|
// Emitted as a unit because the two halves belong together: the base curve
|
|
|
|
|
// is defined in camera RGB and the matrix is what leaves it, so an
|
|
|
|
|
// operation that replaces one has necessarily replaced the other. Keeping
|
|
|
|
|
// them as one string is what makes that impossible to get half right.
|
|
|
|
|
// The base curve, which an operation may have taken over.
|
|
|
|
|
let rendering_tail = if op_renders {
|
|
|
|
|
" // The camera profile's base curve and the conversion out of camera\n // space are both absent: an operation declaring `Operation::renders`\n // has done both, and doing them again would render the picture twice.\n"
|
|
|
|
|
" // 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"
|
|
|
|
|
.to_string()
|
|
|
|
|
} else {
|
|
|
|
|
" // ==== camera profile: the base curve (FR-DEV-3e) ====
|
|
|
|
@@ -1110,27 +1172,10 @@ fn compose_inner(
|
|
|
|
|
// is not one, and the difference is what several tests count on to tell
|
|
|
|
|
// an edit apart from the reading of a file.
|
|
|
|
|
//
|
|
|
|
|
// The stage between demosaic and the working space that turns a correct
|
|
|
|
|
// exposure into a photograph. Sensor data is scene-referred and nearly
|
|
|
|
|
// linear; nothing anybody looks at is. Rendering it straight out is the
|
|
|
|
|
// dcraw default, and it is flat, dark through the midtones and clips its
|
|
|
|
|
// highlights instead of rolling them off.
|
|
|
|
|
//
|
|
|
|
|
// **In camera RGB, and after the adjustments**, which is a deliberate pair
|
|
|
|
|
// of choices:
|
|
|
|
|
//
|
|
|
|
|
// - Before the matrix, because that is where a base curve is defined and
|
|
|
|
|
// where every other converter applies one. The curve was tuned against
|
|
|
|
|
// this body's own primaries; moving it after the conversion would apply
|
|
|
|
|
// a Canon rendering to sRGB values and change what it does.
|
|
|
|
|
// - After exposure and the tonal operations, because those are corrections
|
|
|
|
|
// to *capture* and are only meaningful on linear values. A stop is a
|
|
|
|
|
// doubling; run exposure after a curve and it stops being one.
|
|
|
|
|
//
|
|
|
|
|
// Per channel rather than on luminance. It desaturates the extremes
|
|
|
|
|
// slightly, and that is the point — it is what makes a blown sky roll
|
|
|
|
|
// toward white rather than toward a saturated corner of the gamut, and it
|
|
|
|
|
// is what the camera's own JPEG does.
|
|
|
|
|
// 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 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
|
|
|
|
@@ -1155,17 +1200,6 @@ fn compose_inner(
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Camera space -> linear sRGB. Applied after the adjustments so white
|
|
|
|
|
// balance and exposure act on sensor-native values, which is where they
|
|
|
|
|
// are physically meaningful.
|
|
|
|
|
//
|
|
|
|
|
// Identity for a non-linear source, which is already in sRGB primaries.
|
|
|
|
|
c = vec3<f32>(
|
|
|
|
|
dot(u.cam_to_srgb_0.rgb, c),
|
|
|
|
|
dot(u.cam_to_srgb_1.rgb, c),
|
|
|
|
|
dot(u.cam_to_srgb_2.rgb, c),
|
|
|
|
|
);
|
|
|
|
|
"
|
|
|
|
|
.to_string()
|
|
|
|
|
};
|
|
|
|
@@ -2236,12 +2270,11 @@ mod tests {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_rendering_operation_takes_over_the_base_curve_and_the_camera_matrix() {
|
|
|
|
|
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, and
|
|
|
|
|
// the film node converts out of camera space itself. Emitting the
|
|
|
|
|
// profile's rendering as well would render the scene twice and convert
|
|
|
|
|
// it twice — a picture that comes out looking like neither the camera's
|
|
|
|
|
// A film stock's characteristic curve does the base curve's job.
|
|
|
|
|
// Emitting the profile's 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.
|
|
|
|
|
let mut film = crate::ops::FilmSim::new();
|
|
|
|
@@ -2262,26 +2295,25 @@ mod tests {
|
|
|
|
|
assert!(film.is_active(), "the fixture did not load");
|
|
|
|
|
|
|
|
|
|
let source = compose(&[Box::new(film) as Box<dyn Operation>]).source;
|
|
|
|
|
assert!(
|
|
|
|
|
source.contains("---- film_sim ----"),
|
|
|
|
|
"the operation itself must still be emitted"
|
|
|
|
|
);
|
|
|
|
|
let film_at = source
|
|
|
|
|
.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"
|
|
|
|
|
);
|
|
|
|
|
// Asserted on the composer's own comment, not on the conversion
|
|
|
|
|
// itself: the film fragment performs exactly the same three dot
|
|
|
|
|
// products, so a substring search cannot tell the composer's copy from
|
|
|
|
|
// the operation's. What must be gone is the *second* one.
|
|
|
|
|
assert!(
|
|
|
|
|
!source.contains("Camera space -> linear sRGB"),
|
|
|
|
|
"the composer converted out of camera space after the film already had"
|
|
|
|
|
);
|
|
|
|
|
// D19: the film no longer converts out of camera space itself. The
|
|
|
|
|
// composer does, once, ahead of it — the film is handed working-space
|
|
|
|
|
// colour like every other scene-stage operation.
|
|
|
|
|
assert_eq!(
|
|
|
|
|
source.matches("dot(u.cam_to_srgb_0.rgb, c)").count(),
|
|
|
|
|
1,
|
|
|
|
|
"camera space is left exactly once, and it is the film that does it"
|
|
|
|
|
"camera space is left exactly once"
|
|
|
|
|
);
|
|
|
|
|
let matrix = source.find("camera profile: the matrix").expect("matrix");
|
|
|
|
|
assert!(
|
|
|
|
|
matrix < film_at,
|
|
|
|
|
"the film must be handed working-space colour"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -2292,7 +2324,7 @@ mod tests {
|
|
|
|
|
// 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("Camera space -> linear sRGB"));
|
|
|
|
|
assert!(source.contains("camera profile: the matrix"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
@@ -2304,43 +2336,57 @@ mod tests {
|
|
|
|
|
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("Camera space -> linear sRGB"));
|
|
|
|
|
assert!(source.contains("camera profile: the matrix"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_camera_matrix_is_applied_after_the_operations() {
|
|
|
|
|
// Adjustments are meaningful in sensor-native space, where highlight
|
|
|
|
|
// headroom still exists; converting first would clip it away.
|
|
|
|
|
let ops = vec![fake(DESC_A.clone(), 2.0, false)];
|
|
|
|
|
fn the_camera_matrix_follows_white_balance_and_precedes_the_scene() {
|
|
|
|
|
// TRACES: FR-DEV-2 | FR-DEV-3e
|
|
|
|
|
// D19. White balance's multipliers are defined on the sensor's own
|
|
|
|
|
// channels, so it is handed camera RGB. Every other operation is
|
|
|
|
|
// handed working-space colour: before this, the edits ran in camera
|
|
|
|
|
// RGB and `luminance()`'s Rec.709 weights were applied to a body's
|
|
|
|
|
// own primaries. Graph order puts the scene operation first here on
|
|
|
|
|
// purpose — the stage, not the order, decides which side of the
|
|
|
|
|
// matrix an operation lands on.
|
|
|
|
|
let mut wb = crate::ops::WhiteBalance::new();
|
|
|
|
|
wb.set_param(crate::ops::white_balance::TEMPERATURE, 30.0);
|
|
|
|
|
let ops: Vec<Box<dyn Operation>> = vec![fake(DESC_A.clone(), 2.0, false), Box::new(wb)];
|
|
|
|
|
let source = compose(&ops).source;
|
|
|
|
|
let wb = source.find("---- white_balance ----").expect("wb present");
|
|
|
|
|
let matrix = source.find("camera profile: the matrix").expect("matrix");
|
|
|
|
|
let op = source.find("---- op_a ----").expect("op present");
|
|
|
|
|
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
|
|
|
|
|
assert!(op < matrix, "the camera matrix must come after operations");
|
|
|
|
|
assert!(wb < matrix, "white balance must run in camera RGB");
|
|
|
|
|
assert!(
|
|
|
|
|
matrix < op,
|
|
|
|
|
"a scene operation must be handed working-space colour"
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_base_curve_runs_after_the_operations_and_before_the_camera_matrix() {
|
|
|
|
|
fn an_empty_chain_still_leaves_camera_space() {
|
|
|
|
|
// TRACES: FR-DEV-3e
|
|
|
|
|
// Both halves matter and for different reasons.
|
|
|
|
|
//
|
|
|
|
|
// After the operations: 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 in the panel starts lying about
|
|
|
|
|
// what it does.
|
|
|
|
|
//
|
|
|
|
|
// Before the matrix: the curve was tuned against this body's own
|
|
|
|
|
// primaries. Applied after the conversion it would be a Canon
|
|
|
|
|
// rendering acting on sRGB values, which is a different curve.
|
|
|
|
|
// The matrix is emitted at the first scene operation, so a chain with
|
|
|
|
|
// none needs it emitted anyway, or an unedited RAW opens in camera
|
|
|
|
|
// primaries.
|
|
|
|
|
let source = compose(&[]).source;
|
|
|
|
|
assert_eq!(source.matches("dot(u.cam_to_srgb_0.rgb, c)").count(), 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn the_base_curve_runs_after_the_operations() {
|
|
|
|
|
// TRACES: FR-DEV-3e
|
|
|
|
|
// 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
|
|
|
|
|
// in the panel starts lying about what it does.
|
|
|
|
|
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");
|
|
|
|
|
let matrix = source.find("u.cam_to_srgb_0").expect("matrix applied");
|
|
|
|
|
assert!(op < curve, "the base curve must come after the operations");
|
|
|
|
|
assert!(curve < matrix, "and before the camera matrix");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|