Unapologeticly AI slop readme. Might find the time to rewrite it to a version that doesn't give you cancer sometime
Film Look LUTs — Tri-X 400, Velvia 50, Kodachrome 64, Fuji Provia 100F, Kodak Ektachrome 100D, Kodak Portra 400, Kodak Ektar 100, Kodak Gold 200, Kodak Ultramax 400, Fuji Superia Reala, Fuji Superia X-tra 400
Physically grounded film emulation as .cube LUTs for darktable (or any software supporting 3D LUTs). These replace your tone mapper — they do the complete scene-to-display job that AgX, filmic, or sigmoid would otherwise handle.
Tri-X 400 — black and white. 6 contrast levels × 6 Wratten glass filters = 36 LUTs per variant. Velvia 50, Kodachrome 64, Fuji Provia 100F, Kodak Ektachrome 100D — color reversal (slide) film. Each has 8 looks: 5 through a real duplicating internegative (ExtraSoft/Soft/Normal/Punchy/ExtraPunchy) and 3 printed directly onto a real reversal print paper with no internegative (RadianceIII/IlfochromeM/IlfochromeP) — no filters (glass filters would alter a color film's rendering, which is the whole point of choosing one) = 16 LUTs per film (8 looks × classic/modern). See "Why a reversal print crushes without correction" below for what the direct-print route is and why it needed more than just swapping the paper. Kodak Portra 400, Kodak Ektar 100, Kodak Gold 200, Kodak Ultramax 400, Fuji Superia Reala, Fuji Superia X-tra 400 — color negative film. Same 5-look / no-filters shape as the reversal films' internegative route (10 LUTs per film), but a shorter 2-stage print cascade — see "What these replicate" below for why.
Every color-film look is produced purely by choice of real print paper — see "Choosing a print paper" below — not a synthetic contrast multiplier. Total: 196 LUTs.
Each LUT encodes a complete photographic reproduction chain:
Tri-X: scene light → Kodak Wratten glass filter (spectral transmission × film sensitivity) → Tri-X 400 negative (H&D characteristic curve at 7 min development) → Kodak Polymax Fine-Art enlarging paper (at a specific contrast grade) → print reflectance → display. This is the full negative-to-print darkroom process.
Velvia 50 / Kodachrome 64 / Fuji Provia 100F / Kodak Ektachrome 100D: each of these four reversal (slide) films gets two independent print routes, both shipped, in the same folder.
The internegative route (5 looks — ExtraSoft through ExtraPunchy): scene light → the reversal film's own H&D characteristic curve (3 independent dye layers) → a real duplicating internegative (EASTMAN Color Internegative II Film 5272/7272) → a real RA-4 print paper (see the paper ladder below) → print reflectance → display — none of these four can go straight onto ordinary negative print paper, so real darkroom labs got a printable (forgiving) result from a slide by first shooting a duplicating internegative from it, then printing that internegative like an ordinary negative. That's exactly what this cascade does.
The direct-print route (3 looks — RadianceIII/IlfochromeM/IlfochromeP): scene light → the reversal film's own H&D curve, gamma-corrected first → a real print paper built to accept a reversal original directly, with no internegative stage → print reflectance → display. See "Why a reversal print crushes without correction" below for why the correction step is there and what it's based on — without it, this route reproduces the same over-contrasty crush an earlier, uncorrected version of this project shipped once and replaced (see "Honest limitations").
Kodak Portra 400 / Kodak Ektar 100 / Kodak Gold 200 / Kodak Ultramax 400 / Fuji Superia Reala / Fuji Superia X-tra 400: scene light → the negative's own H&D characteristic curve (3 independent dye layers), gamma-corrected against whichever real RA-4 print paper follows it → that paper → print reflectance → display. These are genuine camera negatives — unlike the four reversal stocks above, a negative already prints straight onto paper (that's what negative film is for), so there's no internegative stage to route through. An earlier version of this project briefly included three of these same films (Portra 400, Kodak Gold 200, Ektar 100), folded into the reversal-film lineup, and removed them because they didn't exercise the internegative pipeline the reversal-film architecture exists to demonstrate. They're back now as a genuinely separate, shorter cascade (NEGATIVE_FILMS/_negative_gammacorrect_stage_fn() in generate_film_looks.py) instead of being forced into the reversal shape — see "Choosing a print paper" for why the same 5-paper ladder works for negatives too, and "Why a reversal print crushes without correction" for why these are gamma-corrected too, not just the reversal direct-print route.
- Turn AgX / filmic / sigmoid OFF.
- Add a LUT 3D module instance. Set application color space to Adobe RGB (the default
.cubefiles) — or to PQ Rec.2020, if you generated the LUTs with--colorspace pq2020(see "Colour space options" below). - Place it where the tone mapper would normally sit (end of the scene-referred section of the pipeline, after exposure, colour balance, etc).
- Load one .cube file.
- Use the exposure module to position middle grey. The LUT supplies the tonal shape; exposure decides where your scene sits on it. If the image looks too dark or too bright, that's an exposure placement issue — nudge it until a known mid-grey reads as mid-grey.
That's it. Output is neutral B&W for Tri-X, full colour for the ten color films.
Files are named TriX_<Filter>_<Look>.cube and live in trix_classic/ or trix_modern/.
Filters (folded into the film's spectral response — genuinely "Tri-X shot through this glass"):
| Filter | What it does to the B&W rendering |
|---|---|
| NoFilter | Straight Tri-X. Renders blue sky light, foliage dark. |
| Yellow8 | Gentle sky darkening. The classic everyday B&W filter. |
| Orange21 | Stronger sky contrast, cuts haze. |
| Red25 | Dramatic near-black skies, luminous skin. |
| Green58 | Lifts foliage, flatters skin. |
| Blue47 | Brightens blues, darkens reds. Opposite of red. |
Looks (contrast — Kodak Polymax Fine-Art paper grade):
| Look | Polymax grade | Character |
|---|---|---|
| ExtraSoft | 0 | Very open shadows, gentle highlights |
| Soft | 1 | Slightly flat, good for high-contrast scenes |
| Normal | 2 | Standard darkroom printing |
| Punchy | 3 | Snappier midtones |
| ExtraPunchy | 4 | Strong separation, deep shadows |
| Hard | 5 | Dramatic, highlights push to white |
Files are named <Film>_<Classic|Modern>_<Look>.cube and live in one folder per film — velvia/, kodachrome64/, provia100f/, ektachrome100d/ — not split into classic/modern subfolders the way Tri-X is, and not split by print route either: internegative-route and direct-print-route looks live side by side in the same folder. Tri-X needs the classic/modern split because each variant already holds 36 files (6 looks × 6 filters); these color films have no filter dimension, so classic+modern together is only 16 files, small enough for one folder. Putting Classic/Modern right after the film name in the filename means a plain alphabetical listing groups all 8 Classic looks together, then all 8 Modern looks together.
No filters — these are all colour films; glass filters would alter their colour rendering, which is the whole point of choosing one.
Looks (contrast — real print paper/route, see "Choosing a print paper" and "Why a reversal print crushes without correction" below):
| Look | Route | Paper |
|---|---|---|
| ExtraSoft | internegative | Fuji Crystal Archive Super Type C |
| Soft | internegative | Fuji Crystal Archive Pro PDII |
| Normal | internegative | Kodak Portra Endura |
| Punchy | internegative | Fuji Crystal Archive DPII |
| ExtraPunchy | internegative | Kodak Supra Endura |
| RadianceIII | direct print, gamma-corrected | Kodak Ektachrome Radiance III |
| IlfochromeM | direct print, gamma-corrected | Ilfochrome Micrographic M |
| IlfochromeP | direct print, gamma-corrected | Ilfochrome Micrographic P |
Same 8 looks, same two routes, for all four films — a film's own native contrast just sets where on either ladder "gentle" vs "punchy" lands. There's no Hard look for color, unlike Tri-X's 6-grade ladder — deliberately: the ladder stays inside the range of real, non-clipping paper contrasts rather than extrapolating past what real materials offer.
Kodak Portra 400, Kodak Ektar 100, Kodak Gold 200, Kodak Ultramax 400, Fuji Superia Reala, Fuji Superia X-tra 400
Files are named the same way as the reversal films — <Film>_<Classic|Modern>_<Look>.cube — but each lives in its own flat, prefixed folder: negative-portra-400/, negative-ektar-100/, negative-gold-200/, negative-ultramax-400/, negative-superia-reala/, negative-superia-xtra-400/. The negative- prefix (rather than a negative/ parent folder) keeps the folder listing flat instead of adding another directory level for six films.
Same paper ladder, same 5 looks (ExtraSoft through ExtraPunchy), no filters, no Hard look — identical shape to the reversal films' file layout, just a shorter cascade underneath (see "What these replicate").
Classic uses per-pixel spectral reconstruction (Tri-X and every color film alike, see "Per-pixel spectral reconstruction" below) for colour-to-exposure conversion, and the real H&D characteristic curves throughout. No perceptual corrections. This is as close to the physical film process as a 3D LUT allows.
Modern adds the Helmholtz-Kohlrausch correction on top. HK accounts for the fact that saturated colours appear brighter to the human eye than their measured luminance — vivid blue sky looks brighter than a grey of equal luminance. Without HK, the B&W conversion can render saturated colours too dark. With it, the tonal separation matches human perception better, at the cost of departing from what the physical film would have produced. For colour films, HK adjusts the overall brightness of saturated inputs, preserving chromaticity while making vivid colours render lighter.
Neither variant is "better" — they serve different goals. Classic is more faithful to the darkroom. Modern produces more satisfying tonal separation to a contemporary viewer.
Every color-film look in this set is a real print paper, not a math knob — the same approach Tri-X already uses with Polymax's grades 0-5, extended to color. film_paper_filter_data/papers/color/for_negatives/ has 7 legitimate reflective RA-4 papers (Kodak Endura Premier, Portra Endura, Supra Endura; Fuji Crystal Archive Super Type C, Pro PDII, DPII, Maxima). Cinema release-print stocks (Kodak 2383/2393/5381-series, Technicolor V) and duratrans/backlit display materials (Fujiflex, Duraflex Plus) are excluded outright as the wrong medium, regardless of how their contrast might otherwise fit.
The ladder is picked by measuring the real cascade, not by estimating one paper at a time. An earlier version of this table picked papers using each candidate's own regression-slope gamma (least-squares slope over the middle 60% of its own exposure range), multiplied by film γ × internegative γ as a proxy for the compounded result. That proxy turned out not to predict what actually gets rendered: side-by-side comparison of the shipped LUTs found ExtraSoft (Kodak Endura Premier) was in practice the punchiest look of the five, and Punchy/ExtraPunchy (Fuji Crystal Archive DPII/Maxima) were barely distinguishable. The proxy also never considered 2 of the 7 real, eligible candidate papers (Fuji Crystal Archive Pro PDII, Kodak Supra Endura) — they were identified as legitimate RA-4 papers early on but never digitized into the shipped ladder.
The fix: tools/measure_paper_punch.py (committed, read-only) runs every one of the 7 real candidate papers through the actual production cascade — build_print_cascade(), _find_anchor-calibrated exactly like every shipped LUT, not an isolated regression on the paper's own curve — for each of the 4 color films, and reports what that cascade actually renders: real sensitometric gamma (Δdensity/Δlog10 exposure) in the shadow/mid/highlight bands, and the encoded output at the real LUT corners (full-white and full-black neutral input). Run it yourself with python3 tools/measure_paper_punch.py any time paper data changes.
The current 5-paper ladder was picked from that measured output: full-range span (white-corner minus black-corner encoded output — the closest single number to "how punchy does this actually render," since it captures both contrast and a paper's own highlight headroom in one measurement) ranks the 7 candidates in the same order on every one of the 4 films, so one shared ladder still works for all of them:
| Look | Paper | Velvia 50 | Kodachrome 64 | Fuji Provia 100F | Kodak Ektachrome 100D |
|---|---|---|---|---|---|
| ExtraSoft | Fuji Crystal Archive Super Type C | 0.851 | 0.845 | 0.833 | 0.835 |
| Soft | Fuji Crystal Archive Pro PDII | 0.866 | 0.860 | 0.847 | 0.849 |
| Normal | Kodak Portra Endura | 0.895 | 0.893 | 0.886 | 0.886 |
| Punchy | Fuji Crystal Archive DPII | 0.897 | 0.900 | 0.894 | 0.893 |
| ExtraPunchy | Kodak Supra Endura | 0.919 | 0.919 | 0.916 | 0.914 |
(Span, not gamma — see tools/measure_paper_punch.py's own output for the full shadow/mid/highlight gamma bands per film.) Kodak Endura Premier and Fuji Crystal Archive Maxima both measure well but sit too close to their neighbors on every film to add a usefully distinct rung — the same reason the previous ladder left Pro PDII and Supra Endura out — so they're the two left unused now instead.
The same ladder is reused, unmodified, for the six negative films (Portra 400, Ektar 100, Gold 200, Ultramax 400, Superia Reala, Superia X-tra 400) — not just because the papers are literally drawn from film_paper_filter_data/papers/color/for_negatives/, but checked directly: re-running the same span measurement through each negative film's own (shorter, 2-stage, no-internegative) cascade reproduces the identical ExtraSoft < Soft < Normal < Punchy < ExtraPunchy rank order on every one of the 6, with no crossovers. tools/measure_paper_punch.py only iterates COLOR_FILMS (the 4 reversal stocks) as shipped — it wasn't extended to loop NEGATIVE_FILMS too, so this was checked ad hoc rather than being a re-runnable part of that script; worth doing if paper data changes again.
Normal and Punchy measure close together (span differs by 0.002-0.007) — a real, measured near-tie, not an oversight. They're kept as adjacent rungs anyway because they're the same two papers (Portra Endura, Fuji Crystal Archive DPII) the previous "Soft"/"Punchy" ladder already shipped, side-by-side comparison already confirmed they render as distinguishable in practice, and Normal-below-Punchy is exactly where the real measured data places them.
One real, measured curve-crossover worth flagging, the same kind already documented for Tri-X's Polymax grades 0/1 (tasks/06-extrasoft-soft-midtone-contrast-inversion.md) — not a code defect: Pro PDII ("Soft") has more local midtone gamma than Portra Endura ("Normal") on every film, even though Pro PDII's overall span is lower. Portra Endura spreads its contrast more gradually across a wider exposure range instead of concentrating it around grey, so Soft/Normal are correctly ordered by overall shadow-to-highlight spread, not by local contrast right around grey — a viewer comparing the two on a subject with detail concentrated near midtone grey may see the "softer" look as locally punchier there.
This section explains the physics behind the direct-print route's gamma_correct_curve() step in generate_film_looks.py — a piece of ~100-year-old sensitometric theory that isn't documented anywhere else in this project, so it gets a full writeup here rather than a one-line comment. Full source trail (every PDF, patent, and web page cited below) lives in papers/ and papers/masking_research/README.md.
The problem, measured. Print a reversal film's own H&D curve straight onto a real print paper — no internegative, no correction — and the result crushes to only about 3-3.5 stops of real tonal separation around grey, even though the film's own digitized curve spans roughly 9 stops. Concretely, Kodak Ektachrome Radiance III printed straight from Kodachrome 64, uncorrected: encoded output is already pinned to ~0.93 by +1.5 EV over grey and ~0.005 by -1.5 EV. That's not a data error or a code bug — every number in that chain is real, digitized manufacturer data, correctly cascaded. It's a real, physical property of the material pairing.
Why. L.A. Jones's 1920 paper "On the Theory of Tone Reproduction, with a Graphic Method for the Solution of Problems" (papers/jones_1920_theory_of_tone_reproduction.pdf, p.64 — the origin of what's sometimes called Goldberg's rule) states it plainly: "the product of the gamma of the negative by that of the positive is equal to that of the reproduction curve" — γ₁ × γ₂ × ... = γ_system, with Jones's own worked example landing at γ_system ≈ 1.01. For a print to faithfully reproduce the tonal range of the scene it depicts, the gammas of every stage in the chain need to multiply out to about 1.0. Every real photographic material has gamma > 0, so unless something in the chain has gamma measurably below 1, the product overshoots 1 and the print comes out more contrasty than the scene — exactly the crush measured above. A still reversal film's own native gamma is already high before it even reaches a paper: measured directly off each film's own digitized curve (_measured_gamma()), Velvia 50 ≈ 2.0-2.1, Kodachrome 64 ≈ 1.7-1.9, Provia 100F ≈ 1.6-1.8, Ektachrome 100D ≈ 1.4-1.9 per layer. Pair that directly with a real print paper's own real gamma (Radiance III ≈ 1.2-1.3, Ilfochrome Micrographic M ≈ 1.9-2.1, Ilfochrome Micrographic P ≈ 1.4-1.6, also measured off their own digitized curves) and the product is well past 1 before you even account for anything else.
Real duplicating labs hit this exact wall. EASTMAN Color Internegative Film (the stock this project's internegative route already uses) was engineered so internegative gamma (~0.5) times a real print-paper gamma (~2.0) lands close to 1.0 — but that arithmetic assumes a gamma-≈1.0 input. Its real intended input was Ektachrome Commercial, a low-contrast duplicating positive, "never projected" (papers/masking_research/brianpritchard_FAOL_colour_duplicating_film_stocks.html) — not a full-contrast camera original. Feed it (or any similarly-engineered paper) a real reversal film instead, and the identical mismatch appears, just hidden behind an extra stage — which is exactly why this project's internegative route (left untouched by this fix, see below) still has the same problem underneath, and exactly why an earlier version of this project's uncorrected direct Radiance III cascade (commit cf14a88, replaced in 881f3da) was, in the previous README's own words, "structurally very contrasty."
Real labs' own fixes for a too-contrasty original were real, but neither has a published formula: flashing — a second, spectrally-neutral pre-exposure, confirmed as real hardware in US Patent 4,739,375 (papers/patent_US4739375_internegative_contrast_correction_flash.pdf, a photoflash-based internegative duplicator with a dedicated "contrast correction exposure" unit) — and optical contrast-reduction sandwich masking (papers/masking_research/freestylephoto_contrast_masking_traditional_print.html). The most authoritative secondary source found on this (brianpritchard.com's Film Archive Online Library, written for working motion-picture archivists) says of flashing: "none [are] satisfactory." Flashing also only ever partially fixes the problem it's used for — it adds density to the toe/shadows but leaves the shoulder/highlights' own gamma close to untouched — which doesn't match the crush measured above, which happens symmetrically at both ends.
Jones's product rule targets unity system gamma — the mathematically correct target for faithful (colorimetrically exact) reproduction, and what real labs' own internegative engineering was built around. But unity gamma is only the right target when the final image is viewed under the same conditions as the original scene. It isn't: a scene is viewed in full, bright, wide-field adaptation; any real display or print is viewed dimmer, smaller, without the eye locally re-adapting the way it did across the real scene. C. J. Bartleson and E. J. Breneman's landmark study, "Brightness perception in complex fields" (J. Opt. Soc. Am. 57, pp.953-957, 1967 — full text not accessible during this research, JOSA paywalled; cited here via two real secondary sources saved locally that carry the exact citation and mechanism: papers/masking_research/choi_bartleson_breneman_brightness_stevens_power_law.pdf, a 1994 IS&T paper by a Kodak Eastman scientist working through the derivation, and papers/masking_research/roufs_global_brightness_contrast_perceptual_image_quality.pdf, an independent Eindhoven University study) established that the reproduction gamma humans actually prefer is greater than 1 for every viewing condition tested, and depends on how dark the surround is relative to the display: ≈1.1 for a light/bright surround (a reflection print in a lit room), ≈1.5-1.6 for a dark-surround transparency/projection viewing, and a separate figure specifically for TV (a self-luminous display, not a reflection print).
An earlier version of this project targeted 1.1, on the reasoning that Radiance III/Ilfochrome prints and PAPER_LADDER are real reflection-print materials, viewed the way any photograph is. That reasoning was wrong for what this project actually ships: these prints aren't the end product — they're the physically-real proxy material this whole cascade uses to derive each film's correct toe/shoulder/color response (see the module docstring and "What these replicate"), but the actual output is a .cube LUT, applied inside a raw processor, that replaces the tone mapper entirely and is displayed on a self-luminous monitor. That's the TV/display viewing condition, not the reflection-print one — and real-world use confirmed it: images rendered flat and washed-out on screen relative to what a reflection print's own 1.1 target should look like in a lit room.
Roufs, Koselka & van Tongeren's own experimental rig makes the right figure directly measurable rather than just asserted: their test chain was a slide (reversal-film) scanner feeding a monitor — almost exactly this project's own output path, a digitized film image displayed on a screen — and they report "the gamma of the slide scanner - monitor chain was about 1.2 in all cases," with "the optimal value for the effective gamma... about 1.2-1.3... very near what Bartleson and Breneman found for TV in 1967." The target was first moved to 1.25, the midpoint of that directly-measured range — but real-world use showed even that still short of the punch a reversal film should have on screen. Rather than re-deriving a fourth single "correct" fixed number, GAMMA_CORRECT_TARGET is now a tunable default (1.35) exposed via --gamma (see "Generating" below) — still inside the real cited range this literature actually supports (~1.1 light-surround print to ~1.5-1.6 dark-surround/projection, with 1.2-1.3 the specific TV/display figure), but past the narrow TV midpoint, since the measured-preference literature itself only pins down TV viewing to a point estimate under specific lab conditions, not this project's exact viewing setup.
Two further mechanisms were tried after the target was fixed at 1.1, and both were rejected — the full history, with measured numbers for each, is in generate_film_looks.py's own GAMMA_CORRECT_TARGET comment block (referred to there as v1-v4; this section covers v3 and v4).
v3 — a single straight line. Jones's product rule is explicit about its own scope: "for the straight line portions where gradient is constant and replaceable by gamma" (p.64) — he never claims it governs the toe or shoulder. An earlier version applied one scalar to the entire digitized curve including the toe/shoulder, which are already lower-gamma than the straight line by definition, so scaling them down again by the same factor flattened them far more than the theory justifies and measurably starved the print of shadow density reach. A window-scoped version (rescale only inside the curve's own straight-line region, defined by a slope threshold, carry the toe/shoulder forward unmodified outside it) did better but left an arbitrary, physically-meaningless threshold governing where the correction stopped, and local gamma right at grey still ran 20-40% hotter than the target — the window-average gamma matched 1.1, but gamma at any specific point inside the window did not. Replacing all of that with a single straight line through the pivot, extended only as far as the film's own real measured Dmin/Dmax required, fixed the shortfall almost completely (Kodachrome 64 × Radiance III: print's shadow density reached 2.517 against the paper's real digitized Dmax of 2.521) — but it discarded the film's own toe/shoulder curvature entirely. That curvature is real, measured, material-specific data — how gradually a given film's response saturates near black and white is part of what makes it that film — and replacing it with a straight line for mathematical convenience was rejected on exactly those grounds.
v4 (current) — fit a real model instead. The physical origin of the H&D curve's toe/straight-line/shoulder shape is well established in sensitometry: an emulsion is a population of individual silver-halide grains, each becoming developable once its own quantum catch crosses its own threshold, and that population has a real, measured spread of individual grain sensitivities. J.H. Webb, "Graphical Analysis of Photographic Exposure and a New Theoretical Formulation of the H and D Curve," J. Opt. Soc. Am. 29, 314-326 (1939), derives the H&D curve from exactly this picture — primary source paywalled and not accessible during this research (see papers/masking_research/README.md for the honest citation trail on this one), but the finding that Webb's own equation "cannot be integrated mathematically" is itself consistent with a cumulative-Gaussian origin, whose integral has no elementary closed form either. The curve's value at any exposure is therefore a cumulative distribution of how many grains have crossed threshold by that exposure — the standard idealization of a threshold-crossing process over a log-normally-distributed population is a cumulative Gaussian (normal) distribution against log exposure.
tools/gamma_correction_fit/ (a separate uv-managed tool using scipy/numpy — kept out of the dependency-free main generator, the same pattern tools/curve_digitizer/ already uses) fits an asymmetric cumulative-Gaussian ("split-normal": independently-fit widths sigma_lo/sigma_hi either side of the inflection point) to each reversal film's and each direct-print paper's own real digitized curve, via least-squares regression against the real data — asymmetric because the toe and shoulder are physically different mechanisms (grain-threshold statistics near Dmin; dye/silver exhaustion near Dmax) with no reason to share a width, and real emulsions are visibly not symmetric toe-to-shoulder. Fit quality is checked, not assumed: R² > 0.998 and max residual under 0.07 density units on every one of the 21 real curves fit (12 film layers, 9 paper layers). The fitted parameters (*_SPLITGAUSS_FIT in generate_film_looks.py) are transcribed as data, exactly like every other derived constant in this file.
gamma_correct_curve() then rescales the fitted model's exposure axis only, around the pivot — every fitted density value is kept exactly as the model computes it (so the model's own real fitted Dmin/Dmax are reached, not truncated), just relabeled to a new exposure position stretched by whatever factor makes the model's own exact analytic local gamma at the pivot (the derivative of a normal CDF is a normal PDF — computed directly, no window-average or finite-difference approximation) times the paper's own fitted local gamma at its real grey-reproduction point equal GAMMA_CORRECT_TARGET. A pure horizontal rescale preserves the fitted curve's shape exactly — toe:shoulder proportions unchanged — while spreading the same real density swing over more exposure, which is physically correct: lower gamma means exactly that, the same density change now needs more exposure, not a smaller density change. generate_film_looks.py itself stays scipy-free: _norm_cdf()/_norm_pdf() reimplement the standard normal CDF/PDF with only math.erf (stdlib), verified to match scipy.stats.norm to float precision.
The result, measured the same way. Kodachrome 64 × Radiance III: the print's shadow density now reaches 2.519 against the fitted model's own real Dmax of 2.561 — 0.04 short, comparable to v3's 0.004 shortfall, but with the film's real toe/shoulder curvature intact rather than replaced by a straight line. Checked across all 4 reversal films × 3 direct-print papers × 3 layers (36 combinations): shortfall against each paper's own fitted real Dmax is under 0.06 density units (a small fraction of a stop) in every case — this number is essentially unchanged by the GAMMA_CORRECT_TARGET value itself (it's measured at an extreme, deep-shadow exposure where the fitted model has already reached its own asymptote regardless of how the mid-tones are stretched to get there). What the target does control is local gamma near grey: Velvia 50 × Radiance III runs ≈1.11-1.26 through -1 EV to +1 EV at the default 1.35 target (was ≈0.9-1.03 at the too-conservative 1.1 this project started with), tapering smoothly into the toe/shoulder with no window-boundary kink.
v5 — correcting the toe and shoulder independently. v4's shortfall check above only ever measured the deep-shadow end (an extreme, off-scale exposure where the model has already reached its own flat asymptote regardless of how the mid-tones were stretched) — it never checked the symmetric question at the highlight end, at the one exposure every real image actually supplies: a normalized linear pixel value of 1.0, only ~2.47 stops over 18% grey (log2(1/GREY)). Checked directly, v4 fell well short there: every one of the 4 reversal films × 3 direct-print papers needed more than 3 stops above grey to reach 90% of its own asymptotic white, so an encoded-white pixel topped out around 0.72-0.85 reflectance — never approaching paper white no matter how bright the source image, on every single combination.
The cause was v4's single shared stretch factor k, derived from the fitted model's local gamma at whichever real-world reference exposure (na0, a reversal film's own density-midpoint) happened to land — then applied identically to both the toe (x<x0) and shoulder (x>=x0) halves. Every material fit in this file has sigma_lo != sigma_hi by design (toe/grain-threshold statistics and shoulder/dye-exhaustion are different physical mechanisms — see the v4 discussion above), and Velvia's own fitted shoulder is ~1.6× wider than its toe. Because na0 happened to fall in the toe half for every material checked, v4's k was already correct for the toe — but reusing that same factor for the shoulder over-stretched it, demanding far more exposure headroom than a [0,1]-normalized image can ever supply.
gamma_correct_curve() now derives two independent factors, k_lo and k_hi, each satisfying the identical Jones-rule criterion v4 already used (local_gamma × downstream_gamma == GAMMA_CORRECT_TARGET) but evaluated at the toe/shoulder junction x0 using that half's own sigma, instead of inheriting whichever factor the other half needed. Both halves still meet continuously at x0 (matching the un-corrected model's own value-continuous/slope-discontinuous behavior there, already an accepted simplification — see split_gaussian_cdf's own docstring). The toe's correction is unchanged from v4 (it was already right); only the shoulder moves. Verified: Velvia × Radiance III now needs ~2.25-2.3 stops to reach 90% of white (within the ~2.47 stops actually available), reflectance ~0.93-0.94 at encoded white — matching the reference point Tri-X reliably reaches, and consistent across all 12 film×paper combinations (2.2-2.8 stops needed, versus 3.0-3.8 under v4). The shadow side was already correctly corrected under v4 and is untouched by this fix; a real reversal film's toe genuinely is narrower than its shoulder in the digitized data, so shadow detail still compresses within roughly a stop below grey — a real, measured material property, not an artifact of the correction mechanism, and a separate question from the highlight bug this fixes.
| Film | RadianceIII span | IlfochromeM span | IlfochromeP span |
|---|---|---|---|
| Velvia 50 | 0.883 | 0.847 | 0.828 |
| Kodachrome 64 | 0.882 | 0.846 | 0.826 |
| Fuji Provia 100F | 0.881 | 0.846 | 0.824 |
| Kodak Ektachrome 100D | 0.883 | 0.848 | 0.827 |
(Span = encoded white-corner minus black-corner output, same metric and methodology as "Choosing a print paper"'s table above; measured at the default GAMMA_CORRECT_TARGET = 1.35, post-v5.) These spans read higher than v4's (0.757-0.848) because the shoulder fix lets the white corner actually reach near-white now, rather than because the target itself changed — the number to trust for "is this still gradual, not crushed" is the local-gamma profile above, not span alone. Ilfochrome Micrographic M needs the largest correction of the three (its own measured gamma, ~1.9-2.1, is the highest of any paper in this file) and was the specific case CLAUDE.md already records as rejected once for compounding too hard uncorrected — it is not re-rejected here; correcting it is the more direct test of whether this fix actually works, and it renders comparably to the other two once corrected.
The reasoning above talked about reversal films specifically, on the assumption that a camera negative's own low native gamma would keep it from overshooting Jones's target the way a reversal original does. That assumption turned out to be wrong in practice, caught by real-world use after the direct-print route above shipped: negative-film LUTs were rendering visibly punchier than the freshly-corrected reversal films — backwards from the real photographic hierarchy, where reversal stock is the punchier material.
Measured directly: negative films' own native gamma is correctly low (0.47–0.68 per layer via _measured_gamma() — exactly the low-native-gamma design every color negative stock uses, so it prints at roughly unity contrast on a normally-graded paper). The problem is on the paper side. PAPER_LADDER's own real measured gammas are steep — 2.5–4.3 across all 5 papers, including "ExtraSoft" — and had never actually been checked against Jones's rule; the ladder's ordering was derived from measuring rendered span (see "Choosing a print paper" above), which is a different question from "does this pairing land near the faithful-reproduction target." Negative film × PAPER_LADDER was landing at local gamma ≈1.4–1.7 near grey (measured before either correction round), versus the reversal direct-print route's post-correction ≈1.1-1.5.
The fix is the identical v4 mechanism, applied to a different pairing: tools/gamma_correction_fit/ fits the same split-normal-CDF model to all 6 negative films' and all 5 PAPER_LADDER papers' real digitized curves (R² > 0.995 on every layer), and _negative_gammacorrect_stage_fn() (replacing the former, uncorrected _negative_stage_fn()) applies the same horizontal-rescale-to-target-GAMMA_CORRECT_TARGET correction, using each specific paper's own fitted local gamma at its real grey-reproduction point as the downstream factor — the same per-layer, per-paper precision the reversal route already has. Verified at the default GAMMA_CORRECT_TARGET = 1.35: Portra 400 × Normal now runs local gamma ≈1.08-1.46 through -0.5 EV to +1 EV, matching the reversal route's target instead of running 30-50% hotter as it did uncorrected.
Negative films also get the v5 independent toe/shoulder correction (same gamma_correct_curve()), but it barely moves anything for them: negative-film fits have sigma_lo and sigma_hi within a few percent of each other (e.g. Portra 400 ≈1.65 vs ≈1.71) — the toe/shoulder asymmetry that made v5 necessary for the reversal films is a property of those materials' own digitized curves, not a property of gamma-correction in general.
What this still doesn't touch. The internegative route (COLOR_FILMS/_reversal_stage_fn()) is left exactly as it was — it has the identical underlying gamma-product problem, just via a different, currently-uncorrected pairing (measured separately: local gamma swings from ~0.2 to ~3.6 across just a few stops, the original crush problem this whole investigation started from), and is a separate piece of work.
Earlier versions of this project computed Tri-X's colour-to-exposure conversion as E = R^w_R × G^w_G × B^w_B (a geometric mean) instead of an arithmetic combination, on the theory that this "models film's logarithmic response to light" (a weighted average in log-density space). That reasoning conflated two different things: exposure is a linear integral of spectral irradiance against sensitivity (E = ∫ S(λ)·L(λ) dλ); it's the film's density response to that exposure — the H&D curve, applied downstream — that is logarithmic. No weighted mixing of channel values, geometric or otherwise, happens inside a single panchromatic emulsion. Measured consequence: a saturated red (0.8, 0, 0) rendered paper black (E ≈ 0.0000) under the geometric mean, when real Tri-X's actual spectral sensitivity (strong through ~640nm) exposes a saturated red brighter than grey. The claimed benefit — "physically correct zero-exposure when a filter completely blocks a channel" — doesn't actually need the geometric mean either: _weights() (the function that fed it) already integrated sensitivity×filter transmission per channel, so a filter that blocks a channel drives that channel's weight, not just its value, to ~0.
Ticket 21 (tasks/21-trix-geometric-mean-not-physical-model.md) replaced it with the same per-pixel spectral reconstruction every color film already used (see "Per-pixel spectral reconstruction" below), extended with a filter dimension — trix_exposure_grid() integrates TRIX_SENS (optionally through a Wratten filter's own spectral transmission) against each LUT grid point's reconstructed reflectance spectrum, instead of collapsing sensitivity to a fixed RGB weight triple first. This is not a regression to the plain arithmetic mixing rejected once before, either: a fixed-weight arithmetic model has the identical metamerism problem Ticket 16 fixed for color films (two colours with the same RGB triple but different real spectra would still expose Tri-X identically, which isn't physical) — the spectral route is the only one of the three ever tried that is actually the physical model. It also strengthens the classic dramatic filter looks rather than flattening them: because a Wratten filter narrows the effective sensitivity to a real narrow spectral band (much like a colour dye layer), a red object shot through a green filter goes markedly closer to black under the spectral model than the old fixed-weight arithmetic version would have (measured: −3.0 stops for Green58 × saturated red) — the fixed-weight model was leaking the red primary's own spectral power distribution into the filter's passband.
For every color film (Velvia, Kodachrome 64, Provia 100F, Ektachrome 100D, and the six negative stocks), each of the 3 dye layers computes its own exposure from the input RGB via an arithmetic combination (not geometric), because each layer responds to a narrow spectral band where a geometric mean's cross-channel suppression doesn't apply physically. As of the spectral reconstruction step below, that combination is no longer a fixed 3-number weighted sum; see the next section for what replaced it and why — Tri-X (one panchromatic curve, optionally through a filter, rather than 3 dye layers) works the same way, via trix_exposure_grid().
Every color film's per-layer exposure, and Tri-X's own single-curve exposure, used to come from a fixed weight triple (layer_weights() for color films, _weights() for Tri-X, both since removed): the real digitized spectral sensitivity curve was integrated once against D65 and the target colour space's own RGB primaries, producing three numbers (w_R, w_G, w_B), and every pixel's exposure was then just R·w_R + G·w_G + B·w_B (color films) or the geometric-mean equivalent (Tri-X, see above). That is mathematically equivalent to assuming every photographed colour is exactly a linear-light mixture of the three primaries' own spectral power distributions — real reflectance spectra are not that, and a real film's spectral sensitivity is not colorimetric, so two real-world colours that are metamers under the CIE 1931 observer (i.e. the same RGB triple) can and do expose real film differently. A fixed-weight model cannot represent that by construction, no matter how accurate the underlying sensitivity data is.
Each LUT grid point now instead reconstructs a real, physically-plausible reflectance spectrum for its own (R,G,B) coordinate — Jakob, W., & Hanika, J. (2019), "A Low-Dimensional Function Space for Efficient Spectral Upsampling," Computer Graphics Forum 38(2), 147-155 (doi:10.1111/cgf.13626; saved at papers/spectral_upsampling/, alongside the research trail into the reference C++ implementation and an independent Python port studied to confirm the exact math) — and integrates each layer's (or, for Tri-X, the one panchromatic curve's, optionally through a Wratten filter's own spectral transmission) real sensitivity curve against that instead of against the primaries. The model represents a reflectance spectrum as a smooth "sigmoid polynomial," R(λ) = 1/2 + U/(2·√(1+U²)) with U = c0·λ² + c1·λ + c2, chosen because an emulsion-adjacent smooth, low-parameter family reconstructs real-world reflectance spectra (validated against measured corpora, not just against the target colour) far better than an arbitrary basis — see that same physical smoothness argument this project already leans on for GAMMA_CORRECT_TARGET's own curve-fitting rationale.
Solving for (c0, c1, c2) per pixel at generation time would be far too slow (and would need scipy, which generate_film_looks.py deliberately does not depend on). Instead, tools/spectral_upsample_fit/ (a separate uv-managed offline tool, same pattern as tools/gamma_correction_fit/) fits a coefficient table once per LUT-module colour space (Adobe RGB, PQ Rec.2020) against this project's own CIE/D65/primary-matrix data — not colour-science's bundled datasets, so the fit is consistent with what generate_film_looks.py actually integrates at runtime — and bakes it to spectral_upsampling_tables/<colorspace>.json. generate_film_looks.py only ever evaluates that table (stdlib json + plain arithmetic: a largest-channel-relative trilinear interpolation, then the closed-form sigmoid above), never re-fits it, the same "fit offline, consume as data" split already established for *_SPLITGAUSS_FIT.
Because the reconstructed spectrum depends only on the LUT grid's (R,G,B) coordinate and the target colour space (not on which film is being rendered), it's computed once per (size, colorspace) run and shared across every one of that run's LUTs, color and B&W alike — the normalization is chosen so an achromatic grey pixel's exposure equals the grey value itself unchanged, preserving every existing GREY = 0.18 cascade-anchor calibration without touching that machinery.
Based on Fairchild & Pirrotta 1991 (Color Research and Application 16(6)). The model operates in CIELCh space:
L** = L* + (2.5 − 0.025·L*) · (0.116·|sin((h−90°)/2)| + 0.085) · C*
The correction is strongest for blue (h ≈ 270°) and red (h ≈ 0°/360°), weakest for yellow-green. It's converted to a linear exposure multiplier via the L*→Y inverse, then applied to the film exposure.
The exposure multiplier is capped at HK_MAX_MUL = 3.0× (an explicit invariant, alongside GREY = 0.18). The formula above has no built-in ceiling — dL grows linearly with C* forever, and its (2.5 − 0.025·L*) term is largest at low lightness, so dark, saturated pixels get the biggest (and least-validated) boost. Fairchild & Pirrotta fit and tested the model only against real Munsell surface chips (their published Table I): L* in roughly [30, 87], C* in roughly [6, 87]. Adobe RGB scene-linear data (used here deliberately for its wide gamut) routinely produces chroma well outside that range — e.g. saturated blue reaches C* ≈ 136, almost 60% past anything the model was ever checked against — and the formula happily extrapolates into multipliers of 6–7× rather than tapering off.
To pick a defensible ceiling rather than an arbitrary one: the largest luminance-matching ratio implied by any measured (not merely modelled) data point in Fairchild & Pirrotta's own Table I is about 2.7× (sample 5PB3/10 — a dark, saturated purple-blue chip at L* = 30.42, C* = 44.05 — matched to an achromatic lightness of 48.6, versus its own L* of 30.42). HK_MAX_MUL = 3.0 sits just above that best-supported real data point, leaving ordinary saturated shadows and midtones (which land well under the cap — a cool-shadow color cast typically multiplies exposure by 1.6–1.9×) untouched, while cutting off the unbounded extrapolation that only wide-gamut synthetic colors ever reach.
Note this bounds hk_mul()'s own output, not necessarily the final pixel-value ratio between classic and modern LUTs — the negative/paper (Tri-X) or reversal (color films) transfer function's own local contrast can still amplify or damp a bounded exposure change, same as it does for any other exposure difference. That's expected film-curve behavior, not a regression of this bound.
Five additional perceptual phenomena were evaluated for inclusion in the "modern" variants:
Bezold-Brücke shift (hue shifts with luminance): for B&W, the output is achromatic — no hue to shift. For the color films, the effect magnitude at SDR display luminance is approximately 2-5nm of hue shift, which is below the threshold of practical relevance and would alter the film's authentic hue rendering.
Purkinje effect (scotopic sensitivity shift): only applies at scotopic (rod-mediated) light levels. Display viewing is photopic. Not applicable.
Hunt effect (colorfulness increases with luminance): partially captured by HK's lightness-dependent term. For the reversal films, their own steep dye curves already produce a natural saturation boost in highlights. Explicit Hunt correction risks over-saturation on already-punchy stocks.
Abney effect (white-light-induced hue shift): too subtle at display luminance levels to produce a visible difference in the output.
Chromatic adaptation: handled by the white balance module upstream in the pipeline. Not the LUT's job.
Highlight clipping: both adobergb and pq2020 clip input at exactly the same real exposure — middle-grey + 2.5 stops at 0 EV — because in a .cube's [0,1] domain, encoded 1.0 always means linear pixel value 1.0, regardless of whether the curve between 0 and 1 is a fixed gamma or PQ. PQ doesn't move that ceiling. What it changes is only how the [0,1] code axis is distributed: PQ compresses shadow/mid detail heavily and leaves the clip point approached very gradually, which darktable's scene-referred lut3d input mode (this project's own darktable branch; not upstream) extrapolates past more gracefully than gamma's steep near-peak slope. Middle grey lands high on the PQ axis (code ~0.816) as a result, but this needs no exposure compensation — the LUT is anchored on scene-linear grey, so a normally-exposed frame renders grey as grey either way (see "Colour space options" for the one dropdown that must match). Either way, the print shoulder (Tri-X) and reversal shoulder (color films) already roll off highlights, so for most pictorial photography the loss is small.
Every color-film look ladder is now real material data, not a synthetic contrast curve — Velvia's grades used to be a parametric power-curve adjustment (no real multi-grade print data existed), and the one real-paper Velvia look that did exist (direct printing onto Kodak Ektachrome Radiance III, uncorrected) was structurally very contrasty, because printing a reversal film directly onto any reversal print paper compounds two already-high-contrast stages (confirmed against real Cibachrome/Ilfochrome and R-3 process accounts, not just this dataset). The fix at the time was architectural, not a better parameter: real darkroom labs mostly didn't print slides directly either, for the same reason — they duplicated the slide onto an internegative first, which is a genuinely low-contrast material (measured γ≈0.527, digitized from EASTMAN Color Internegative II Film 5272/7272's real datasheet — see "Choosing a print paper"), then printed that like an ordinary negative. Ilfochrome Micrographic M/P was tried alongside Radiance III for that old direct-print approach and rejected too, for the identical uncorrected-compounding reason. Both are back now, alongside Radiance III, as the direct-print route described in "Why a reversal print crushes without correction" above — the difference this time is a real-physics gamma correction applied first, not a rejection reversed on a whim. Kodak Dye Transfer was also considered at the time and stays excluded regardless: flagged by its own source library as "very experimental and unreliable," and its "for Slides" variant turned out to be a renamed copy of the unfinished Kodachrome curve, not independent data.
Negative films are a shorter cascade than the reversal films, by design, not an oversight: Portra 400 / Ektar 100 / Gold 200 / Ultramax 400 / Superia Reala / Superia X-tra 400 print straight onto the same real RA-4 paper the reversal films use, with no internegative stage — the same 2-stage shape build_trix_cascade() already uses for Tri-X, generalized in NEGATIVE_FILMS/_negative_gammacorrect_stage_fn() (gamma-corrected against PAPER_LADDER, see "Why a reversal print crushes without correction" → "Negative films needed the same correction too"). An earlier version of this project briefly added three of these same films folded into the reversal-film lineup and removed them because they didn't exercise the internegative pipeline that lineup exists to demonstrate; they're back now as their own separate lineup instead, which is the correct fix, not a reversal of that decision.
Kodak Portra 400 / Ektar 100 / Gold 200's own H&D and spectral-sensitivity curves were independently re-digitized from the real Kodak Alaris publication PDFs (E-4050, E-4046, E-7022 — see "Data provenance") as a cross-check against the community-sourced spectral_film_lut transcription already shipped, rather than trusting a single source uncritically. Per-layer gamma (contrast) matched within roughly 2-6% across all 9 (3 films × 3 layers) comparisons, and spectral peak wavelengths matched within about 5-20nm — good agreement for independent graph digitization, and specifically confirms that Portra 400, Ektar 100, and Gold 200 really do have quite similar per-layer gamma and spectral response to each other in real life (not a transcription artifact), which is why their rendered LUTs look closer to each other than to, say, Superia Reala's.
Fuji Superia X-tra 400's green-sensitive layer had one bad source sample dropped: the raw spectral_film_lut-derived JSON had a single point at 565.28nm reading 1.0051 log-sensitivity, a steep dive-and-recovery between two otherwise-smooth neighbors — confirmed as a digitization error (not a real spectral feature) before excluding it; see the comment above SUPERIA_XTRA400_SENS in generate_film_looks.py.
Grain and halation: spatial effects that a per-pixel LUT cannot represent. Use darktable's grain module and/or the Diffuse or Sharpen module with red-channel blend mode for halation simulation.
Chromatic aberration interaction: strong contrast filters (Red25, Blue47) amplify edge colour artifacts from darktable's chromatic aberration correction module. This is because the filter maps near-invisible colour shifts at edges into large luminance differences. Disable automatic CA correction when using strong filters, or use it sparingly.
Spectral reconstruction is a plausible spectrum, not necessarily the original scene spectrum: by the time a scene has been reduced to an RGB triple, the original reflectance spectrum's exact shape is fundamentally, irreducibly lost — many different real-world spectra are metamers of each other under any given observer, and no amount of downstream processing can recover which one a particular pixel actually came from. The per-pixel spectral reconstruction described in "The colour science" (Jakob & Hanika 2019) doesn't undo that ambiguity; it picks the smooth, low-parameter member of that metamer set the model construction favors, integrated against each film's own real sensitivity curve, instead of the cruder assumption (the RGB triple is a linear mix of the display primaries) the fixed-weight approach it replaced made implicitly. That's a real improvement in how differently-colored metamers get rendered relative to each other, not a claim that any given pixel's reconstructed spectrum matches the specific physical surface that was actually photographed. Tri-X (B&W) is unaffected either way — see tasks/16-fixed-rgb-weights-no-spectral-reconstruction.md for the full before/after and citation trail.
Two B&W stocks only: Tri-X 400 and Double-X 5222 are the only B&W negatives with real digitized data available (from JanLohse/spectral_film_lut). The famous rest of the B&W roster — HP5, FP4, T-Max, Acros, Delta — is not included because the data does not exist in digitized form and we did not fabricate it.
Iconic reversal stocks left out on purpose: Kodak Aerochrome III (false-color infrared — a distinct creative effect, not "what a normal photo looked like," and its own native gamma is steep enough to compound close to the clipping zone even through the internegative) and Fuji FP-100C / Instax color (integral instant print materials — the shot is already a print the moment it develops; there's no real historical practice of duplicating an instant print onto an internegative, so the whole cascade this project builds wouldn't correspond to anything real for them).
--colorspace picks which LUT-module application colour space the generated .cube files target. It has to match whatever you set in darktable's lut3d module (step 2 of "Quick start"), because it changes both the input/output transfer curve baked into the LUT and the RGB primaries used to compute the film's spectral weights (R=/G=/B= in each file's header comment).
adobergb (default): A .cube LUT needs gamma-encoded input so the grid distributes its sample points where shadows and midtones need precision. Among the gamma-based encodings darktable's lut3d module offers, Adobe RGB has the widest colour gamut. This means saturated scene colours survive into the spectral weighting rather than being gamut-clipped before the film "sees" them. The gamma precision is equivalent to sRGB; the wider primaries are the deciding advantage. Its fixed 2.2-ish gamma is also what clips highlights at roughly middle-grey + 2.5 stops — see "Highlight clipping" below.
pq2020: Rec.2020 primaries (wider still than Adobe RGB) encoded with SMPTE ST 2084 (PQ) instead of a fixed gamma, applied completely unscaled — pqenc/pqdec are the exact same formula and constants darktable's own PQ Rec.2020 profile uses internally, which they have to be: the code axis position has to mean the same real exposure to this script as it does to darktable, and that requires an exact mathematical inverse, not a relabelled or rescaled one (an earlier version of this tool multiplied in a 203-nit "reference white" factor and got measurably wrong output — the film math read every image ~5.6 stops overexposed, so everything blew out — because it silently required the user to hit one exact, undocumented exposure value for the round-trip to stay correct). One consequence of the exact match: encoded 1.0 always means linear pixel value 1.0 for any [0,1]-domain transfer curve, gamma or PQ, so pq2020's hard clip point sits at exactly the same real exposure as adobergb's (~middle-grey + 2.5 stops at 0 EV) — PQ does not raise that ceiling. What PQ changes is only how the code axis is distributed: it's heavily compressed near the top, so most of the axis carries shadow/mid detail. Middle grey therefore lands high on the axis, at code ~0.816 (vs Adobe RGB's ~0.459) — but that needs no exposure compensation, because the LUT is anchored on scene-linear grey: feed it a normally-exposed frame (grey ≈ 0.18) and grey renders as grey, identical to the adobergb build in scene-linear terms. (An earlier version of this note claimed you needed ~-5 EV to "move grey off the compressed region" — that was wrong, and would render the image near-black.)
The one setting that must be right. A
pq2020.cuberenders correctly only if darktable's LUT 3D module has application color space = "PQ Rec2020 RGB" (this project's darktable branch adds it) — and, per the project's intent, input = scene-referred. That dropdown defaults to sRGB. An Adobe RGB cube tolerates the sRGB default because Adobe's gamma ≈ sRGB (only a slight tone shift), so it's easy to never touch the dropdown — but a PQ cube read as sRGB is a wild curve mismatch: the effective transfer is flat and dark from −3 to +1 stop, then steps to white by +2, giving an image made of only crushed and blown pixels with exposure just shifting the ratio between them. If apq2020render looks like that, the application color space dropdown is wrong, not the LUT.
hk_mul()'s HK_MAX_MUL = 3.0 cap (see "Helmholtz-Kohlrausch correction" below) was derived against Adobe RGB's gamut specifically and hasn't been re-derived for Rec.2020's wider primaries — it still applies as a conservative ceiling, just not necessarily an optimal one, when this option is selected.
Tri-X 400: spectral sensitivity from the Tri-X Pan 5063 emulsion as published in Kodak datasheet F-4017. Characteristic curve at 7 minutes development.
Velvia 50, Kodachrome 64, Fuji Provia 100F, Kodak Ektachrome 100D: spectral sensitivity (3 dye layers) and characteristic curves (3 layers, reversal) from the published Fujifilm/Kodak datasheets.
Kodak Portra 400, Kodak Ektar 100, Kodak Gold 200, Kodak Ultramax 400, Fuji Superia Reala, Fuji Superia X-tra 400: spectral sensitivity (3 dye layers) and characteristic curves (3 layers, negative) from film_paper_filter_data/films/color/negative/*.json, pooled from the same spectral_film_lut source as the reversal films above. Portra 400, Ektar 100, and Gold 200's curves were additionally cross-checked against the real Kodak Alaris publication PDFs — E-4050 (Portra 400), E-4046 (Ektar 100), E-7022 (Gold 200), downloaded from Kodak Alaris's own site and kept in papers/ — by independently re-digitizing the characteristic-curve and spectral-sensitivity charts straight from each PDF's vector drawing commands (the same technique tools/curve_digitizer/ uses, extended to separate same-color, uncolored curve traces by position instead of by fill color, since these particular Kodak Alaris consumer/pro datasheets draw all three dye-layer curves in plain black rather than color-coding them). Per-layer gamma matched the shipped data within ~2-6%, spectral peaks within ~5-20nm — see "Honest limitations" for what that confirmed.
Polymax Fine-Art: characteristic curves at contrast grades 0 through 5 from the published Kodak datasheet.
EASTMAN Color Internegative II Film 5272/7272: spectral sensitivity and characteristic curve (3 layers), digitized directly from the real Kodak/Eastman datasheet TI1301 (papers/kodak_internegative_ii_5272_TI1301.pdf) via tools/curve_digitizer/ — a purpose-built tool that reads the PDF's own vector drawing commands for each curve rather than tracing a rendered image, so extraction precision is limited only by the source document's own geometry, not by any rasterization DPI. See that tool's README for the extraction method (axis calibration from tick-label text positions, monotonicity enforcement via isotonic regression for the real material curve, Ramer-Douglas-Peucker simplification to a shape-preserving sparse point set).
Print papers (Kodak Endura Premier, Kodak Portra Endura, Kodak Supra Endura, Fuji Crystal Archive Super Type C / Pro PDII / DPII / Maxima): characteristic curves (3 layers each) from published manufacturer datasheets.
Direct-print papers for reversal originals (Kodak Ektachrome Radiance III, Ilfochrome Micrographic M/P): characteristic curves (3 layers each) from film_paper_filter_data/papers/color/for_reversal/*.json, the same spectral_film_lut-derived pool as everything else in this section — see "Why a reversal print crushes without correction" for how these are used (gamma-corrected first) and why. The gamma-correction physics itself (GAMMA_CORRECT_TARGET, gamma_correct_curve()) is sourced from L.A. Jones's 1920 tone-reproduction paper and a set of secondary sources (a 1988 internegative-duplicator patent, duplicating-film datasheets, archival-industry and darkroom-technique references) rather than a single manufacturer datasheet — the full citation trail and every source PDF/HTML page is in papers/ and papers/masking_research/README.md.
Wratten filters: spectral transmission data from Kodak Publication B-3, "Handbook of Kodak Photographic Filters" (1990, ISBN 0-87985-658-0), transcribed by Paul Repacholi (1992), hosted at the University of Coimbra (mat.uc.pt). Cross-validated against the UEA Colour Group's wratten-db.
Film and paper curves and spectral sensitivities not otherwise noted above were digitized by the open-source project spectral_film_lut (MIT license), pooled for reference in film_paper_filter_data/. CIE 1931 colour matching functions and the D65 illuminant are international standards.
python generate_film_looks.py # 65^3, all 196 LUTs, Adobe RGB
python generate_film_looks.py --size 33 # faster, smaller files
python generate_film_looks.py --only trix # Tri-X only (72 LUTs)
python generate_film_looks.py --only velvia kodachrome64 # just these two (32 LUTs)
python generate_film_looks.py --only negative-portra-400 negative-ektar-100 # just these two (20 LUTs)
python generate_film_looks.py --colorspace pq2020 # Rec.2020 + PQ instead of Adobe RGB
python generate_film_looks.py --gamma 1.5 # override the Jones system-gamma target (default 1.35)
--only accepts any subset of trix velvia kodachrome64 provia100f ektachrome100d negative-portra-400 negative-ektar-100 negative-gold-200 negative-ultramax-400 negative-superia-reala negative-superia-xtra-400. Omit it for everything.
--colorspace accepts adobergb (default) or pq2020 — see "Colour space options" below. It changes what the LUT expects as input/output, so match it to whatever you set as the lut3d module's application color space.
--gamma overrides GAMMA_CORRECT_TARGET (default 1.35) for the direct-print and negative-film gamma correction — see "Why the target defaults to 1.35" above for the citation trail behind the number and why it's exposed as a switch instead of a fixed constant.
No dependencies beyond Python 3 standard library.
The generator script and resulting LUT files are provided as-is. Film data is from published manufacturer datasheets, digitized under MIT license. CIE and D65 data are international standards. Wratten data is published reference material.