dr-export gains write_linear_dng — LinearRaw, DNG 1.4, u16 samples at the sensor's scale, the body's matrices with their illuminants, the as-shot neutral, the EXIF block an export writes — streamed strip by strip through a closure so the composite is never held (FR-MRG-11). The tiff crate's directory is a map, so PhotometricInterpretation is written over what new_image set, which is the trick the S15.1 spike thought it had to hand-roll around. The test reads the file back through rawler. dr-decode's RawImage carries samples_per_pixel (a linear DNG is 3), the body's profile with its calibrations mapped back to EXIF illuminant codes, and the cleaned make and model. The GPU uploads a three-sample image as it is, normalised by black and white like a photosite, through a full f16 conversion — subnormals kept, because a 14-bit LSB sits at f16's smallest normal and rounding it to zero would crush exactly the shadows the file was written to keep.
182 lines
7.1 KiB
Rust
182 lines
7.1 KiB
Rust
//! 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,
|
||
samples_per_pixel: 1,
|
||
profile: None,
|
||
make: String::new(),
|
||
model: String::new(),
|
||
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;
|
||
}
|
||
}
|