diff --git a/core/dr-film/src/boolean_grain.rs b/core/dr-film/src/boolean_grain.rs new file mode 100644 index 0000000..54e4c61 --- /dev/null +++ b/core/dr-film/src/boolean_grain.rs @@ -0,0 +1,294 @@ +//! TRACES: FR-DEV-3f +//! Grain as a Boolean model — grains that overlap, rather than noise that does +//! not. +//! +//! # Why the counting model was not enough +//! +//! [`crate::grain`] gets the *variance* of a developed density right: a count +//! of independent yes/no events, `D(Dmax − uD)/N`, calibrated from published +//! granularity. What it cannot get right is the **structure**, because it +//! treats every pixel as an independent draw. +//! +//! Measured at 35 mm, a pixel of a 5472-wide frame covers about 6.6 µm and +//! holds some 300 crystals of ~370 nm. Three hundred independent events per +//! pixel average almost flat, and the little that survives has no spatial +//! extent — which is exactly why it reads as sensor noise rather than as film. +//! +//! Real grain is visible because it *clumps*. A crystal is far smaller than a +//! pixel, but crystals overlap into structures that are not, and those survive +//! the filtering that averages independent noise away. +//! +//! # The model +//! +//! Newson, Delon & Galerne, *A Stochastic Film Grain Model for +//! Resolution-Independent Rendering* (Computer Graphics Forum, 2017). +//! +//! Grain centres are a Poisson process of local intensity `λ(y)`; each centre +//! carries a disc. The developed film is the **union** of those discs, and a +//! point is opaque exactly when some disc covers it. Because a disc covers a +//! whole neighbourhood, nearby points are *correlated* — and that correlation +//! is the clumping, which arrives for free rather than being added. +//! +//! # Why it couples to density and not to a grey level +//! +//! The paper drives `λ` from an image's grey level, because it renders grain +//! onto a finished picture. We are not doing that: we have a *density* per +//! layer, from a measured characteristic curve, and a dye that absorbs through +//! it. +//! +//! The two meet exactly. In a Boolean model the chance a point is left +//! uncovered is +//! +//! ```text +//! P(uncovered) = exp(−λ · E[A]) +//! ``` +//! +//! which is Beer–Lambert. So the model's coverage *is* optical density, and +//! +//! ```text +//! λ = D · ln(10) / E[A] +//! ``` +//! +//! puts our measured densities straight into it — with `E[A]`, the mean grain +//! area, already computed from published RMS granularity in +//! [`crate::grain`]. Nothing here is tuned by eye. +//! +//! # What it costs +//! +//! Monte Carlo per pixel, against the counting model's two hashes and a square +//! root. The shader form stores no grains: space is cut into cells, a +//! generator is seeded from each cell's index, and only the cells a sample +//! could reach are visited. That keeps it inside the fused pass — no +//! neighbouring *pixel* is read — but it is emphatically not free, and +//! [`BooleanGrain::samples_for`] is where that trade is made explicit. + +use crate::grain::Grain; + +/// Mean grain area, in µm², recovered from the counting model's calibration. +/// +/// The two models are the same emulsion seen two ways, so they must not +/// disagree about how big a crystal is: `Grain` already inverts published RMS +/// granularity for exactly this number, and taking it from there is what stops +/// a Boolean render and a counting render describing different films. +pub fn mean_grain_area_um2(grain: &Grain, pixel_size_um: f32) -> [f32; 3] { + let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6); + grain.particles.map(|n| pixel_area / n.max(1e-6)) +} + +/// TRACES: FR-DEV-3f +/// What the shader needs to render the Boolean model. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct BooleanGrain { + /// Grain radius per layer, in *pixels* at the current sampling scale. + /// + /// In pixels rather than micrometres because that is the unit the shader + /// works in, and converting once here keeps the conversion out of the + /// inner loop. + pub radius_px: [f32; 3], + /// `ln(10) / E[A]`, per layer: the factor taking a density to a Poisson + /// intensity. Precomputed because it is constant per bake and the shader + /// would otherwise recompute a logarithm per pixel per layer. + pub lambda_per_density: [f32; 3], + /// The density each layer saturates at, as in the counting model. + pub density_max: [f32; 3], + /// Monte Carlo samples per pixel. + pub samples: u32, + /// Standard deviation of the sampling kernel, in pixels. + /// + /// The pixel's own footprint: what a scanner or an eye integrates over. + /// Too small and the render is binary salt and pepper; too large and the + /// grain is blurred out of existence. + pub sigma_px: f32, +} + +impl BooleanGrain { + /// Derive the parameters for a stock at a given sampling scale. + pub fn new(grain: &Grain, pixel_size_um: f32, samples: u32) -> Self { + let area = mean_grain_area_um2(grain, pixel_size_um); + let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6); + + let mut radius_px = [0.0f32; 3]; + let mut lambda_per_density = [0.0f32; 3]; + for l in 0..3 { + // A disc of this area, expressed as a fraction of a pixel. + let area_px = area[l] / pixel_area; + radius_px[l] = (area_px / std::f32::consts::PI).sqrt(); + // Beer-Lambert, read backwards: coverage exp(-lambda*E[A]) is + // transmittance 10^-D, so lambda = D * ln(10) / E[A]. + lambda_per_density[l] = std::f32::consts::LN_10 / area_px.max(1e-9); + } + + Self { + radius_px, + lambda_per_density, + density_max: grain.density_max, + samples: samples.max(1), + // Half a pixel: the footprint of one sample of a sensor whose + // pixels abut. Wider would be a soft scanner, narrower a sharper + // one than exists. + sigma_px: 0.5, + } + } + + /// How many Monte Carlo samples a given quality asks for. + /// + /// The estimator's own noise falls as `1/sqrt(N)`, so this trades one kind + /// of grain against another: too few samples and the *sampling* shows as a + /// second, wrong texture on top of the film's. + pub fn samples_for(quality: Quality) -> u32 { + match quality { + Quality::Preview => 16, + Quality::Export => 64, + } + } + + /// Expected coverage at a density — what the render must average to. + /// + /// The Boolean model's mean is analytic even though its texture is not, + /// which is what makes it testable without rendering anything: whatever + /// the grain does locally, across a flat patch it has to come back to the + /// density the characteristic curve asked for. + pub fn expected_coverage(&self, layer: usize, density: f32) -> f32 { + let d = density.clamp(0.0, self.density_max[layer]); + 1.0 - 10f32.powf(-d) + } +} + +/// How hard to work at the Monte Carlo. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Quality { + /// Interactive. Some sampling noise, which at preview scale is hidden + /// under the grain it is sampling. + Preview, + /// Final render, where the sampling noise must be well below the grain. + Export, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile::Profile; + + fn portra() -> Profile { + Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap() + } + + fn model(pixel_size_um: f32) -> BooleanGrain { + let g = Grain::for_pixel_size(&portra(), pixel_size_um); + BooleanGrain::new(&g, pixel_size_um, 16) + } + + #[test] + fn coverage_is_beer_lambert() { + // The identity the whole coupling rests on: a Boolean model's uncovered + // fraction is exp(-lambda E[A]), and transmittance is 10^-D, so the + // model's coverage *is* the film's opacity. If this drifts, the grain + // is no longer rendering the density the curve asked for. + let m = model(6.6); + // Inside the layer's own range. Past its Dmax the coverage clamps — + // correctly, since a film cannot develop denser than its maximum — and + // an earlier version of this test probed 2.0 against a layer that + // reaches 1.798, then blamed the model for the clamp. + for d in [0.0f32, 0.3, 1.0, 1.7] { + let coverage = m.expected_coverage(1, d); + let transmittance = 1.0 - coverage; + assert!( + (transmittance - 10f32.powf(-d)).abs() < 1e-5, + "at density {d}: transmittance {transmittance}, expected {}", + 10f32.powf(-d) + ); + } + } + + #[test] + fn clear_film_has_no_grains_and_fully_developed_film_is_nearly_solid() { + let m = model(6.6); + assert!(m.expected_coverage(1, 0.0) < 1e-6); + // At its own maximum, not at some density it never reaches: Portra's + // green layer tops out near 1.8, which transmits about 1.6% — dense, + // and not opaque. A film that went fully black would be one whose + // shadows carried no detail at all. + let dmax = m.density_max[1]; + assert!( + m.expected_coverage(1, dmax) > 0.98, + "{}", + m.expected_coverage(1, dmax) + ); + } + + #[test] + fn coverage_clamps_at_the_layers_own_maximum() { + // The property the two tests above tripped over, asserted directly: + // asking for more density than the emulsion has gives the emulsion's + // own ceiling rather than extrapolating one. + let m = model(6.6); + let dmax = m.density_max[1]; + assert_eq!( + m.expected_coverage(1, dmax), + m.expected_coverage(1, dmax + 5.0) + ); + } + + #[test] + fn the_two_models_describe_the_same_crystal() { + // The counting model and this one are one emulsion seen two ways. If + // they disagreed about grain size they would render as different + // films, and the difference would look like a modelling choice rather + // than the bug it is. + let px = 6.6; + let g = Grain::for_pixel_size(&portra(), px); + let area = mean_grain_area_um2(&g, px); + // Portra's green layer: ~0.14 um^2, about 370 nm across. + assert!( + (0.10..0.20).contains(&area[1]), + "grain area {} um^2 is not what the counting model calibrated", + area[1] + ); + let m = BooleanGrain::new(&g, px, 16); + // And the radius in pixels must match that area at this scale. + let area_px = area[1] / (px * px); + let expect_r = (area_px / std::f32::consts::PI).sqrt(); + assert!((m.radius_px[1] - expect_r).abs() < 1e-6); + } + + #[test] + fn zooming_in_makes_the_grains_bigger_in_pixels() { + // Resolution independence, which is the paper's headline claim and the + // thing the counting model can only approximate: a grain is a fixed + // size *on the film*, so looking closer must resolve it, not merely + // reduce the variance. + let close = model(2.0); + let far = model(12.0); + assert!( + close.radius_px[1] > far.radius_px[1] * 3.0, + "close {} far {}", + close.radius_px[1], + far.radius_px[1] + ); + } + + #[test] + fn a_finer_stock_has_smaller_grains() { + let mut fine = portra(); + let mut coarse = portra(); + fine.rms_granularity = [4.0; 3]; + coarse.rms_granularity = [16.0; 3]; + + let f = BooleanGrain::new(&Grain::for_pixel_size(&fine, 6.6), 6.6, 16); + let c = BooleanGrain::new(&Grain::for_pixel_size(&coarse, 6.6), 6.6, 16); + assert!( + c.radius_px[1] > f.radius_px[1], + "coarse {} is not larger than fine {}", + c.radius_px[1], + f.radius_px[1] + ); + } + + #[test] + fn export_samples_more_than_preview() { + assert!( + BooleanGrain::samples_for(Quality::Export) + > BooleanGrain::samples_for(Quality::Preview) + ); + } +} diff --git a/core/dr-film/src/grain.rs b/core/dr-film/src/grain.rs index be33b64..0569f1a 100644 --- a/core/dr-film/src/grain.rs +++ b/core/dr-film/src/grain.rs @@ -76,13 +76,65 @@ const RMS_APERTURE_AREA_UM2: f32 = std::f32::consts::PI * 24.0 * 24.0; /// The net density the granularity figure is quoted at. const RMS_REFERENCE_NET_DENSITY: f32 = 1.0; -/// A 35 mm frame's width, in micrometres. +/// TRACES: FR-DEV-3f +/// The frame a photograph is being simulated on. /// -/// What turns a pixel count into a grain size. A photograph has no inherent -/// film format, so simulating one means choosing what the frame *would have -/// been*; 35 mm is the choice that makes the numbers mean what a photographer -/// expects, since published granularity and every intuition about how grainy a -/// stock looks come from 35 mm. +/// **Grain is a function of enlargement, and this is the half of it the +/// photograph cannot supply.** A crystal is a fixed size in micrometres, so +/// how grainy a picture looks depends entirely on how much the frame was +/// magnified to make it — and that is film size against output size. +/// +/// The same emulsion on 4x5 packs about 3,800 crystals into the pixel that +/// holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same +/// output size. Treating everything as 35 mm, as this did, made every +/// photograph as grainy as the smallest common format. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + Mm35, + Format645, + Format6x6, + Format6x7, + Sheet4x5, + Sheet8x10, +} + +impl Format { + /// The formats, in the order the picker offers them. + /// + /// Smallest first, so index zero is 35 mm — the commonest frame, and the + /// one whose grain every published figure and every photographer's + /// intuition is calibrated against. + pub const ALL: [Format; 6] = [ + Format::Mm35, + Format::Format645, + Format::Format6x6, + Format::Format6x7, + Format::Sheet4x5, + Format::Sheet8x10, + ]; + + /// The frame's width in micrometres — the *image* area, not the sheet. + pub fn width_um(self) -> f32 { + match self { + Format::Mm35 => 36_000.0, + // 6x4.5 and 6x6 share a 56 mm gate; only the other axis differs, + // and grain scales with the linear magnification of the axis being + // enlarged. + Format::Format645 | Format::Format6x6 => 56_000.0, + Format::Format6x7 => 70_000.0, + // The image area of a sheet, which is smaller than the nominal + // inches: a "4x5" exposes about 121 x 97 mm. + Format::Sheet4x5 => 121_000.0, + Format::Sheet8x10 => 248_000.0, + } + } + + pub fn from_index(i: usize) -> Self { + Self::ALL.get(i).copied().unwrap_or(Format::Mm35) + } +} + +/// A 35 mm frame's width, in micrometres. The default format. pub const FRAME_WIDTH_UM: f32 = 36_000.0; /// TRACES: FR-DEV-3f @@ -245,6 +297,41 @@ mod tests { ); } + #[test] + fn a_larger_format_is_less_grainy_at_the_same_output_size() { + // TRACES: FR-DEV-3f + // The point of the whole control, and a fact about photography rather + // than about this code: enlarge 35 mm and 4x5 to the same print and the + // sheet is visibly smoother, because each of its pixels averages far + // more crystals. Treating every frame as 35 mm made a large-format + // photograph as grainy as a small one. + let p = portra(); + let out_px = 5472.0; + let small = Grain::for_pixel_size(&p, Format::Mm35.width_um() / out_px); + let large = Grain::for_pixel_size(&p, Format::Sheet4x5.width_um() / out_px); + + let d = small.density_max[1] * 0.5; + let ratio = small.sigma(1, d) / large.sigma(1, d); + // Linear magnification is 121/36, so the crystal count per pixel goes + // as its square and sigma as its reciprocal: about 3.4x. + assert!( + (2.5..4.5).contains(&ratio), + "35mm is {ratio:.2}x grainier than 4x5, which is not the enlargement" + ); + } + + #[test] + fn the_formats_are_ordered_smallest_first() { + // Index zero has to be the neutral choice — 35 mm, which is what every + // published granularity figure is calibrated against. + let widths: Vec = Format::ALL.iter().map(|f| f.width_um()).collect(); + assert_eq!(widths[0], FRAME_WIDTH_UM); + assert!( + widths.windows(2).all(|w| w[0] <= w[1]), + "formats are not ordered by size: {widths:?}" + ); + } + #[test] fn a_pixel_never_holds_less_than_one_grain() { // Past this the model describes a pixel smaller than a crystal, where diff --git a/core/dr-film/src/lib.rs b/core/dr-film/src/lib.rs index 109d3af..41a3ead 100644 --- a/core/dr-film/src/lib.rs +++ b/core/dr-film/src/lib.rs @@ -38,6 +38,7 @@ //! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019). pub mod bake; +pub mod boolean_grain; mod built_in; pub mod grain; pub mod profile; @@ -45,7 +46,8 @@ pub mod spectrum; pub mod tables; pub use bake::{bake, Baked, Recipe}; -pub use grain::Grain; +pub use boolean_grain::BooleanGrain; +pub use grain::{Format, Grain}; pub use profile::{Kind, Profile, Stage, Support}; use built_in::BUILT_IN; diff --git a/core/dr-pipeline/src/ops/film_sim.rs b/core/dr-pipeline/src/ops/film_sim.rs index a4c64f5..49b8a16 100644 --- a/core/dr-pipeline/src/ops/film_sim.rs +++ b/core/dr-pipeline/src/ops/film_sim.rs @@ -37,6 +37,23 @@ pub const ID: OpId = OpId("film_sim"); pub const EXPOSURE: ParamId = ParamId("exposure"); pub const PRINT_EXPOSURE: ParamId = ParamId("print_exposure"); pub const PUSH: ParamId = ParamId("push"); +pub const FORMAT: ParamId = ParamId("format"); + +/// TRACES: FR-DEV-3f +/// The frames a photograph can be simulated on, smallest first. +/// +/// A genuinely fixed list, unlike the stocks: nobody invents a film format, so +/// this is a declared `enum` parameter and gets its control, its place in the +/// sidecar and its undo step for free. The *sizes* live in `dr_film::Format`; +/// this crate carries only the names, in the same order. +static FORMATS: [LocalizedKey; 6] = [ + LocalizedKey("param.film_sim.format.35mm"), + LocalizedKey("param.film_sim.format.645"), + LocalizedKey("param.film_sim.format.6x6"), + LocalizedKey("param.film_sim.format.6x7"), + LocalizedKey("param.film_sim.format.4x5"), + LocalizedKey("param.film_sim.format.8x10"), +]; /// How many samples a characteristic curve carries. /// @@ -70,6 +87,13 @@ static DESCRIPTOR: OpDescriptor = OpDescriptor { // actually published: Double-X's measured axis spans about -1 to +2, // and beyond a range like that a curve would have to be invented. ParamDescriptor::stops("push", "param.film_sim.push", -1.0, 3.0), + // TRACES: FR-DEV-3f + // Which frame this was taken on — the half of the enlargement a + // photograph cannot supply. A crystal is a fixed size in micrometres, + // so how grainy a picture looks is film size against output size, and + // the same emulsion on 4x5 renders about three times smoother than on + // 35mm at the same print. + ParamDescriptor::choice("format", "param.film_sim.format", &FORMATS), ], }; @@ -129,6 +153,8 @@ pub struct FilmSim { exposure: f32, print_exposure: f32, push: f32, + /// Index into `FORMATS`. Zero is 35 mm, which is the neutral choice. + format: f32, tables: Option, } @@ -173,6 +199,7 @@ impl Operation for FilmSim { EXPOSURE => self.exposure = value, PRINT_EXPOSURE => self.print_exposure = value, PUSH => self.push = value, + FORMAT => self.format = value, _ => log::warn!("film_sim: unknown parameter {id}"), } } @@ -182,6 +209,7 @@ impl Operation for FilmSim { EXPOSURE => self.exposure, PRINT_EXPOSURE => self.print_exposure, PUSH => self.push, + FORMAT => self.format, _ => 0.0, } } diff --git a/ui/dr-ui/src/develop.rs b/ui/dr-ui/src/develop.rs index c574993..504d3b6 100644 --- a/ui/dr-ui/src/develop.rs +++ b/ui/dr-ui/src/develop.rs @@ -760,6 +760,41 @@ fn no_choices() -> slint::ModelRc { EMPTY.with(Clone::clone) } +/// The choices model for one enum parameter, built once per variant list. +/// +/// Memoised for exactly the reason [`no_choices`] is shared: `ModelRc` compares +/// by *identity*, so building a fresh one each call makes the row differ from +/// itself on every parameter event. `sync_rows` would then replace the row — +/// destroying the elements built from it, including whichever `TouchArea` is +/// holding the current gesture — and the enum's own control would fight every +/// slider drag elsewhere in the panel. +/// +/// Curve rows solve the same problem the other way, by writing new values +/// through the existing model. That is not available here: a variant list is +/// fixed at compile time, so the model never needs updating and can simply be +/// the same one every time. +/// +/// Keyed on the labels rather than the slice's address, because they are +/// resolved through the UI's catalogue and two operations offering the same +/// choices should share one model. +fn choices_model(labels: &[slint::SharedString]) -> slint::ModelRc { + use std::cell::RefCell; + use std::collections::HashMap; + + thread_local! { + static CACHE: RefCell>> = + RefCell::new(HashMap::new()); + } + let key = labels.join("\u{1f}"); + CACHE.with(|cache| { + cache + .borrow_mut() + .entry(key) + .or_insert_with(|| slint::ModelRc::new(slint::VecModel::from(labels.to_vec()))) + .clone() + }) +} + /// Whether this frontend has an implementation of `widget` **anywhere**. /// /// "Anywhere" is doing real work: a widget may be drawn in the panel, as the @@ -979,7 +1014,7 @@ pub(crate) fn rows_filtered( choices: if choices.is_empty() { no_choices() } else { - slint::ModelRc::new(slint::VecModel::from(choices)) + choices_model(&choices) }, }); } @@ -2394,8 +2429,23 @@ impl DevelopSession { // stock looks comes from. The sensor's width in pixels then says how // much film one pixel covers, and the grain model needs nothing else // to be correct at any zoom. + // TRACES: FR-DEV-3f + // The frame this is being simulated on, against the pixels it is being + // rendered to: together they are the enlargement, and the enlargement + // is what decides how grainy the result looks. A crystal is a fixed + // size in micrometres — the same emulsion on a sheet averages far more + // of them into each pixel than it does on 35 mm. + let format = dr_film::Format::from_index( + self.graph + .param( + dr_pipeline::ops::film_sim::ID, + dr_pipeline::ops::film_sim::FORMAT, + ) + .unwrap_or(0.0) + .max(0.0) as usize, + ); let (source_width, _) = self.demosaiced.size(); - let pixel_size_um = dr_film::grain::FRAME_WIDTH_UM / source_width.max(1) as f32; + let pixel_size_um = format.width_um() / source_width.max(1) as f32; let grain = dr_film::Grain::for_pixel_size(profile, pixel_size_um); let baked = dr_film::bake(&dr_film::Recipe {