Files
lightcraft/tools/spectral_camera_table.py
T
ROSSAandClaude Opus 5.5 82ff68d4f3 M11.4: raws from 52 camera models start from matrices fitted to measured spectral sensitivities
A raw without colour matrices of its own now gets DNG ColorMatrix1/2 and
ForwardMatrix1/2 for its model when the model is one of the 52 cameras
in rawtoaces-data (Academy Software Foundation, Apache-2.0): 22 Canon,
11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names its
alias list gives (Rebel T3i and Kiss X5 for the EOS 600D; bodies it
lists as sharing a sensor). tools/spectral_camera_table.py fits, under
Standard Illuminant A and D65, a white-preserving 3x3 to the measured
spectral sensitivities over rawtoaces-data's 190 training reflectances
and writes crates/raw/src/spectral_table.rs. No number comes from Adobe
or from a raw decoder. lightcraft_raw::spectral looks a camera up by
Exif make and model; the DNG colour model does the rest.

camera_preview::starting_colour takes the first available of: the
file's own matrices, a camera profile for the model (fitted to the
JPEG for tone), the spectral matrices, the file-local JPEG fit, the
neutral fallback. A profiled model whose profile fit fails takes its
spectral matrices. White balance stays relative to the as-shot look for
every raw without matrices of its own, so Photo::relative_wb and the
controls are unchanged; calibrate still pools covered models.

Tests: one per precedence step (synthetic), the table's lookup, aliases
and DNG shape, and a corpus test of the order on ILCE-7M4 (profile),
EOS 5D Mark III and D5100 (spectral), EOS 7D (own fit) and a DNG. The
ILCE-7RM2 and GFX100S corpus tests now expect the spectral matrices.
Attribution in assets/ATTRIBUTION.md and NOTICE. RENDER_CACHE_VERSION 23.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-09 11:56:06 -05:00

123 lines
5.9 KiB
Python

# Writes crates/raw/src/spectral_table.rs: DNG-shaped colour matrices per camera model, fitted to the
# measured spectral sensitivities in rawtoaces-data (Academy Software Foundation, Apache-2.0).
#
# git clone https://github.com/AcademySoftwareFoundation/rawtoaces-data ../rawtoaces-data
# git -C ../rawtoaces-data checkout v1.1.0
# python3 -m venv .venv-spectral && .venv-spectral/bin/pip install -r tools/requirements-spectral-camera-table.txt
# .venv-spectral/bin/python tools/spectral_camera_table.py ../rawtoaces-data crates/raw/src/spectral_table.rs
#
# Method (Academy P-2013-001, written from its description; no rawtoaces code is used): the camera and the CIE 1931
# observer both look at the 190 training reflectances of rawtoaces-data under CIE Standard Illuminant A and under D65.
# The camera values are white balanced to that illuminant, and a white-preserving 3x3 to XYZ is fitted by least
# mean CIE76 error in Lab. Per illuminant this gives ColorMatrix (XYZ -> camera, DNG CalibrationIlluminant 17 / 21)
# and ForwardMatrix (white-balanced camera -> XYZ D50). The aliases are rawtoaces-data's own `data/aliases.json`.
import json
import os
import subprocess
import sys
import colour
import numpy as np
from scipy.optimize import minimize
WL = np.arange(380, 781, 5)
D50 = colour.xy_to_XYZ(colour.CCS_ILLUMINANTS["CIE 1931 2 Degree Standard Observer"]["D50"])
def spectral(path, columns):
data = json.load(open(path))["spectral_data"]["data"]["main"]
return np.array([[data[str(w)][i] for i in range(columns)] for w in WL], dtype=float)
def illuminant(name):
sd = colour.SDS_ILLUMINANTS[name]
return np.array([sd[w] for w in WL], dtype=float)
def fit(cmf, refl, ssf, light, target_white):
"""White-balanced camera RGB -> XYZ adapted to `target_white`; maps (1, 1, 1) to `target_white`."""
w = ssf.T @ light
cam = (ssf.T @ (light[:, None] * refl)) / w[:, None]
k = 1.0 / (cmf[:, 1] @ light)
xyz = k * (cmf.T @ (light[:, None] * refl))
white = k * (cmf.T @ light)
cat = colour.adaptation.matrix_chromatic_adaptation_VonKries(white, target_white, transform="Bradford")
xyz = cat @ xyz
target_xy = colour.XYZ_to_xy(target_white)
lab = colour.XYZ_to_Lab(xyz.T, target_xy)
def build(p):
return np.stack([p[:3], p[3:], target_white - p[:3] - p[3:]], axis=1)
def loss(p):
return float(np.mean(np.linalg.norm(colour.XYZ_to_Lab((build(p) @ cam).T, target_xy) - lab, axis=1)))
m0 = xyz @ np.linalg.pinv(cam)
r = minimize(loss, np.concatenate([m0[:, 0], m0[:, 1]]), method="Nelder-Mead", options={"maxiter": 20000, "xatol": 1e-9, "fatol": 1e-9})
return build(r.x), loss(r.x)
def dng_tags(cmf, refl, ssf):
"""[(ColorMatrix, ForwardMatrix, mean training dE76)] for Standard Illuminant A, then D65."""
out = []
for name in ("A", "D65"):
light = illuminant(name)
forward, error = fit(cmf, refl, ssf, light, D50)
k = 1.0 / (cmf[:, 1] @ light)
white = k * (cmf.T @ light)
to_xyz, _ = fit(cmf, refl, ssf, light, white)
cam_from_xyz = np.diag(ssf.T @ light) @ np.linalg.inv(to_xyz)
cam_from_xyz = cam_from_xyz / (cam_from_xyz @ white).max()
out.append((cam_from_xyz.round(6), forward.round(6), error))
return out
def mat(m):
return "Mat3([" + ", ".join("[" + ", ".join(f"{v:.6}" for v in row) + "]" for row in m) + "])"
def main(root, out):
commit = subprocess.run(["git", "-C", root, "rev-parse", "HEAD"], capture_output=True, text=True, check=True).stdout.strip()
cmf = spectral(os.path.join(root, "data/cmf/cmf_1931.json"), 3)
training = json.load(open(os.path.join(root, "data/training/training_spectral.json")))["spectral_data"]["data"]["main"]
refl = np.array([training[str(w)] for w in WL], dtype=float)
rows = []
for f in sorted(os.listdir(os.path.join(root, "data/camera"))):
if not f.endswith("_380_780_5.json"):
continue
path = os.path.join(root, "data/camera", f)
header = json.load(open(path))["header"]
if header.get("license") != "Apache-2.0":
sys.exit(f"{f}: licence {header.get('license')!r}, expected Apache-2.0")
(cm1, fm1, e1), (cm2, fm2, e2) = dng_tags(cmf, refl, spectral(path, 3))
print(f"{f}: training dE76 A {e1:.3f}, D65 {e2:.3f}", file=sys.stderr)
rows.append(
f' Camera {{\n make: "{header["manufacturer"]}",\n model: "{header["model"]}",\n'
f" color_matrix: [{mat(cm1)}, {mat(cm2)}],\n forward_matrix: [{mat(fm1)}, {mat(fm2)}],\n }},"
)
aliases = json.load(open(os.path.join(root, "data/aliases.json")))["data"]["camera"]["model"]
alias_rows = [
f' ("{make}", "{alias}", "{target["model"]}"), // {target.get("comments", "")}'
for make, models in aliases.items()
for alias, target in models.items()
]
rust = (
"// Generated by tools/spectral_camera_table.py from rawtoaces-data "
f"(https://github.com/AcademySoftwareFoundation/rawtoaces-data, commit {commit}).\n"
"// Derived from the camera spectral sensitivities of rawtoaces-data (Academy Software Foundation), licensed under\n"
"// the Apache License 2.0 (LICENSE-APACHE); see NOTICE and assets/ATTRIBUTION.md. Do not edit by hand.\n"
"use super::Camera;\n"
"use lightcraft_color::Mat3;\n\n"
"#[rustfmt::skip]\npub(super) const CAMERAS: &[Camera] = &[\n" + "\n".join(rows) + "\n];\n\n"
"/// `(make, model as sold, table model)`: other names of a camera in [`CAMERAS`], or of a camera with the same sensor.\n"
"#[rustfmt::skip]\npub(super) const ALIASES: &[(&str, &str, &str)] = &[\n" + "\n".join(alias_rows) + "\n];\n"
)
open(out, "w").write(rust)
print(f"{len(rows)} cameras, {len(alias_rows)} aliases -> {out}", file=sys.stderr)
if __name__ == "__main__":
if len(sys.argv) != 3:
sys.exit("usage: spectral_camera_table.py <rawtoaces-data checkout> <output .rs>")
main(sys.argv[1], sys.argv[2])