Files
DarkRoom/core/dr-film/src/profile.rs
T
dtourolleandClaude Opus 5 ce6458547a Develop longer, from the measurements rather than from a contrast slider
Pushing was not a thing to simulate. It was measured data being thrown
away: Double-X and 2302 each ship five characteristic curves, one per
development time, and this shipped the 6.5-minute column and discarded
four. All five now ship and interpolate.

The axis is real. Double-X runs 4 to 12 minutes, and across it the average
gradient goes 0.472 to 1.034 while Dmax goes 1.19 to 2.56.

The control is in stops, because that is what a photographer means, and one
stop is a factor of about 1.41 in time. That mapping is checked rather than
assumed: against Double-X's own axis it lands within 2% of the 9-minute
column for +1, and near 12 minutes for +2, which are the times the datasheet
gives for exactly that. There is a test.

**Pushing must not recover shadow detail, and this does not.** Across the
whole measured range the speed point moves about a third of a stop while the
gradient doubles; three stops under mid-grey, density goes from 0.008 to
0.035, which is still nothing. Developing longer multiplies what was already
recorded and cannot record what never hit the film. A push built as added
exposure or global contrast brightens those shadows instead and looks
convincing until someone who shoots film sees it, so that property has a
test of its own.

Interpolated in *log* time, because development is multiplicative: 4 to 5
minutes is the same amount of push as 9 to 12, and interpolating linearly
would bunch the control at one end. Clamped at both ends, because past the
published range there is no data and extrapolating a contrast curve invents
an emulsion nobody tested. A stock measured at one process ignores the
control entirely rather than inventing a curve for it -- Portra 800's pushes
are separate *measured* profiles, which is the honest way to offer those.

Costs nothing per pixel and changes no shader. The curves are a per-stock
table, so the interpolation happens on the CPU at bake time, where choosing a
stock and moving its sliders already rebakes. The Vulkan shader is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 17:35:25 +02:00

604 lines
24 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! A film stock, as measured.
//!
//! # Why it is data
//!
//! The same argument `dr_decode::base_curve` makes for camera bodies, and for
//! the same requirement: under the GPLv3 a stock should be contributable
//! without a release. 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<String>,
/// 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<f32>,
/// 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<Vec<[f32; 3]>>,
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<Self, 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)
}
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<Vec<[f32; 3]>, D::Error>
where
D: serde::Deserializer<'de>,
{
Vec::<[f32; 3]>::deserialize(d)
}
fn spectral_scalars<'de, D>(d: D) -> Result<Spectrum, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
let v = Vec::<f32>::deserialize(d)?;
v.try_into().map_err(|v: Vec<f32>| {
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}");
}
}
}
}