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>
604 lines
24 KiB
Rust
604 lines
24 KiB
Rust
//! 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}");
|
||
}
|
||
}
|
||
}
|
||
}
|