CI runs cargo fmt --check and clippy -D warnings, and this branch had never been through either. Both would have failed it. The bulk was the generated colour tables: eight significant figures where an f32 carries about 7.2, so the eighth is noise that rounds away at compile time and clippy's excessive_precision says so 109 times over. Fixed in the generator rather than only in the file, so it stays fixed -- and the file is trimmed in place rather than re-derived, because regenerating it needs a colour-science stack that has nothing to do with the defect. The format! in the composer is mine too, from extracting the rendering tail: the braces in it were escaped because the text used to live inside a larger template, and once extracted the escapes are noise and the call formats nothing. Also here, and clearly not mine: an unused import and a shadowed binding in dr-gpu, and an unused import in a test. They are pre-existing -- clippy has been failing on master before this branch existed, on lints like is_multiple_of that arrived with a toolchain rather than with anyone's code. Fixed because CI cannot go green around them, and called out because a merge commit is a bad place to quietly edit someone else's crate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
179 lines
6.6 KiB
Rust
179 lines
6.6 KiB
Rust
//! TRACES: FR-DEV-3f
|
|
//! A film stock, end to end on a device.
|
|
//!
|
|
//! The tests either side of this one check halves, and neither would catch the
|
|
//! failure that matters. `dr-film` asserts the spectral model reproduces a
|
|
//! reference implementation written in another language; `dr-pipeline` asserts
|
|
//! the generated WGSL evaluates a film in the right place and suppresses the
|
|
//! camera profile's rendering. Both pass if the tables are uploaded
|
|
//! transposed, or the density lookup is indexed in the wrong axis order, or
|
|
//! the curve texture is read a channel out — every one of which renders a
|
|
//! plausible photograph with the wrong colours in it.
|
|
//!
|
|
//! So this renders real pixels through the real shader and compares them
|
|
//! against the same stock evaluated on the CPU. That closes the chain: the CPU
|
|
//! model is checked against the reference, and the shader is checked against
|
|
//! the CPU model.
|
|
|
|
use dr_decode::{BaseCurve, CfaPattern, CropRect, RawImage};
|
|
use dr_film::bake::{bake, Recipe};
|
|
use dr_gpu::{AdjustPass, Demosaicer, GpuContext};
|
|
use dr_pipeline::ops::FilmTables;
|
|
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.
|
|
///
|
|
/// Identity matrix and neutral balance, so the only thing that can move a
|
|
/// pixel is the film. A real body's matrix would make every assertion below a
|
|
/// statement about that body instead.
|
|
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]),
|
|
// Off deliberately: a film replaces the camera's rendering, and
|
|
// leaving a curve here would test the suppression rather than the
|
|
// film. `dr-pipeline` asserts the suppression on the generated source.
|
|
base_curve: BaseCurve::IDENTITY,
|
|
crop: CropRect {
|
|
x: 0,
|
|
y: 0,
|
|
width: SIZE,
|
|
height: SIZE,
|
|
},
|
|
}
|
|
}
|
|
|
|
/// `dr-film`'s baked output in the layout `dr-pipeline` binds.
|
|
///
|
|
/// The conversion is spelled out rather than derived, because it is exactly
|
|
/// the seam this test exists to check: the two crates share no types on
|
|
/// purpose, and a field pasted into the wrong slot here is invisible until
|
|
/// pixels come back wrong.
|
|
fn tables(baked: &dr_film::Baked) -> FilmTables {
|
|
FilmTables {
|
|
exposure_matrix: baked.exposure_matrix,
|
|
curves: baked.curves.clone(),
|
|
curve_log_min: baked.curve_log_min,
|
|
curve_log_max: baked.curve_log_max,
|
|
lut: baked.lut.clone(),
|
|
density_max: baked.density_max,
|
|
lut_size: baked.lut_size,
|
|
}
|
|
}
|
|
|
|
/// A name for the baked stock.
|
|
///
|
|
/// The id is not carried on `Baked` — it is the *recipe's*, and a bake is a
|
|
/// pile of numbers. The tests here only need the graph to hold something, and
|
|
/// what it holds is checked by the sidecar's own tests rather than by pixels.
|
|
fn film_stock_of(_baked: &dr_film::Baked) -> &'static str {
|
|
"under_test"
|
|
}
|
|
|
|
/// Render a flat frame through a stock and return the centre pixel, 0..1.
|
|
///
|
|
/// The centre rather than a corner: a demosaic invents its edges, and the
|
|
/// border of a 16x16 frame is not where anyone should read a tone off.
|
|
fn rendered(ctx: &GpuContext, level: u16, baked: &dr_film::Baked) -> [f32; 3] {
|
|
let source = Demosaicer::new(ctx)
|
|
.expect("demosaicer")
|
|
.run(&flat_raw(level))
|
|
.expect("demosaic");
|
|
|
|
let mut graph = EditGraph::default_chain();
|
|
graph.set_film(Some(dr_pipeline::graph::Film {
|
|
stock: film_stock_of(baked).to_string(),
|
|
print: None,
|
|
tables: tables(baked),
|
|
}));
|
|
let shader = graph.compose();
|
|
|
|
let mut adjust = AdjustPass::new(ctx);
|
|
adjust.set_film(Some(&tables(baked)));
|
|
adjust.render(&source, &shader, SIZE, SIZE).expect("render");
|
|
|
|
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
|
let c = (((SIZE / 2) * SIZE + SIZE / 2) * 4) as usize;
|
|
// Undo the sRGB encode the fused pass applies on the way out, so the
|
|
// comparison happens in the linear space the CPU model works in.
|
|
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
|
|
}
|
|
|
|
fn srgb_to_linear(v: f32) -> f32 {
|
|
if v <= 0.04045 {
|
|
v / 12.92
|
|
} else {
|
|
((v + 0.055) / 1.055).powf(2.4)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_stock_renders_on_the_gpu_the_way_it_does_on_the_cpu() {
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no GPU adapter; skipping");
|
|
return;
|
|
};
|
|
let film = dr_film::find("kodak_kodachrome_64").expect("stock");
|
|
let baked = bake(&Recipe::new(film, None));
|
|
|
|
for level in [4_000u16, 12_000, 30_000, 50_000] {
|
|
// What the shader was handed, expressed the way the CPU model reads
|
|
// it: a flat RGGB frame at `level` demosaics to that fraction of full
|
|
// scale in all three channels, and the identity matrix leaves it there.
|
|
let input = f32::from(level) / f32::from(u16::MAX);
|
|
let expected = baked.apply([input; 3]);
|
|
let got = rendered(&ctx, level, &baked);
|
|
|
|
for c in 0..3 {
|
|
assert!(
|
|
(got[c] - expected[c]).abs() < 0.02,
|
|
"level {level}, channel {c}: GPU gave {:.4}, the model says {:.4}\n\
|
|
got {got:?}\n want {expected:?}",
|
|
got[c],
|
|
expected[c]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_negative_and_its_print_are_not_the_same_picture() {
|
|
// The print stage is where the orange mask goes and where the picture
|
|
// turns the right way up. If the paper profile were being ignored -- a
|
|
// plausible wiring mistake, since both are just "a stock" -- the two
|
|
// renders would agree, and a scanned negative would be offered as a
|
|
// photograph.
|
|
let Some(ctx) = ctx() else {
|
|
eprintln!("no GPU adapter; skipping");
|
|
return;
|
|
};
|
|
let film = dr_film::find("kodak_portra_400").expect("stock");
|
|
let paper = dr_film::default_print(film).expect("paper");
|
|
|
|
let scanned = rendered(&ctx, 12_000, &bake(&Recipe::new(film, None)));
|
|
let printed = rendered(&ctx, 12_000, &bake(&Recipe::new(film, Some(paper))));
|
|
|
|
assert!(
|
|
scanned[0] > scanned[2] * 3.0,
|
|
"the scanned negative has lost its orange mask: {scanned:?}"
|
|
);
|
|
let spread = printed.iter().cloned().fold(f32::MIN, f32::max)
|
|
- printed.iter().cloned().fold(f32::MAX, f32::min);
|
|
assert!(
|
|
spread < 0.06,
|
|
"the print of a neutral is not neutral: {printed:?}"
|
|
);
|
|
}
|