//! A film stock, as measured. //! //! # Why it is data //! //! Under the GPLv3 a stock should be contributable without a release (the //! argument the retired per-body base curves made for camera bodies, before //! D19). A profile is three tables and a handful of facts, all of //! them published in the manufacturer's datasheet, so adding a stock is adding //! a file — not a code change, not a shader, and not a new operation. //! //! # What a stock actually is //! //! Three numbers per wavelength, three times over: //! //! - **Spectral sensitivity** — how strongly each emulsion layer responds to //! light of each wavelength. Decides what the film *sees*. //! - **The characteristic curve** — density against log exposure, per layer. //! Decides the film's contrast, its latitude, and where it clips. //! - **Dye density** — the spectral absorption each layer's developed dye //! contributes. Decides what the film *looks* like. //! //! A print paper is the same three things; `support` is the only field that //! distinguishes it, and it exists so that a UI can offer papers separately //! rather than because the renderer treats them differently. use serde::Deserialize; use crate::spectrum::Spectrum; use crate::tables::SPECTRUM; /// How many samples a characteristic curve carries. /// /// Enough that the toe and the shoulder survive linear interpolation, which is /// the resolution that matters: the 3D LUT downstream is deliberately coarse /// because the dye mixing is smooth, and it can only be coarse if the curve's /// shape is carried exactly here rather than folded into it. pub const CURVE_SAMPLES: usize = 256; /// Which way the material works. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Kind { /// More light, more density: a camera negative, and also every print paper. Negative, /// More light, *less* density: a reversal stock, viewed as it comes. Positive, } /// What the emulsion is coated on. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Support { Film, Paper, } /// Which end of the process a stock belongs to. /// /// **Not the same question as [`Support`], and that is the point.** A cine /// print film like Kodak 2383 is coated on film and is nevertheless what a /// negative is printed *onto* — it is the Vision3 stocks' paper. Filtering a /// picker on `Support` alone offers it as something to shoot on, which is not /// a thing anyone does. #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Stage { /// Loaded in a camera. Filming, /// Exposed under an enlarger or a printer, through a negative. Printing, } /// TRACES: FR-DEV-3f /// One stock's measured response. #[derive(Debug, Clone, Deserialize)] pub struct Profile { /// The upstream profile's version, so a mismatch is diagnosable. #[serde(default)] pub version: String, /// The identifier a recipe names, e.g. `kodak_portra_400`. pub stock: String, /// The stock's display name. Not a localisation key: "Kodak Portra 400" is /// a product name and does not translate. pub name: String, pub kind: Kind, pub support: Support, /// Defaults to `Filming` so that a profile written before this field /// existed still loads, and loads as the common case. #[serde(default = "filming")] pub stage: Stage, /// Whether the stock has one emulsion rather than three. /// /// Carried for the interface's sake, not the renderer's: a monochrome /// stock's single measured layer is spread across all three at conversion, /// with its dye divided three ways so the three sum back to the one. The /// renderer therefore needs no special case at all, and this field exists /// 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. pub viewing_illuminant: String, /// For a negative, the paper it was designed to be printed on. #[serde(default)] pub target_print: Option, /// log₁₀ sensitivity per layer. `-9` marks a wavelength the datasheet does /// not cover, which the renderer reads as blindness rather than as data. #[serde(deserialize_with = "spectral_triples")] pub log_sensitivity: Vec<[f32; 3]>, /// Spectral density contributed by each layer's dye at unit density. #[serde(deserialize_with = "spectral_triples")] pub dye_density: Vec<[f32; 3]>, /// The support's own density — film base, plus a colour negative's orange /// mask. #[serde(deserialize_with = "spectral_scalars")] pub base_density: Spectrum, /// TRACES: FR-DEV-3f /// The development times the curves were measured at, in minutes. /// /// Absent for a stock measured at one process, which is most of them. #[serde(default)] pub development_times: Vec, /// The manufacturer's standard process, and the one [`Self::density_curves`] /// carries. #[serde(default)] pub development_normal: f32, /// One full set of characteristic curves per entry in /// [`Self::development_times`]. #[serde(default)] pub development_curves: Vec>, pub log_exposure_min: f32, pub log_exposure_max: f32, /// Density against log exposure, per layer, uniformly sampled across /// `[log_exposure_min, log_exposure_max]`. pub density_curves: Vec<[f32; 3]>, } 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 { 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) } fn validate(&self) -> Result<(), String> { // Checked rather than assumed because a profile is a *contributed* // file. A short table would otherwise be caught as an index panic // somewhere in the baker, which names neither the file nor the field. if self.log_sensitivity.len() != SPECTRUM { return Err(format!( "{}: log_sensitivity has {} rows, expected {SPECTRUM}", self.stock, self.log_sensitivity.len() )); } if self.dye_density.len() != SPECTRUM { return Err(format!( "{}: dye_density has {} rows, expected {SPECTRUM}", self.stock, self.dye_density.len() )); } if self.density_curves.len() < 2 { return Err(format!( "{}: density_curves needs at least two samples", self.stock )); } if self.log_exposure_max <= self.log_exposure_min { return Err(format!( "{}: log exposure range [{}, {}] is empty or inverted", self.stock, self.log_exposure_min, self.log_exposure_max )); } Ok(()) } /// Linear spectral sensitivity, undoing the log the datasheet quotes. pub fn sensitivity(&self) -> Vec<[f32; 3]> { self.log_sensitivity .iter() .map(|row| row.map(|v| if v <= -8.0 { 0.0 } else { 10f32.powf(v) })) .collect() } /// The density each layer reaches at a given log exposure. /// /// Clamped at both ends rather than extrapolated: past the shoulder a real /// emulsion stops responding, and a linear extrapolation of the last two /// samples would keep climbing and turn a blown highlight into a colour. pub fn density_at(&self, log_exposure: [f32; 3]) -> [f32; 3] { let last = self.density_curves.len() - 1; let span = self.log_exposure_max - self.log_exposure_min; let mut out = [0.0f32; 3]; for (c, slot) in out.iter_mut().enumerate() { let t = ((log_exposure[c] - self.log_exposure_min) / span).clamp(0.0, 1.0) * last as f32; let i = (t.floor() as usize).min(last - 1); let f = t - i as f32; *slot = self.density_curves[i][c] * (1.0 - f) + self.density_curves[i + 1][c] * f; } out } /// TRACES: FR-DEV-3f /// How much longer to develop, for a given push in stops. /// /// One stop is a factor of about 1.41 in time, which is not a guess: taken /// against Double-X's measured axis it lands within 2% of the 9-minute /// column for +1 and near the 12-minute column for +2, and those are the /// times the datasheet gives for exactly that. pub fn development_for_push(&self, push_stops: f32) -> f32 { self.development_normal * 2f32.powf(0.5 * push_stops) } /// The characteristic curves at a given push, in stops. /// /// Interpolated between the measured development times rather than snapped /// to one: the axis is coarse — five columns spanning three stops — and a /// photographer moving a control expects the picture to move with it. /// /// Clamped at both ends, and deliberately. Beyond the measured range there /// is no data, and extrapolating a contrast curve invents an emulsion that /// was never tested; a stock simply stops getting contrastier past what /// the manufacturer published. /// /// Falls back to [`Self::density_curves`] for a stock measured once, so a /// push control over such a film does nothing rather than something made /// up. pub fn curves_at_push(&self, push_stops: f32) -> Vec<[f32; 3]> { if self.development_curves.len() < 2 || self.development_times.len() < 2 { return self.density_curves.clone(); } let want = self.development_for_push(push_stops); let times = &self.development_times; if want <= times[0] { return self.development_curves[0].clone(); } if want >= times[times.len() - 1] { return self.development_curves[times.len() - 1].clone(); } let hi = times .iter() .position(|t| *t >= want) .unwrap_or(times.len() - 1); let lo = hi - 1; // Interpolated in *log* time, because development is multiplicative: // the step from 4 to 5 minutes is the same amount of push as 9 to 12, // and interpolating linearly would bunch the control at one end. let f = (want.ln() - times[lo].ln()) / (times[hi].ln() - times[lo].ln()); let a = &self.development_curves[lo]; let b = &self.development_curves[hi]; a.iter() .zip(b) .map(|(x, y)| [0, 1, 2].map(|c| x[c] * (1.0 - f) + y[c] * f)) .collect() } /// Whether this stock was measured at more than one development. pub fn is_pushable(&self) -> bool { self.development_curves.len() >= 2 && self.development_times.len() >= 2 } /// 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 .iter() .flat_map(|row| row.iter()) .fold(0.0f32, |a, &b| a.max(b)) } /// Spectral transmittance of the developed material at these densities. /// /// Beer–Lambert: densities add in log space, so the layers' dyes and the /// support's own base compose by summing before the exponent. pub fn transmittance(&self, density: [f32; 3]) -> Spectrum { let mut out = [0.0f32; SPECTRUM]; for (i, slot) in out.iter_mut().enumerate() { let dye = &self.dye_density[i]; let total = density[0] * dye[0] + density[1] * dye[1] + density[2] * dye[2] + self.base_density[i]; *slot = 10f32.powf(-total); } out } } fn spectral_triples<'de, D>(d: D) -> Result, D::Error> where D: serde::Deserializer<'de>, { Vec::<[f32; 3]>::deserialize(d) } fn spectral_scalars<'de, D>(d: D) -> Result where D: serde::Deserializer<'de>, { use serde::de::Error as _; let v = Vec::::deserialize(d)?; v.try_into().map_err(|v: Vec| { D::Error::custom(format!("expected {SPECTRUM} samples, got {}", v.len())) }) } #[cfg(test)] mod tests { use super::*; fn portra() -> Profile { Profile::parse(include_str!("../profiles/kodak_portra_400.yaml")).unwrap() } fn kodachrome() -> Profile { Profile::parse(include_str!("../profiles/kodak_kodachrome_64.yaml")).unwrap() } #[test] fn the_shipped_profiles_load() { for p in [portra(), kodachrome()] { assert_eq!(p.log_sensitivity.len(), SPECTRUM); assert_eq!(p.density_curves.len(), CURVE_SAMPLES); } } #[test] fn a_negative_gains_density_with_exposure_and_a_positive_loses_it() { // The one property that decides whether the picture comes out or comes // out inverted, and it is a property of the *data*, not of the code // that reads it — so it is asserted against the shipped files. let shadow = [-2.0; 3]; let highlight = [2.0; 3]; assert!(portra().density_at(highlight)[1] > portra().density_at(shadow)[1]); assert!(kodachrome().density_at(highlight)[1] < kodachrome().density_at(shadow)[1]); } #[test] fn the_curve_is_clamped_rather_than_extrapolated() { // Past the shoulder a real emulsion stops responding. Extrapolating // would keep climbing, which turns a blown highlight into a colour // cast that gets stronger the more it is overexposed. let p = portra(); let shoulder = p.density_at([p.log_exposure_max; 3]); let far_past = p.density_at([p.log_exposure_max + 40.0; 3]); assert_eq!(shoulder, far_past); } #[test] fn an_undeveloped_frame_transmits_its_base_and_nothing_else() { let p = portra(); let clear = p.transmittance([0.0; 3]); for (i, t) in clear.iter().enumerate() { assert!((t - 10f32.powf(-p.base_density[i])).abs() < 1e-5); } } #[test] fn a_monochrome_stock_is_grey_at_every_exposure() { // The property that says the one-emulsion-into-three-layers conversion // is right. Three identical layers respond identically, so every // density triple is neutral; if the dye had been replicated instead of // divided the picture would be neutral too — and three times too // dense — so this is paired with the density check below. let p = Profile::parse(include_str!("../profiles/kodak_trix.yaml")).unwrap(); assert!(p.monochrome); for log_e in [-2.0f32, -0.5, 0.0, 1.0, 2.5] { let d = p.density_at([log_e; 3]); assert!( (d[0] - d[1]).abs() < 1e-4 && (d[1] - d[2]).abs() < 1e-4, "densities are not equal at logE {log_e}: {d:?}" ); } } #[test] fn a_monochrome_dye_sums_to_one_emulsion_not_three() { // The half the test above cannot see. The renderer adds the three // layers' dye contributions, so a single emulsion replicated unchanged // would absorb three times over — a stock that renders far too dark, // and neutrally, which is exactly the kind of wrong that looks // deliberate. let p = Profile::parse(include_str!("../profiles/kodak_trix.yaml")).unwrap(); let d = 1.0f32; let t = p.transmittance([d; 3]); for (i, v) in t.iter().enumerate() { let dye_total: f32 = p.dye_density[i].iter().sum(); let expected = 10f32.powf(-(d * dye_total + p.base_density[i])); assert!((v - expected).abs() < 1e-5); // And the three parts really are a third each. let one = p.dye_density[i][0]; assert!( (p.dye_density[i][1] - one).abs() < 1e-6 && (p.dye_density[i][2] - one).abs() < 1e-6, "a monochrome dye is not split evenly at sample {i}" ); } } fn doublex() -> Profile { Profile::parse(include_str!("../profiles/kodak_doublex.yaml")).unwrap() } /// Average gradient over the straight portion — the film's contrast. fn gradient(curves: &[[f32; 3]], p: &Profile) -> f32 { let span = p.log_exposure_max - p.log_exposure_min; let at = |log_e: f32| { let t = ((log_e - p.log_exposure_min) / span) * (curves.len() - 1) as f32; curves[t.round().clamp(0.0, (curves.len() - 1) as f32) as usize][1] }; (at(0.5) - at(-1.0)) / 1.5 } #[test] fn pushing_raises_contrast() { // TRACES: FR-DEV-3f // The whole of what a push is. Measured, not modelled: Double-X's own // axis runs 0.47 to 1.03 in average gradient across its published // development times. let p = doublex(); assert!(p.is_pushable()); let pull = gradient(&p.curves_at_push(-1.0), &p); let normal = gradient(&p.curves_at_push(0.0), &p); let push = gradient(&p.curves_at_push(2.0), &p); assert!(pull < normal && normal < push, "{pull} {normal} {push}"); assert!( push > normal * 1.3, "two stops barely moved it: {normal} -> {push}" ); } #[test] fn pushing_does_not_recover_shadow_detail() { // **The property a push control is most likely to get wrong**, and the // reason to take it from measurements rather than from a contrast // slider. Developing longer multiplies what was already recorded; it // cannot record what never hit the film. So three stops under mid-grey // stays empty however hard the film is pushed, which is exactly what // every photographer who has pushed a roll knows. // // A "push" implemented as added exposure or global contrast brightens // those shadows instead, and looks convincing until someone who shoots // film sees it. let p = doublex(); let span = p.log_exposure_max - p.log_exposure_min; let deep_shadow = ((-1.5 - p.log_exposure_min) / span * 255.0) as usize; let normal = p.curves_at_push(0.0)[deep_shadow][1]; let pushed = p.curves_at_push(2.0)[deep_shadow][1]; assert!( pushed < 0.1, "three stops under, a two-stop push produced density {pushed} — \ the shadows came back, which they must not" ); assert!( pushed >= normal, "pushing removed density: {normal} -> {pushed}" ); } #[test] fn push_is_clamped_to_what_was_measured() { // Past the published range there is no data, and extrapolating a // contrast curve invents an emulsion nobody tested. let p = doublex(); let far = p.curves_at_push(20.0); let edge = p.curves_at_push(6.0); assert_eq!(far, edge, "push ran off the end of the measured axis"); } #[test] fn a_stock_measured_once_ignores_the_push_control() { // Rather than inventing a curve for it. Portra 800's pushes are // separate *measured* profiles, which is the honest way to offer them. let p = portra(); assert!(!p.is_pushable()); assert_eq!(p.curves_at_push(2.0), p.density_curves); } #[test] fn one_stop_of_push_is_the_development_time_the_datasheet_gives() { // The mapping, checked against the measurement it claims to match: // Double-X normal is 6.5 minutes and its next published time is 9. let p = doublex(); let plus_one = p.development_for_push(1.0); assert!( (plus_one - 9.0).abs() < 0.3, "+1 stop mapped to {plus_one} minutes, not the measured 9" ); } #[test] fn the_orange_mask_is_in_the_data() { // Portra's base is a real orange mask: it must absorb blue far more // than red. If this fails the base density has been dropped or // zeroed, and the print balance downstream would have nothing to // correct — which looks like a working picture, only wrong. let p = portra(); let blue = p.base_density[16]; // 460nm let red = p.base_density[64]; // 700nm assert!( blue > red + 0.2, "base density blue {blue} red {red} is not a mask" ); } #[test] fn a_blind_wavelength_reads_as_zero_sensitivity() { let p = kodachrome(); let sens = p.sensitivity(); for (i, row) in sens.iter().enumerate() { for (c, v) in row.iter().enumerate() { assert!(v.is_finite() && *v >= 0.0, "sensitivity[{i}][{c}] = {v}"); } } } }