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}