Files
DarkRoom/core/dr-gpu/tests/base_curve.rs
T
dtourolleandClaude Opus 5 c75849040c Format the tree the way the gate asks for it
`cargo fmt --check` is a required step and had drifted across 45 files. Most of
it arrived this week: several operations were written in parallel worktrees and
merged by hand, and a hand-merge resolves conflicts without ever running the
formatter over the result.

No behaviour changes — this is `cargo fmt --all` and nothing else, kept as its
own commit so the next reader can skip it wholesale rather than search it for
one that matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 21:16:34 +02:00

178 lines
7.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! TRACES: FR-DEV-3e
//! The camera profile's base curve, end to end on a device.
//!
//! The unit tests either side of this one check halves. `dr-decode` asserts
//! that the shipped database parses and that every curve in it lifts its
//! midtones; `dr-pipeline` asserts that the generated WGSL evaluates a curve
//! in the right place. Neither would notice if the two agreed with each other
//! and both were wrong — a curve packed into the wrong uniform slots, or a
//! flag read from the wrong component, satisfies both and renders nothing.
//!
//! So this renders real pixels twice, once with a profiled body's curve and
//! once with the identity, and asserts the difference is the one a base curve
//! is for: midtones lifted, black still black, white still white.
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
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, carrying `curve`.
///
/// Every photosite the same value, so the demosaic result is a uniform grey
/// and the only thing that can move a pixel is the curve. The colour matrix is
/// the identity and the balance is neutral for the same reason: this test is
/// about one stage, and a real body's matrix would make every assertion below
/// a statement about that body instead.
fn flat_raw(level: u16, curve: BaseCurve) -> 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]),
base_curve: curve,
crop: CropRect {
x: 0,
y: 0,
width: SIZE,
height: SIZE,
},
}
}
/// Render a neutral edit over a flat frame and return the centre pixel's red.
///
/// The centre rather than a corner: a demosaic has to invent its edges, and
/// the interpolated border of a 16×16 frame is not where anyone should be
/// reading a tone off.
fn rendered_level(ctx: &GpuContext, level: u16, curve: BaseCurve) -> u8 {
let raw = flat_raw(level, curve);
let source = Demosaicer::new(ctx)
.expect("demosaicer")
.run(&raw)
.expect("demosaic");
let shader = EditGraph::default_chain().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]
}
/// The Canon EOS 6D's curve, from the shipped profile database.
///
/// Looked up by name rather than written out, so this also asserts the thing
/// no other test can: that a curve travels from the YAML, through the body
/// match, onto the decoded image and into the uniform block that the shader
/// actually reads.
fn six_d() -> BaseCurve {
let curve = dr_decode::base_curve::for_body("Canon", "EOS 6D");
assert!(
!curve.is_identity(),
"the shipped database must have a curve for the EOS 6D"
);
curve
}
#[test]
fn a_profiled_body_renders_brighter_midtones_than_a_flat_one() {
// **The whole requirement, in one assertion.** A linear midtone renders
// roughly half a stop dark, which is the flat, lifeless look FR-DEV-3e
// exists to get away from. If the curve did not reach the shader — wrong
// slot, wrong flag, wrong stage — this is the only test that would fail.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
// 13% of full scale: roughly where a camera places middle grey, leaving
// about two and a half stops of highlight headroom above it.
let level = (0.13 * 65535.0) as u16;
let flat = rendered_level(&ctx, level, BaseCurve::IDENTITY);
let profiled = rendered_level(&ctx, level, six_d());
assert!(
profiled > flat + 8,
"the profile lifted middle grey from {flat} only to {profiled}"
);
}
#[test]
fn the_curve_leaves_black_black_and_white_white() {
// A base curve renders the range between the endpoints; it must not move
// the endpoints themselves. A curve that lifted black would put a grey
// veil over every night photograph, and one that pulled white down would
// make a correctly exposed frame look underexposed.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
assert_eq!(rendered_level(&ctx, 0, curve), 0, "black moved");
assert_eq!(rendered_level(&ctx, u16::MAX, curve), 255, "white moved");
}
#[test]
fn an_unprofiled_body_renders_exactly_as_it_did_before_profiles_existed() {
// The graceful fallback, asserted as a number rather than as a promise.
// With no curve the pipeline must still be a pass-through: black level
// out, white level in, sRGB encoding on the way to the screen and nothing
// else. "Never worse than today" is the one property this change was not
// allowed to trade away, and the way it would break is silently — a flag
// read from the wrong component would apply a curve nobody asked for.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
for level in [0u16, 4_000, 8_520, 32_768, 60_000, u16::MAX] {
let scene = f32::from(level) / f32::from(u16::MAX);
let expected = (dr_types::Transfer::Srgb.encode(scene) * 255.0).round() as i32;
let got = i32::from(rendered_level(&ctx, level, BaseCurve::IDENTITY));
// Two 8-bit steps: the texture holding the demosaiced frame is
// `Rgba16Float`, so a value round-trips through eleven mantissa bits
// before it is encoded. That is well under one step at any level, and
// the tolerance is for the rounding either side of it rather than for
// the transform being approximate.
assert!(
(got - expected).abs() <= 2,
"raw {level} rendered as {got}, expected about {expected}"
);
}
}
#[test]
fn the_curve_is_monotone_through_the_whole_range() {
// The property the spline's tangent limiting exists to guarantee, checked
// where it actually matters: on the device, through the real uniform
// packing. A curve that dipped anywhere would put a dark band across a
// smooth gradient — a sky, most visibly — and it would read as a
// rendering fault rather than as a bad profile.
let Some(ctx) = ctx() else {
eprintln!("skipping: no GPU adapter");
return;
};
let curve = six_d();
let mut previous = 0u8;
for step in 0..=16u32 {
let level = (step * 65535 / 16) as u16;
let value = rendered_level(&ctx, level, curve);
assert!(
value >= previous,
"the curve fell from {previous} to {value} at raw level {level}"
);
previous = value;
}
}