The rendering half of camera-profiles.md §11-§12 (D21). The view transform gains a curve choice — Camera Raw (the default) or D19's sigmoid. Camera Raw converts to linear ProPhoto, clips to [0, 1], runs the curve on the largest and smallest channel and places the middle one at its old fraction between them (RefBaselineRGBTone), and converts back: hue kept, saturation raised where the curve is steep, which is where Adobe Standard's look desaturated. White sets the input scale (1 at its default, so sensor white is display white) and contrast bends the input about grey (1 at its default). The curve rides in the profile buffer after the tables: the profile's own, else the ACR3 default, which the placeholder every profile-less source binds also carries — so a CR2 with no .dcp still gets Camera Raw's tone. Baseline exposure is a gain folded into the rendering matrix at upload; RawImage::color_matrix stays the file's for the merge's linear DNG. camera_raw::apply_reference is the CPU statement; GPU tests hold the shader to it on 256 colours and on greys against the ACR3 table. The sigmoid's own tests now choose it explicitly.
215 lines
7.8 KiB
Rust
215 lines
7.8 KiB
Rust
//! TRACES: FR-DEV-3j | FR-DEV-2
|
|
//! The view transform, end to end on a device.
|
|
//!
|
|
//! `dr-pipeline` checks the curve on the CPU and that the composer emits it in
|
|
//! the right place. Neither would notice a shader that disagreed with the CPU
|
|
//! reference, or a clamp somewhere upstream that made two highlights the same
|
|
//! number before the curve ever saw them — which is exactly what the retired
|
|
//! base curve did, and why D19 exists. So this renders real pixels.
|
|
|
|
use dr_decode::{CfaPattern, CropRect, RawImage};
|
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
|
use dr_pipeline::view::Sigmoid;
|
|
use dr_pipeline::EditGraph;
|
|
|
|
const SIZE: u32 = 16;
|
|
|
|
fn ctx() -> Option<GpuContext> {
|
|
pollster::block_on(GpuContext::new_headless()).ok()
|
|
}
|
|
|
|
/// A flat RGGB frame at `level` out of 65535, with an identity matrix and a
|
|
/// neutral balance, so the only things that move a pixel are the edit and the
|
|
/// view transform.
|
|
fn flat_raw(level: u16) -> RawImage {
|
|
RawImage {
|
|
width: SIZE,
|
|
height: SIZE,
|
|
data: vec![level; (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,
|
|
profile_tables: None,
|
|
baseline_exposure: 0.0,
|
|
make: String::new(),
|
|
model: String::new(),
|
|
crop: CropRect {
|
|
x: 0,
|
|
y: 0,
|
|
width: SIZE,
|
|
height: SIZE,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// The default chain with D19's sigmoid chosen: Camera Raw's tone is the
|
|
/// default since D21, and these tests are about the sigmoid.
|
|
fn sigmoid_chain() -> EditGraph {
|
|
let mut g = EditGraph::default_chain();
|
|
g.set_param(
|
|
dr_pipeline::ops::view_transform::ID,
|
|
dr_pipeline::ops::view_transform::CURVE,
|
|
dr_pipeline::ops::view_transform::SIGMOID,
|
|
);
|
|
g
|
|
}
|
|
|
|
#[test]
|
|
fn camera_raw_tone_is_the_default_and_agrees_with_the_acr3_curve() {
|
|
// TRACES: FR-DEV-3j
|
|
// D21: a raw with no profile renders a grey through Camera Raw's default
|
|
// curve, which the profile buffer's placeholder carries.
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("skipping: no GPU adapter");
|
|
return;
|
|
};
|
|
let graph = EditGraph::default_chain();
|
|
for level in [500u16, 4_000, 8_520, 20_000, 40_000] {
|
|
let scene = f32::from(level) / f32::from(u16::MAX);
|
|
let display = dr_types::tone::evaluate(&dr_types::tone::ACR3_DEFAULT, scene);
|
|
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
|
|
let got = i32::from(rendered(&ctx, level, &graph));
|
|
assert!(
|
|
(got - expected).abs() <= 2,
|
|
"raw {level} rendered as {got}, the ACR3 curve says {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Render `graph` over a flat frame and return the centre pixel's red.
|
|
///
|
|
/// The centre rather than a corner: a demosaic has to invent its edges.
|
|
fn rendered(ctx: &GpuContext, level: u16, graph: &EditGraph) -> u8 {
|
|
let source = Demosaicer::new(ctx)
|
|
.expect("demosaicer")
|
|
.run(&flat_raw(level))
|
|
.expect("demosaic");
|
|
let shader = graph.compose();
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
|
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
|
pixels[centre as usize]
|
|
}
|
|
|
|
#[test]
|
|
fn the_shader_agrees_with_the_cpu_reference() {
|
|
// TRACES: FR-DEV-3j
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("skipping: no GPU adapter");
|
|
return;
|
|
};
|
|
let curve = Sigmoid::default_curve();
|
|
let graph = sigmoid_chain();
|
|
for level in [0u16, 500, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
|
|
let scene = f32::from(level) / f32::from(u16::MAX);
|
|
let display = curve.channel(scene).min(1.0);
|
|
let expected = (dr_types::Transfer::Srgb.encode(display) * 255.0).round() as i32;
|
|
let got = i32::from(rendered(&ctx, level, &graph));
|
|
// Two 8-bit steps, for the `Rgba16Float` intermediate and the
|
|
// rounding either side of the encode.
|
|
assert!(
|
|
(got - expected).abs() <= 2,
|
|
"raw {level} rendered as {got}, expected about {expected}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn highlights_above_one_stay_distinct() {
|
|
// TRACES: FR-DEV-2 | FR-DEV-3j
|
|
// The failure D19 names first. Two stops of exposure put these two
|
|
// frames at 1.0 and 1.5 of sensor saturation. The base curve was flat
|
|
// past 1.0, so both rendered as the same white; the view transform's
|
|
// shoulder still separates them.
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("skipping: no GPU adapter");
|
|
return;
|
|
};
|
|
let mut graph = sigmoid_chain();
|
|
graph.set_param(
|
|
dr_pipeline::ops::exposure::ID,
|
|
dr_pipeline::ops::exposure::EXPOSURE,
|
|
2.0,
|
|
);
|
|
let lower = rendered(&ctx, u16::MAX / 4, &graph);
|
|
let upper = rendered(&ctx, (u16::MAX / 8) * 3, &graph);
|
|
assert!(
|
|
upper > lower,
|
|
"scene 1.0 rendered {lower} and scene 1.5 rendered {upper}"
|
|
);
|
|
assert!(upper < 255, "scene 1.5 is below the default white point");
|
|
}
|
|
|
|
#[test]
|
|
fn the_rendering_is_monotone_through_the_whole_range() {
|
|
// TRACES: FR-DEV-3j
|
|
// A dip anywhere puts a dark band across a smooth gradient — a sky, most
|
|
// visibly.
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("skipping: no GPU adapter");
|
|
return;
|
|
};
|
|
let graph = EditGraph::default_chain();
|
|
let mut last = 0u8;
|
|
for step in 0..=32u32 {
|
|
let level = (step * u32::from(u16::MAX) / 32) as u16;
|
|
let got = rendered(&ctx, level, &graph);
|
|
assert!(got >= last, "raw {level} rendered {got}, below {last}");
|
|
last = got;
|
|
}
|
|
}
|
|
|
|
/// Render `graph` over a flat frame through `render_detailed`, the path every
|
|
/// frontend takes, and return the centre pixel's red.
|
|
fn rendered_detailed(ctx: &GpuContext, level: u16, graph: &EditGraph) -> (u8, AdjustPass) {
|
|
let source = Demosaicer::new(ctx)
|
|
.expect("demosaicer")
|
|
.run(&flat_raw(level))
|
|
.expect("demosaic");
|
|
let shader = graph.compose_for(dr_types::ColourSpace::Srgb);
|
|
let detail = graph.compose_detail(source.size(), (SIZE, SIZE));
|
|
let key = graph.invalidation().through(dr_pipeline::Affects::Colour);
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust
|
|
.render_detailed(&source, &shader, SIZE, SIZE, None, &detail, key)
|
|
.expect("render");
|
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
|
let centre = ((SIZE / 2) * SIZE + SIZE / 2) * 4;
|
|
(pixels[centre as usize], adjust)
|
|
}
|
|
|
|
#[test]
|
|
fn a_detail_stage_renders_through_the_view_pass_unchanged() {
|
|
// TRACES: FR-DEV-3j | FR-DEV-2
|
|
// With a detail stage the view transform is a dispatch of its own after
|
|
// it (D19). Sharpening a flat field changes nothing, so the same frame
|
|
// with and without it must render the same: the view pass read the detail
|
|
// stage's result, applied the view transform once, and encoded once.
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("skipping: no GPU adapter");
|
|
return;
|
|
};
|
|
let plain = rendered(&ctx, 8_520, &EditGraph::default_chain());
|
|
|
|
let mut sharpened = EditGraph::default_chain();
|
|
let id = dr_pipeline::ops::capture_sharpen::ID;
|
|
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::AMOUNT, 100.0);
|
|
sharpened.set_param(id, dr_pipeline::ops::capture_sharpen::RADIUS, 1.0);
|
|
let (detailed, pass) = rendered_detailed(&ctx, 8_520, &sharpened);
|
|
|
|
assert!(
|
|
pass.detail_dispatches() > 0,
|
|
"the premise: a detail stage ran"
|
|
);
|
|
assert_eq!(pass.view_dispatches(), 1);
|
|
assert!(
|
|
detailed.abs_diff(plain) <= 1,
|
|
"with a detail stage {detailed}, without {plain}"
|
|
);
|
|
}
|