Build and test / Desktop (Linux) (push) Successful in 20m48s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Failing after 25s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 33m33s
They were asked for and they are here, but not on the same footing as the
Kodak profiles, and the files say so in their first line.
What I had claimed, and had to withdraw: that Delta 100 is "quoted around 9"
and HP5 "around 12". Ilford publish no such figures. The word granularity does
not occur anywhere in their technical information -- grain is described as
"fine" and "finest" and nothing more. That claim was in this crate's
documentation as though it came from a datasheet; it is corrected there too.
Two further traps found while looking:
- Kodak colour negatives publish Print Grain Index, not RMS granularity.
PGI is a perceptual scale from viewer surveys -- 25 is roughly the
threshold of visibility, four units a just-noticeable difference -- and
Kodak state it cannot be compared to RMS. So a Portra number cannot be
dropped into the granularity field, and none has been.
- RMS proper is published mostly for black-and-white, reversal and motion
picture stocks. Every shipped stock therefore still carries the same
default, which means grain does not yet tell one film from another. That
is per-stock data, not code, and is now written down where somebody will
find it.
So the Ilford profiles are built rather than extracted, and each part rests on
something different:
speed published and exact -- ISO 400/27 for HP5 is a fact
contrast ISO 6:1993's normal development, average gradient 0.62
spectral borrowed from Kodak Double-X, a *measured* panchromatic
negative, shifted by the speed difference. Conventional
panchromatic sensitisation is much alike across black-and-white
films, and this is far better founded than reading pixels off a
printed curve
silver neutral, which is not an approximation: developed silver
absorbs flat, and Double-X's measurement is flat
granularity estimated, ordered by each film's known relative grain
They render as a film of that speed and contrast. They are not a measurement
of that emulsion, and the two stocks that share a speed differ only in the
estimated part.
`every_shipped_stock_bakes` is tightened to match, because a constructed
profile fails in a way a measured one does not: the curve parses, bakes, and
sits entirely off one end of its own exposure range, rendering every frame
black or blown while passing a finiteness check. It now asserts mid-grey lands
somewhere photographic and that the tone response runs the way the stock's
kind says it should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
11 KiB
Rust
256 lines
11 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;
|
||
|
||
/// 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);
|
||
}
|
||
}
|