Ship every stock that exists, black and white included
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>
This commit is contained in:
+161
-12
@@ -24,15 +24,65 @@ PROFILE_DIR = ROOT / "core/dr-film/profiles"
|
||||
UPSTREAM = "https://raw.githubusercontent.com/andreavolpato/spektrafilm/main"
|
||||
UPSTREAM_PROFILES = f"{UPSTREAM}/src/spektrafilm/data/profiles"
|
||||
|
||||
# The stocks we ship. Each is a colour stock whose datasheet spektrafilm has
|
||||
# already digitised; a print paper is a stock like any other, distinguished
|
||||
# only by `support: paper`.
|
||||
# Every stock spektrafilm publishes. A print paper is a stock like any other,
|
||||
# distinguished only by `support: paper`; a cine print film likewise, by
|
||||
# `stage: printing`. The renderer treats all three the same and the *picker*
|
||||
# decides what a photographer is offered, which is why they are all converted
|
||||
# here rather than filtered at the source.
|
||||
STOCKS = [
|
||||
"kodak_portra_400",
|
||||
"fujifilm_c200",
|
||||
"fujifilm_crystal_archive_typeii",
|
||||
"fujifilm_pro_400h",
|
||||
"fujifilm_provia_100f",
|
||||
"fujifilm_velvia_100",
|
||||
"fujifilm_xtra_400",
|
||||
"kodak_2383",
|
||||
"kodak_2393",
|
||||
"kodak_ektachrome_100",
|
||||
"kodak_ektacolor_edge",
|
||||
"kodak_ektar_100",
|
||||
"kodak_endura_premier",
|
||||
"kodak_gold_200",
|
||||
"kodak_kodachrome_64",
|
||||
"kodak_portra_160",
|
||||
"kodak_portra_400",
|
||||
"kodak_portra_800",
|
||||
"kodak_portra_800_push1",
|
||||
"kodak_portra_800_push2",
|
||||
"kodak_portra_endura",
|
||||
"kodak_supra_endura",
|
||||
"kodak_ultra_endura",
|
||||
"kodak_ultramax_400",
|
||||
"kodak_verita_200d",
|
||||
"kodak_vision3_50d",
|
||||
"kodak_vision3_200t",
|
||||
"kodak_vision3_250d",
|
||||
"kodak_vision3_500t",
|
||||
# Black and white. Upstream ships these on `dev` only, and they are the
|
||||
# only open, measured monochrome profiles in existence -- everything else
|
||||
# is a datasheet graph nobody has digitised.
|
||||
"kodak_trix",
|
||||
"kodak_doublex",
|
||||
"kodak_2302",
|
||||
]
|
||||
|
||||
# Stocks that live on upstream's `dev` branch rather than `main`.
|
||||
#
|
||||
# The black-and-white ones, which is the whole reason for the exception: there
|
||||
# is no monochrome stock on `main` at all, and these are the only open,
|
||||
# measured B&W profiles that exist anywhere. Pinned per stock rather than
|
||||
# moving everything to `dev`, so the colour stocks stay on the released branch.
|
||||
DEV_BRANCH = {"kodak_trix", "kodak_doublex", "kodak_2302"}
|
||||
|
||||
# Which development time to ship, for a stock whose curves are measured at
|
||||
# several. Index into the profile's own `development_time` array.
|
||||
#
|
||||
# The middle of the published range, which is the manufacturer's standard
|
||||
# process: 6.5 minutes for Double-X, 5 for 2302. The others are push and pull,
|
||||
# and are the obvious next control -- the data for them ships in the upstream
|
||||
# file and is dropped here only because nothing can yet ask for it.
|
||||
DEVELOPMENT_INDEX = 2
|
||||
|
||||
CACHE = pathlib.Path(__file__).parent / "upstream"
|
||||
|
||||
|
||||
@@ -42,8 +92,13 @@ def fetch():
|
||||
dest = CACHE / f"{stock}.json"
|
||||
if dest.exists():
|
||||
continue
|
||||
print(f"fetching {stock}", file=sys.stderr)
|
||||
urllib.request.urlretrieve(f"{UPSTREAM_PROFILES}/{stock}.json", dest)
|
||||
branch = "dev" if stock in DEV_BRANCH else "main"
|
||||
print(f"fetching {stock} ({branch})", file=sys.stderr)
|
||||
urllib.request.urlretrieve(
|
||||
f"https://raw.githubusercontent.com/andreavolpato/spektrafilm/{branch}"
|
||||
f"/src/spektrafilm/data/profiles/{stock}.json",
|
||||
dest,
|
||||
)
|
||||
lic = CACHE / "SPEKTRAFILM_LICENSE.txt"
|
||||
if not lic.exists():
|
||||
urllib.request.urlretrieve(f"{UPSTREAM}/SPEKTRAFILM_LICENSE.txt", lic)
|
||||
@@ -61,9 +116,67 @@ def row(values, places=6):
|
||||
return "[" + ", ".join(num(v, places) for v in values) + "]"
|
||||
|
||||
|
||||
def columns(rows, want=3):
|
||||
"""Normalise a table to `want` columns per row.
|
||||
|
||||
Two shapes arrive. A colour stock gives three columns and passes through.
|
||||
A black-and-white stock gives **one** -- it has one emulsion -- and is
|
||||
spread across all three, which is exact rather than approximate: three
|
||||
layers with identical sensitivity and identical curves respond identically,
|
||||
which is what one layer does.
|
||||
|
||||
The dye is the exception and is handled by `split_dye`.
|
||||
"""
|
||||
out = []
|
||||
for row in rows:
|
||||
if len(row) >= want:
|
||||
out.append(row[:want])
|
||||
else:
|
||||
out.append([row[0]] * want)
|
||||
return out
|
||||
|
||||
|
||||
def split_dye(rows):
|
||||
"""A single emulsion's dye, divided across the three layers.
|
||||
|
||||
The renderer sums the three layers' contributions -- Beer-Lambert, so
|
||||
densities add -- and a monochrome stock has one dye, not three. Replicating
|
||||
it unchanged would treat one emulsion as three stacked copies of itself and
|
||||
render everything three times too dense. A third each reconstructs the
|
||||
single layer exactly, and stays correct anywhere the three densities differ,
|
||||
which is where the baked lookup interpolates.
|
||||
"""
|
||||
out = []
|
||||
for row in rows:
|
||||
if len(row) >= 3:
|
||||
out.append(row[:3])
|
||||
else:
|
||||
v = row[0]
|
||||
out.append([None if v is None else v / 3.0] * 3)
|
||||
return out
|
||||
|
||||
|
||||
def pick_development(rows, index):
|
||||
"""One development time's column, for a stock measured at several."""
|
||||
return [[row[min(index, len(row) - 1)]] if isinstance(row, list) else [row] for row in rows]
|
||||
|
||||
|
||||
def convert(stock):
|
||||
d = json.loads((CACHE / f"{stock}.json").read_text())
|
||||
info, data = d["info"], d["data"]
|
||||
monochrome = info.get("channel_model") == "bw"
|
||||
|
||||
# A monochrome stock may be measured at several development times. Take one
|
||||
# column before anything else, so everything below sees the usual shape.
|
||||
if monochrome:
|
||||
curves = data["density_curves"]
|
||||
if curves and isinstance(curves[0], list) and len(curves[0]) > 1:
|
||||
data["density_curves"] = pick_development(curves, DEVELOPMENT_INDEX)
|
||||
base = data.get("base_density")
|
||||
if base and isinstance(base[0], list):
|
||||
data["base_density"] = [
|
||||
row[min(DEVELOPMENT_INDEX, len(row) - 1)] for row in base
|
||||
]
|
||||
n = len(data["wavelengths"])
|
||||
assert data["wavelengths"][0] == 380.0 and data["wavelengths"][-1] == 780.0 and n == 81, (
|
||||
f"{stock}: unexpected wavelength grid; the conversion assumes 380-780nm at 5nm"
|
||||
@@ -82,6 +195,8 @@ def convert(stock):
|
||||
f"name: {info['name']!r}",
|
||||
f"kind: {info['type']} # negative | positive",
|
||||
f"support: {info['support']} # film | paper",
|
||||
f"stage: {info['stage']} # filming | printing",
|
||||
f"monochrome: {'true' if monochrome else 'false'}",
|
||||
f"reference_illuminant: {info['reference_illuminant']}",
|
||||
f"viewing_illuminant: {info['viewing_illuminant']}",
|
||||
]
|
||||
@@ -94,7 +209,7 @@ def convert(stock):
|
||||
"# blindness, so it is written as the sentinel the loader reads as such.",
|
||||
"log_sensitivity:",
|
||||
]
|
||||
for wl, triple in zip(data["wavelengths"], data["log_sensitivity"]):
|
||||
for wl, triple in zip(data["wavelengths"], columns(data["log_sensitivity"])):
|
||||
vals = ["-9" if (v is None or v != v) else num(v) for v in triple]
|
||||
out.append(f" - [{', '.join(vals)}] # {wl:.0f}nm")
|
||||
|
||||
@@ -103,7 +218,7 @@ def convert(stock):
|
||||
"# Spectral density of each layer's dye at unit density, same grid and order.",
|
||||
"dye_density:",
|
||||
]
|
||||
for wl, triple in zip(data["wavelengths"], data["channel_density"]):
|
||||
for wl, triple in zip(data["wavelengths"], split_dye(data["channel_density"])):
|
||||
out.append(f" - {row(triple)} # {wl:.0f}nm")
|
||||
|
||||
base = data.get("base_density")
|
||||
@@ -119,7 +234,7 @@ def convert(stock):
|
||||
f"log_exposure_max: {num(data['log_exposure'][-1])}",
|
||||
"density_curves:",
|
||||
]
|
||||
for triple in data["density_curves"]:
|
||||
for triple in columns(data["density_curves"]):
|
||||
out.append(f" - {row(triple, 5)}")
|
||||
return "\n".join(out) + "\n"
|
||||
|
||||
@@ -203,6 +318,12 @@ def emit_tables(dest):
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--fetch", action="store_true", help="download upstream profiles first")
|
||||
ap.add_argument(
|
||||
"--tables",
|
||||
action="store_true",
|
||||
help="also regenerate src/tables.rs (needs colour-science; the tables are "
|
||||
"fixed colour science and do not change when a stock is added)",
|
||||
)
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.fetch:
|
||||
@@ -220,9 +341,37 @@ def main():
|
||||
if lic.exists():
|
||||
(PROFILE_DIR / "LICENSE-PROFILES.txt").write_text(lic.read_text())
|
||||
|
||||
tables = ROOT / "core/dr-film/src/tables.rs"
|
||||
emit_tables(tables)
|
||||
print(f"{tables.relative_to(ROOT)} {tables.stat().st_size / 1024:.1f} kB")
|
||||
if args.tables:
|
||||
tables = ROOT / "core/dr-film/src/tables.rs"
|
||||
emit_tables(tables)
|
||||
print(f"{tables.relative_to(ROOT)} {tables.stat().st_size / 1024:.1f} kB")
|
||||
|
||||
registry = ROOT / "core/dr-film/src/built_in.rs"
|
||||
emit_registry(registry)
|
||||
print(f"{registry.relative_to(ROOT)} {registry.stat().st_size / 1024:.1f} kB")
|
||||
|
||||
|
||||
def emit_registry(dest):
|
||||
"""The compiled-in stock list.
|
||||
|
||||
Generated rather than hand-maintained, because it has to agree exactly with
|
||||
what is in `profiles/` -- a stock converted but never listed is a file that
|
||||
ships and cannot be chosen, which looks like a bug in the picker.
|
||||
"""
|
||||
lines = [
|
||||
"//! Generated by tools/film-profiles/convert.py. Do not edit.",
|
||||
"//!",
|
||||
"//! The stocks compiled in as a floor. A floor rather than the whole",
|
||||
"//! story: a stock is a file, and the point of the format is that",
|
||||
"//! anyone can add one without a release.",
|
||||
"",
|
||||
"/// Each stock's id and its YAML, in the order the converter ran.",
|
||||
"pub static BUILT_IN: &[(&str, &str)] = &[",
|
||||
]
|
||||
for stock in STOCKS:
|
||||
lines.append(f' ("{stock}", include_str!("../profiles/{stock}.yaml")),')
|
||||
lines.append("];")
|
||||
dest.write_text("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user