Let the tone curve continue past 1.0, and test that nothing clips
The tone curve clamped its input to [0, 1] and its output back to 1.0 around a 2.2 gamma, mid-chain — master and per-channel both. So any edit with a curve made every scene value above 1.0 the same number before the view transform ever saw it: D19's fourth finding. Beyond its last point the curve now continues along its last span, whose secant is the tangent the spline already gives that point, so the join is smooth and an identity curve stays the identity to any height. The only clamp left is the floor at zero, where there is no light to curve. FR-DEV-2's acceptance test is how the rule stays true. `scene_referred_until_the_view` wraps every point operation, at non-neutral settings, between a gain of sixteen and one of a sixty-fourth, with an identity in the view transform's place, and renders a ramp from 0.4 to 15.2 times sensor saturation through it. The output must still increase with the input and still separate the top of the ramp. Run against the previous commit's tone curve it fails with [21, 29, 33, 33, 33, 33, 33, 33]; every other operation already passed. It renders on a device because dr-pipeline has none, and the spec's pointer to it says so.
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
//! TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
//! Scene-referred until the view transform (D19, ARCH §6.14), on a device.
|
||||
//!
|
||||
//! The rule is about every operation between the camera matrix and the view
|
||||
//! transform, so this runs each of them over a ramp that reaches sixteen
|
||||
//! times sensor saturation and asserts the two things a clip or an early
|
||||
//! encode would break: the output still increases with the input, and values
|
||||
//! above 1.0 still differ from one another.
|
||||
//!
|
||||
//! A clip above 1.0 cannot be seen through an 8-bit display encode on its
|
||||
//! own, so each operation is wrapped: a gain of sixteen ahead of it puts the
|
||||
//! ramp into the range the rule is about, a gain of one sixty-fourth after it
|
||||
//! brings the result back under 1.0 — with two stops to spare, for the
|
||||
//! operations that brighten — and an identity in the view transform's
|
||||
//! place stops the sigmoid compressing what is being measured. A fragment
|
||||
//! that clamps, or encodes and decodes through a clamped range, flattens the
|
||||
//! top of the ramp, and the last few steps come out equal.
|
||||
//!
|
||||
//! The view stage and the detail stage are excluded. The view transform and
|
||||
//! film simulation clip into a display range because that is their job, and
|
||||
//! a neighbourhood operation is a pass of its own that a flat frame cannot
|
||||
//! exercise.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use dr_decode::{CfaPattern, CropRect, RawImage};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
||||
use dr_pipeline::descriptor::{Attribute, LocalizedKey, OpDescriptor, OpId, ParamId, ParamKind};
|
||||
use dr_pipeline::operation::{Operation, Stage, Uniform};
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
|
||||
fn ctx() -> Option<GpuContext> {
|
||||
pollster::block_on(GpuContext::new_headless()).ok()
|
||||
}
|
||||
|
||||
/// A gain, as a scene-stage operation, or an identity in the view stage.
|
||||
struct Probe {
|
||||
id: &'static str,
|
||||
gain: f32,
|
||||
stage: Stage,
|
||||
}
|
||||
|
||||
impl Operation for Probe {
|
||||
fn descriptor(&self) -> Arc<OpDescriptor> {
|
||||
Arc::new(OpDescriptor {
|
||||
id: OpId(self.id),
|
||||
label: LocalizedKey(self.id),
|
||||
params: Vec::new(),
|
||||
attributes: vec![Attribute::Tone],
|
||||
})
|
||||
}
|
||||
fn set_param(&mut self, _: ParamId, _: f32) {}
|
||||
fn param(&self, _: ParamId) -> f32 {
|
||||
0.0
|
||||
}
|
||||
fn is_active(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn stage(&self) -> Stage {
|
||||
self.stage
|
||||
}
|
||||
/// The identity view claims the view transform's place: while it is
|
||||
/// active the composer emits it rather than the sigmoid.
|
||||
fn renders(&self) -> bool {
|
||||
self.stage == Stage::View
|
||||
}
|
||||
fn wgsl_body(&self) -> String {
|
||||
"c = c * gain;".into()
|
||||
}
|
||||
fn uniforms(&self) -> Vec<Uniform> {
|
||||
vec![Uniform {
|
||||
name: "gain",
|
||||
value: self.gain,
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
fn probe(id: &'static str, gain: f32, stage: Stage) -> Box<dyn Operation> {
|
||||
Box::new(Probe { id, gain, stage })
|
||||
}
|
||||
|
||||
/// A flat frame at `level` of sensor saturation, identity matrix, neutral
|
||||
/// balance.
|
||||
fn flat(ctx: &GpuContext, level: f32) -> dr_gpu::DemosaicedImage {
|
||||
let raw = RawImage {
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
data: vec![(level * f32::from(u16::MAX)).round() as u16; (SIZE * SIZE) as usize],
|
||||
cfa_pattern: CfaPattern::Rggb,
|
||||
black_level: [0; 4],
|
||||
white_level: u16::MAX,
|
||||
wb_coeffs: [1.0, 1.0, 1.0, 1.0],
|
||||
color_matrix: Some([1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0]),
|
||||
samples_per_pixel: 1,
|
||||
profile: None,
|
||||
make: String::new(),
|
||||
model: String::new(),
|
||||
crop: CropRect {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
},
|
||||
};
|
||||
Demosaicer::new(ctx)
|
||||
.expect("demosaicer")
|
||||
.run(&raw)
|
||||
.expect("demosaic")
|
||||
}
|
||||
|
||||
/// Every parameter moved off its default, a third of the way toward its
|
||||
/// maximum — or toward its minimum where the default is the maximum.
|
||||
///
|
||||
/// The tone curve is the exception, because its neutral is a relationship:
|
||||
/// its parameters are point coordinates, and moving every x and y the same
|
||||
/// fraction leaves the points on the diagonal. It gets a lifted midpoint on
|
||||
/// the master and on the red curve instead — the two helpers that clamped.
|
||||
fn non_neutral(op: &mut dyn Operation) {
|
||||
use dr_pipeline::ops::curve::{coordinate, Axis, Channel};
|
||||
if op.descriptor().id == dr_pipeline::ops::curve::ID {
|
||||
op.set_param(coordinate(Channel::Master, 2, Axis::Y), 0.65);
|
||||
op.set_param(coordinate(Channel::Red, 2, Axis::Y), 0.6);
|
||||
return;
|
||||
}
|
||||
for p in &op.descriptor().params {
|
||||
if let ParamKind::Scalar { min, max, .. } = p.kind {
|
||||
let toward = if p.default < max { max } else { min };
|
||||
op.set_param(p.id, p.default + (toward - p.default) / 3.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ramp, as scene values after the sixteenfold gain: 0.4 to 16.
|
||||
///
|
||||
/// Kept below 1.0 at the sensor, and away from its last 1.5%, because the
|
||||
/// prologue's highlight desaturation fades a photosite toward neutral there —
|
||||
/// a sensor fact, not an operation's, and flat grey is neutral already.
|
||||
const LEVELS: [f32; 8] = [0.025, 0.05, 0.1, 0.2, 0.4, 0.6, 0.8, 0.95];
|
||||
|
||||
#[test]
|
||||
fn scene_referred_until_the_view() {
|
||||
// TRACES: FR-DEV-2 | FR-DEV-3j
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("skipping: no GPU adapter");
|
||||
return;
|
||||
};
|
||||
let sources: Vec<_> = LEVELS.iter().map(|&l| flat(&ctx, l)).collect();
|
||||
let mut adjust = AdjustPass::new(&ctx);
|
||||
|
||||
let mut checked = 0;
|
||||
for mut op in dr_pipeline::ops::chain() {
|
||||
if op.detail().is_some() || op.stage() == Stage::View {
|
||||
continue;
|
||||
}
|
||||
let id = op.descriptor().id.0;
|
||||
non_neutral(op.as_mut());
|
||||
assert!(op.is_active(), "{id}: the edit above left it neutral");
|
||||
let ops = vec![
|
||||
probe("probe_up", 16.0, Stage::Scene),
|
||||
op,
|
||||
probe("probe_down", 1.0 / 64.0, Stage::Scene),
|
||||
probe("probe_view", 1.0, Stage::View),
|
||||
];
|
||||
let shader = dr_pipeline::compose(&ops);
|
||||
assert!(
|
||||
!shader.source.contains("view_sigmoid"),
|
||||
"the identity must take the view transform's place"
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
for source in &sources {
|
||||
adjust.render(source, &shader, SIZE, SIZE).expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let centre = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
|
||||
out.push([pixels[centre], pixels[centre + 1], pixels[centre + 2]]);
|
||||
}
|
||||
|
||||
for channel in 0..3 {
|
||||
let ramp: Vec<u8> = out.iter().map(|p| p[channel]).collect();
|
||||
assert!(
|
||||
ramp.windows(2).all(|w| w[1] >= w[0]),
|
||||
"{id} is not monotone in channel {channel}: {ramp:?}"
|
||||
);
|
||||
// The top three levels are scene 9.6, 12.8 and 15.2: all far
|
||||
// above 1.0, and a clip anywhere below them makes them equal.
|
||||
let top = &ramp[LEVELS.len() - 3..];
|
||||
assert!(
|
||||
top[0] < top[1] && top[1] < top[2],
|
||||
"{id} flattens values above 1.0 in channel {channel}: {ramp:?}"
|
||||
);
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
assert!(checked >= 10, "only {checked} operations were checked");
|
||||
}
|
||||
@@ -488,6 +488,36 @@ fn curve_eval(
|
||||
}",
|
||||
};
|
||||
|
||||
/// TRACES: FR-DEV-2
|
||||
/// The curve continued past its last point, for scene values above the
|
||||
/// widget's axis.
|
||||
const CURVE_EXTEND: Helper = Helper {
|
||||
name: "curve_extend",
|
||||
source: "\
|
||||
// A five-point curve at `x`, continued past its last point along the slope of
|
||||
// its last span.
|
||||
//
|
||||
// The widget draws a 0..1 axis, and scene-referred values do not stop at 1
|
||||
// (D19): exposure and highlight recovery put them above it, and the view
|
||||
// transform after every operation is what brings them down. Flat past the last
|
||||
// point — which is what `curve_eval` gives, and what this curve did until
|
||||
// D19 — made every one of them the same number, a hard clip in the middle of
|
||||
// the chain. Continued along the last span instead, an identity curve stays
|
||||
// the identity to any height, and a curve that lifts the highlights keeps
|
||||
// lifting them. The slope is the last span's secant, which is also the
|
||||
// tangent `curve_eval` gives the last point, so the join is smooth; monotone
|
||||
// points make it non-negative.
|
||||
fn curve_extend(
|
||||
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
|
||||
x3: f32, y3: f32, x4: f32, y4: f32, x: f32,
|
||||
) -> f32 {
|
||||
if (x <= x4) {
|
||||
return curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, x);
|
||||
}
|
||||
return y4 + (x - x4) * max((y4 - y3) / (x4 - x3), 0.0);
|
||||
}",
|
||||
};
|
||||
|
||||
/// One colour component through its own curve.
|
||||
const CHANNEL_CURVE: Helper = Helper {
|
||||
name: "channel_curve",
|
||||
@@ -504,20 +534,21 @@ const CHANNEL_CURVE: Helper = Helper {
|
||||
// it changes the proportions between the components, which is what makes it
|
||||
// chromatic where the master is tonal.
|
||||
//
|
||||
// The clamp is the curve's promise rather than an oversight: its last point
|
||||
// *is* white, so a component arriving above the axis takes the value the curve
|
||||
// gives at 1. The master does the same to a luminance above 1, through the
|
||||
// gain it applies; a channel curve that instead let highlights past unchanged
|
||||
// would tint them differently from every tone below them, which reads as a
|
||||
// coloured fringe along a blown edge.
|
||||
// Above the axis the curve continues along its last span (`curve_extend`),
|
||||
// exactly as the master's does, so a highlight is tinted the way every tone
|
||||
// just below it is — a component that stopped at the curve's top instead
|
||||
// would put a coloured fringe along a blown edge, and flattened every
|
||||
// scene-referred highlight into one value besides (D19). Below zero there is
|
||||
// no light to curve; the floor is the one clamp left, and it is at zero, not
|
||||
// at one.
|
||||
fn channel_curve(
|
||||
v: f32,
|
||||
x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32,
|
||||
x3: f32, y3: f32, x4: f32, y4: f32,
|
||||
) -> f32 {
|
||||
let encoded = pow(clamp(v, 0.0, 1.0), 1.0 / 2.2);
|
||||
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
return pow(clamp(curved, 0.0, 1.0), 2.2);
|
||||
let encoded = pow(max(v, 0.0), 1.0 / 2.2);
|
||||
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
return pow(max(curved, 0.0), 2.2);
|
||||
}",
|
||||
};
|
||||
|
||||
@@ -535,10 +566,11 @@ static MASTER_HELPERS: &[Helper] = &[
|
||||
helpers::APPLY_TONE_GAIN,
|
||||
CURVE_SPAN,
|
||||
CURVE_EVAL,
|
||||
CURVE_EXTEND,
|
||||
];
|
||||
|
||||
/// The per-channel curves alone.
|
||||
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CHANNEL_CURVE];
|
||||
static CHANNEL_HELPERS: &[Helper] = &[CURVE_SPAN, CURVE_EVAL, CURVE_EXTEND, CHANNEL_CURVE];
|
||||
|
||||
/// Both.
|
||||
static ALL_HELPERS: &[Helper] = &[
|
||||
@@ -546,6 +578,7 @@ static ALL_HELPERS: &[Helper] = &[
|
||||
helpers::APPLY_TONE_GAIN,
|
||||
CURVE_SPAN,
|
||||
CURVE_EVAL,
|
||||
CURVE_EXTEND,
|
||||
CHANNEL_CURVE,
|
||||
];
|
||||
|
||||
@@ -553,16 +586,17 @@ static ALL_HELPERS: &[Helper] = &[
|
||||
const MASTER_BODY: &str = "\
|
||||
let luma = luminance(c);
|
||||
if (luma > 0.0001) {
|
||||
// The curve is authored on a display-referred 0..1 axis, which is where
|
||||
// the eye reads tone and where the widget's grid lives. Scene-referred
|
||||
// luminance is unbounded, so it is encoded to that axis, curved, and
|
||||
// decoded back — otherwise a point placed at the middle of the grid
|
||||
// would not correspond to the middle of the visible range.
|
||||
let encoded = pow(clamp(luma, 0.0, 1.0), 1.0 / 2.2);
|
||||
// The curve is authored on a 0..1 axis, which is where the widget's grid
|
||||
// lives, with a 2.2 gamma so that a point placed at the middle of the
|
||||
// grid means the middle of the visible range. Scene-referred luminance
|
||||
// does not stop at 1: above the axis the curve continues along its last
|
||||
// span (`curve_extend`) rather than clipping, because the view transform
|
||||
// after every operation is what brings a highlight down (D19).
|
||||
let encoded = pow(luma, 1.0 / 2.2);
|
||||
|
||||
let curved = curve_eval(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
let curved = curve_extend(x0, y0, x1, y1, x2, y2, x3, y3, x4, y4, encoded);
|
||||
|
||||
let decoded = pow(clamp(curved, 0.0, 1.0), 2.2);
|
||||
let decoded = pow(max(curved, 0.0), 2.2);
|
||||
// Applied as a ratio so hue is preserved, exactly as contrast does.
|
||||
c = apply_tone_gain(c, decoded / luma);
|
||||
}";
|
||||
@@ -1288,7 +1322,10 @@ mod tests {
|
||||
c.set_param(P2_Y, 0.7);
|
||||
|
||||
let body = c.wgsl_body();
|
||||
assert!(body.contains("curve_eval("), "the master curve is missing");
|
||||
assert!(
|
||||
body.contains("curve_extend("),
|
||||
"the master curve is missing"
|
||||
);
|
||||
assert!(
|
||||
!body.contains("channel_curve("),
|
||||
"an untouched channel reached the shader:\n{body}"
|
||||
|
||||
@@ -1272,9 +1272,9 @@ and unbounded, and no operation clamps above 1.0, applies a transfer function or
|
||||
gamut. The view transform (FR-DEV-3j) is the one stage that does, and the output transform after
|
||||
it clips and encodes. This is the constraint the base curve broke and ARCH §5.2 had drawn all
|
||||
along: a display-referred curve in the middle of the chain throws away what every later stage,
|
||||
the neighbourhood ones above all, needs. A test (`scene_referred_until_the_view`) runs every point
|
||||
operation over a ramp to 16.0 so that a fragment that clips fails the build rather than the
|
||||
photograph.
|
||||
the neighbourhood ones above all, needs. A test (`scene_referred_until_the_view`, in `dr-gpu`)
|
||||
runs every point operation over a ramp to 16.0 so that a fragment that clips fails the build rather
|
||||
than the photograph.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -316,8 +316,10 @@ the operations measure hue against; moving the primaries to Rec.2020 is deferred
|
||||
|
||||
*Acceptance:* every point operation at non-neutral settings, handed a ramp to 16.0, returns values
|
||||
that are still monotone in the ramp and still above 1.0 where the ramp is, before the view
|
||||
transform (`scene_referred_until_the_view` in `dr-pipeline`). The view transform itself and film
|
||||
simulation are excluded, because clipping into a display range is their job.
|
||||
transform (`scene_referred_until_the_view`, `core/dr-gpu/tests/scene_referred.rs`, rendered on a
|
||||
device: `dr-pipeline` has none). The view transform itself and film simulation are excluded,
|
||||
because clipping into a display range is their job, and so is the detail stage, which a flat frame
|
||||
cannot exercise.
|
||||
|
||||
**FR-DEV-3 — Adjustment set (v1).**
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user