Add five Ilford stocks, and say plainly that they are constructed
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>
This commit is contained in:
2026-08-26 10:08:51 +02:00
co-authored by Claude Opus 5
parent 4b2ee0ac50
commit e9b3598841
9 changed files with 2517 additions and 9 deletions
+150 -1
View File
@@ -83,6 +83,56 @@ DEV_BRANCH = {"kodak_trix", "kodak_doublex", "kodak_2302"}
# file and is dropped here only because nothing can yet ask for it.
DEVELOPMENT_INDEX = 2
# ---------------------------------------------------------------------------
# Constructed black-and-white stocks.
# ---------------------------------------------------------------------------
#
# **These are not measurements, and the profiles say so.** Ilford publish
# spectral sensitivity and characteristic curves as *graphs*, and no
# granularity figure at all -- the word does not appear in their technical
# information. Nobody has digitised them, so a profile has to be constructed.
#
# What each part actually rests on:
#
# speed Published and exact. ISO 400/27 for HP5 Plus is a fact.
# contrast ISO 6:1993 defines normal development as an average gradient
# of 0.62 over 1.30 log-E. A standard, not a guess.
# spectral *Borrowed* from Kodak Double-X, which is a measured
# panchromatic negative -- peak near 430nm, the dip at 500, the
# cutoff at 660 -- shifted by the speed difference. Conventional
# panchromatic sensitisation is much alike across black-and-white
# stocks, and this is far better founded than reading pixels off
# a printed curve.
# silver Neutral, which is not an approximation: developed silver
# absorbs flat across the visible band, and Double-X's measured
# density is flat 1.0.
# granularity **Estimated.** Ordered by each film's known relative grain and
# nothing more.
#
# So these render as an Ilford-*shaped* stock -- right speed, right contrast,
# right relative grain -- and not as a measurement of one. Anyone who digitises
# the real curves should replace them outright.
PARAMETRIC_BW = [
# stock, display name, ISO, Dmax, granularity estimate
("ilford_pan_f_plus", "Ilford Pan F Plus", 50, 2.5, 5.0),
("ilford_delta_100", "Ilford Delta 100", 100, 2.4, 6.0),
("ilford_fp4_plus", "Ilford FP4 Plus", 125, 2.5, 7.0),
("ilford_delta_400", "Ilford Delta 400", 400, 2.4, 9.0),
("ilford_hp5_plus", "Ilford HP5 Plus", 400, 2.6, 11.0),
]
# The stock whose measured panchromatic response the constructed ones borrow.
BW_REFERENCE = "kodak_doublex"
BW_REFERENCE_ISO = 250
# ISO 6:1993's normal-development contrast.
BW_GAMMA = 0.62
# Where mid-grey sits above the speed point, in log exposure. At gamma 0.62
# this puts an 18% card near 0.68 above fog, which is what a correctly exposed
# negative reads.
BW_SPEED_POINT = -1.1
CACHE = pathlib.Path(__file__).parent / "upstream"
@@ -239,6 +289,100 @@ def convert(stock):
return "\n".join(out) + "\n"
def build_parametric_bw(stock, name, iso, density_max, granularity):
"""A constructed black-and-white profile. See PARAMETRIC_BW for its basis."""
import math
ref = json.loads((CACHE / f"{BW_REFERENCE}.json").read_text())["data"]
wavelengths = ref["wavelengths"]
n = len(wavelengths)
# The measured panchromatic response, moved by the speed difference. A
# faster film is more sensitive at every wavelength, which is a shift of
# the log curve rather than a change in its shape.
speed_shift = math.log10(iso / BW_REFERENCE_ISO)
sensitivity = []
for row_in in ref["log_sensitivity"]:
v = row_in[0]
sensitivity.append(None if v is None or v != v else v + speed_shift)
# Developed silver, which absorbs neutrally. A third each, because the
# renderer sums the three layers -- the same reason `split_dye` exists.
dye = [[1.0 / 3.0] * 3 for _ in range(n)]
base = [0.1] * n
# A straight line of slope `gamma`, softened into a toe below and a
# shoulder above.
log_exposure = [-3.0 + 7.0 * i / 255.0 for i in range(256)]
toe, shoulder = 0.35, 0.45
curves = []
for x in log_exposure:
d = (
BW_GAMMA * toe * math.log10(1 + 10 ** ((x - BW_SPEED_POINT) / toe))
- BW_GAMMA
* shoulder
* math.log10(
1 + 10 ** ((x - BW_SPEED_POINT - density_max / BW_GAMMA) / shoulder)
)
)
curves.append([d] * 3)
g = num(granularity)
out = [
"# Generated by tools/film-profiles/convert.py.",
"#",
"# *** CONSTRUCTED, NOT MEASURED. ***",
"#",
"# Ilford publish this film's spectral sensitivity and characteristic",
"# curve as graphs, and no granularity figure at all, so this profile is",
"# built rather than extracted. Its speed is the published ISO rating,",
"# and its contrast is ISO 6:1993's normal development (average gradient",
"# 0.62). Its spectral response is borrowed from Kodak Double-X, a",
"# *measured* panchromatic negative, shifted by the speed difference.",
"# Its granularity is an estimate, ordered against the other films here.",
"#",
"# So it renders as a film of this speed and contrast, not as a",
"# measurement of this emulsion. Replace it outright if anyone digitises",
"# the real curves.",
"",
"version: 'constructed-1'",
"stock: " + stock,
"name: '" + name + "'",
"kind: negative # negative | positive",
"support: film # film | paper",
"stage: filming # filming | printing",
"monochrome: true",
"reference_illuminant: D55",
"viewing_illuminant: D50",
"",
"rms_granularity: [" + g + ", " + g + ", " + g + "]",
"",
"# log10 spectral sensitivity per layer, 380-780nm at 5nm.",
"log_sensitivity:",
]
for wl, v in zip(wavelengths, sensitivity):
cell = "-9" if v is None else num(v)
out.append(" - [" + cell + ", " + cell + ", " + cell + "] # %.0fnm" % wl)
out += ["", "# Developed silver: neutral across the band.", "dye_density:"]
for wl, triple in zip(wavelengths, dye):
out.append(" - " + row(triple) + " # %.0fnm" % wl)
out += [
"",
"# Base plus fog. No orange mask on a black-and-white film.",
"base_density: " + row(base),
"",
"# The characteristic curve, parametric.",
"log_exposure_min: " + num(log_exposure[0]),
"log_exposure_max: " + num(log_exposure[-1]),
"density_curves:",
]
for triple in curves:
out.append(" - " + row(triple, 5))
return "\n".join(out) + "\n"
TABLE_HEADER = '''//! Generated by tools/film-profiles/convert.py. Do not edit.
//!
//! The fixed colour science: the observer, the illuminants and the spectral
@@ -345,6 +489,11 @@ def main():
dest.write_text(convert(stock))
print(f"{dest.relative_to(ROOT)} {dest.stat().st_size / 1024:.1f} kB")
for stock, name, iso, dmax, gran in PARAMETRIC_BW:
dest = PROFILE_DIR / (stock + ".yaml")
dest.write_text(build_parametric_bw(stock, name, iso, dmax, gran))
print(f"{dest.relative_to(ROOT)} {dest.stat().st_size / 1024:.1f} kB (constructed)")
lic = CACHE / "SPEKTRAFILM_LICENSE.txt"
if lic.exists():
(PROFILE_DIR / "LICENSE-PROFILES.txt").write_text(lic.read_text())
@@ -376,7 +525,7 @@ def emit_registry(dest):
"/// Each stock's id and its YAML, in the order the converter ran.",
"pub static BUILT_IN: &[(&str, &str)] = &[",
]
for stock in STOCKS:
for stock in STOCKS + [p[0] for p in PARAMETRIC_BW]:
lines.append(f' ("{stock}", include_str!("../profiles/{stock}.yaml")),')
lines.append("];")
dest.write_text("\n".join(lines) + "\n")