Skip to main content

kallo_api/
render.rs

1//! One photograph, developed at one time and printed on one paper.
2
3use std::path::Path;
4
5use kallo_core::{
6    Couplers, EmulsionBlur, Finishing, GrainField, Halation, InterImage, Metering, RenderParams,
7    Scanner, render_pixels_finished,
8};
9use kallo_io::{Image, save};
10
11use crate::error::Error;
12use crate::selection::Selection;
13
14/// How the exposure is chosen.
15#[derive(Debug, Clone, Copy)]
16pub enum Exposure {
17    /// A fixed offset in stops.
18    Stops(f64),
19    /// Meter the frame and place its mean luminance at 18 % grey.
20    Auto,
21}
22
23impl std::str::FromStr for Exposure {
24    type Err = String;
25
26    fn from_str(text: &str) -> Result<Self, Self::Err> {
27        if text.eq_ignore_ascii_case("auto") {
28            return Ok(Self::Auto);
29        }
30        text.parse::<f64>()
31            .map(Self::Stops)
32            .map_err(|error| format!("expected a number of stops or `auto`: {error}"))
33    }
34}
35
36/// Everything a render needs beyond the image and the stocks.
37#[derive(Debug, Clone)]
38pub struct RenderOptions {
39    /// Exposure offset, or metering.
40    pub ev: Exposure,
41    /// Extra print exposure in stops.
42    pub print_ev: f64,
43    /// Print on the balance solved for this development instead of this one.
44    ///
45    /// A test strip is one exposure: every print is made with the enlarger
46    /// where the first print set it, so that what changes between the prints is
47    /// the development and not the timer. The reference is a development time
48    /// of the same film and paper; the print keeps its own negative.
49    pub balance_from: Option<f64>,
50    /// Width of the film frame the image represents, in millimetres.
51    pub frame_mm: f64,
52    /// Add the emulsion's own blur.
53    pub emulsion: bool,
54    /// Add halation.
55    pub halation: bool,
56    /// Add DIR couplers.
57    pub couplers: bool,
58    /// Add the cyan-sensitive layer's inhibitor.
59    pub interimage: bool,
60    /// Add grain.
61    pub grain: bool,
62    /// Override the grain's correlation length.
63    pub grain_um: Option<f64>,
64    /// Grain seed.
65    pub grain_seed: u64,
66    /// Scan the print.
67    pub scanner: bool,
68    /// Gaussian optics of the scan, in micrometres.
69    pub scan_optics_um: Option<f64>,
70    /// Render on the GPU when there is one.
71    pub gpu: bool,
72}
73
74impl Default for RenderOptions {
75    fn default() -> Self {
76        Self {
77            ev: Exposure::Auto,
78            print_ev: 0.0,
79            balance_from: None,
80            frame_mm: 36.0,
81            emulsion: true,
82            halation: false,
83            couplers: false,
84            interimage: false,
85            grain: false,
86            grain_um: None,
87            grain_seed: 0,
88            scanner: false,
89            scan_optics_um: None,
90            gpu: true,
91        }
92    }
93}
94
95/// Which renderer did the work.
96#[derive(Debug, Clone)]
97pub enum Backend {
98    /// The `kallo-core` model, in f64.
99    Cpu,
100    /// The device, as wgpu describes it.
101    Gpu(String),
102}
103
104impl std::fmt::Display for Backend {
105    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106        match self {
107            Self::Cpu => write!(formatter, "cpu"),
108            Self::Gpu(name) => write!(formatter, "gpu — {name}"),
109        }
110    }
111}
112
113/// The finishing stages, owned, so an outcome can outlive the call that made it.
114#[derive(Debug, Default)]
115pub struct OwnedFinishing {
116    /// The emulsion's blur, if it was on.
117    pub emulsion: Option<EmulsionBlur>,
118    /// Halation, if it was on.
119    pub halation: Option<Halation>,
120    /// Grain, if it was on.
121    pub grain: Option<GrainField>,
122    /// DIR couplers, if they were on.
123    pub couplers: Option<Couplers>,
124    /// The cyan-sensitive layer's inhibitor, if it was on.
125    pub interimage: Option<InterImage>,
126}
127
128impl OwnedFinishing {
129    /// Borrow the stages for a render call.
130    #[must_use]
131    pub fn as_finishing(&self) -> Finishing<'_> {
132        Finishing {
133            emulsion: self.emulsion.as_ref(),
134            halation: self.halation.as_ref(),
135            grain: self.grain.as_ref(),
136            couplers: self.couplers.as_ref(),
137            interimage: self.interimage.as_ref(),
138        }
139    }
140}
141
142/// What a render did.
143pub struct RenderOutcome {
144    /// Width in pixels.
145    pub width: u32,
146    /// Height in pixels.
147    pub height: u32,
148    /// Who rendered it.
149    pub backend: Backend,
150    /// The parameters the model was run with.
151    pub params: RenderParams,
152    /// The curves, after the development.
153    pub prepared: kallo_core::PreparedRender,
154    /// The finishing stages that were on.
155    pub finishing: OwnedFinishing,
156    /// The scan's texture and optics, if it was on.
157    pub scanner: Option<Scanner>,
158    /// What the meter read, if the exposure was metered.
159    pub metering: Option<Metering>,
160    /// Things the caller should say out loud but that did not stop the render.
161    pub warnings: Vec<String>,
162}
163
164/// Develop a whole image and write it where asked.
165///
166/// The image is taken at the size it will be rendered at: the meter averages
167/// it, the grain is scaled by it, and the emulsions blur across it, so a
168/// caller that resizes afterwards would be rendering a different photograph.
169/// `film` overrides the stock the selection resolved to — that is how a
170/// `.kalofilm` file reaches the model without this crate knowing the format.
171pub fn render(
172    image: &mut Image,
173    film: Option<&'static kallo_core::FilmStock>,
174    selection: &Selection,
175    options: &RenderOptions,
176    output: &Path,
177) -> Result<RenderOutcome, Error> {
178    let outcome = render_image(image, film, selection, options)?;
179    ensure_parent(output)?;
180    save(output, image).map_err(|source| Error::Write {
181        path: output.to_path_buf(),
182        source,
183    })?;
184    Ok(outcome)
185}
186
187/// Develop and print an image in place, without writing anything.
188///
189/// This is [`render`] minus the file: the pixels stay in memory for a caller
190/// that has a window to put them in. A preview session renders the same
191/// pixels the print would, so a preview and the print it stands for cannot
192/// drift apart.
193pub fn render_image(
194    image: &mut Image,
195    film: Option<&'static kallo_core::FilmStock>,
196    selection: &Selection,
197    options: &RenderOptions,
198) -> Result<RenderOutcome, Error> {
199    let film = film.unwrap_or(selection.film);
200    let (width, height) = (image.width, image.height);
201    let (width, height) = (width as usize, height as usize);
202
203    // The meter reads the frame, and the offset it solves is part of the
204    // parameters, so it runs between the decode and the prepare.
205    let (exposure_ev, metering) = match options.ev {
206        Exposure::Stops(ev) => (ev, None),
207        Exposure::Auto => {
208            let metered = Metering::of(&image.pixels);
209            (metered.ev, Some(metered))
210        }
211    };
212
213    // The flag is asked for before anything is prepared: the fourth integral is
214    // part of the model, not a pass over a finished density.
215    let interimage = options.interimage && film.cl_layer.is_some();
216    let prepared = selection.prepare_with_film(exposure_ev, options.print_ev, interimage, film)?;
217    let prepared = match options.balance_from {
218        Some(minutes) => {
219            if !(minutes.is_finite() && minutes > 0.0) {
220                return Err(Error::InvalidNumber {
221                    flag: "--balance-from",
222                    value: minutes,
223                    expected: "a positive number of minutes",
224                });
225            }
226            let mut reference = selection.clone();
227            reference.development.time_min = minutes;
228            let reference =
229                reference.prepare_with_film(exposure_ev, options.print_ev, interimage, film)?;
230            prepared.printed_like(&reference)
231        }
232        None => prepared,
233    };
234    let params = selection.render_params(exposure_ev, options.print_ev, interimage, film);
235
236    let pixel_um = options.frame_mm * 1000.0 / width.max(1) as f64;
237    // The field takes its grain density from the stock: the measured one
238    // differs by a factor of nine between its layers, and a stock without a
239    // measurement falls back to the placeholder.
240    let grain = options.grain.then(|| {
241        let field =
242            GrainField::for_stock(width, height, options.frame_mm, options.grain_seed, film);
243        match options.grain_um {
244            Some(diameter) => field.with_grain_sizes([diameter; 3]),
245            None => field,
246        }
247    });
248    let emulsion = options
249        .emulsion
250        .then(|| EmulsionBlur::for_stock(film, pixel_um));
251    let halation = options.halation.then(|| Halation::typical(pixel_um));
252    let couplers = options.couplers.then(|| Couplers::typical(pixel_um));
253    let interimage_field = interimage.then(|| InterImage::typical(pixel_um));
254    let finishing = OwnedFinishing {
255        emulsion,
256        halation,
257        grain,
258        couplers,
259        interimage: interimage_field,
260    };
261
262    let mut warnings = Vec::new();
263    let backend = render_pixels(
264        &mut image.pixels,
265        &prepared,
266        width,
267        height,
268        &finishing.as_finishing(),
269        options.gpu,
270        &mut warnings,
271    )?;
272
273    // The scan stage is a post-pass on the finished print, so it is the same
274    // arithmetic whichever backend drew the pixels: the scanner is not part of
275    // the film, and it is not part of either renderer either.
276    let scanner = options.scanner.then(|| {
277        let mut scanner = Scanner::measured();
278        if let Some(optics) = options.scan_optics_um {
279            scanner.optics_um = optics;
280        }
281        scanner
282    });
283    if let Some(scanner) = scanner.as_ref() {
284        scanner.apply(&mut image.pixels, width, height, pixel_um);
285    }
286
287    Ok(RenderOutcome {
288        width: width as u32,
289        height: height as u32,
290        backend,
291        params,
292        prepared,
293        finishing,
294        scanner,
295        metering,
296        warnings,
297    })
298}
299
300/// Render the pixels, on the GPU if asked and possible, on the CPU otherwise.
301///
302/// The fallback is deliberate and loud. A machine with no adapter, a render the
303/// backend does not implement, or a device that rejects a frame gets a warning
304/// and a correct image, not an error and not a silently different one. The
305/// device's own failure is a property of the driver, not of the photograph.
306#[cfg(feature = "gpu")]
307fn render_pixels(
308    pixels: &mut [[f64; 3]],
309    prepared: &kallo_core::PreparedRender,
310    width: usize,
311    height: usize,
312    finishing: &Finishing,
313    on_gpu: bool,
314    warnings: &mut Vec<String>,
315) -> Result<Backend, Error> {
316    if !on_gpu {
317        render_pixels_finished(pixels, prepared, width, height, finishing);
318        return Ok(Backend::Cpu);
319    }
320
321    let gpu = match kallo_gpu::Gpu::new() {
322        Ok(gpu) => gpu,
323        Err(error) => {
324            warnings.push(format!("{error}; rendering on the cpu"));
325            render_pixels_finished(pixels, prepared, width, height, finishing);
326            return Ok(Backend::Cpu);
327        }
328    };
329
330    match gpu.render_finished(pixels, prepared, width, height, finishing) {
331        Ok(rendered) => {
332            pixels.copy_from_slice(&rendered);
333            Ok(Backend::Gpu(gpu.describe()))
334        }
335        // The backend names what it cannot do — a table, a dictionary — and
336        // asking it is what decides: the API keeps no second list of supported
337        // stages to drift out of step with the crate's. A device error is the
338        // same kind of answer: the CPU has the model and the print is worth
339        // finishing, so a reject is a warning rather than a failed render.
340        Err(error) => {
341            warnings.push(format!("{error}; rendering on the cpu"));
342            render_pixels_finished(pixels, prepared, width, height, finishing);
343            Ok(Backend::Cpu)
344        }
345    }
346}
347
348/// Without the feature there is no device to ask for, so the render is the CPU's
349/// and the report says so. A build that cannot honour the default must not fail
350/// because of it.
351#[cfg(not(feature = "gpu"))]
352fn render_pixels(
353    pixels: &mut [[f64; 3]],
354    prepared: &kallo_core::PreparedRender,
355    width: usize,
356    height: usize,
357    finishing: &Finishing,
358    on_gpu: bool,
359    warnings: &mut Vec<String>,
360) -> Result<Backend, Error> {
361    if on_gpu {
362        warnings.push("this build has no GPU backend; rendering on the cpu".to_owned());
363    }
364    render_pixels_finished(pixels, prepared, width, height, finishing);
365    Ok(Backend::Cpu)
366}
367
368/// Create the output's directory, so `-o out/foo.png` works on a fresh clone.
369pub(crate) fn ensure_parent(path: &Path) -> Result<(), Error> {
370    let Some(parent) = path.parent() else {
371        return Ok(());
372    };
373    if parent.as_os_str().is_empty() {
374        return Ok(());
375    }
376    std::fs::create_dir_all(parent).map_err(|source| Error::CreateDir {
377        path: parent.to_path_buf(),
378        source,
379    })
380}