The view transform's second curve is the DNG SDK's published reference
rendering — the ACR3 default curve applied by RefBaselineRGBTone — so
it is the "DNG Reference" curve in the panel, D21 and the code, not a
name borrowed from another product. Comments and docs that justified a
choice by another editor doing it ("as their Amount", "so a
photographer arriving from it finds the name") now give the actual
reason. The Vivid presets no longer describe themselves as reaching
for another editor's look; they are DarkRoom's own.
Factual mentions stay: which program wrote the library's DNGs, what
was measured against, and preset import. camera-profiles.md gains §15,
on starting a photograph from the edit it already carries.
221 lines
8.0 KiB
Rust
221 lines
8.0 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 explicitly, so these tests
|
|
/// stay about the sigmoid whichever curve is the default (D21).
|
|
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_agrees_with_the_acr3_curve() {
|
|
// TRACES: FR-DEV-3j
|
|
// D21: a raw with no profile, the DNG reference curve chosen, renders a grey
|
|
// through the ACR3 default curve, which the profile buffer's placeholder
|
|
// carries.
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("skipping: no GPU adapter");
|
|
return;
|
|
};
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_param(
|
|
dr_pipeline::ops::view_transform::ID,
|
|
dr_pipeline::ops::view_transform::CURVE,
|
|
dr_pipeline::ops::view_transform::CAMERA_RAW,
|
|
);
|
|
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}"
|
|
);
|
|
}
|