Count the silver instead of adding noise
An emulsion is a suspension of crystals. Light sensitises some; development
turns a sensitised one opaque, all or nothing. So a patch of film's density
is a *count* of developed grains, and a count of independent yes/no events
has a variance whether or not anyone wanted texture:
mean = D
variance = D * (Dmax - u * D) / N
That expression is the whole feature. It peaks in the middle of the density
range and vanishes at both ends -- clear film has nothing developed to vary,
black film has nothing left to develop -- so grain lives in the midtones as a
consequence rather than as a "midtone bias" slider.
I was wrong earlier that this needs the detail stage. Nothing in it reads a
neighbouring pixel; the only reason to move it was that grain must be fixed in
film space rather than screen space, and that solves itself: N is grains *per
pixel*, so it scales with the film a pixel covers. Zoom out, each pixel
averages more grains, less variance -- correct, with nothing super-sampled and
nothing filtered. It stays in the fused pass.
Grain goes on the density and *before* the dye, which is the physical order
and not cosmetic. Perturbing the finished colour -- what an effect does --
tints highlights wrong, because that noise never passes through the dye.
Crystal habit lives in `rms_granularity`, the number every datasheet
publishes, now a profile field. It measures exactly what differs between a
cubic emulsion and a tabular one: at equal speed, tabular crystals present
more area per unit silver, so the film reads finer. Delta 100 is quoted near 9
where HP5 is near 12, and that gap *is* the habit. Adding a stock whose grain
is its whole reputation is therefore editing one line, not writing a model.
Three things this cost, all of them worth writing down:
- The default granularity is a colour negative's, blue coarsest. Applied to
Tri-X it put *colour* speckle on a black and white photograph. Monochrome
stocks collapse it at parse, where every other per-layer table is already
replicated from the one measured channel.
- Helpers cannot read uniforms. The composer prefixes a uniform with its
operation's id and rewrites references inside a fragment body only;
helpers are shared and deduplicated, so a bare `gn0` names nothing.
`film_lut` already took its size as an argument for this reason, and now
says so.
- The end-to-end test compares the shader against the CPU model, and grain
is stochastic, so that comparison now runs with grain off. Which means a
grain that never left the CPU would look exactly like a passing suite --
hence a second test that grain off is bit-identical, one grain per pixel
moves it, and ten thousand move it less.
Not here, deliberately: no grain slider. The parameters are physical and
`rms_granularity` is the honest place to scale one from, but its range wants
choosing rather than guessing. Nor a film format -- 35 mm is assumed, and
medium format at the same stock is far less grainy per unit of picture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,235 @@
|
||||
//! 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`], the number every datasheet
|
||||
//! publishes. It is a measurement of 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. Ilford Delta 100 is quoted around
|
||||
//! 9 where HP5 Plus is around 12, and that gap *is* the crystal habit.
|
||||
//!
|
||||
//! So a stock's grain character needs no new model and no new code — it is one
|
||||
//! number in the profile, taken from the manufacturer's own measurement.
|
||||
|
||||
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;
|
||||
|
||||
/// A 35 mm frame's width, in micrometres.
|
||||
///
|
||||
/// 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.
|
||||
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_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);
|
||||
}
|
||||
}
|
||||
@@ -39,11 +39,13 @@
|
||||
|
||||
pub mod bake;
|
||||
mod built_in;
|
||||
pub mod grain;
|
||||
pub mod profile;
|
||||
pub mod spectrum;
|
||||
pub mod tables;
|
||||
|
||||
pub use bake::{bake, Baked, Recipe};
|
||||
pub use grain::Grain;
|
||||
pub use profile::{Kind, Profile, Stage, Support};
|
||||
|
||||
use built_in::BUILT_IN;
|
||||
|
||||
@@ -97,6 +97,28 @@ pub struct Profile {
|
||||
/// so a picker can say "black and white" without inspecting the numbers.
|
||||
#[serde(default)]
|
||||
pub monochrome: bool,
|
||||
/// TRACES: FR-DEV-3f
|
||||
/// RMS granularity per layer, as the datasheet publishes it.
|
||||
///
|
||||
/// The standard deviation of density read through a 48 µm aperture, times
|
||||
/// a thousand. It is the one number that carries a stock's grain
|
||||
/// character, and **it is where crystal habit lives**: a tabular emulsion
|
||||
/// presents more area per unit of silver than a cubic one, so at equal
|
||||
/// speed it reads finer and its published figure is lower. Adding a stock
|
||||
/// whose grain is its whole reputation is therefore editing one line.
|
||||
///
|
||||
/// Defaults are a middling colour negative's, blue coarsest — the top
|
||||
/// layer of the stack is, in every film. A stock that has not been given
|
||||
/// its own figure grains plausibly rather than not at all.
|
||||
#[serde(default = "default_granularity")]
|
||||
pub rms_granularity: [f32; 3],
|
||||
/// How uniformly grains develop.
|
||||
///
|
||||
/// Just under one. At exactly one the variance expression collapses at the
|
||||
/// top of the curve, and a real emulsion's crystals are not identical
|
||||
/// anyway.
|
||||
#[serde(default = "default_uniformity")]
|
||||
pub grain_uniformity: f32,
|
||||
/// The light the sensitivity data was measured under.
|
||||
pub reference_illuminant: String,
|
||||
/// The light the developed result is meant to be looked at under.
|
||||
@@ -128,11 +150,34 @@ fn filming() -> Stage {
|
||||
Stage::Filming
|
||||
}
|
||||
|
||||
fn default_granularity() -> [f32; 3] {
|
||||
[6.0, 8.0, 10.0]
|
||||
}
|
||||
|
||||
fn default_uniformity() -> f32 {
|
||||
0.97
|
||||
}
|
||||
|
||||
impl Profile {
|
||||
/// Read a profile from YAML.
|
||||
pub fn parse(yaml: &str) -> Result<Self, String> {
|
||||
let profile: Profile = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
|
||||
let mut profile: Profile = serde_norway::from_str(yaml).map_err(|e| e.to_string())?;
|
||||
profile.validate()?;
|
||||
|
||||
// TRACES: FR-DEV-3f
|
||||
// A monochrome stock has one emulsion, so its three layers must agree
|
||||
// about grain. The default granularity does not — it is a colour
|
||||
// negative's, blue coarsest — and left alone it would put *colour*
|
||||
// speckle on a black and white photograph, which is both wrong and
|
||||
// the most visible way this could be wrong.
|
||||
//
|
||||
// Collapsed here rather than asked of whoever writes the profile:
|
||||
// every other per-layer table is already replicated from one measured
|
||||
// channel at conversion, and this is the same fact arriving by a
|
||||
// different door.
|
||||
if profile.monochrome {
|
||||
profile.rms_granularity = [profile.rms_granularity[0]; 3];
|
||||
}
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -196,6 +241,34 @@ impl Profile {
|
||||
out
|
||||
}
|
||||
|
||||
/// The largest density each layer reaches, separately.
|
||||
///
|
||||
/// Per layer rather than pooled, because grain is a per-layer count and
|
||||
/// pooling would give the shadow layers the ceiling of the deepest one —
|
||||
/// which reads as too little grain in exactly the channel carrying most of
|
||||
/// it.
|
||||
pub fn max_density_per_layer(&self) -> [f32; 3] {
|
||||
let mut out = [0.0f32; 3];
|
||||
for row in &self.density_curves {
|
||||
for (c, slot) in out.iter_mut().enumerate() {
|
||||
*slot = slot.max(row[c]);
|
||||
}
|
||||
}
|
||||
// A degenerate curve would otherwise divide by zero downstream.
|
||||
out.map(|v| v.max(1e-3))
|
||||
}
|
||||
|
||||
/// The smallest density each layer reaches — the stock's own fog.
|
||||
pub fn min_density_per_layer(&self) -> [f32; 3] {
|
||||
let mut out = [f32::MAX; 3];
|
||||
for row in &self.density_curves {
|
||||
for (c, slot) in out.iter_mut().enumerate() {
|
||||
*slot = slot.min(row[c]);
|
||||
}
|
||||
}
|
||||
out.map(|v| v.max(0.0))
|
||||
}
|
||||
|
||||
/// The largest density any layer reaches. The 3D LUT's upper bound.
|
||||
pub fn max_density(&self) -> f32 {
|
||||
self.density_curves
|
||||
|
||||
Reference in New Issue
Block a user