The stock model rendered correctly and nothing could ask for it. This is the picker, and the sidecar key that makes the choice outlive the session. How the choice persists was the open question, and the answer was already written down twice in sidecar.rs: `rating` is a top-level key "because a rating is not an edit", and `masks` are one "because a layer is not a scalar". A stock is that kind of thing -- a choice of material, not a number a slider moves -- so it is a top-level key too. It stores the **id**, not an index. Stocks are files that users add, so an index would mean installing a profile silently changed which film every existing photograph had been developed on. A name this build has no profile for still round-trips untouched, because the alternative is that syncing to an older phone quietly un-develops the picture. Only the names travel. Turning one back into tables needs the profile database, which dr-pipeline deliberately does not link, so `Version::apply` clears the film and the session re-bakes -- after the parameters, because the bake reads the film's own exposure sliders and the print balance is solved against them. That is also why moving those sliders rebuilds the lookup where no other control in the panel does: an enlarger's filtration depends on how the negative was exposed. The panel keeps its rule. It still names no operation and still generates every control from a declared parameter kind; the stock gets a bespoke control beside those, exactly as the mask stack does, and for the same reason. The film's exposure and print exposure arrive as ordinary generated sliders. Two defaults worth stating. Picking a colour negative prints it, because an unprinted one is an orange strip and offering that as the first thing somebody sees after choosing Portra reads as a bug rather than as a choice -- the toggle is there for anyone who wants the scan. And a paste carries no film: a preset is a parameter map, and a stock is not a parameter, so pasting one would paste a choice the clipboard never took. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
171 lines
6.5 KiB
Rust
171 lines
6.5 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:?}");
|
|
}
|