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>
This commit is contained in:
2026-08-26 17:35:25 +02:00
co-authored by Claude Opus 5
parent 8f863ec56f
commit ce6458547a
7 changed files with 281 additions and 7 deletions
+19 -2
View File
@@ -59,6 +59,13 @@ pub struct Recipe<'a> {
pub exposure_ev: f32,
/// Enlarger exposure, in stops. Ignored without a `print`.
pub print_exposure_ev: f32,
/// TRACES: FR-DEV-3f
/// Development, in stops of push. Positive develops longer.
///
/// Ignored by a stock measured at one process, of which there are many —
/// see [`crate::profile::Profile::curves_at_push`], which returns the one
/// measured curve rather than inventing a pushed one.
pub push_stops: f32,
}
impl<'a> Recipe<'a> {
@@ -70,6 +77,7 @@ impl<'a> Recipe<'a> {
print,
exposure_ev: 0.0,
print_exposure_ev: 0.0,
push_stops: 0.0,
}
}
}
@@ -260,8 +268,17 @@ pub fn bake(recipe: &Recipe) -> Baked {
}
}
let curves = film.density_curves.clone();
let density_max = film.max_density().max(1e-3);
// TRACES: FR-DEV-3f
// Developed to the requested push before anything else reads the curves:
// the density ceiling, the print balance and the grain all depend on how
// far this film was taken, and a push that only reached one of them would
// be a contrast change wearing a push's name.
let curves = film.curves_at_push(recipe.push_stops);
let density_max = curves
.iter()
.flat_map(|row| row.iter())
.fold(0.0f32, |a, &b| a.max(b))
.max(1e-3);
let viewing = match recipe.print {
Some(paper) => Viewing::new(&paper.viewing_illuminant),
+169
View File
@@ -139,6 +139,21 @@ pub struct Profile {
#[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
@@ -241,6 +256,68 @@ impl Profile {
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
@@ -406,6 +483,98 @@ mod tests {
}
}
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