Build and test / Desktop (Linux) (push) Successful in 19m6s
Build and test / Layer separation (push) Successful in 28s
Traceability / Requirement traces (push) Failing after 26s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 3s
Build and test / Android (aarch64) (push) Failing after 33m7s
A crystal is a fixed size in micrometres. How grainy a photograph looks is therefore not a property of the emulsion alone -- it is film size against output size, and the frame is the half a digital file cannot supply. This assumed 35 mm for everything. The same emulsion on 4x5 averages about 3,800 crystals into the pixel that holds 300 on 35 mm, so it renders roughly 3.5 times smoother at the same print; every large-format photograph was being rendered as grainy as a half-frame. `Format` now carries the real image widths -- the gate, not the nominal inches, since a "4x5" exposes about 121 mm -- and the film node asks for it. It is a genuinely fixed list, unlike the stocks, so it is a declared `enum` parameter and gets its control, its sidecar entry and its undo step for nothing. It is also the first enum in the develop chain, and it broke two tests by being one. A row has to compare equal to itself across two builds or `sync_rows` replaces it on every parameter event -- destroying the elements built from it, including whichever TouchArea holds the current gesture, so the format picker would have fought every slider drag in the panel. `ModelRc` compares by identity and the row built a fresh choices model each call. `no_choices` already shares one empty model for exactly this reason, and the build site already said "see no_choices for why the identity matters". The fix follows it: memoise the model per variant list. Curve rows solve the same problem the other way, writing values through the existing model, which is not needed here -- a variant list is fixed at compile time, so one model can serve forever. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
343 lines
14 KiB
Rust
343 lines
14 KiB
Rust
//! TRACES: FR-DEV-3f
|
||
//! Grain — the silver that did or did not develop.
|
||
//!
|
||
//! # Why this is not noise
|
||
//!
|
||
//! An emulsion is a suspension of silver-halide crystals. Light sensitises
|
||
//! some of them; development turns a sensitised crystal into an opaque grain,
|
||
//! all-or-nothing. So the density a patch of film reaches is a *count* of
|
||
//! developed grains, and a count of independent yes/no events has a variance
|
||
//! whether or not anybody wanted texture.
|
||
//!
|
||
//! Writing it down gives the look for free, rather than as a slider:
|
||
//!
|
||
//! ```text
|
||
//! p = D / Dmax the chance one grain develops
|
||
//! N grains in this pixel's patch of film
|
||
//! mean = D
|
||
//! variance = D · (Dmax − u · D) / N
|
||
//! ```
|
||
//!
|
||
//! The variance peaks near the middle of the density range and vanishes at
|
||
//! both ends — clear film has nothing to develop, fully black film has nothing
|
||
//! left undeveloped. That is why grain lives in the midtones, and it is a
|
||
//! consequence here rather than a "midtone bias" control.
|
||
//!
|
||
//! # Why the zoom problem is not a problem
|
||
//!
|
||
//! `N` is grains *per pixel*, so it scales with how much film a pixel covers.
|
||
//! Zoomed out, each pixel averages more grains and the variance falls, which
|
||
//! is exactly what happens when you look at a print from further away. Nothing
|
||
//! has to be super-sampled and nothing has to be filtered: the model is already
|
||
//! a function of scale, and [`Grain::for_pixel_size`] is where the scale
|
||
//! enters.
|
||
//!
|
||
//! # Where crystal habit lives
|
||
//!
|
||
//! In [`crate::profile::Profile::rms_granularity`]. It measures exactly the
|
||
//! thing that differs between a traditional cubic emulsion and a tabular one:
|
||
//! for the same speed, tabular crystals present more area per unit of silver,
|
||
//! so fewer, flatter grains cover the frame and the film reads finer. A
|
||
//! stock's grain character therefore needs no new model and no new code — it
|
||
//! is one number in the profile.
|
||
//!
|
||
//! # Getting that number is the hard part
|
||
//!
|
||
//! It is **not** simply "what the datasheet says", and assuming so is a trap
|
||
//! worth naming:
|
||
//!
|
||
//! - **Kodak colour negatives publish Print Grain Index, not RMS.** PGI is a
|
||
//! perceptual scale from viewer surveys — 25 is roughly the threshold of
|
||
//! visibility and four units is one just-noticeable difference — and Kodak
|
||
//! state it *cannot* be compared to RMS granularity. There is no published
|
||
//! conversion, so a PGI figure cannot be dropped into this field.
|
||
//! - **Ilford publish no granularity figure at all.** Their technical sheets
|
||
//! describe grain only in words ("fine grain", "finest grain"); the word
|
||
//! granularity does not appear in them. Spectral sensitivity and the
|
||
//! characteristic curves are there, but as graphs.
|
||
//! - RMS granularity proper is published mostly for black-and-white, reversal
|
||
//! and motion-picture stocks.
|
||
//!
|
||
//! So the shipped defaults are plausible rather than measured, and every stock
|
||
//! currently carries the same ones — which means **grain does not yet tell one
|
||
//! stock from another**. Fixing that is per-stock data, not code, and for the
|
||
//! films whose figures are unpublished it needs a defensible estimate rather
|
||
//! than a number copied off a page.
|
||
|
||
use crate::profile::Profile;
|
||
|
||
/// The aperture RMS granularity is defined against: a 48 µm circle.
|
||
///
|
||
/// Fixed by the measurement, not by us. Every published granularity figure is
|
||
/// a standard deviation of density read through this aperture, so inverting it
|
||
/// for a particle area needs the same one.
|
||
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;
|
||
|
||
/// TRACES: FR-DEV-3f
|
||
/// The frame a photograph is being simulated on.
|
||
///
|
||
/// **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
|
||
/// What the shader needs to add grain to a density.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub struct Grain {
|
||
/// Grains in one pixel's patch of film, per layer. The whole scale
|
||
/// dependence is in here.
|
||
pub particles: [f32; 3],
|
||
/// Each layer's maximum density — the ceiling `p = D/Dmax` is taken
|
||
/// against.
|
||
pub density_max: [f32; 3],
|
||
/// How uniformly grains develop. Just below 1: a real emulsion's grains
|
||
/// are not identical, and at exactly 1 the variance expression collapses
|
||
/// at the top of the curve.
|
||
pub uniformity: f32,
|
||
}
|
||
|
||
impl Grain {
|
||
/// Derive the parameters for a stock at a given sampling scale.
|
||
///
|
||
/// `pixel_size_um` is the film distance one rendered pixel covers. It is
|
||
/// the only argument that changes with zoom, and it is what makes a
|
||
/// preview show less grain than a 100% view without anything being
|
||
/// filtered.
|
||
pub fn for_pixel_size(profile: &Profile, pixel_size_um: f32) -> Self {
|
||
let density_max = profile.max_density_per_layer();
|
||
let density_min = profile.min_density_per_layer();
|
||
let uniformity = profile.grain_uniformity;
|
||
|
||
let pixel_area = (pixel_size_um * pixel_size_um).max(1e-6);
|
||
let mut particles = [0.0f32; 3];
|
||
for layer in 0..3 {
|
||
// Invert the granularity definition for the area of one grain:
|
||
//
|
||
// sigma48^2 = D_ref (Dmax - u D_ref) a / A48
|
||
//
|
||
// then count how many of those fit in a pixel. A stock with a
|
||
// *lower* published granularity has smaller grains, so more of
|
||
// them per pixel, so less variance -- which is the whole content
|
||
// of the number.
|
||
let sigma48 = profile.rms_granularity[layer] / 1000.0;
|
||
let d_ref = RMS_REFERENCE_NET_DENSITY + density_min[layer];
|
||
let denom = (d_ref * (density_max[layer] - uniformity * d_ref)).max(1e-6);
|
||
let area = (sigma48 * sigma48 * RMS_APERTURE_AREA_UM2 / denom).max(1e-9);
|
||
|
||
// At least one grain per pixel. Below that the model is describing
|
||
// a pixel smaller than a single crystal, where "how many developed"
|
||
// stops being a useful question and the variance would run away.
|
||
particles[layer] = (pixel_area / area).max(1.0);
|
||
}
|
||
|
||
Self {
|
||
particles,
|
||
density_max,
|
||
uniformity,
|
||
}
|
||
}
|
||
|
||
/// The standard deviation of the developed density at density `d`.
|
||
///
|
||
/// The shader evaluates this per pixel; it is here so the property can be
|
||
/// asserted without a device.
|
||
pub fn sigma(&self, layer: usize, d: f32) -> f32 {
|
||
let dmax = self.density_max[layer];
|
||
let d = d.clamp(0.0, dmax);
|
||
let var = d * (dmax - self.uniformity * d) / self.particles[layer];
|
||
var.max(0.0).sqrt()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn portra() -> Profile {
|
||
Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap()
|
||
}
|
||
|
||
fn trix() -> Profile {
|
||
Profile::parse(include_str!("../profiles/kodak_trix.yaml")).unwrap()
|
||
}
|
||
|
||
#[test]
|
||
fn grain_vanishes_at_both_ends_and_peaks_between() {
|
||
// The property that makes this a model rather than a texture: clear
|
||
// film has nothing developed to vary, and fully developed film has
|
||
// nothing left to develop. Everything interesting is in the middle.
|
||
let g = Grain::for_pixel_size(&portra(), 6.6);
|
||
let dmax = g.density_max[1];
|
||
let clear = g.sigma(1, 0.0);
|
||
let mid = g.sigma(1, dmax * 0.5);
|
||
let full = g.sigma(1, dmax);
|
||
assert!(clear < mid && full < mid, "{clear} {mid} {full}");
|
||
assert!(clear < 1e-6, "clear film is not grainless: {clear}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_smaller_pixel_sees_more_grain() {
|
||
// The scale dependence, and the reason nothing needs super-sampling: a
|
||
// pixel covering less film averages fewer grains, so it is noisier. A
|
||
// model that got this backwards would show *more* grain in a zoomed-out
|
||
// preview than in the export.
|
||
let p = portra();
|
||
let close = Grain::for_pixel_size(&p, 3.0);
|
||
let far = Grain::for_pixel_size(&p, 12.0);
|
||
let d = close.density_max[1] * 0.5;
|
||
assert!(
|
||
close.sigma(1, d) > far.sigma(1, d),
|
||
"close {} far {}",
|
||
close.sigma(1, d),
|
||
far.sigma(1, d)
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn halving_the_pixel_halves_the_grain() {
|
||
// Not merely monotone: variance goes as 1/N and N goes as area, so
|
||
// sigma goes as 1/pixel_size. Asserted because the exponent is what
|
||
// decides whether a preview at half scale looks like the export.
|
||
let p = portra();
|
||
let a = Grain::for_pixel_size(&p, 4.0);
|
||
let b = Grain::for_pixel_size(&p, 8.0);
|
||
let d = a.density_max[1] * 0.5;
|
||
let ratio = a.sigma(1, d) / b.sigma(1, d);
|
||
assert!(
|
||
(ratio - 2.0).abs() < 0.05,
|
||
"sigma ratio {ratio}, expected 2"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn a_coarser_stock_is_grainier_at_the_same_scale() {
|
||
// What the granularity number is for, and what will carry crystal
|
||
// habit when a tabular stock arrives: the same pixel, the same
|
||
// density, a coarser emulsion, more noise.
|
||
let mut fine = portra();
|
||
let mut coarse = portra();
|
||
fine.rms_granularity = [4.0; 3];
|
||
coarse.rms_granularity = [16.0; 3];
|
||
|
||
let d = fine.max_density_per_layer()[1] * 0.5;
|
||
let f = Grain::for_pixel_size(&fine, 6.6).sigma(1, d);
|
||
let c = Grain::for_pixel_size(&coarse, 6.6).sigma(1, d);
|
||
assert!(c > f * 2.0, "fine {f} coarse {c}");
|
||
}
|
||
|
||
#[test]
|
||
fn a_monochrome_stock_grains_every_layer_alike() {
|
||
// Its three layers are one emulsion spread three ways, so a difference
|
||
// between them would be grain the film does not have — and it would
|
||
// show as colour speckle on a black and white photograph, which is the
|
||
// most obvious way this could look wrong.
|
||
let g = Grain::for_pixel_size(&trix(), 6.6);
|
||
let d = g.density_max[0] * 0.5;
|
||
let s: Vec<f32> = (0..3).map(|l| g.sigma(l, d)).collect();
|
||
assert!(
|
||
(s[0] - s[1]).abs() < 1e-5 && (s[1] - s[2]).abs() < 1e-5,
|
||
"monochrome grain differs by layer: {s:?}"
|
||
);
|
||
}
|
||
|
||
#[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<f32> = 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
|
||
// the variance runs away and the picture fills with salt and pepper.
|
||
let g = Grain::for_pixel_size(&portra(), 0.001);
|
||
assert!(g.particles.iter().all(|n| *n >= 1.0), "{:?}", g.particles);
|
||
}
|
||
}
|