Files
lightcraft/tools/spectral_camera_table.py
T
06c1b9ae07 Use crw craftlib (#3503)
* CRW-LIB: decode Canon CRW through the shared craft-crw crate

LightCraft decodes Canon CRW (the CIFF raws of the EOS D30, D60, 10D, 300D and the
PowerShot G1/G6) through craft-libs' `craft-crw`, the clean-room decoder derived and
verified on the `clean-room-crw-format-decode` branch and moved there with its frozen
tables and derivation record. `crates/raw/src/vendor/crw.rs` is now the adapter: it maps
the crate's header and samples onto `RawImage` (CFA, levels, active area, orientation,
as-shot white balance, the make/model/time records a CRW keeps instead of Exif) and
converts the capture time through chrono. The dependency is a local path
(`../craft-libs/crates/crw`) until the craft-libs branch merges; then it becomes a git
revision.

Everything LightCraft-side from that branch comes along unchanged: `probe_bytes` fills
metadata gaps from the decoder's records so CRW imports name their camera; the EOS D60
bundled camera profile and its chart-measured dual-illuminant matrices (`chart_table.rs`,
`spectral::Camera` carries its illuminant pair) give the D60 a Kelvin white-balance scale;
CRW joins the JPEG-fitted starting look, the catalog's raw formats and `lightcraft-cli
calibrate`; `RENDER_CACHE_VERSION` 71; the CC0 CRW corpus entries for `cargo xtask corpus`;
coverage, parity, gaps, raw-parity, target-app-parity and ROADMAP updates. The G5 stays
preview-only (the crate refuses it with the reason) and the robustness test keeps its CRW
sample.

What does not come along: the decoder, its tables and `crw_oracle.rs` (now craft-crw's
digest-pinned sample test) and the derivation record, which lives in craft-libs
`crates/crw/clean-room/`. `clean-room/crw/` here keeps only the EOS D60 chart-calibration
document and its capture list, with a README pointing at the record.

Gates: fmt, clippy -D warnings (raw all targets; engine, catalog, cli), lightcraft-raw tests
(475 + 134, the corpus files decoding through the adapter), catalog tests, engine lib tests
(1,300 pass; the two failures, `tests_import::recursive_discovery_…` and
`originals::tests::write_targets_pin_absent_paths_…`, are in files unchanged from main),
`cargo xtask parity`, `assets`, `layers`, `coverage.py --check`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* no message

* no message

* Update documentation after moving code to craft-libs

* Review coment fixes

* CRW: pin craft-crw f3b62ec, a truncated entropy stream is Corrupt

craft-libs #472 makes craft_crw::decode refuse a stream that ends more than
Limits::max_stream_end_fill (64 samples, one block) before the frame does,
instead of filling the rest with predictor values and returning success.
The adapter already maps craft_crw::Error::Corrupt to RawError::Corrupt;
a regression test proves a truncated CRW now fails at this boundary (it
decoded as a flat frame on the old pin) while its header still reads.

Only craft-crw moves; craft-ui/docpath/update/window stay on c5a1cbf.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Code review comment fix

* Add some tests

---------

Co-authored-by: Robert Woldberg <314159+rwoldberg@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-02 20:41:34 -05:00

160 lines
7.2 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
from pathlib import Path
def _existing_dir(arg):
"""Resolve a directory CLI argument; require it to exist."""
path = Path(arg).resolve()
if not path.is_dir():
sys.exit(f"not a directory: {arg}")
return str(path)
def _out_path(arg):
"""Resolve an output path CLI argument; require its parent to exist."""
path = Path(arg).resolve()
if not path.parent.is_dir():
sys.exit(f"output directory does not exist: {arg}")
if path.exists() and not path.is_file():
sys.exit(f"output path is not a regular file: {arg}")
return str(path)
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 rust_lit(s):
"""The body of a Rust string literal: text from a checkout becomes data, never code."""
out = []
for ch in s:
if ch == "\\": out.append("\\\\")
elif ch == '"': out.append('\\"')
elif ch == "\t": out.append("\\t")
elif ord(ch) < 0x20 or ord(ch) == 0x7F: out.append(f"\\u{{{ord(ch):x}}}")
else: out.append(ch)
return "".join(out)
def rust_comment(s):
"""One line of printable text: a raw newline in an external comment would introduce Rust code."""
return rust_lit(" ".join(str(s).split()))
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: "{rust_lit(header["manufacturer"])}",\n model: "{rust_lit(header["model"])}",\n'
" illuminant: [17, 21],\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' ("{rust_lit(make)}", "{rust_lit(alias)}", "{rust_lit(target["model"])}"), // {rust_comment(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(_existing_dir(sys.argv[1]), _out_path(sys.argv[2]))