Files
DarkRoom/core/dr-film/src/profile.rs
T
dtourolle 37a6d99dc4 Replace the per-body base curve with a scene-referred view transform
The base curve was a five-point spline on the unit square, flat past its
last point: every value above 1.0 left it as the same number, per
channel. Exposure and highlight recovery put values up there, and the
curve threw them away, then handed the result on as though it were
still scene-linear. The six per-body curves were also, by their own
file's account, hand-tuned shapes rather than measurements, and not
enough is known about where they came from to keep them (D19).

In their place, one view transform for every body (FR-DEV-3j): a
log-logistic sigmoid per channel, with the middle channel put back
between the other two so a hue survives the shoulder. Its two free
constants are solved from two conditions rather than set: scene grey
0.13, where the retired default curve put it, lands on display 0.18,
and the scene white four stops above grey lands on 1.0. So a highlight
a stop past sensor saturation still rolls into white, and the midtones
stay within 0.26 EV of the retired default between scene 0.03 and 1.0.
`dr_pipeline::view` holds the CPU reference and the WGSL, and the tests
there are FR-DEV-3j's acceptance criteria.

It is still fixed and still in the fused pass's tail, so a detail stage
still sees rendered values; the next commits make it an operation and
move it after the detail stage. It is skipped for a JPEG, as the base
curve was, and absent from the camera-space tap.

The base curve's database, its lookup and its twelve uniform slots go.
`RawImage` and `DemosaicedImage` lose the field, and the GPU test that
proved a curve reached the shader is replaced by one that renders the
view transform against the CPU reference and shows two highlights above
1.0 still render apart. The JPEG-and-sensor test now asserts the two
differ by exactly the view transform, where before an identity fixture
curve had made them match.
2026-09-27 16:52:53 -04: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
//!
//! 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<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}");
}
}
}
}