Skip to main content

kallo_api/
selection.rs

1//! Which stocks, which sensor, which exposure — the choices a render is made of.
2
3use std::path::{Path, PathBuf};
4
5use kallo_core::{CameraProfile, DevelopmentParams, FilmStock, PaperStock, RenderParams};
6use kallo_core::{film_stock, paper_stock};
7use kallo_spectral::Sensor;
8
9use crate::error::Error;
10
11/// The stock and camera choices a job is made from, before they are resolved.
12///
13/// The CLI fills this from its flags and the app from its own state; neither
14/// shape leaks into the model. A `.kalofilm` file is not here — it is read by
15/// the caller that has one and passed as the film of a job, so this crate needs
16/// no file format of its own.
17#[derive(Debug, Clone)]
18pub struct StockChoice {
19    /// Film stock id (see `kalo stocks`).
20    pub film: String,
21    /// Paper stock id.
22    pub paper: String,
23    /// The enlarger's colour pack, in Kodak CC units: `30M`, `20Y+10M`.
24    pub filters: String,
25    /// A heat-absorbing glass in the light path, by name.
26    pub heat_glass: Option<String>,
27    /// Development time in minutes; the stock's reference time when absent.
28    pub dev_time: Option<f64>,
29    /// Developer temperature in degrees Celsius; the stock's when absent.
30    pub temp: Option<f64>,
31    /// Expose through the spectral path.
32    pub spectral: bool,
33    /// Table side per axis for a baked exposure, or 0.
34    pub lut_size: usize,
35    /// Reflectance CSVs for the dictionary prior.
36    pub dictionary: Vec<PathBuf>,
37    /// A table baked by `kalo bake`.
38    pub lut: Option<PathBuf>,
39    /// Load a table even if its recorded model differs from this build's.
40    pub allow_model_mismatch: bool,
41    /// Which sensor the input is assumed to have come from.
42    pub camera: Option<String>,
43    /// Print through spectral dye absorption and paper layer sensitivities.
44    pub spectral_print: bool,
45    /// Scan through spectral reflectance, illuminant and observer.
46    pub spectral_scan: bool,
47}
48
49impl Default for StockChoice {
50    fn default() -> Self {
51        Self {
52            film: "vision3_500t".to_owned(),
53            paper: "portra_endura".to_owned(),
54            filters: String::new(),
55            heat_glass: None,
56            dev_time: None,
57            temp: None,
58            spectral: false,
59            lut_size: 0,
60            dictionary: Vec::new(),
61            lut: None,
62            allow_model_mismatch: false,
63            camera: None,
64            spectral_print: false,
65            spectral_scan: false,
66        }
67    }
68}
69
70/// A stock selection resolved against the model and this build.
71#[derive(Clone)]
72pub struct Selection {
73    /// How the negative was developed.
74    pub development: DevelopmentParams,
75    /// The negative.
76    pub film: &'static FilmStock,
77    /// The paper.
78    pub paper: &'static PaperStock,
79    /// Whether to expose through the spectral path.
80    pub spectral: bool,
81    /// Which sensor the input is assumed to have come from.
82    pub camera: CameraProfile,
83    /// Side of the baked exposure table, or 0 for no table.
84    pub lut_size: usize,
85    /// Reflectance CSVs for the dictionary prior.
86    pub dictionary: Vec<PathBuf>,
87    /// A table baked by `kalo bake`.
88    pub lut: Option<PathBuf>,
89    /// Whether a model mismatch in the table is tolerated.
90    pub allow_model_mismatch: bool,
91    /// Whether to print through the spectral model.
92    pub spectral_print: bool,
93    /// The enlarger's colour pack, in Kodak CC units.
94    pub filters: kallo_core::filters::FilterPack,
95    /// A heat-absorbing glass in the light path.
96    pub heat_glass: Option<kallo_core::filters::HeatGlass>,
97    /// Whether to scan through the spectral model.
98    pub spectral_scan: bool,
99}
100
101impl Selection {
102    /// Bundle the selection into the core's render parameters.
103    #[must_use]
104    pub fn render_params(
105        &self,
106        exposure_ev: f64,
107        print_ev: f64,
108        interimage: bool,
109        film: &'static FilmStock,
110    ) -> RenderParams {
111        RenderParams {
112            film,
113            paper: self.paper,
114            development: self.development,
115            exposure_ev,
116            print_exposure_ev: print_ev,
117            spectral: self.spectral || self.lut.is_some(),
118            camera: self.camera.clone(),
119            lut_size: self.lut_size,
120            spectral_print: self.spectral_print,
121            spectral_scan: self.spectral_scan,
122            // The layer only exists on some sheets, so the flag that asks for
123            // it is the render command's, not the selection's.
124            interimage,
125            filters: self.filters,
126            heat_glass: self.heat_glass,
127        }
128    }
129
130    /// Load the table or dictionary, if requested, and prepare the render.
131    pub fn prepare(
132        &self,
133        exposure_ev: f64,
134        interimage: bool,
135    ) -> Result<kallo_core::PreparedRender, Error> {
136        self.prepare_with_film(exposure_ev, 0.0, interimage, self.film)
137    }
138
139    /// Prepare a render whose film came from somewhere other than the compiled
140    /// table — a `.kalofilm` file, in practice.
141    pub fn prepare_with_film(
142        &self,
143        exposure_ev: f64,
144        print_ev: f64,
145        interimage: bool,
146        film: &'static FilmStock,
147    ) -> Result<kallo_core::PreparedRender, Error> {
148        let params = self.render_params(exposure_ev, print_ev, interimage, film);
149
150        if let Some(path) = &self.lut {
151            let table = kallo_core::ExposureLut::read(path).map_err(|error| Error::Lut {
152                path: path.clone(),
153                message: error.to_string(),
154            })?;
155
156            let expected = kallo_core::SpectralContext::for_stock(film).identity();
157            if let Err(message) = check_model(table.identity(), &expected) {
158                if !self.allow_model_mismatch {
159                    return Err(Error::Lut {
160                        path: path.clone(),
161                        message,
162                    });
163                }
164                eprintln!("warning: {message}");
165            }
166
167            return Ok(params.prepare_with_lut(table));
168        }
169
170        let atoms = if self.dictionary.is_empty() {
171            None
172        } else {
173            let mut atoms = Vec::new();
174            for path in &self.dictionary {
175                let set = kallo_spectral::load_csv(path).map_err(|error| Error::Dictionary {
176                    path: path.clone(),
177                    message: error.to_string(),
178                })?;
179                atoms.extend(set.samples.iter().map(|sample| sample.reflectance.clone()));
180            }
181            Some(atoms)
182        };
183        Ok(params.prepare_with(atoms.as_deref()))
184    }
185}
186
187/// Decide whether a table's recorded model matches the one this build implements.
188///
189/// Split out from the loader so it can be tested without crafting a corrupt file.
190pub fn check_model(table: &str, build: &str) -> Result<(), String> {
191    if table == build {
192        Ok(())
193    } else {
194        Err(format!(
195            "baked for a different model: table says `{table}`, this build is `{build}`. \
196             Pass --allow-model-mismatch to use it anyway."
197        ))
198    }
199}
200
201/// Validate the numbers and look up both stocks.
202pub fn resolve(stock: &StockChoice) -> Result<Selection, Error> {
203    let film = film_stock(&stock.film).ok_or_else(|| Error::UnknownStock {
204        kind: "film",
205        id: stock.film.clone(),
206    })?;
207    let dev_time = stock.dev_time.unwrap_or(film.reference_time_min);
208    let temp = stock.temp.unwrap_or(film.reference_temp_c);
209    if !(dev_time.is_finite() && dev_time > 0.0) {
210        return Err(Error::InvalidNumber {
211            flag: "--dev",
212            value: dev_time,
213            expected: "a positive number of minutes",
214        });
215    }
216    if !temp.is_finite() {
217        return Err(Error::InvalidNumber {
218            flag: "--temp",
219            value: temp,
220            expected: "a finite temperature in degrees Celsius",
221        });
222    }
223
224    let paper = paper_stock(&stock.paper).ok_or_else(|| Error::UnknownStock {
225        kind: "paper",
226        id: stock.paper.clone(),
227    })?;
228
229    // The sheet's development is the reference and the default; `--dev` moves
230    // away from it rather than from a number this program invented.
231    let development = DevelopmentParams {
232        time_min: dev_time,
233        temp_c: temp,
234        reference_time_min: film.reference_time_min,
235        reference_temp_c: film.reference_temp_c,
236        ..DevelopmentParams::default()
237    };
238
239    Ok(Selection {
240        development,
241        film,
242        paper,
243        spectral: stock.spectral,
244        camera: camera_profile(stock.camera.as_deref())?,
245        lut_size: stock.lut_size,
246        dictionary: stock.dictionary.clone(),
247        lut: stock.lut.clone(),
248        allow_model_mismatch: stock.allow_model_mismatch,
249        spectral_print: stock.spectral_print,
250        spectral_scan: stock.spectral_scan,
251        filters: parse_filters(&stock.filters)?,
252        heat_glass: parse_heat_glass(stock.heat_glass.as_deref())?,
253    })
254}
255
256/// `30M`, `20Y+10M`, `5C` — the pack an operator would dial.
257///
258/// One letter per colour, the number in CC units; a bare number is magenta,
259/// because that is the dial a colour head actually uses.
260fn parse_filters(text: &str) -> Result<kallo_core::filters::FilterPack, Error> {
261    let mut pack = kallo_core::filters::FilterPack::none();
262    for part in text
263        .split('+')
264        .map(str::trim)
265        .filter(|part| !part.is_empty())
266    {
267        let split = part
268            .find(|c: char| c.is_ascii_alphabetic())
269            .unwrap_or(part.len());
270        let (number, colour) = part.split_at(split);
271        let value: f64 = number.trim().parse().map_err(|_| Error::InvalidNumber {
272            flag: "--filters",
273            value: f64::NAN,
274            expected: "a CC setting such as 30M",
275        })?;
276        match colour.trim().to_ascii_uppercase().as_str() {
277            "Y" => pack.yellow = value,
278            "M" | "" => pack.magenta = value,
279            "C" => pack.cyan = value,
280            _ => {
281                return Err(Error::InvalidNumber {
282                    flag: "--filters",
283                    value: f64::NAN,
284                    expected: "one of Y, M or C",
285                });
286            }
287        }
288    }
289    Ok(pack)
290}
291
292/// The heat glass by name.
293fn parse_heat_glass(text: Option<&str>) -> Result<Option<kallo_core::filters::HeatGlass>, Error> {
294    use kallo_core::filters::HeatGlass;
295    let Some(text) = text else {
296        return Ok(None);
297    };
298    match text.trim().to_ascii_lowercase().as_str() {
299        "kg1" => Ok(Some(HeatGlass::Kg1)),
300        "kg2" => Ok(Some(HeatGlass::Kg2)),
301        "kg3" => Ok(Some(HeatGlass::Kg3)),
302        "kg5" => Ok(Some(HeatGlass::Kg5)),
303        _ => Err(Error::InvalidNumber {
304            flag: "--heat-glass",
305            value: f64::NAN,
306            expected: "one of kg1, kg2, kg3, kg5",
307        }),
308    }
309}
310
311/// Resolve `--camera` into a profile.
312///
313/// Two names are built in; anything else is a path to a CSV imported by
314/// a measured sensitivity, with one column per channel named red, green and blue.
315pub fn camera_profile(value: Option<&str>) -> Result<CameraProfile, Error> {
316    let Some(value) = value else {
317        return Ok(CameraProfile::default());
318    };
319    match value {
320        "srgb" => Ok(CameraProfile::Srgb),
321        "generic" => Ok(CameraProfile::Generic),
322        "observer" => Ok(CameraProfile::Observer),
323        path => {
324            let set = kallo_spectral::load_csv(path).map_err(|error| Error::Camera {
325                path: path.into(),
326                message: error.to_string(),
327            })?;
328            let channel = |name: &str| {
329                set.sample(name)
330                    .map(|sample| sample.reflectance.clone())
331                    .ok_or_else(|| Error::Camera {
332                        path: path.into(),
333                        message: format!("no column named {name:?}"),
334                    })
335            };
336            let channels = [channel("red")?, channel("green")?, channel("blue")?];
337            // The dataset name is the directory's; the camera's is the file's.
338            let name = Path::new(path)
339                .file_stem()
340                .map(|stem| stem.to_string_lossy().into_owned())
341                .unwrap_or_else(|| path.to_owned());
342            let sensor = Sensor::new(name, channels)
343                .map_err(|error| Error::Camera {
344                    path: path.into(),
345                    message: error.to_string(),
346                })?
347                .area_normalized();
348            Ok(CameraProfile::Measured(sensor))
349        }
350    }
351}
352
353#[cfg(test)]
354mod tests {
355    use super::{camera_profile, check_model};
356    use kallo_core::CameraProfile;
357
358    #[test]
359    fn the_built_in_camera_names_resolve() {
360        // The default is the sRGB primaries: the input is an sRGB image, and
361        // that basis is the one that means what the file says.
362        assert!(matches!(camera_profile(None).unwrap(), CameraProfile::Srgb));
363        assert!(matches!(
364            camera_profile(Some("srgb")).unwrap(),
365            CameraProfile::Srgb
366        ));
367        assert!(matches!(
368            camera_profile(Some("generic")).unwrap(),
369            CameraProfile::Generic
370        ));
371        assert!(matches!(
372            camera_profile(Some("observer")).unwrap(),
373            CameraProfile::Observer
374        ));
375    }
376
377    #[test]
378    fn a_missing_camera_file_names_the_path() {
379        let message = camera_profile(Some("/nonexistent/camera.csv")).unwrap_err();
380        assert!(
381            message.to_string().contains("/nonexistent/camera.csv"),
382            "{message}"
383        );
384    }
385
386    #[test]
387    fn a_matching_identity_passes() {
388        assert!(check_model("camera=a;film=b", "camera=a;film=b").is_ok());
389    }
390
391    #[test]
392    fn a_foreign_identity_names_both_sides() {
393        let message = check_model("camera=old;film=b", "camera=new;film=b").unwrap_err();
394        assert!(message.contains("camera=old"), "{message}");
395        assert!(message.contains("camera=new"), "{message}");
396        assert!(message.contains("--allow-model-mismatch"), "{message}");
397    }
398}