Files
DarkRoom/core/dr-gpu/tests/film_sim.rs
T
dtourolle acab0d7abb A linear DNG in and out: the writer, and a three-sample RawImage
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.
2026-09-19 15:24:12 +02:00

244 lines
9.2 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,
samples_per_pixel: 1,
profile: None,
make: String::new(),
model: String::new(),
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 {
tables_with_grain(baked, [0.0; 3])
}
/// The same, with grain switched on at a chosen particle count.
///
/// Grain is *stochastic*, so a grained render cannot be compared against the
/// CPU model pixel for pixel — the comparison below therefore runs with it off,
/// and `grain_reaches_the_shader` is what says it is wired at all. Without that
/// split a grain that never left the CPU would look exactly like a passing
/// test suite.
fn tables_with_grain(baked: &dr_film::Baked, particles: [f32; 3]) -> 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,
grain_particles: particles,
grain_density_max: [baked.density_max; 3],
grain_uniformity: 0.97,
}
}
/// 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] {
rendered_with(ctx, level, tables(baked))
}
fn rendered_with(ctx: &GpuContext, level: u16, tables: FilmTables) -> [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: "under_test".to_string(),
print: None,
tables: tables.clone(),
}));
let shader = graph.compose();
let mut adjust = AdjustPass::new(ctx);
adjust.set_film(Some(&tables));
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:?}"
);
}
#[test]
fn grain_reaches_the_shader_and_scales_with_the_pixel() {
// TRACES: FR-DEV-3f
// Two claims the CPU tests cannot make, because both are about the shader:
// that grain is applied at all, and that fewer grains per pixel means more
// of it. A flat frame is the right probe — every pixel is handed the same
// density, so anything that differs between them is grain and nothing else.
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));
let spread = |particles: [f32; 3]| {
let t = tables_with_grain(&baked, particles);
let mut lo = f32::MAX;
let mut hi = f32::MIN;
// Several pixels of one flat render, not several renders: the hash is
// seeded by position, so this reads the variation across the frame.
for level in [12_000u16, 12_000, 12_000] {
let px = rendered_with(&ctx, level, t.clone());
lo = lo.min(px[1]);
hi = hi.max(px[1]);
}
(lo, hi)
};
let none = spread([0.0; 3]);
assert!(
(none.1 - none.0).abs() < 1e-6,
"grain is being applied when it was switched off: {none:?}"
);
// A single grain per pixel is the noisiest the model goes; ten thousand is
// effectively smooth. If the uniform never arrived, these would agree.
let coarse = rendered_with(&ctx, 12_000, tables_with_grain(&baked, [1.0; 3]));
let fine = rendered_with(&ctx, 12_000, tables_with_grain(&baked, [10_000.0; 3]));
let ungrained = rendered_with(&ctx, 12_000, tables_with_grain(&baked, [0.0; 3]));
let coarse_err = (coarse[1] - ungrained[1]).abs();
let fine_err = (fine[1] - ungrained[1]).abs();
assert!(
coarse_err > fine_err,
"grain did not scale with the particle count: coarse {coarse_err}, fine {fine_err}"
);
assert!(
coarse_err > 1e-4,
"grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}"
);
}