Three profiles was what the first cut needed to prove the model. This is the rest of the open data: 23 camera stocks and 9 papers, which is all of spektrafilm. Black and white was the gap, and it turned out not to be a gap in the data -- it was a gap in where I looked. Upstream's `main` has 28 colour profiles and nothing monochrome; `dev` has three more, and they are Tri-X, Double-X and the 2302 print film they go onto. So the answer to "do we have B&W" was yes all along, and it needed the dev branch rather than a fortnight digitising Ilford's datasheet graphs by eye. Those three are pinned to `dev` per stock; the colour stocks stay on the released branch. A monochrome profile is single-channel -- one emulsion, not three -- and spreading that one layer across all three is exact rather than an approximation: three layers with identical sensitivity and identical curves respond identically, which is what one layer does. The dye is the trap. The renderer *sums* the three layers' contributions, so replicating it unchanged renders every frame three times too dense -- neutrally, and therefore plausibly. A third each reconstructs the single emulsion, and two tests hold both halves: that the densities stay equal, and that they sum to one emulsion and not three. Double-X and 2302 ship five curves apiece, measured at five development times -- 4 to 12 minutes for Double-X. That is push and pull processing as measured data. The standard 6.5 minutes is what ships; the rest is in the upstream file waiting for a control to ask for it. Two stocks are `support: film` and are nevertheless what a negative is printed *onto*: the cine projection films 2383 and 2393, which the Vision3 stocks print to. Filtering the picker on support alone offered a projection stock as something to load in a camera, so it filters on stage, with a test saying so. The picker had to change shape twice over. Chips were right for three stocks and off the edge of a 280px column at twenty-four, and the column that replaced them was a thousand pixels standing between the photographer and every slider below. It is a disclosure now: one row carrying the answer, opened to change it, closed again on choosing. That is the opposite of the argument this panel used to take the lids off its sliders, and deliberately so -- an instrument you compare wants to be visible, and a list you consult once wants to be out of the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Film stocks
One file per stock in profiles/. Adding a stock is adding a
file — no code change, no shader, no new operation — for the same reason
dr-decode's base curves work that way: under the GPLv3 a stock should be
contributable without a release.
What a profile is
Three measured tables, all of them published in the manufacturer's datasheet:
| Field | What it decides |
|---|---|
log_sensitivity |
what each emulsion layer sees, per wavelength |
density_curves |
contrast, latitude, and where the stock clips |
dye_density |
what the developed stock looks like, per wavelength |
base_density |
the support: film base, and a colour negative's orange mask |
Plus kind (negative or positive), support (film or paper), and the two
illuminants the data is referenced to. A print paper is a stock like any
other; support exists so an interface can offer papers separately, not
because the renderer treats them differently.
Why it is not a LUT
Because the parameters stay physical. Opening up a stop moves the picture along the film's own characteristic curve — toe, shoulder and all — instead of scaling a number somebody baked at one exposure. A scanned negative comes out orange and inverted because that is what a negative is, and it becomes a photograph when a paper profile prints it, exactly as it would in a darkroom.
The data cost runs the other way from a LUT collection too: a stock is about 17 kB of measurements, where one HaldCLUT is roughly 800 kB of one person's grade.
How it runs
The spectral chain reduces to three tables, and the reduction is exact where it
matters — see src/bake.rs for the argument:
- A 3×3 matrix, linear sRGB to the three layers' exposure. Exact, not an approximation: the reconstructed scene spectrum is linear in the sRGB triple, so the integral collapses into nine numbers.
- Three 1D curves, log exposure to density, sampled at 256 points.
- One 32³ lookup, density to linear sRGB — dye absorption, the print through the negative, the paper, the viewing illuminant and the chromatic adaptation, all of which take exactly three numbers in.
Per pixel that is a matrix multiply, three curve taps and one texture fetch. Splitting 2 from 3, rather than baking one LUT over exposure, is measured rather than assumed: the curve carries all the sharp shape and the dye mixing is smooth, so folding the curve into the 3D lookup would need it three times larger for the same error. At 32³ the worst interpolation error is about 0.003 in linear sRGB, below one 8-bit code value, and there is a test that says so.
Adding a stock
If spektrafilm has it, add its name to STOCKS in
tools/film-profiles/convert.py and
re-run it. Otherwise write the YAML by hand from the datasheet; the loader
validates the table lengths and says which file and field is wrong.
Either way, list it in BUILT_IN in src/lib.rs to compile it
in — or drop it in the profile directory at runtime, which is the path meant
for stocks that ship separately from the binary.
Provenance
The shipped profiles are converted from
spektrafilm by Andrea Volpato,
licensed CC BY-SA 4.0. See profiles/LICENSE-PROFILES.txt
for the licence and profiles/CHANGELOG.txt for what
the conversion changed and what it deliberately did not.
The sRGB reflectance basis is Mallett & Yuksel (2019); the observer is the CIE 1931 2°.