Color space transform (CST) research: fitting camera-RGB → XYZ correction methods (polynomial, root-polynomial, scalable-rational) on colorchecker data and evaluating them on real scenes.
uv sync
Managed with uv; uv run ... picks up the
project's virtualenv automatically, no manual activation needed.
The main experiment scripts under experiments/ fit
PolynomialCST/RootPolynomialCST/ScalableRationalCST (degree 1-5, see
methods/) and report mean/median/p95 of a color-difference metric
(--metric: ciede2000 (default), luv, or angular).
Camera-side RGB (X) is always normalized by a single scalar,
spectral.render.rgb_scale(camera_sensitivity, illuminant) — not per-channel
(that would be von Kries) — before it reaches any method's fit/predict.
render() itself is a raw, unnormalized spectral integral (its scale tracks
illuminant power × wavelength sample count, e.g. ~[18, 9800] for a typical
camera under D65); left that large, high-degree polynomial features (X**5)
push the least-squares feature matrix's condition number past float64's
representable precision and np.linalg.lstsq silently returns a numerically
meaningless fit (this is why PolynomialCST at degree ≥ 4 used to look like
it was "overfitting" — it wasn't; it was a numerics bug). datasets/chart.py's
make_chart_dataset and experiments/hsi_cst.py's render_test_pixels both
apply the same scalar for a given (camera, illuminant), so a model's training
and test data always share the same scale. XYZ-side y is left unscaled.
Every experiment script writes both a granular per-combo (per-camera, often
per-source too) file and a top-level summary.csv/combined_*.csv for
each -o invocation - the granular files are as much a real result as the
summary, not scratch: they're what a summary was computed from, and they're
the only place a per-camera breakdown survives (every top-level summary
averages across camera, among other axes).
When identifying which camera a file or row belongs to, always read it from
the camera column already present in every granular CSV - never parse it
out of the filename. Camera names aren't underscore-safe for that (e.g.
Sony_DXC-930 itself contains an underscore), so positional filename
parsing silently misparses it.
results/ in this repo is a pre-computed, trimmed set of outputs kept as
tracked source (not .gitignored) — every result cited in the paper is
traceable to a file under it. Detailed, per-pixel/per-scene granular CSVs
that aren't cited anywhere (scene galleries, per-color/per-scene breakdowns,
raw Monte-Carlo traces) were pruned to keep the tree small; everything that
remains is regeneratable by rerunning the commands below. Table below maps
each paper result to the file(s) it comes from:
| Paper result | File(s) under results/ |
|---|---|
| Table 2 (LOO accuracy on DC/SG/Munsell), Table 7 (solver choice) | training_sweep_{dc,sg,munsell}/ |
| Section 3.2 paired Wilcoxon significance | paired_significance/ |
| Table 3 (real hyperspectral scenes) | hsi_cst/*_summary.csv (+ per camera×illuminant granular CSVs) |
| Table 4 (FLOPs, throughput), Fig. 3 | benchmark_inference/inference_performance.csv |
| Table 5 (sensor-noise robustness) | noise_sweep/chart-loo_*.csv |
| Table 6 (analytic vs. Monte-Carlo noise-gain) | noise_deformation/combined_mc_summary.csv, *_mc_summary.csv |
| Fig. 2, Fig. 3 (regenerated) | experiments/paper_figures.py, fed by training_sweep_* and benchmark_inference |
| Table 1 (candidate/retained feature counts) | methods/*.py + data/derived_bases/ (not under results/) |
hsi_training_sweep/ (solver/precision sweep on HSI data) isn't directly
cited in the paper but backs the "single fixed solver costs nothing" claim
of Section 4.5, evaluated there on chart data via training_sweep_*/Table 7.
Trains and tests on the same colorchecker (DC, SG, and/or Munsell), using
leave-one-out cross-validation, across every (method, degree, precision,
solver) combination in experiments/common.py's PRECISION_CONFIGS ×
SOLVERS — this is the canonical source for chart-only accuracy numbers
(there used to be a separate chart_cst.py with a narrower, in-sample-only
evaluation; it was removed as a duplicate — this script's solver=lstsq_numpy, train_dtype=float64 rows are exactly that comparison, done properly, plus a
measured cond_number per (method, degree) and every other solver/precision
choice alongside it).
uv run python experiments/chart_cst_training_sweep.py -o results/training_sweep
uv run python experiments/chart_cst_training_sweep.py -o results/training_sweep --chart dc --metric luv
uv run python experiments/chart_cst_training_sweep.py -o results/training_sweep --solvers lstsq_numpy,ridge_1e-3,qr
Writes one CSV per camera×chart plus summary.csv (mean over cameras and
charts) into -o. See experiments/common.py's SOLVERS for the available
solver/regularization choices.
For a given hyperspectral dataset (CAVE, KAUST, or Doomer) and colorchecker: for every camera × illuminant ("source") pair, trains on the colorchecker rendered under that camera+source, then tests against real scenes from the dataset rendered under the same camera+source and normalized the same way (see the scalar-normalization note above — this is not per-channel von Kries). Results are averaged over sources, then over cameras.
uv run python experiments/hsi_cst.py -o results/hsi_cst --dataset cave --chart dc
uv run python experiments/hsi_cst.py -o results/hsi_cst --dataset kaust --chart sg --take-every 8
uv run python experiments/hsi_cst.py -o results/hsi_cst --dataset doomer --chart dc
Required flags: --dataset {cave,kaust,doomer}, --chart {dc,sg}. dc and
sg are run as separate invocations — results for different colorcheckers
are not averaged together.
Doomer is different from CAVE/KAUST: its scenes are real captures, not
pure reflectance — each HSI cube is calibrated radiance with a real, already
baked-in illuminant (values well above 1, not bounded to [0, 1]). Most scenes
also contain a gray calibration ball (mask); since its true reflectance is
known (data/doomer/ball_reflectance.npy), DoomerDataset estimates that
scene's illuminant from the ball region and divides it out, recovering a
reflectance cube that plugs into the same rendering pipeline as CAVE/KAUST
unchanged. Scenes without a ball annotation (~28% of them) are dropped since
there is then nothing to estimate the illuminant from.
--take-every N (default 8) spatially subsamples each scene's pixels by
stride N before rendering (image[::N, ::N]) — this keeps the KAUST run
(409 scenes, ~28GB) tractable. Lower it for more test pixels at the cost of
runtime/memory; N must be ≥ 1.
Dataset roots default to $HSI_DATA_ROOT/CAVE / $HSI_DATA_ROOT/KAUST /
$HSI_DATA_ROOT/doomer/fullsize_hsi (HSI_DATA_ROOT env var, falling back
to data/hyperspectral/) but can be overridden by constructing
CaveDataset(root=...) / KaustDataset(root=...) / DoomerDataset(root=...)
directly (datasets/hyperspectral.py) if scripting against them from
elsewhere. Neither CAVE, KAUST, nor Doomer scene data is bundled in this
repo — fetch them from their respective publishers and point HSI_DATA_ROOT
(or the per-dataset root=) at wherever you put them. Likewise the Munsell
reflectance set (datasets/chart.py's MUNSELL_PATH, override via the
MUNSELL_PATH env var) isn't bundled.
Cameras used: canon600d, Sony_DXC-930 (bundled), canon60d (bundled).
The single canonical list lives in experiments/common.py (CAMERAS);
every experiment script imports it from there rather than declaring its own
copy.
experiments/common.py also declares EXTRA_CAMERAS (45 more real
sensitivities — phones, DSLRs, industrial cameras — used internally to
derive data/derived_bases/*.npz, see below) and ALL_CAMERAS = CAMERAS + EXTRA_CAMERAS. Only the 3 cameras in CAMERAS are bundled in this public
copy's data/sensitivities/; EXTRA_CAMERAS names are kept for provenance
but their CSVs aren't included, so anything iterating ALL_CAMERAS (e.g.
experiments/derive_orthonormal_basis.py, some tests) needs those files
supplied separately to run. This was also a deliberate default even with
the files present: every camera-iterating experiment would get ~16x more
expensive at 48 cameras instead of 3 (the LOO sweep in
chart_cst_training_sweep.py goes from minutes to hours; the HSI sweeps,
whose one existing 4-camera/4-solver bake-off already took ~26h, would
become infeasible).
experiments/derive_orthonormal_basis.py pools RPCC/SRCC candidate features
across all 48 ALL_CAMERAS sensitivities, all 9 illuminants, and all 3 chart
targets to derive the well-conditioned projection basis used everywhere in
Section 3 (the max-cond basis referenced throughout the paper). The
resulting data/derived_bases/orthonormal_bases*.npz files are bundled
pre-computed, so every other experiment/table in this repo reproduces
without rerunning it. Rerunning the derivation itself needs the 45
EXTRA_CAMERAS sensitivity CSVs, which (see above) aren't bundled here.
Sources (illuminants) used: all of A, C, D50, D55, D65, D75, LED-B2, FL12, HP1 (datasets/chart.py's SOURCES).
Writes, per invocation: one CSV per camera×source, one ..._<camera>_summary.csv
per camera (mean over sources), and one ..._summary.csv (mean over sources
and cameras) — all prefixed {dataset}_{chart}_... in -o.
For a first look or a quick sanity check on a couple of scenes rather than
the full dataset, slice the dataset before calling run() directly instead
of adding a CLI flag for it, e.g.:
from experiments.hsi_cst import run, DATASETS
ds = DATASETS["kaust"]()
ds.filepaths = ds.filepaths[:2]
DATASETS["kaust"] = lambda: ds
run("kaust", "dc", "ciede2000", take_every=8, outdir=...)(Note: experiments/ isn't an installed package, so from experiments.hsi_cst import ... only works if something already put the repo root on sys.path;
running the module directly via uv run python experiments/hsi_cst.py is the
normal path and needs no such workaround.)
uv run pytest
Dataset-dependent tests (CAVE/KAUST/Doomer, and cameras from EXTRA_CAMERAS
not bundled in this copy) skip automatically if the relevant files aren't
present.