Skip to main content

kallo_cli/
cli.rs

1//! The command line surface.
2
3use std::path::PathBuf;
4
5use clap::{Args, Parser, Subcommand, ValueEnum};
6use kallo_io::InputEncoding;
7
8/// Render a linear image through a physical film-and-paper model.
9///
10/// The model covers the characteristic curve and its dependence on development
11/// time and temperature, spectral exposure, dye-based printing and scanning,
12/// grain, halation and DIR couplers.
13#[derive(Debug, Parser)]
14#[command(
15    name = "kalo",
16    version,
17    about = "Kallo — film simulation, with developing time as a dimension",
18    propagate_version = true
19)]
20pub struct Cli {
21    /// The command to run.
22    #[command(subcommand)]
23    pub command: Command,
24}
25
26/// What `kalo` can do.
27#[derive(Debug, Subcommand)]
28pub enum Command {
29    /// Render an image through a film stock and a paper.
30    Render(RenderArgs),
31    /// Print a contact proof: one exposure, every development, on one sheet.
32    Contact(ContactArgs),
33    /// List the built-in film and paper stocks.
34    Stocks {
35        /// Write the catalogue as JSON, for a program to read.
36        #[arg(long)]
37        json: bool,
38    },
39    /// Work with a stock file: its provenance, its curves and its spectra.
40    Stock {
41        /// What to do with it.
42        #[command(subcommand)]
43        command: StockCommand,
44    },
45    /// Show what a development does to the curves.
46    Curve(CurveArgs),
47    /// Bake the spectral exposure into a reusable table.
48    Bake(BakeArgs),
49}
50
51/// What `kalo stock` can do.
52#[derive(Debug, Subcommand)]
53pub enum StockCommand {
54    /// Check a stock file: provenance, curves and spectra.
55    Check(StockCheckArgs),
56}
57
58/// `kalo stock check <FILE>`
59#[derive(Debug, Args)]
60pub struct StockCheckArgs {
61    /// The stock file to check.
62    pub file: PathBuf,
63}
64
65/// Options shared by every command that touches a film and a paper.
66#[derive(Debug, Args)]
67pub struct StockArgs {
68    /// Film stock id (see `kalo stocks`).
69    ///
70    /// Every built-in stock is digitised from a manufacturer's data sheet; its
71    /// file says field by field what was measured and what was borrowed. The
72    /// default is the one stock with nothing borrowed at all.
73    #[arg(long, default_value = "vision3_500t", conflicts_with = "film_file")]
74    pub film: String,
75
76    /// A `.kalofilm` file to render instead of a compiled stock.
77    ///
78    /// The file carries the film's curves, spectra and a field-by-field account
79    /// of what was measured; `data/films/` in the repository has twenty-four.
80    /// Loading one needs no recompile, which is the point of the format.
81    #[arg(long, value_name = "PATH", conflicts_with = "film")]
82    pub film_file: Option<std::path::PathBuf>,
83
84    /// Paper stock id (see `kalo stocks`).
85    #[arg(long, default_value = "portra_endura")]
86    pub paper: String,
87
88    /// The enlarger's colour pack, in Kodak CC units: `30M`, `20Y+10M`.
89    ///
90    /// Empty is the model without a filter pack. The dial settings are the
91    /// film's own convention — 1 CC is 0.01 of density — and the spectra behind
92    /// them are KODAK's tables, so a pack moves the print the way the filter it
93    /// names would.
94    #[arg(long, value_name = "Y/M/C", default_value = "")]
95    pub filters: String,
96
97    /// A heat-absorbing glass in the light path, e.g. `kg3`.
98    #[arg(long, value_name = "GLASS")]
99    pub heat_glass: Option<String>,
100
101    /// Development time in minutes. Defaults to the time the stock's data
102    /// sheet drew its curves at, which is not the same for every film.
103    #[arg(long = "dev", value_name = "MINUTES")]
104    pub dev_time: Option<f64>,
105
106    /// Developer temperature in degrees Celsius. Defaults to the sheet's.
107    #[arg(long, value_name = "C")]
108    pub temp: Option<f64>,
109
110    /// Expose through the spectral path instead of the per-channel scalar one.
111    #[arg(long)]
112    pub spectral: bool,
113
114    /// Bake the spectral exposure into a table of this size per axis.
115    /// Requires --spectral. Zero disables the table.
116    #[arg(long, value_name = "N", default_value_t = 0)]
117    pub lut_size: usize,
118
119    /// Reflectance CSV to use as the spectral dictionary prior. Repeat it to
120    /// mix material families into one dictionary. Requires --spectral. Without
121    /// it the polynomial prior is used alone.
122    #[arg(long, value_name = "PATH")]
123    pub dictionary: Vec<std::path::PathBuf>,
124
125    /// Use a table baked by `kalo bake` instead of computing the exposure.
126    #[arg(long, value_name = "PATH")]
127    pub lut: Option<std::path::PathBuf>,
128
129    /// Load a table even if it was baked for a different camera or film.
130    #[arg(long)]
131    pub allow_model_mismatch: bool,
132
133    /// Which sensor the input is assumed to have come from.
134    ///
135    /// `srgb` (the input's own definition, the default), `observer` (CIE 1931,
136    /// for input that is already XYZ), `generic` (three broad Gaussians), or a
137    /// path to a measured sensitivity CSV. Requires --spectral.
138    #[arg(long, value_name = "NAME|PATH")]
139    pub camera: Option<String>,
140
141    /// Print through spectral dye absorption and paper layer sensitivities.
142    #[arg(long)]
143    pub spectral_print: bool,
144
145    /// Scan through spectral reflectance, illuminant and observer.
146    #[arg(long)]
147    pub spectral_scan: bool,
148}
149
150/// `kalo render`
151#[derive(Debug, Args)]
152pub struct RenderArgs {
153    /// Input image. 8/16-bit sRGB by default; pass --input-encoding linear for
154    /// a linear-light TIFF. A camera raw (ARW, NEF, CR3, RAF, DNG, ...) is
155    /// decoded to linear light directly, if this build has the `raw` feature.
156    pub input: PathBuf,
157
158    /// Output image. The extension picks the format (png, tiff, ...).
159    #[arg(short, long)]
160    pub output: PathBuf,
161
162    /// Film, paper and development.
163    #[command(flatten)]
164    pub stock: StockArgs,
165
166    /// Exposure offset in stops, applied before the negative.
167    ///
168    /// Pass `auto` to meter the frame instead of naming the offset: the render
169    /// then places the frame's mean luminance at 18 % grey, the way an
170    /// averaging exposure meter and a laboratory's average printer both do.
171    /// The report says what it solved, so the result can be repeated with a
172    /// fixed number. A scene whose key is not the average — a night street, a
173    /// snowfield — is better served by a fixed offset.
174    #[arg(
175        long,
176        value_name = "EV|auto",
177        default_value = "0",
178        allow_hyphen_values = true
179    )]
180    pub ev: Exposure,
181
182    /// Print exposure in stops, on top of the exposure that prints a mid grey
183    /// as a mid grey.
184    ///
185    /// The print is solved so that an 18 % grey negative lands at the paper's
186    /// target: this is the timer, not the balance. Positive is more light and a
187    /// darker print, which is what a printer would call "one stop more".
188    #[arg(
189        long,
190        value_name = "EV",
191        default_value = "0",
192        allow_hyphen_values = true
193    )]
194    pub print_ev: f64,
195
196    /// Print on the balance solved for this development, not the requested one.
197    ///
198    /// This is a test strip's one exposure: a print is made at each development
199    /// with the enlarger where the first print set it, so the comparison is the
200    /// development and not the timer.
201    #[arg(long, value_name = "MINUTES")]
202    pub balance_from: Option<f64>,
203
204    /// How to interpret the values stored in the input file.
205    #[arg(long, value_enum, default_value_t = EncodingArg::Srgb)]
206    pub input_encoding: EncodingArg,
207
208    /// Add grain to the negative, as a particle process on the film's scale.
209    ///
210    /// The graininess depends on how much film area a pixel covers, so it needs
211    /// --frame-mm to know how big the frame is.
212    #[arg(long)]
213    pub grain: bool,
214
215    /// Render at this width, in pixels, instead of at the input's own.
216    ///
217    /// The bench works at the size a screen can show, and a 26 megapixel camera
218    /// file is not that. It is a resize and not a crop — the whole frame — and
219    /// the grain is computed for the sampling that results, because a smaller
220    /// render is a coarser scan and should look like one.
221    #[arg(long, value_name = "PIXELS")]
222    pub size: Option<u32>,
223
224    /// Width of the film frame the image represents, in millimetres.
225    ///
226    /// 36 is a 35 mm still frame; use 24 for half-frame, and the short side if
227    /// the image is portrait.
228    #[arg(long, value_name = "MM", default_value_t = 36.0)]
229    pub frame_mm: f64,
230
231    /// Correlation length of the grain in micrometres, overriding the stock's.
232    ///
233    /// The distance over which neighbouring pixels agree, as measured from a
234    /// flat scan by `scripts/grain_spectrum.py` — on a mid-density flat, where
235    /// the developed silver is what the scanner sees. Until a stock's own is
236    /// measured, the model uses the mean spacing the granularity figure
237    /// implies, which is a lower bound.
238    ///
239    /// Note the stencil: five taps, so a clump much wider than a pixel is
240    /// drawn narrower than asked. Below about 3 µm per pixel the render is a
241    /// close-up of the film and the kernel is the limit.
242    #[arg(long, value_name = "MM")]
243    pub grain_um: Option<f64>,
244
245    /// Grain seed. The same seed and the same image give the same grain.
246    #[arg(long, value_name = "N", default_value_t = 0)]
247    pub grain_seed: u64,
248
249    /// Render on the CPU.
250    ///
251    /// The GPU is the default: everything the model does — both exposure paths,
252    /// grain, halation, the DIR couplers — except a baked table and a dictionary
253    /// prior, which are solvers rather than models. A render that needs one, or
254    /// a machine with no adapter, falls back to the CPU and says so. Use this
255    /// flag to stay on the CPU anyway.
256    #[arg(long = "no-gpu", action = clap::ArgAction::SetFalse, default_value_t = true)]
257    pub gpu: bool,
258
259    /// Add DIR couplers: development inhibition between layers, with diffusion.
260    ///
261    /// This is what produces interimage colour correction and the Mackie line
262    /// along an edge. The matrix is a placeholder.
263    #[arg(long)]
264    pub couplers: bool,
265
266    /// Skip the film's own blur, from the measured modulation-transfer curve.
267    ///
268    /// It is on by default and is a no-op below about 12 megapixels: a 35 mm
269    /// frame sampled every 30 µm cannot show a 3 µm blur. Render at 12000 pixels
270    /// across and it starts to bite.
271    #[arg(long = "no-emulsion")]
272    pub no_emulsion: bool,
273
274    /// Add halation: light scattered in the emulsion and reflected off the base.
275    ///
276    /// Needs --frame-mm for the same reason grain does — the kernels are
277    /// expressed in micrometres of film, not in pixels.
278    #[arg(long)]
279    pub halation: bool,
280
281    /// Add the cyan-sensitive layer's inhibitor (ADR 0018).
282    ///
283    /// Fuji's fourth layer releases development inhibitor against the
284    /// red-sensitive layer instead of forming cyan dye, which is why PRO 400H's
285    /// cyan curve shows no second emulsion. The effect is local: it takes cyan
286    /// away where that layer sees more cyan light than the frame's average and
287    /// lets it through where it sees less, so a uniform frame is unchanged and
288    /// an edge is not. Its strength and diffusion length are placeholders, so
289    /// this is a demonstration of the mechanism, not a prediction — and it is a
290    /// no-op for a stock whose sheet gives no such layer.
291    #[arg(long)]
292    pub interimage: bool,
293
294    /// Scan the print: the scanner's measured texture, and its optics when
295    /// asked for.
296    ///
297    /// The texture is the one a negative scan showed: 0.60 % of density
298    /// through a 48 µm aperture, correlated over 3.2 µm, and mostly common to
299    /// the three channels — which is how it was shown to be the scan and not
300    /// the film. Until this stage the render is the print's reflectance; this
301    /// is what turns it into a scan of the print, noise and all.
302    #[arg(long)]
303    pub scanner: bool,
304
305    /// Gaussian optics of the scan, in micrometres, on the print.
306    ///
307    /// Unmeasured, which is why it defaults to off: the scan that measured the
308    /// texture cannot separate the optics from the texture that rides on it,
309    /// and a point-spread function invented from that measurement would be a
310    /// second number pretending to be the first. Enabling it enables --scanner.
311    #[arg(long, value_name = "MM", requires = "scanner")]
312    pub scan_optics_um: Option<f64>,
313}
314
315/// `kalo contact`
316///
317/// The proof, not the print: every development of one frame laid on a sheet,
318/// exposed once, with the film's rebate and edge markings around it. What the
319/// sheet then shows is what the film carries, frame against frame — the one
320/// comparison a print, which is corrected one frame at a time, cannot make.
321#[derive(Debug, Args)]
322pub struct ContactArgs {
323    /// The photograph. It is cropped to 3:2 and sampled at the frame's size.
324    pub input: PathBuf,
325
326    /// Where the sheet is written.
327    #[arg(short, long)]
328    pub output: PathBuf,
329
330    /// Where the sheet's frame rects are written, in pixels, for a viewer.
331    #[arg(long, value_name = "PATH")]
332    pub record: Option<PathBuf>,
333
334    /// Film stock id (see `kalo stocks`).
335    #[arg(long, default_value = "vision3_500t")]
336    pub film: String,
337
338    /// Paper stock id (see `kalo stocks`).
339    #[arg(long, default_value = "portra_endura")]
340    pub paper: String,
341
342    /// Development times in minutes, one strip each: `--dev 3,6,9,12,15`.
343    ///
344    /// Empty is the interface's ladder, three to fifteen.
345    #[arg(long, value_name = "MINUTES", value_delimiter = ',')]
346    pub dev: Vec<f64>,
347
348    /// Developer temperature, in degrees Celsius.
349    #[arg(long, value_name = "C")]
350    pub temp: Option<f64>,
351
352    /// The exposure the frame was given: a number of stops, or `auto` to meter
353    /// it. One exposure, used by every strip.
354    #[arg(
355        long,
356        value_name = "EV|auto",
357        default_value = "auto",
358        allow_hyphen_values = true
359    )]
360    pub ev: Exposure,
361
362    /// The enlarger's exposure on top of the solved balance, in stops.
363    #[arg(
364        long,
365        value_name = "EV",
366        default_value = "0",
367        allow_hyphen_values = true
368    )]
369    pub print_ev: f64,
370
371    /// Sheet resolution, in dots per inch.
372    #[arg(long, value_name = "DPI", default_value_t = 600.0)]
373    pub dpi: f64,
374
375    /// How to interpret the values stored in the input file.
376    #[arg(long, value_enum, default_value_t = EncodingArg::Srgb)]
377    pub input_encoding: EncodingArg,
378
379    /// Expose and print through the spectral model.
380    #[arg(long)]
381    pub spectral: bool,
382
383    /// Add grain to the negative, as a particle process on the film's scale.
384    #[arg(long)]
385    pub grain: bool,
386
387    /// Add halation: light scattered in the emulsion and reflected off the base.
388    #[arg(long)]
389    pub halation: bool,
390
391    /// Add DIR couplers: development inhibition between layers, with diffusion.
392    #[arg(long)]
393    pub couplers: bool,
394
395    /// Do not print the edge markings: the stock, the frame number, the time.
396    #[arg(long)]
397    pub no_edge_codes: bool,
398}
399
400/// `kalo bake`
401#[derive(Debug, Args)]
402pub struct BakeArgs {
403    /// Output table path, conventionally `.klut`.
404    #[arg(short, long)]
405    pub output: std::path::PathBuf,
406
407    /// Samples per axis.
408    #[arg(long, value_name = "N", default_value_t = 33)]
409    pub size: usize,
410
411    /// Reflectance CSV to use as the spectral dictionary prior. Repeat it to
412    /// mix material families.
413    #[arg(long, value_name = "PATH")]
414    pub dictionary: Vec<std::path::PathBuf>,
415
416    /// Which sensor the input is assumed to have come from. Must match the
417    /// `--camera` used to render, or the table will be refused.
418    #[arg(long, value_name = "NAME|PATH")]
419    pub camera: Option<String>,
420
421    /// Which film's layer sensitivities the table is baked against.
422    ///
423    /// A table holds the film's spectral response, not just the camera's, so it
424    /// is only valid for the stock named here. Must match the `--film` used to
425    /// render, or the table will be refused.
426    #[arg(long, value_name = "ID", default_value = "vision3_500t")]
427    pub film: String,
428}
429
430/// `kalo curve`
431#[derive(Debug, Args)]
432pub struct CurveArgs {
433    /// Film, paper and development.
434    #[command(flatten)]
435    pub stock: StockArgs,
436
437    /// Also print the density reached at a few exposure offsets, in stops from
438    /// mid grey.
439    #[arg(long)]
440    pub sweep: bool,
441}
442
443/// How to read the input file's numbers.
444#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
445pub enum EncodingArg {
446    /// Display-referred sRGB, decoded to linear.
447    Srgb,
448    /// Already linear light.
449    Linear,
450}
451
452impl From<EncodingArg> for InputEncoding {
453    fn from(value: EncodingArg) -> Self {
454        match value {
455            EncodingArg::Srgb => Self::Srgb,
456            EncodingArg::Linear => Self::Linear,
457        }
458    }
459}
460
461/// How the exposure was given on the command line: a fixed offset, or `auto`.
462#[derive(Debug, Clone, Copy, PartialEq)]
463pub enum Exposure {
464    /// A fixed offset in stops.
465    Stops(f64),
466    /// Meter the frame and place its mean luminance at 18 % grey.
467    Auto,
468}
469
470impl std::str::FromStr for Exposure {
471    type Err = String;
472
473    fn from_str(text: &str) -> Result<Self, Self::Err> {
474        if text.eq_ignore_ascii_case("auto") {
475            return Ok(Self::Auto);
476        }
477        match text.parse::<f64>() {
478            Ok(ev) => Ok(Self::Stops(ev)),
479            Err(_) => Err(format!(
480                "expected a number of stops or `auto`, got {text:?}"
481            )),
482        }
483    }
484}
485
486impl std::fmt::Display for Exposure {
487    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
488        match self {
489            Self::Stops(ev) => write!(formatter, "{ev}"),
490            Self::Auto => write!(formatter, "auto"),
491        }
492    }
493}