The stock model landed in dr-film with no way to see it. This is the pipeline node, the two texture bindings it reads, and the end-to-end test that proves the shader agrees with the model. The design point is that a film simulation is not an adjustment. Every other node changes a picture; this one makes it. A stock's characteristic curve does the camera profile's base curve's job -- from measurements rather than from a curve somebody drew -- so running both renders the scene twice: the camera's rendering, and then a film's rendering of that. It looks like neither, and it reads as a colour-management bug with no colour-management bug to find. So `Operation::renders` is new. A node declaring it takes camera RGB and hands back linear sRGB, and the composer emits neither the base curve nor the conversion out of camera space. Both halves move together, and the composer keeps them as one string precisely so that getting half of it right is impossible. The tables are not parameters, for the reason vignetting's coefficients are not: they are measurements. dr-pipeline declares the layout as a plain struct and keeps its no-dependency property; the two crates share no types on purpose. `EditGraph::set_film_tables` offers them to every node rather than to the one that wants them, because knowing which concrete type is which is what the graph is organised not to know. Bindings 4 and 5 follow the masks precedent: declared unconditionally so one bind group layout serves every generated shader, bound to 1x1 placeholders when no stock is loaded. Both are interpolated by hand with textureLoad -- this pipeline binds no sampler, and adding one for two lookups would cost a binding in every shader. Uploads are keyed on content so an unchanged stock does not push half a megabyte across the bus per frame. The end-to-end test earned its place immediately: it found the density lookup being filled z-fastest while a 3D texture upload wants x-fastest, so the red and blue axes were transposed. Green matched exactly, which is what that bug looks like -- a plausible photograph of the wrong colour, and one that every unit test on either side of the seam passes. dr-film now pins the layout in a test that needs no device, and states it where the field is declared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
158 lines
6.1 KiB
Rust
158 lines
6.1 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,
|
|
}
|
|
}
|
|
|
|
/// 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_tables(Some(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:?}");
|
|
}
|