use kallo_core::{Densitometer, FilmStock, PaperEmulsion, PaperStock};
use kallo_view::{StripSpec, strip};
use crate::error::Error;
use crate::selection::{StockChoice, resolve};
#[derive(Debug, Clone)]
pub struct FrameChart {
pub minutes: f64,
pub temp_c: f64,
pub gamma: [f64; 3],
pub d_min: [f64; 3],
pub d_max: [f64; 3],
pub patches: Vec<[f64; 3]>,
pub log_e: Vec<f64>,
}
#[must_use]
pub fn chart(film: &FilmStock, minutes: &[f64], temp_c: f64) -> Vec<FrameChart> {
let spec = StripSpec {
minutes: minutes.to_vec(),
temp_c,
..StripSpec::default()
};
let instrument = Densitometer::for_stock(film);
strip(film, &spec)
.frames
.into_iter()
.map(|frame| FrameChart {
minutes: frame.minutes,
temp_c: frame.temp_c,
gamma: frame.gamma,
d_min: frame.d_min,
d_max: frame.d_max,
patches: frame
.patches
.iter()
.map(|patch| instrument.read(patch.dye_density))
.collect(),
log_e: frame.patches.iter().map(|patch| patch.log_e).collect(),
})
.collect()
}
pub fn chart_for(id: &str, minutes: &[f64], temp_c: f64) -> Result<Vec<FrameChart>, Error> {
let film = kallo_core::film_stock(id).ok_or_else(|| Error::UnknownStock {
kind: "film",
id: id.to_owned(),
})?;
Ok(chart(film, minutes, temp_c))
}
#[derive(Debug, Clone)]
pub struct FilmCurve {
pub film: String,
pub minutes: f64,
pub temp_c: f64,
pub gamma: [f64; 3],
pub d_min: [f64; 3],
pub d_max: [f64; 3],
pub log_e: Vec<f64>,
pub density: Vec<[f64; 3]>,
}
pub fn film_curve(id: &str, minutes: f64, temp_c: f64) -> Result<FilmCurve, Error> {
if !(minutes.is_finite() && minutes > 0.0) {
return Err(Error::InvalidNumber {
flag: "--dev",
value: minutes,
expected: "a positive number of minutes",
});
}
if !temp_c.is_finite() {
return Err(Error::InvalidNumber {
flag: "--temp",
value: temp_c,
expected: "a finite temperature",
});
}
let film = kallo_core::film_stock(id).ok_or_else(|| Error::UnknownStock {
kind: "film",
id: id.to_owned(),
})?;
let development = kallo_core::DevelopmentParams {
time_min: minutes,
temp_c,
reference_time_min: film.reference_time_min,
reference_temp_c: film.reference_temp_c,
..kallo_core::DevelopmentParams::default()
};
let curves = film.channels.map(|channel| development.apply_to(channel));
let half = film
.channels
.iter()
.map(|channel| 0.5 * channel.amplitude() / channel.gamma.max(1e-9))
.fold(0.0_f64, f64::max);
let low = film
.channels
.iter()
.map(|channel| channel.log_e0)
.fold(f64::INFINITY, f64::min)
- half
- 1.0;
let high = film
.channels
.iter()
.map(|channel| channel.log_e0)
.fold(f64::NEG_INFINITY, f64::max)
+ half
+ 1.0;
const SAMPLES: usize = 64;
let log_e: Vec<f64> = (0..=SAMPLES)
.map(|i| low + (high - low) * i as f64 / SAMPLES as f64)
.collect();
let density: Vec<[f64; 3]> = log_e
.iter()
.map(|x| std::array::from_fn(|layer| curves[layer].density(*x)))
.collect();
Ok(FilmCurve {
film: film.id.to_owned(),
minutes,
temp_c,
gamma: curves.map(|curve| curve.gamma),
d_min: curves.map(|curve| curve.d_min),
d_max: curves.map(|curve| curve.d_max),
log_e,
density,
})
}
#[derive(Debug, Clone)]
pub struct PaperChart {
pub id: String,
pub short: String,
pub emulsion: String,
pub d_min: f64,
pub d_max: f64,
pub gamma: f64,
pub log_e0: f64,
pub target_mid_reflectance: f64,
pub log_e: Vec<f64>,
pub density: Vec<f64>,
}
pub fn paper_chart(id: &str) -> Result<PaperChart, Error> {
let paper = kallo_core::paper_stock(id).ok_or_else(|| Error::UnknownStock {
kind: "paper",
id: id.to_owned(),
})?;
Ok(paper_chart_of(paper))
}
fn paper_chart_of(paper: &PaperStock) -> PaperChart {
let curve = paper.channel;
let half = 0.5 * curve.amplitude() / curve.gamma.max(1e-9);
let low = curve.log_e0 - half - 1.0;
let high = curve.log_e0 + half + 1.0;
const SAMPLES: usize = 48;
let log_e: Vec<f64> = (0..=SAMPLES)
.map(|i| low + (high - low) * i as f64 / SAMPLES as f64)
.collect();
let density: Vec<f64> = log_e.iter().map(|x| curve.density(*x)).collect();
PaperChart {
id: paper.id.to_owned(),
short: paper.short.to_owned(),
emulsion: match paper.emulsion {
PaperEmulsion::Colour => "colour",
PaperEmulsion::Silver => "silver",
}
.to_owned(),
d_min: curve.d_min,
d_max: curve.d_max,
gamma: curve.gamma,
log_e0: curve.log_e0,
target_mid_reflectance: paper.target_mid_reflectance,
log_e,
density,
}
}
#[derive(Debug, Clone)]
pub struct PrintChart {
pub film: String,
pub paper: String,
pub minutes: f64,
pub temp_c: f64,
pub shift: [f64; 3],
pub log_e: Vec<f64>,
pub density: Vec<[f64; 3]>,
}
pub fn print_chart(
film_id: &str,
paper_id: &str,
minutes: f64,
temp_c: f64,
) -> Result<PrintChart, Error> {
print_chart_with(
film_id,
paper_id,
minutes,
temp_c,
&PrintChartOptions::default(),
)
}
#[derive(Debug, Clone, Default)]
pub struct PrintChartOptions {
pub print_ev: f64,
pub filters: String,
pub heat_glass: Option<String>,
pub spectral: bool,
}
pub fn print_chart_with(
film_id: &str,
paper_id: &str,
minutes: f64,
temp_c: f64,
options: &PrintChartOptions,
) -> Result<PrintChart, Error> {
if !(minutes.is_finite() && minutes > 0.0) {
return Err(Error::InvalidNumber {
flag: "--dev",
value: minutes,
expected: "a positive number of minutes",
});
}
let choice = StockChoice {
film: film_id.to_owned(),
paper: paper_id.to_owned(),
dev_time: Some(minutes),
temp: Some(temp_c),
filters: options.filters.clone(),
heat_glass: options.heat_glass.clone(),
spectral: options.spectral,
spectral_print: options.spectral,
..StockChoice::default()
};
let selection = resolve(&choice)?;
let prepared = selection.prepare_with_film(0.0, options.print_ev, false, selection.film)?;
let paper = selection.paper.channel;
let shift = prepared.print_shift();
let low = -3.5;
let high = 0.2;
const SAMPLES: usize = 48;
let log_e: Vec<f64> = (0..=SAMPLES)
.map(|i| low + (high - low) * i as f64 / SAMPLES as f64)
.collect();
let density: Vec<[f64; 3]> = log_e
.iter()
.map(|x| std::array::from_fn(|layer| paper.density(x + shift[layer])))
.collect();
Ok(PrintChart {
film: selection.film.id.to_owned(),
paper: selection.paper.id.to_owned(),
minutes,
temp_c,
shift,
log_e,
density,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_chart_is_the_wedge_the_strip_was_drawn_from() {
let frames = chart_for("vision3_500t", &[3.0, 9.0, 15.0], 20.0).expect("the stock exists");
assert_eq!(frames.len(), 3);
for frame in &frames {
assert_eq!(frame.patches.len(), 9);
assert_eq!(frame.log_e.len(), 9);
}
assert!((frames[0].log_e[0] + 2.0).abs() < 1e-9);
assert!((frames[0].log_e[8] - 0.8).abs() < 1e-9);
assert!(frames[1].gamma[0] > frames[0].gamma[0]);
assert!(frames[2].gamma[0] > frames[1].gamma[0]);
}
#[test]
fn the_chart_is_the_strips_dye_read_through_its_base() {
let film = kallo_core::film_stock("vision3_500t").expect("the stock exists");
let minutes = [3.0, 9.0, 15.0];
let spec = StripSpec {
minutes: minutes.to_vec(),
temp_c: 20.0,
..StripSpec::default()
};
let developed = strip(film, &spec);
let frames = chart(film, &minutes, 20.0);
let instrument = Densitometer::for_stock(film);
for (measured, frame) in frames.iter().zip(&developed.frames) {
for (reading, patch) in measured.patches.iter().zip(&frame.patches) {
let expected = instrument.read(patch.dye_density);
for channel in 0..3 {
assert!(
(reading[channel] - expected[channel]).abs() < 1e-12,
"{} min, channel {channel}: {} against {}",
frame.minutes,
reading[channel],
expected[channel]
);
}
}
assert!(measured.patches[0][2] > frame.patches[0].dye_density[2] + 0.5);
assert!(measured.patches[0][0] > frame.patches[0].dye_density[0]);
}
}
#[test]
fn an_unknown_stock_is_an_error() {
assert!(chart_for("no_such_film", &[9.0], 20.0).is_err());
assert!(paper_chart("no_such_paper").is_err());
assert!(print_chart("no_such_film", "portra_endura", 9.0, 20.0).is_err());
}
#[test]
fn the_paper_curve_is_the_papers_own_shape() {
let chart = paper_chart("portra_endura").expect("the paper exists");
assert_eq!(chart.log_e.len(), chart.density.len());
assert!(chart.log_e.len() > 32);
assert!(chart.density.windows(2).all(|pair| pair[1] >= pair[0]));
let low = chart.density[0];
let high = chart.density[chart.density.len() - 1];
assert!(
(low - chart.d_min).abs() < 0.02,
"{low} against {}",
chart.d_min
);
assert!(
(high - chart.d_max).abs() < 0.05,
"{high} against {}",
chart.d_max
);
assert!(chart.target_mid_reflectance > 0.0 && chart.target_mid_reflectance < 1.0);
}
#[test]
fn the_film_curve_has_a_toe_and_a_shoulder() {
let curve = film_curve("vision3_500t", 9.0, 20.0).expect("the film exists");
assert_eq!(curve.log_e.len(), curve.density.len());
assert!(curve.log_e.len() > 32);
for pair in curve.density.windows(2) {
for (before, after) in pair[0].iter().zip(&pair[1]) {
assert!(after >= before);
}
}
let first = curve.density[0];
let last = curve.density[curve.density.len() - 1];
for channel in 0..3 {
assert!((first[channel] - curve.d_min[channel]).abs() < 0.02);
assert!((last[channel] - curve.d_max[channel]).abs() < 0.02);
}
let longer = film_curve("vision3_500t", 15.0, 20.0).expect("the film exists");
assert!(longer.gamma[0] > curve.gamma[0]);
}
#[test]
fn the_print_curve_moves_with_the_timer_and_the_pack() {
let base =
print_chart("vision3_500t", "portra_endura", 9.0, 20.0).expect("the stocks exist");
let over = print_chart_with(
"vision3_500t",
"portra_endura",
9.0,
20.0,
&PrintChartOptions {
print_ev: 1.0,
..PrintChartOptions::default()
},
)
.expect("the stocks exist");
assert!(
over.shift[0] > base.shift[0],
"one stop of timer should move the print curve: {:?} against {:?}",
over.shift,
base.shift
);
let filtered = print_chart_with(
"vision3_500t",
"portra_endura",
9.0,
20.0,
&PrintChartOptions {
filters: "30M".to_owned(),
..PrintChartOptions::default()
},
)
.expect("the stocks exist");
assert_ne!(
filtered.shift, base.shift,
"a colour pack is part of the enlarger the curve is drawn under"
);
}
#[test]
fn the_print_curve_is_the_paper_under_one_balance() {
let chart =
print_chart("vision3_500t", "portra_endura", 9.0, 20.0).expect("the stocks exist");
assert_eq!(chart.log_e.len(), chart.density.len());
for pair in chart.density.windows(2) {
for (before, after) in pair[0].iter().zip(&pair[1]) {
assert!(after >= before);
}
}
assert!(chart.shift.iter().all(|value| value.is_finite()));
assert!(
chart.shift[0] != chart.shift[1] || chart.shift[1] != chart.shift[2],
"a balance with one number on every layer is not a colour head"
);
}
}