1use kallo_core::{Densitometer, FilmStock, PaperEmulsion, PaperStock};
16use kallo_view::{StripSpec, strip};
17
18use crate::error::Error;
19use crate::selection::{StockChoice, resolve};
20
21#[derive(Debug, Clone)]
23pub struct FrameChart {
24 pub minutes: f64,
26 pub temp_c: f64,
28 pub gamma: [f64; 3],
30 pub d_min: [f64; 3],
32 pub d_max: [f64; 3],
37 pub patches: Vec<[f64; 3]>,
40 pub log_e: Vec<f64>,
42}
43
44#[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
72pub 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#[derive(Debug, Clone)]
88pub struct FilmCurve {
89 pub film: String,
91 pub minutes: f64,
93 pub temp_c: f64,
95 pub gamma: [f64; 3],
97 pub d_min: [f64; 3],
99 pub d_max: [f64; 3],
101 pub log_e: Vec<f64>,
103 pub density: Vec<[f64; 3]>,
105}
106
107pub 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 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#[derive(Debug, Clone)]
178pub struct PaperChart {
179 pub id: String,
181 pub short: String,
183 pub emulsion: String,
185 pub d_min: f64,
187 pub d_max: f64,
189 pub gamma: f64,
191 pub log_e0: f64,
193 pub target_mid_reflectance: f64,
195 pub log_e: Vec<f64>,
197 pub density: Vec<f64>,
199}
200
201pub 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 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#[derive(Debug, Clone)]
242pub struct PrintChart {
243 pub film: String,
245 pub paper: String,
247 pub minutes: f64,
249 pub temp_c: f64,
251 pub shift: [f64; 3],
254 pub log_e: Vec<f64>,
256 pub density: Vec<[f64; 3]>,
258}
259
260pub 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#[derive(Debug, Clone, Default)]
282pub struct PrintChartOptions {
283 pub print_ev: f64,
285 pub filters: String,
287 pub heat_glass: Option<String>,
289 pub spectral: bool,
291}
292
293pub 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 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 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 assert!((frames[0].log_e[0] + 2.0).abs() < 1e-9);
369 assert!((frames[0].log_e[8] - 0.8).abs() < 1e-9);
370 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 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 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 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}