mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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]>
123 lines
5.9 KiB
Python
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])
|