From 60d5504fb4b3de01bff125e055dfd3dea51ba8c4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 22 Aug 2026 14:40:51 +0200 Subject: [PATCH] Prove on a device that the profile reaches the screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unit tests either side of the base curve check halves — that the shipped database parses and lifts its midtones, and 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 render real pixels. A flat frame through a neutral edit, once with the Canon EOS 6D's curve looked up by name from the YAML and once with the identity, asserting what a base curve is actually for — midtones lifted, black still black, white still white, monotone the whole way — plus the number an unprofiled body must still produce, so "never worse than today" is a value rather than a promise. Co-Authored-By: Claude Opus 5 (1M context) --- core/dr-gpu/tests/base_curve.rs | 179 ++++++++++++++++++++++++++++++++ docs/traceability.md | 6 +- 2 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 core/dr-gpu/tests/base_curve.rs diff --git a/core/dr-gpu/tests/base_curve.rs b/core/dr-gpu/tests/base_curve.rs new file mode 100644 index 0000000..279e164 --- /dev/null +++ b/core/dr-gpu/tests/base_curve.rs @@ -0,0 +1,179 @@ +//! 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 { + 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; + } +} diff --git a/docs/traceability.md b/docs/traceability.md index 4467697..bd609b5 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -9,8 +9,8 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 151 | -| TRACES tags found | 370 | +| Source files scanned | 152 | +| TRACES tags found | 371 | | Requirements defined | 151 | | Requirements covered | 83 | | **Coverage** | **55.0%** (83/151) | @@ -54,7 +54,7 @@ _None._ | FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:177`](../core/dr-pipeline/src/descriptor.rs#L177), [`core/dr-pipeline/src/framing.rs:257`](../core/dr-pipeline/src/framing.rs#L257), [`core/dr-pipeline/src/graph.rs:54`](../core/dr-pipeline/src/graph.rs#L54), [`core/dr-pipeline/src/operation.rs:107`](../core/dr-pipeline/src/operation.rs#L107) | | FR-DEV-3c | [`core/dr-pipeline/build.rs:1804`](../core/dr-pipeline/build.rs#L1804), [`core/dr-pipeline/ops/exposure.yaml:1`](../core/dr-pipeline/ops/exposure.yaml#L1), [`core/dr-pipeline/src/graph.rs:163`](../core/dr-pipeline/src/graph.rs#L163), [`core/dr-pipeline/src/graph.rs:41`](../core/dr-pipeline/src/graph.rs#L41), [`core/dr-pipeline/src/mask.rs:490`](../core/dr-pipeline/src/mask.rs#L490), [`ui/dr-ui/src/develop.rs:2579`](../ui/dr-ui/src/develop.rs#L2579) | | FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:186`](../core/dr-pipeline/src/framing.rs#L186) | -| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:100`](../core/dr-decode/src/lib.rs#L100), [`core/dr-decode/src/lib.rs:632`](../core/dr-decode/src/lib.rs#L632), [`core/dr-decode/src/lib.rs:672`](../core/dr-decode/src/lib.rs#L672), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:345`](../core/dr-gpu/src/adjust.rs#L345), [`core/dr-gpu/src/adjust.rs:35`](../core/dr-gpu/src/adjust.rs#L35), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-pipeline/src/operation.rs:1079`](../core/dr-pipeline/src/operation.rs#L1079), [`core/dr-pipeline/src/operation.rs:1104`](../core/dr-pipeline/src/operation.rs#L1104), [`core/dr-pipeline/src/operation.rs:1119`](../core/dr-pipeline/src/operation.rs#L1119), [`core/dr-pipeline/src/operation.rs:1140`](../core/dr-pipeline/src/operation.rs#L1140), [`core/dr-pipeline/src/operation.rs:154`](../core/dr-pipeline/src/operation.rs#L154), [`core/dr-pipeline/src/operation.rs:164`](../core/dr-pipeline/src/operation.rs#L164), [`core/dr-pipeline/src/operation.rs:277`](../core/dr-pipeline/src/operation.rs#L277) | +| FR-DEV-3e | [`core/dr-decode/src/base_curve.rs:145`](../core/dr-decode/src/base_curve.rs#L145), [`core/dr-decode/src/base_curve.rs:158`](../core/dr-decode/src/base_curve.rs#L158), [`core/dr-decode/src/base_curve.rs:1`](../core/dr-decode/src/base_curve.rs#L1), [`core/dr-decode/src/base_curve.rs:267`](../core/dr-decode/src/base_curve.rs#L267), [`core/dr-decode/src/base_curve.rs:347`](../core/dr-decode/src/base_curve.rs#L347), [`core/dr-decode/src/base_curve.rs:55`](../core/dr-decode/src/base_curve.rs#L55), [`core/dr-decode/src/lib.rs:100`](../core/dr-decode/src/lib.rs#L100), [`core/dr-decode/src/lib.rs:632`](../core/dr-decode/src/lib.rs#L632), [`core/dr-decode/src/lib.rs:672`](../core/dr-decode/src/lib.rs#L672), [`core/dr-decode/src/profile.rs:102`](../core/dr-decode/src/profile.rs#L102), [`core/dr-decode/src/profile.rs:151`](../core/dr-decode/src/profile.rs#L151), [`core/dr-decode/src/profile.rs:1`](../core/dr-decode/src/profile.rs#L1), [`core/dr-decode/src/profile.rs:235`](../core/dr-decode/src/profile.rs#L235), [`core/dr-decode/src/profile.rs:286`](../core/dr-decode/src/profile.rs#L286), [`core/dr-decode/src/profile.rs:343`](../core/dr-decode/src/profile.rs#L343), [`core/dr-decode/src/profile.rs:458`](../core/dr-decode/src/profile.rs#L458), [`core/dr-decode/src/profile.rs:492`](../core/dr-decode/src/profile.rs#L492), [`core/dr-decode/src/profile.rs:630`](../core/dr-decode/src/profile.rs#L630), [`core/dr-gpu/src/adjust.rs:345`](../core/dr-gpu/src/adjust.rs#L345), [`core/dr-gpu/src/adjust.rs:35`](../core/dr-gpu/src/adjust.rs#L35), [`core/dr-gpu/src/demosaic.rs:121`](../core/dr-gpu/src/demosaic.rs#L121), [`core/dr-gpu/src/demosaic.rs:86`](../core/dr-gpu/src/demosaic.rs#L86), [`core/dr-gpu/tests/base_curve.rs:1`](../core/dr-gpu/tests/base_curve.rs#L1), [`core/dr-pipeline/src/operation.rs:1079`](../core/dr-pipeline/src/operation.rs#L1079), [`core/dr-pipeline/src/operation.rs:1104`](../core/dr-pipeline/src/operation.rs#L1104), [`core/dr-pipeline/src/operation.rs:1119`](../core/dr-pipeline/src/operation.rs#L1119), [`core/dr-pipeline/src/operation.rs:1140`](../core/dr-pipeline/src/operation.rs#L1140), [`core/dr-pipeline/src/operation.rs:154`](../core/dr-pipeline/src/operation.rs#L154), [`core/dr-pipeline/src/operation.rs:164`](../core/dr-pipeline/src/operation.rs#L164), [`core/dr-pipeline/src/operation.rs:277`](../core/dr-pipeline/src/operation.rs#L277) | | FR-DEV-3h | [`core/dr-decode/src/lib.rs:331`](../core/dr-decode/src/lib.rs#L331), [`core/dr-decode/src/preview.rs:29`](../core/dr-decode/src/preview.rs#L29), [`core/dr-pipeline/src/framing.rs:200`](../core/dr-pipeline/src/framing.rs#L200), [`core/dr-types/src/lib.rs:332`](../core/dr-types/src/lib.rs#L332) | | FR-DEV-4 | [`core/dr-gpu/src/lib.rs:211`](../core/dr-gpu/src/lib.rs#L211) | | FR-DEV-5 | [`core/dr-pipeline/src/history.rs:124`](../core/dr-pipeline/src/history.rs#L124), [`core/dr-pipeline/src/history.rs:1`](../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:55`](../core/dr-pipeline/src/history.rs#L55), [`core/dr-pipeline/src/history.rs:71`](../core/dr-pipeline/src/history.rs#L71), [`core/dr-pipeline/src/history.rs:79`](../core/dr-pipeline/src/history.rs#L79), [`ui/dr-ui/src/develop.rs:1786`](../ui/dr-ui/src/develop.rs#L1786), [`ui/dr-ui/src/develop.rs:1796`](../ui/dr-ui/src/develop.rs#L1796), [`ui/dr-ui/src/develop.rs:44`](../ui/dr-ui/src/develop.rs#L44), [`ui/dr-ui/src/lib.rs:1093`](../ui/dr-ui/src/lib.rs#L1093) |