FR-DEV-3f asks for look emulation and proposes HaldCLUT import to inherit
the free film-simulation ecosystem. This takes the other road for the
stocks where the measurements exist: run the physics.
A stock here is its manufacturer's own datasheet -- spectral sensitivity,
characteristic curves, dye densities. 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 a paper profile prints it, with the enlarger's filtration
solved rather than dialled.
What that buys over a LUT is that the parameters stay physical. Opening up
a stop moves the picture along the film's real characteristic curve,
shoulder and all, instead of scaling a number baked at one exposure. The
data cost runs the other way too: a stock is 17 kB of published
measurements where one HaldCLUT is 800 kB of one person's grade.
It looks like it needs a spectral integration per pixel. It does not, and
that is the whole design:
- Exposure is a 3x3 matrix. The reconstructed scene spectrum is linear
in the sRGB triple, so the integral collapses into nine numbers,
exactly -- no approximation.
- The characteristic curve is three 1D functions, sampled exactly.
- Everything after that -- dye absorption, the print through the
negative, the paper, the viewing illuminant, the adaptation -- takes
exactly three numbers in, so it bakes into one 32^3 lookup.
Per pixel: a matrix multiply, three curve taps, one fetch. Splitting the
curve out of the 3D lookup rather than baking one LUT over exposure is
measured, not assumed: the curve carries the sharp shape and the dye
mixing is smooth, so folding them together would need three times the
resolution for the same error. At 32^3 the worst error is 0.003 in linear
sRGB, under one 8-bit code value, and a test says so.
No wgpu dependency, deliberately, and the same isolation argument dr-lens
makes: the model is plain f32 with a documented layout, so every property
worth asserting is asserted on the CPU. Binding it to a texture is dr-gpu's
job and is not done here yet.
The expected values in tests/ came from a Python prototype running against
a different colour-science stack. Agreement to three decimals is evidence
about the model rather than about one implementation of it -- a transposed
matrix or a mispasted observer row would pass every unit test and fail
that one.
Profiles are converted from spektrafilm by Andrea Volpato, CC BY-SA 4.0.
The converter is in the tree and runnable, so what was changed from
upstream is auditable rather than taken on trust; profiles/CHANGELOG.txt
records it, including the one deliberate deviation -- Mallett & Yuksel's
1 kB basis instead of Hanatos's 4 MB table, which costs accuracy at the
gamut edge and saves four megabytes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77 lines
3.5 KiB
Markdown
77 lines
3.5 KiB
Markdown
# Film stocks
|
||
|
||
One file per stock in [`profiles/`](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`](src/bake.rs) for the argument:
|
||
|
||
1. **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.
|
||
2. **Three 1D curves**, log exposure to density, sampled at 256 points.
|
||
3. **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`](../../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`](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](https://github.com/andreavolpato/spektrafilm) by Andrea Volpato,
|
||
licensed CC BY-SA 4.0. See [`profiles/LICENSE-PROFILES.txt`](profiles/LICENSE-PROFILES.txt)
|
||
for the licence and [`profiles/CHANGELOG.txt`](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°.
|