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:
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user