Skip to main content

kallo_api/
chart.rs

1//! What the developed strip did, as a densitometer reads it.
2//!
3//! A print is a picture; the chart beside it is the measurement the picture
4//! came from. The wedge is `kallo-view`'s, because that is the one the
5//! committed strip was drawn from: nine patches from −2.0 to +0.8 log
6//! exposure, the same span in both places, so a chart of a developed roll can
7//! be read against the chart the app ships.
8//!
9//! The numbers are not the curve sampled at nine exposures. They are what an
10//! instrument reads off the developed strip: the dye the development formed,
11//! plus the base, fog and mask the light also passes through. That is the
12//! difference between a characteristic curve and a densitometer reading, and it
13//! is the difference the interface's `isMeasured` names.
14
15use kallo_core::{Densitometer, FilmStock, PaperEmulsion, PaperStock};
16use kallo_view::{StripSpec, strip};
17
18use crate::error::Error;
19use crate::selection::{StockChoice, resolve};
20
21/// One frame's densitometry, as the interface draws it.
22#[derive(Debug, Clone)]
23pub struct FrameChart {
24    /// How long the frame was developed.
25    pub minutes: f64,
26    /// The developer's temperature.
27    pub temp_c: f64,
28    /// Contrast of the developed curve in the red, green and blue records.
29    pub gamma: [f64; 3],
30    /// The dye's density floor, after fog — above the base, like the curve.
31    pub d_min: [f64; 3],
32    /// The dye's density ceiling, above the base, like the curve.
33    ///
34    /// A nine-step strip need not reach it; this is the film's ceiling, not the
35    /// densest reading on the strip.
36    pub d_max: [f64; 3],
37    /// Status density per patch, as the densitometer reads it: the dye plus the
38    /// strip's own base, fog and mask.
39    pub patches: Vec<[f64; 3]>,
40    /// The log10 exposure each patch received.
41    pub log_e: Vec<f64>,
42}
43
44/// Chart a stock at a ladder of development times.
45#[must_use]
46pub fn chart(film: &FilmStock, minutes: &[f64], temp_c: f64) -> Vec<FrameChart> {
47    let spec = StripSpec {
48        minutes: minutes.to_vec(),
49        temp_c,
50        ..StripSpec::default()
51    };
52    let instrument = Densitometer::for_stock(film);
53    strip(film, &spec)
54        .frames
55        .into_iter()
56        .map(|frame| FrameChart {
57            minutes: frame.minutes,
58            temp_c: frame.temp_c,
59            gamma: frame.gamma,
60            d_min: frame.d_min,
61            d_max: frame.d_max,
62            patches: frame
63                .patches
64                .iter()
65                .map(|patch| instrument.read(patch.dye_density))
66                .collect(),
67            log_e: frame.patches.iter().map(|patch| patch.log_e).collect(),
68        })
69        .collect()
70}
71
72/// Chart a stock by id.
73pub fn chart_for(id: &str, minutes: &[f64], temp_c: f64) -> Result<Vec<FrameChart>, Error> {
74    let film = kallo_core::film_stock(id).ok_or_else(|| Error::UnknownStock {
75        kind: "film",
76        id: id.to_owned(),
77    })?;
78    Ok(chart(film, minutes, temp_c))
79}
80
81/// A film's characteristic curve, sampled over its exposure range.
82///
83/// This is the developed curve itself, not the nine-patch wedge the
84/// densitometer reads: a panel that drew the wedge would show a straight line
85/// where the film has a toe and a shoulder. Every development of a stock is
86/// sampled over the same range, so a strip's curves overlay point for point.
87#[derive(Debug, Clone)]
88pub struct FilmCurve {
89    /// The film.
90    pub film: String,
91    /// The development.
92    pub minutes: f64,
93    /// The developer's temperature.
94    pub temp_c: f64,
95    /// Contrast per layer.
96    pub gamma: [f64; 3],
97    /// Density floor per layer.
98    pub d_min: [f64; 3],
99    /// Density ceiling per layer.
100    pub d_max: [f64; 3],
101    /// The log10 exposures the curve was sampled at.
102    pub log_e: Vec<f64>,
103    /// The developed density per layer at each sample.
104    pub density: Vec<[f64; 3]>,
105}
106
107/// The film's developed curve, by id.
108pub fn film_curve(id: &str, minutes: f64, temp_c: f64) -> Result<FilmCurve, Error> {
109    if !(minutes.is_finite() && minutes > 0.0) {
110        return Err(Error::InvalidNumber {
111            flag: "--dev",
112            value: minutes,
113            expected: "a positive number of minutes",
114        });
115    }
116    if !temp_c.is_finite() {
117        return Err(Error::InvalidNumber {
118            flag: "--temp",
119            value: temp_c,
120            expected: "a finite temperature",
121        });
122    }
123    let film = kallo_core::film_stock(id).ok_or_else(|| Error::UnknownStock {
124        kind: "film",
125        id: id.to_owned(),
126    })?;
127    let development = kallo_core::DevelopmentParams {
128        time_min: minutes,
129        temp_c,
130        reference_time_min: film.reference_time_min,
131        reference_temp_c: film.reference_temp_c,
132        ..kallo_core::DevelopmentParams::default()
133    };
134    let curves = film.channels.map(|channel| development.apply_to(channel));
135    // The range is the stock's nominal curve, not this development's: the
136    // exposure axis is the camera's, and a ladder has to share it.
137    let half = film
138        .channels
139        .iter()
140        .map(|channel| 0.5 * channel.amplitude() / channel.gamma.max(1e-9))
141        .fold(0.0_f64, f64::max);
142    let low = film
143        .channels
144        .iter()
145        .map(|channel| channel.log_e0)
146        .fold(f64::INFINITY, f64::min)
147        - half
148        - 1.0;
149    let high = film
150        .channels
151        .iter()
152        .map(|channel| channel.log_e0)
153        .fold(f64::NEG_INFINITY, f64::max)
154        + half
155        + 1.0;
156    const SAMPLES: usize = 64;
157    let log_e: Vec<f64> = (0..=SAMPLES)
158        .map(|i| low + (high - low) * i as f64 / SAMPLES as f64)
159        .collect();
160    let density: Vec<[f64; 3]> = log_e
161        .iter()
162        .map(|x| std::array::from_fn(|layer| curves[layer].density(*x)))
163        .collect();
164    Ok(FilmCurve {
165        film: film.id.to_owned(),
166        minutes,
167        temp_c,
168        gamma: curves.map(|curve| curve.gamma),
169        d_min: curves.map(|curve| curve.d_min),
170        d_max: curves.map(|curve| curve.d_max),
171        log_e,
172        density,
173    })
174}
175
176/// A paper's characteristic curve, sampled for a panel to draw.
177#[derive(Debug, Clone)]
178pub struct PaperChart {
179    /// Stable id.
180    pub id: String,
181    /// The name on the box.
182    pub short: String,
183    /// `colour` or `silver`.
184    pub emulsion: String,
185    /// Density of the unexposed paper.
186    pub d_min: f64,
187    /// Density at full exposure.
188    pub d_max: f64,
189    /// The straight-line slope.
190    pub gamma: f64,
191    /// log10 exposure at the middle of the straight line.
192    pub log_e0: f64,
193    /// Linear reflectance a balanced print reaches for an 18 % grey card.
194    pub target_mid_reflectance: f64,
195    /// The log10 exposures the curve was sampled at.
196    pub log_e: Vec<f64>,
197    /// The paper's density at each sample.
198    pub density: Vec<f64>,
199}
200
201/// A paper's curve, by id.
202pub fn paper_chart(id: &str) -> Result<PaperChart, Error> {
203    let paper = kallo_core::paper_stock(id).ok_or_else(|| Error::UnknownStock {
204        kind: "paper",
205        id: id.to_owned(),
206    })?;
207    Ok(paper_chart_of(paper))
208}
209
210fn paper_chart_of(paper: &PaperStock) -> PaperChart {
211    let curve = paper.channel;
212    // A decade either side of the curve's own toe and shoulder: enough to see
213    // the shape end, not so much that the ends are a flat line.
214    let half = 0.5 * curve.amplitude() / curve.gamma.max(1e-9);
215    let low = curve.log_e0 - half - 1.0;
216    let high = curve.log_e0 + half + 1.0;
217    const SAMPLES: usize = 48;
218    let log_e: Vec<f64> = (0..=SAMPLES)
219        .map(|i| low + (high - low) * i as f64 / SAMPLES as f64)
220        .collect();
221    let density: Vec<f64> = log_e.iter().map(|x| curve.density(*x)).collect();
222    PaperChart {
223        id: paper.id.to_owned(),
224        short: paper.short.to_owned(),
225        emulsion: match paper.emulsion {
226            PaperEmulsion::Colour => "colour",
227            PaperEmulsion::Silver => "silver",
228        }
229        .to_owned(),
230        d_min: curve.d_min,
231        d_max: curve.d_max,
232        gamma: curve.gamma,
233        log_e0: curve.log_e0,
234        target_mid_reflectance: paper.target_mid_reflectance,
235        log_e,
236        density,
237    }
238}
239
240/// The print's tone: the paper's curve through one enlarger balance.
241#[derive(Debug, Clone)]
242pub struct PrintChart {
243    /// The film.
244    pub film: String,
245    /// The paper.
246    pub paper: String,
247    /// The development the print is made at.
248    pub minutes: f64,
249    /// The developer's temperature.
250    pub temp_c: f64,
251    /// The enlarger's exposure per layer, in decades — the balance plus the
252    /// timer. Three numbers, because the colour head moves them apart.
253    pub shift: [f64; 3],
254    /// The negative's log10 exposure, increasing to the right.
255    pub log_e: Vec<f64>,
256    /// The paper's density per channel at each sample.
257    pub density: Vec<[f64; 3]>,
258}
259
260/// The print curve, for a film, a paper and a development.
261///
262/// This is the scalar print's curve, which is the one the model runs unless a
263/// caller asks for a spectral print. The x axis is the exposure the negative
264/// receives; a longer development moves the negative's density up this curve.
265pub fn print_chart(
266    film_id: &str,
267    paper_id: &str,
268    minutes: f64,
269    temp_c: f64,
270) -> Result<PrintChart, Error> {
271    print_chart_with(
272        film_id,
273        paper_id,
274        minutes,
275        temp_c,
276        &PrintChartOptions::default(),
277    )
278}
279
280/// The enlarger settings a print curve is drawn under.
281#[derive(Debug, Clone, Default)]
282pub struct PrintChartOptions {
283    /// Extra print exposure in stops, on top of the solved balance.
284    pub print_ev: f64,
285    /// The enlarger's colour pack, in Kodak CC units.
286    pub filters: String,
287    /// A heat-absorbing glass in the light path, by name.
288    pub heat_glass: Option<String>,
289    /// Print through the spectral path.
290    pub spectral: bool,
291}
292
293/// The print curve under one enlarger setting.
294///
295/// The timer and the light path are part of the balance, so the curve the panel
296/// draws moves when they do. The colour pack and the heat glass are already in
297/// the selection; a non-empty pack or a glass turns on the spectral print in
298/// the model, and the shift the curve is drawn under includes it.
299pub fn print_chart_with(
300    film_id: &str,
301    paper_id: &str,
302    minutes: f64,
303    temp_c: f64,
304    options: &PrintChartOptions,
305) -> Result<PrintChart, Error> {
306    if !(minutes.is_finite() && minutes > 0.0) {
307        return Err(Error::InvalidNumber {
308            flag: "--dev",
309            value: minutes,
310            expected: "a positive number of minutes",
311        });
312    }
313    let choice = StockChoice {
314        film: film_id.to_owned(),
315        paper: paper_id.to_owned(),
316        dev_time: Some(minutes),
317        temp: Some(temp_c),
318        filters: options.filters.clone(),
319        heat_glass: options.heat_glass.clone(),
320        spectral: options.spectral,
321        // The curve is the paper under the print; the scan is after it, so
322        // only the print stage of the spectral switch belongs here.
323        spectral_print: options.spectral,
324        ..StockChoice::default()
325    };
326    let selection = resolve(&choice)?;
327    let prepared = selection.prepare_with_film(0.0, options.print_ev, false, selection.film)?;
328    let paper = selection.paper.channel;
329    let shift = prepared.print_shift();
330    // The negative's exposure: clear film at the right, the densest shadow at
331    // the left. The paper's curve is shifted by the enlarger's balance.
332    let low = -3.5;
333    let high = 0.2;
334    const SAMPLES: usize = 48;
335    let log_e: Vec<f64> = (0..=SAMPLES)
336        .map(|i| low + (high - low) * i as f64 / SAMPLES as f64)
337        .collect();
338    let density: Vec<[f64; 3]> = log_e
339        .iter()
340        .map(|x| std::array::from_fn(|layer| paper.density(x + shift[layer])))
341        .collect();
342    Ok(PrintChart {
343        film: selection.film.id.to_owned(),
344        paper: selection.paper.id.to_owned(),
345        minutes,
346        temp_c,
347        shift,
348        log_e,
349        density,
350    })
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    #[test]
358    fn the_chart_is_the_wedge_the_strip_was_drawn_from() {
359        let frames = chart_for("vision3_500t", &[3.0, 9.0, 15.0], 20.0).expect("the stock exists");
360        assert_eq!(frames.len(), 3);
361        for frame in &frames {
362            assert_eq!(frame.patches.len(), 9);
363            assert_eq!(frame.log_e.len(), 9);
364        }
365        // The committed strip spans −2.0 to +0.8 log exposure; a chart of a
366        // developed roll has to be the same wedge or the two cannot be read
367        // against each other.
368        assert!((frames[0].log_e[0] + 2.0).abs() < 1e-9);
369        assert!((frames[0].log_e[8] - 0.8).abs() < 1e-9);
370        // Longer development, more contrast: the ladder has to move.
371        assert!(frames[1].gamma[0] > frames[0].gamma[0]);
372        assert!(frames[2].gamma[0] > frames[1].gamma[0]);
373    }
374
375    #[test]
376    fn the_chart_is_the_strips_dye_read_through_its_base() {
377        let film = kallo_core::film_stock("vision3_500t").expect("the stock exists");
378        let minutes = [3.0, 9.0, 15.0];
379        let spec = StripSpec {
380            minutes: minutes.to_vec(),
381            temp_c: 20.0,
382            ..StripSpec::default()
383        };
384        let developed = strip(film, &spec);
385        let frames = chart(film, &minutes, 20.0);
386        let instrument = Densitometer::for_stock(film);
387
388        for (measured, frame) in frames.iter().zip(&developed.frames) {
389            for (reading, patch) in measured.patches.iter().zip(&frame.patches) {
390                let expected = instrument.read(patch.dye_density);
391                for channel in 0..3 {
392                    assert!(
393                        (reading[channel] - expected[channel]).abs() < 1e-12,
394                        "{} min, channel {channel}: {} against {}",
395                        frame.minutes,
396                        reading[channel],
397                        expected[channel]
398                    );
399                }
400            }
401            // The reading is gross: the base is in every patch, and the orange
402            // mask makes it larger in blue than in red.
403            assert!(measured.patches[0][2] > frame.patches[0].dye_density[2] + 0.5);
404            assert!(measured.patches[0][0] > frame.patches[0].dye_density[0]);
405        }
406    }
407
408    #[test]
409    fn an_unknown_stock_is_an_error() {
410        assert!(chart_for("no_such_film", &[9.0], 20.0).is_err());
411        assert!(paper_chart("no_such_paper").is_err());
412        assert!(print_chart("no_such_film", "portra_endura", 9.0, 20.0).is_err());
413    }
414
415    #[test]
416    fn the_paper_curve_is_the_papers_own_shape() {
417        let chart = paper_chart("portra_endura").expect("the paper exists");
418        assert_eq!(chart.log_e.len(), chart.density.len());
419        assert!(chart.log_e.len() > 32);
420        assert!(chart.density.windows(2).all(|pair| pair[1] >= pair[0]));
421        let low = chart.density[0];
422        let high = chart.density[chart.density.len() - 1];
423        assert!(
424            (low - chart.d_min).abs() < 0.02,
425            "{low} against {}",
426            chart.d_min
427        );
428        assert!(
429            (high - chart.d_max).abs() < 0.05,
430            "{high} against {}",
431            chart.d_max
432        );
433        assert!(chart.target_mid_reflectance > 0.0 && chart.target_mid_reflectance < 1.0);
434    }
435
436    #[test]
437    fn the_film_curve_has_a_toe_and_a_shoulder() {
438        let curve = film_curve("vision3_500t", 9.0, 20.0).expect("the film exists");
439        assert_eq!(curve.log_e.len(), curve.density.len());
440        assert!(curve.log_e.len() > 32);
441        for pair in curve.density.windows(2) {
442            for (before, after) in pair[0].iter().zip(&pair[1]) {
443                assert!(after >= before);
444            }
445        }
446        // The sampled range reaches past both corners: the ends are the
447        // plateaus, not the middle of the straight line.
448        let first = curve.density[0];
449        let last = curve.density[curve.density.len() - 1];
450        for channel in 0..3 {
451            assert!((first[channel] - curve.d_min[channel]).abs() < 0.02);
452            assert!((last[channel] - curve.d_max[channel]).abs() < 0.02);
453        }
454        let longer = film_curve("vision3_500t", 15.0, 20.0).expect("the film exists");
455        assert!(longer.gamma[0] > curve.gamma[0]);
456    }
457
458    #[test]
459    fn the_print_curve_moves_with_the_timer_and_the_pack() {
460        let base =
461            print_chart("vision3_500t", "portra_endura", 9.0, 20.0).expect("the stocks exist");
462        let over = print_chart_with(
463            "vision3_500t",
464            "portra_endura",
465            9.0,
466            20.0,
467            &PrintChartOptions {
468                print_ev: 1.0,
469                ..PrintChartOptions::default()
470            },
471        )
472        .expect("the stocks exist");
473        assert!(
474            over.shift[0] > base.shift[0],
475            "one stop of timer should move the print curve: {:?} against {:?}",
476            over.shift,
477            base.shift
478        );
479
480        let filtered = print_chart_with(
481            "vision3_500t",
482            "portra_endura",
483            9.0,
484            20.0,
485            &PrintChartOptions {
486                filters: "30M".to_owned(),
487                ..PrintChartOptions::default()
488            },
489        )
490        .expect("the stocks exist");
491        assert_ne!(
492            filtered.shift, base.shift,
493            "a colour pack is part of the enlarger the curve is drawn under"
494        );
495    }
496
497    #[test]
498    fn the_print_curve_is_the_paper_under_one_balance() {
499        let chart =
500            print_chart("vision3_500t", "portra_endura", 9.0, 20.0).expect("the stocks exist");
501        assert_eq!(chart.log_e.len(), chart.density.len());
502        for pair in chart.density.windows(2) {
503            for (before, after) in pair[0].iter().zip(&pair[1]) {
504                assert!(after >= before);
505            }
506        }
507        // A colour head is unlikely to be perfectly neutral on a measured
508        // mask; the three shifts are a balance, not one number repeated.
509        assert!(chart.shift.iter().all(|value| value.is_finite()));
510        assert!(
511            chart.shift[0] != chart.shift[1] || chart.shift[1] != chart.shift[2],
512            "a balance with one number on every layer is not a colour head"
513        );
514    }
515}