Develop a mask layer's film on its own settings
A layer offered the film's sliders and they moved nothing: its copy of the node was never given the stock, so it stayed inactive. Film now works in a layer the way the other adjustments do, as offsets to the photograph's settings, but blended as settings rather than as results, since a film is a rendering and cross-fading two developments is not what a region on a pushed film looks like. - dr-film bakes no slider. Exposure is a gain in the shader; push interpolates the stock's measured processes, one curve row each; the print is split at the paper's log exposure, so print exposure is an addition between two lookups and exact at any setting. The enlarger stays balanced at the photograph's exposure. - film_sim reads all four settings as uniforms, format one-hot over a grain count per format, so every uniform is linear in what it does. - Operation::blends_settings lets the composer average each overlapping layer's uniforms with the global ones by mask weight, the global setting taking whatever weight the layers leave, and run the fragment once. Three layers at full weight give the mean of their settings. - The stock picker is hidden on a layer. Only the photograph's exposure re-solves the print balance; push, print exposure and format need no rebake at all now.
This commit is contained in:
@@ -680,15 +680,18 @@ fn film_tables() -> FilmTables {
|
||||
FilmTables {
|
||||
exposure_matrix: baked.exposure_matrix,
|
||||
curves: baked.curves.clone(),
|
||||
push_stations: baked.push_stations.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,
|
||||
// Viewed directly: `STOCK` is baked without a paper above.
|
||||
paper: None,
|
||||
// Grain off. It is a per-pixel hash and would be measured; it is also
|
||||
// not part of every edit, and the chain being measured here is "every
|
||||
// operation active", not "every option of every operation".
|
||||
grain_particles: [0.0; 3],
|
||||
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
|
||||
grain_density_max: [baked.density_max; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
|
||||
@@ -382,9 +382,23 @@ fn film_key(t: &dr_pipeline::ops::FilmTables) -> u64 {
|
||||
t.curve_log_max,
|
||||
t.density_max,
|
||||
t.lut_size as f32,
|
||||
t.curves.len() as f32,
|
||||
t.lut.len() as f32,
|
||||
] {
|
||||
mix(v.to_bits());
|
||||
}
|
||||
for v in &t.push_stations {
|
||||
mix(v.to_bits());
|
||||
}
|
||||
// The paper's balance is left out on purpose: it reaches the shader as
|
||||
// uniforms, not texels, and it is what moves when the photograph's
|
||||
// exposure does — keying on it would re-upload a megabyte per tick of a
|
||||
// slider that changes three floats.
|
||||
if let Some(p) = &t.paper {
|
||||
for v in [p.log_min, p.log_max, p.density_max] {
|
||||
mix(v.to_bits());
|
||||
}
|
||||
}
|
||||
for e in t.lut.iter().step_by(8).chain(t.curves.iter().step_by(8)) {
|
||||
mix(e[0].to_bits() ^ e[1].to_bits().rotate_left(11) ^ e[2].to_bits().rotate_left(22));
|
||||
}
|
||||
@@ -431,12 +445,16 @@ impl AdjustPass {
|
||||
return;
|
||||
}
|
||||
|
||||
// One row per curve — the film's at each measured push, then the
|
||||
// paper's — and one cube per stage stacked in depth. The shader reads
|
||||
// the layout from `FilmTables`' uniforms, not from these sizes.
|
||||
let samples = dr_pipeline::ops::film_sim::CURVE_SAMPLES as u32;
|
||||
let curves = self.upload_film(
|
||||
"adjust-film-curves",
|
||||
wgpu::TextureDimension::D2,
|
||||
wgpu::Extent3d {
|
||||
width: t.curves.len() as u32,
|
||||
height: 1,
|
||||
width: samples,
|
||||
height: t.curves.len() as u32 / samples,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
&to_rgba(&t.curves),
|
||||
@@ -448,7 +466,7 @@ impl AdjustPass {
|
||||
wgpu::Extent3d {
|
||||
width: n,
|
||||
height: n,
|
||||
depth_or_array_layers: n,
|
||||
depth_or_array_layers: t.lut.len() as u32 / (n * n),
|
||||
},
|
||||
&to_rgba(&t.lut),
|
||||
);
|
||||
@@ -2303,7 +2321,9 @@ mod tests {
|
||||
lut: vec![[0.5, 0.5, 0.5]; N * N * N],
|
||||
density_max: 3.0,
|
||||
lut_size: N,
|
||||
grain_particles: [0.0; 3],
|
||||
push_stations: vec![0.0],
|
||||
paper: None,
|
||||
grain_particles: [[0.0; 3]; dr_pipeline::ops::film_sim::FORMAT_COUNT],
|
||||
grain_density_max: [3.0; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
//! 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_film::bake::{bake, Recipe, Settings};
|
||||
use dr_gpu::{AdjustPass, Demosaicer, GpuContext, LabelField, MaskPass};
|
||||
use dr_pipeline::mask::{MaskLayer, MaskSource};
|
||||
use dr_pipeline::ops::film_sim;
|
||||
use dr_pipeline::ops::film_sim::FORMAT_COUNT;
|
||||
use dr_pipeline::ops::{FilmTables, PaperTables};
|
||||
use dr_pipeline::EditGraph;
|
||||
|
||||
const SIZE: u32 = 16;
|
||||
@@ -77,15 +80,31 @@ fn tables(baked: &dr_film::Baked) -> FilmTables {
|
||||
/// 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 {
|
||||
// The paper, when there is one, rides behind the film: its curve as one
|
||||
// more row, its cube stacked after the film's.
|
||||
let mut curves = baked.curves.clone();
|
||||
let mut lut = baked.lut.clone();
|
||||
let paper = baked.paper.as_ref().map(|p| {
|
||||
curves.extend_from_slice(&p.curves);
|
||||
lut.extend_from_slice(&p.lut);
|
||||
PaperTables {
|
||||
balance: p.balance,
|
||||
log_min: p.log_min,
|
||||
log_max: p.log_max,
|
||||
density_max: p.density_max,
|
||||
}
|
||||
});
|
||||
FilmTables {
|
||||
exposure_matrix: baked.exposure_matrix,
|
||||
curves: baked.curves.clone(),
|
||||
curves,
|
||||
push_stations: baked.push_stations.clone(),
|
||||
curve_log_min: baked.curve_log_min,
|
||||
curve_log_max: baked.curve_log_max,
|
||||
lut: baked.lut.clone(),
|
||||
lut,
|
||||
density_max: baked.density_max,
|
||||
lut_size: baked.lut_size,
|
||||
grain_particles: particles,
|
||||
paper,
|
||||
grain_particles: [particles; FORMAT_COUNT],
|
||||
grain_density_max: [baked.density_max; 3],
|
||||
grain_uniformity: 0.97,
|
||||
}
|
||||
@@ -241,3 +260,241 @@ fn grain_reaches_the_shader_and_scales_with_the_pixel() {
|
||||
"grain never reached the shader: the coarsest setting moved the pixel by {coarse_err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The same render, with the film's sliders set and mask layers laid over it.
|
||||
///
|
||||
/// Every layer is a `Regions` mask over a field splitting the frame down the
|
||||
/// middle: region 0, the left half, at full weight, and the right half
|
||||
/// untouched. Returns the left and right centre pixels, linear.
|
||||
fn rendered_split(
|
||||
ctx: &GpuContext,
|
||||
level: u16,
|
||||
tables: FilmTables,
|
||||
global: Settings,
|
||||
layers: Vec<MaskLayer>,
|
||||
) -> ([f32; 3], [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(),
|
||||
}));
|
||||
graph.set_param(film_sim::ID, film_sim::EXPOSURE, global.exposure_ev);
|
||||
graph.set_param(film_sim::ID, film_sim::PUSH, global.push_stops);
|
||||
graph.set_param(
|
||||
film_sim::ID,
|
||||
film_sim::PRINT_EXPOSURE,
|
||||
global.print_exposure_ev,
|
||||
);
|
||||
for layer in layers {
|
||||
graph.masks_mut().push(layer);
|
||||
}
|
||||
let shader = graph.compose();
|
||||
|
||||
let labels: Vec<u32> = (0..SIZE * SIZE)
|
||||
.map(|i| u32::from(i % SIZE >= SIZE / 2))
|
||||
.collect();
|
||||
let field = LabelField::upload(ctx, &labels, SIZE, SIZE, 2).expect("label upload");
|
||||
let mut masks = MaskPass::new(ctx).expect("mask pass");
|
||||
let array = masks
|
||||
.render(graph.masks(), Some(&field), None, None, SIZE, SIZE)
|
||||
.expect("rasterise");
|
||||
|
||||
let mut adjust = AdjustPass::new(ctx);
|
||||
adjust.set_film(Some(&tables));
|
||||
adjust
|
||||
.render_masked(&source, &shader, SIZE, SIZE, Some(array))
|
||||
.expect("render");
|
||||
let (pixels, _, _) = adjust.export_pixels().expect("readback");
|
||||
let at = |x: u32| {
|
||||
let c = (((SIZE / 2) * SIZE + x) * 4) as usize;
|
||||
[0, 1, 2].map(|i| srgb_to_linear(f32::from(pixels[c + i]) / 255.0))
|
||||
};
|
||||
(at(SIZE / 4), at(3 * SIZE / 4))
|
||||
}
|
||||
|
||||
/// A layer over the left half holding these film offsets.
|
||||
fn left_half(id: &str, offsets: &[(dr_pipeline::descriptor::ParamId, f32)]) -> MaskLayer {
|
||||
let mut layer = MaskLayer::new(
|
||||
id,
|
||||
MaskSource::Regions {
|
||||
signature: 1,
|
||||
level: 2,
|
||||
ids: vec![0],
|
||||
},
|
||||
);
|
||||
for (param, v) in offsets {
|
||||
layer.set_param(film_sim::ID.0, *param, *v);
|
||||
}
|
||||
layer
|
||||
}
|
||||
|
||||
fn assert_close(got: [f32; 3], want: [f32; 3], what: &str) {
|
||||
for c in 0..3 {
|
||||
assert!(
|
||||
(got[c] - want[c]).abs() < 0.02,
|
||||
"{what}, channel {c}: GPU gave {got:?}, the model says {want:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_print_renders_on_the_gpu_the_way_it_does_on_the_cpu_at_any_setting() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// The print path — the film's lookup into the paper's log exposure, the
|
||||
// enlarger added between, the paper's curve and its own lookup — is read
|
||||
// from the same two textures as the film, at offsets. Every one of those
|
||||
// offsets is a way to render a plausible print of the wrong thing.
|
||||
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 baked = bake(&Recipe::new(film, Some(paper)));
|
||||
|
||||
for settings in [
|
||||
Settings::default(),
|
||||
Settings {
|
||||
print_exposure_ev: -1.3,
|
||||
..Settings::default()
|
||||
},
|
||||
Settings {
|
||||
exposure_ev: 0.4,
|
||||
print_exposure_ev: 0.8,
|
||||
..Settings::default()
|
||||
},
|
||||
] {
|
||||
for level in [6_000u16, 20_000] {
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
|
||||
assert_close(
|
||||
got,
|
||||
baked.apply_at([input; 3], &settings),
|
||||
&format!("{settings:?} at {level}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_push_between_two_measured_processes_renders_as_the_model_does() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// Double-X measures five processes; a push between two is a mix of two
|
||||
// rows of the curve texture, found by searching the stations uniform.
|
||||
let Some(ctx) = ctx() else {
|
||||
eprintln!("no GPU adapter; skipping");
|
||||
return;
|
||||
};
|
||||
let film = dr_film::find("kodak_doublex").expect("stock");
|
||||
let baked = bake(&Recipe::new(film, None));
|
||||
assert!(baked.curve_rows > 2, "Double-X has a development series");
|
||||
|
||||
for push in [-0.8f32, 0.4, 1.3, 2.9] {
|
||||
let settings = Settings {
|
||||
push_stops: push,
|
||||
..Settings::default()
|
||||
};
|
||||
let level = 12_000u16;
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
let (got, _) = rendered_split(&ctx, level, tables(&baked), settings, Vec::new());
|
||||
assert_close(
|
||||
got,
|
||||
baked.apply_at([input; 3], &settings),
|
||||
&format!("push {push}"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_layer_develops_its_region_on_its_own_settings() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// Offsets to the photograph's: print exposure +1 on a photograph at +0.5
|
||||
// is +1.5 under the layer, and the rest of the print is untouched. Before
|
||||
// film was blended as settings the layer's sliders moved and nothing
|
||||
// happened, because the layer's copy of the node had no stock.
|
||||
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 baked = bake(&Recipe::new(film, Some(paper)));
|
||||
let level = 12_000u16;
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
|
||||
let global = Settings {
|
||||
print_exposure_ev: 0.5,
|
||||
..Settings::default()
|
||||
};
|
||||
let layer = left_half(
|
||||
"burn",
|
||||
&[(film_sim::PRINT_EXPOSURE, 1.0), (film_sim::EXPOSURE, -0.5)],
|
||||
);
|
||||
let (left, right) = rendered_split(&ctx, level, tables(&baked), global, vec![layer]);
|
||||
|
||||
let under = Settings {
|
||||
exposure_ev: -0.5,
|
||||
print_exposure_ev: 1.5,
|
||||
..Settings::default()
|
||||
};
|
||||
assert_close(left, baked.apply_at([input; 3], &under), "under the layer");
|
||||
assert_close(right, baked.apply_at([input; 3], &global), "outside it");
|
||||
assert!(
|
||||
left[1] < right[1] - 0.01,
|
||||
"the burn did not darken: {left:?} vs {right:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_layers_take_the_average_of_their_settings() {
|
||||
// TRACES: FR-DEV-3f
|
||||
// Three layers over the same pixels at full weight: the plain mean of what
|
||||
// each asks for, and the global setting has no weight left. Summed, the
|
||||
// offsets would be -2 stops of print exposure and +1 of exposure; the mean
|
||||
// is (-1, -1, 0) / 3 and (0, 0, +1) / 3.
|
||||
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 baked = bake(&Recipe::new(film, Some(paper)));
|
||||
let level = 12_000u16;
|
||||
let input = f32::from(level) / f32::from(u16::MAX);
|
||||
|
||||
let layers = vec![
|
||||
left_half("a", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
|
||||
left_half("b", &[(film_sim::PRINT_EXPOSURE, -1.0)]),
|
||||
left_half("c", &[(film_sim::EXPOSURE, 1.0)]),
|
||||
];
|
||||
let (left, right) = rendered_split(&ctx, level, tables(&baked), Settings::default(), layers);
|
||||
|
||||
let mean = Settings {
|
||||
exposure_ev: 1.0 / 3.0,
|
||||
print_exposure_ev: -2.0 / 3.0,
|
||||
..Settings::default()
|
||||
};
|
||||
let summed = Settings {
|
||||
exposure_ev: 1.0,
|
||||
print_exposure_ev: -2.0,
|
||||
..Settings::default()
|
||||
};
|
||||
let (m, s) = (
|
||||
baked.apply_at([input; 3], &mean)[1],
|
||||
baked.apply_at([input; 3], &summed)[1],
|
||||
);
|
||||
// Three times the tolerance the GPU is held to below, or a sum could pass
|
||||
// for a mean.
|
||||
assert!(
|
||||
(m - s).abs() > 0.06,
|
||||
"the mean and the sum render alike ({m} vs {s}), so this proves nothing"
|
||||
);
|
||||
assert_close(left, baked.apply_at([input; 3], &mean), "under all three");
|
||||
assert_close(right, baked.apply([input; 3]), "outside them");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user