Build and test / Desktop (Linux) (push) Successful in 20m48s
Build and test / Layer separation (push) Successful in 27s
Traceability / Requirement traces (push) Failing after 25s
🐳 Android image / Build and push (push) Successful in 2s
Build and test / android-image (push) Successful in 2s
Build and test / Android (aarch64) (push) Failing after 33m33s
They were asked for and they are here, but not on the same footing as the
Kodak profiles, and the files say so in their first line.
What I had claimed, and had to withdraw: that Delta 100 is "quoted around 9"
and HP5 "around 12". Ilford publish no such figures. The word granularity does
not occur anywhere in their technical information -- grain is described as
"fine" and "finest" and nothing more. That claim was in this crate's
documentation as though it came from a datasheet; it is corrected there too.
Two further traps found while looking:
- Kodak colour negatives publish Print Grain Index, not RMS granularity.
PGI is a perceptual scale from viewer surveys -- 25 is roughly the
threshold of visibility, four units a just-noticeable difference -- and
Kodak state it cannot be compared to RMS. So a Portra number cannot be
dropped into the granularity field, and none has been.
- RMS proper is published mostly for black-and-white, reversal and motion
picture stocks. Every shipped stock therefore still carries the same
default, which means grain does not yet tell one film from another. That
is per-stock data, not code, and is now written down where somebody will
find it.
So the Ilford profiles are built rather than extracted, and each part rests on
something different:
speed published and exact -- ISO 400/27 for HP5 is a fact
contrast ISO 6:1993's normal development, average gradient 0.62
spectral borrowed from Kodak Double-X, a *measured* panchromatic
negative, shifted by the speed difference. Conventional
panchromatic sensitisation is much alike across black-and-white
films, and this is far better founded than reading pixels off a
printed curve
silver neutral, which is not an approximation: developed silver
absorbs flat, and Double-X's measurement is flat
granularity estimated, ordered by each film's known relative grain
They render as a film of that speed and contrast. They are not a measurement
of that emulsion, and the two stocks that share a speed differ only in the
estimated part.
`every_shipped_stock_bakes` is tightened to match, because a constructed
profile fails in a way a measured one does not: the curve parses, bakes, and
sits entirely off one end of its own exposure range, rendering every frame
black or blown while passing a finiteness check. It now asserts mid-grey lands
somewhere photographic and that the tone response runs the way the stock's
kind says it should.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
174 lines
6.7 KiB
Rust
174 lines
6.7 KiB
Rust
//! TRACES: FR-DEV-3f
|
|
//! Spectral film simulation — what a stock would have done with this light.
|
|
//!
|
|
//! # What this is, and what it is not
|
|
//!
|
|
//! Not a look-up table someone graded by eye. Each stock here is its
|
|
//! manufacturer's own measurements — spectral sensitivity, characteristic
|
|
//! curves, dye densities — run through the physics: light exposes three
|
|
//! emulsion layers, the layers develop to densities, the densities are dyes
|
|
//! that absorb, and what is left is what reaches the eye. A colour negative
|
|
//! comes out orange and upside down because that is what a colour negative is;
|
|
//! it becomes a photograph when [`bake::Recipe`] prints it on paper.
|
|
//!
|
|
//! What that buys over a LUT is that the *parameters are physical*. Exposing a
|
|
//! stop over moves the picture along the film's real characteristic curve,
|
|
//! shoulder and all, rather than scaling a number someone baked at one
|
|
//! exposure. And the data cost is the other way round from a LUT collection: a
|
|
//! stock is about 17 kB of published measurements, where one HaldCLUT is 800 kB
|
|
//! of one person's grade.
|
|
//!
|
|
//! # The shape of the crate
|
|
//!
|
|
//! - [`profile`] — a stock, as measured. Data, contributable as a file.
|
|
//! - [`spectrum`] — the fixed colour science: observer, illuminants, basis.
|
|
//! - [`bake`] — the reduction to three tables a shader can run.
|
|
//!
|
|
//! No wgpu dependency, deliberately: what comes out is plain `f32` with a
|
|
//! documented layout, and every property worth asserting about the model is
|
|
//! asserted on the CPU.
|
|
//!
|
|
//! # Provenance
|
|
//!
|
|
//! The shipped profiles are converted from **spektrafilm** by Andrea Volpato
|
|
//! (<https://github.com/andreavolpato/spektrafilm>), licensed CC BY-SA 4.0 and
|
|
//! modified for DarkRoom — see `profiles/LICENSE-PROFILES.txt` and
|
|
//! `profiles/CHANGELOG.txt`. The conversion is reproducible from
|
|
//! `tools/film-profiles/convert.py` rather than pasted, so what changed is
|
|
//! auditable. The sRGB reflectance basis is Mallett & Yuksel (2019).
|
|
|
|
pub mod bake;
|
|
mod built_in;
|
|
pub mod grain;
|
|
pub mod profile;
|
|
pub mod spectrum;
|
|
pub mod tables;
|
|
|
|
pub use bake::{bake, Baked, Recipe};
|
|
pub use grain::Grain;
|
|
pub use profile::{Kind, Profile, Stage, Support};
|
|
|
|
use built_in::BUILT_IN;
|
|
|
|
/// Every stock that is compiled in, parsed on first use.
|
|
pub fn built_in() -> &'static [Profile] {
|
|
use std::sync::OnceLock;
|
|
static PARSED: OnceLock<Vec<Profile>> = OnceLock::new();
|
|
PARSED.get_or_init(|| {
|
|
BUILT_IN
|
|
.iter()
|
|
.filter_map(|(stock, yaml)| match Profile::parse(yaml) {
|
|
Ok(p) => Some(p),
|
|
Err(e) => {
|
|
// A compiled-in profile that does not parse is a build
|
|
// mistake, but refusing to start over one would take the
|
|
// whole application down for a stock nobody asked for.
|
|
log::error!("built-in film profile {stock} is malformed: {e}");
|
|
None
|
|
}
|
|
})
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
/// The stocks a photographer can put in a camera.
|
|
///
|
|
/// Filtered on [`Stage`], not on [`Support`], and the difference is not
|
|
/// pedantry: Kodak 2383 is a *film* that a negative is printed onto, so a
|
|
/// picker built on `Support` would offer a projection print stock as something
|
|
/// to shoot on.
|
|
pub fn camera_stocks() -> impl Iterator<Item = &'static Profile> {
|
|
built_in().iter().filter(|p| p.stage == Stage::Filming)
|
|
}
|
|
|
|
/// Find a compiled-in stock by its identifier.
|
|
pub fn find(stock: &str) -> Option<&'static Profile> {
|
|
built_in().iter().find(|p| p.stock == stock)
|
|
}
|
|
|
|
/// The paper a stock should be printed on, if it names one and we have it.
|
|
///
|
|
/// A reversal stock names none, and needs none: it is the picture already.
|
|
pub fn default_print(film: &Profile) -> Option<&'static Profile> {
|
|
film.target_print.as_deref().and_then(find)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn every_built_in_profile_parses() {
|
|
assert_eq!(
|
|
built_in().len(),
|
|
BUILT_IN.len(),
|
|
"a built-in profile failed to parse"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_negative_finds_the_paper_it_names() {
|
|
let portra = find("kodak_portra_400").unwrap();
|
|
assert_eq!(default_print(portra).unwrap().stock, "kodak_portra_endura");
|
|
}
|
|
|
|
#[test]
|
|
fn every_shipped_stock_bakes() {
|
|
// Cheap and worth it: a profile can parse and still be unusable — a
|
|
// curve that never reaches the density its paper needs, a sensitivity
|
|
// table that is all sentinel. Baking every one is the only check that
|
|
// says the whole database renders.
|
|
for film in camera_stocks() {
|
|
let baked = bake::bake(&bake::Recipe::new(film, default_print(film)));
|
|
let mid = baked.apply([bake::MID_GREY; 3]);
|
|
assert!(
|
|
mid.iter().all(|c| c.is_finite()),
|
|
"{} rendered mid-grey as {mid:?}",
|
|
film.stock
|
|
);
|
|
|
|
// And that it is a *photograph*. Finiteness alone passes for a
|
|
// stock that renders every frame black or blown, which is the way
|
|
// a constructed profile fails: the curve parses, bakes, and sits
|
|
// entirely off one end of its own exposure range.
|
|
let luma = (mid[0] + mid[1] + mid[2]) / 3.0;
|
|
assert!(
|
|
(0.02..0.75).contains(&luma),
|
|
"{} put mid-grey at {luma:.3}, which is not a rendering of it",
|
|
film.stock
|
|
);
|
|
|
|
// Monotone, too. A stock that darkens as it is exposed is either
|
|
// inverted or broken, and either way is not what the picker
|
|
// offered.
|
|
let shadow = baked.apply([0.02; 3]);
|
|
let highlight = baked.apply([0.8; 3]);
|
|
let rising = highlight[1] > shadow[1];
|
|
let inverted = film.kind == Kind::Negative && default_print(film).is_none();
|
|
assert_eq!(
|
|
rising, !inverted,
|
|
"{}: shadow {:.3} highlight {:.3} runs the wrong way",
|
|
film.stock, shadow[1], highlight[1]
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_projection_print_stock_is_not_offered_as_a_camera_film() {
|
|
// Kodak 2383 is `support: film` and is nevertheless the Vision3
|
|
// stocks' paper. Filtering on support alone would put it in the picker.
|
|
assert!(find("kodak_2383").is_some(), "the profile is shipped");
|
|
assert!(
|
|
!camera_stocks().any(|p| p.stock == "kodak_2383"),
|
|
"a projection print film is being offered as a camera stock"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_reversal_stock_names_no_paper() {
|
|
let k64 = find("kodak_kodachrome_64").unwrap();
|
|
assert_eq!(k64.kind, Kind::Positive);
|
|
assert!(default_print(k64).is_none());
|
|
}
|
|
}
|