Prove on a device that the profile reaches the screen
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<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;
|
||||
}
|
||||
}
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user