Lamp testbench in Rust around the HPCS 6500
lampbench station / export / label / tm30 / calibrate replace the Python lamp_station.py, lamp_export.py, label_printer.py, tm30.py and calibration.py that lived in HPCS6500-py. The driver is a submodule (HPCS6500/, like mppt-testbench). Workspace crates: tm30 (TM-30-18 port of colour-science 0.4.7), brother-ql (raster protocol, byte-identical to brother_ql) and lamp-label (QR + ID labels for the QL-820NWB). Bundles are byte-identical to lamp_export.py's (golden tests); the station runs end to end in a test against the simulated instrument.
This commit is contained in:
commit
2a3f28001c
241 files changed
+46390
No files matched your search
@@ -0,0 +1,162 @@
|
||||
"""Extract the spectral tables used by the TM-30-18 path of colour-science.
|
||||
|
||||
Writes plain CSV files into ``crates/tm30/data`` containing the *effective*
|
||||
arrays colour-science 0.4.7 uses when ``colour_fidelity_index_ANSIIESTM3018``
|
||||
is called on a 380-780 nm / 1 nm spectral distribution (which is what
|
||||
``tm30.py`` always builds). "Effective" matters: colour re-aligns its 1 nm
|
||||
CMFs and 5 nm D-series basis functions with a Sprague interpolator, which is
|
||||
not bit-exact even on the original nodes, so the arrays are dumped exactly as
|
||||
the pipeline sees them (same reshape calls, same order).
|
||||
|
||||
Usage (from the repository root, with a Python that has colour-science 0.4.7):
|
||||
|
||||
python -W ignore crates/tm30/tools/extract_data.py
|
||||
|
||||
Values are written with ``repr`` (shortest round-trip), so Rust's
|
||||
``str::parse::<f64>`` recovers the identical doubles.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
import colour
|
||||
from colour.appearance.ciecam02 import CAT_INVERSE_CAT02, MATRIX_XYZ_TO_HPE
|
||||
from colour.adaptation import CAT_CAT02
|
||||
from colour.colorimetry import (
|
||||
MSDS_CMFS,
|
||||
SDS_BASIS_FUNCTIONS_CIE_ILLUMINANT_D_SERIES,
|
||||
SDS_ILLUMINANTS,
|
||||
SPECTRAL_SHAPE_DEFAULT,
|
||||
SpectralShape,
|
||||
reshape_msds,
|
||||
reshape_sd,
|
||||
)
|
||||
from colour.quality.cfi2017 import load_TCS_CIE2017
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA = os.path.normpath(os.path.join(HERE, "..", "data"))
|
||||
|
||||
SHAPE = SpectralShape(380, 780, 1)
|
||||
|
||||
|
||||
def fmt(x: float) -> str:
|
||||
return repr(float(x))
|
||||
|
||||
|
||||
def write_csv(name: str, header: list[str], rows) -> None:
|
||||
path = os.path.join(DATA, name)
|
||||
with open(path, "w", newline="\n") as f:
|
||||
f.write(",".join(header) + "\n")
|
||||
for row in rows:
|
||||
f.write(",".join(fmt(v) for v in row) + "\n")
|
||||
print(f"wrote {path}")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
assert colour.__version__ == "0.4.7", colour.__version__
|
||||
os.makedirs(DATA, exist_ok=True)
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
|
||||
# --- CIE 1931 2 degree observer -------------------------------------
|
||||
# handle_spectral_arguments() reshapes the default observer to
|
||||
# SPECTRAL_SHAPE_DEFAULT (360-780 nm, 1 nm); this array feeds the
|
||||
# Ohno (2013) Planckian table (CCT_to_uv_Planck1900).
|
||||
cmfs_2_default = reshape_msds(
|
||||
MSDS_CMFS["CIE 1931 2 Degree Standard Observer"],
|
||||
SPECTRAL_SHAPE_DEFAULT,
|
||||
copy=False,
|
||||
)
|
||||
assert cmfs_2_default.shape == SpectralShape(360, 780, 1)
|
||||
# sd_to_XYZ_integration() then re-aligns *that* array to the test
|
||||
# shape (380-780 nm) for the test SPD CCT and the reference mixing
|
||||
# normalisation.
|
||||
cmfs_2_test = reshape_msds(cmfs_2_default, SHAPE, copy=False)
|
||||
|
||||
# --- CIE 1964 10 degree observer ------------------------------------
|
||||
cmfs_10 = reshape_msds(
|
||||
MSDS_CMFS["CIE 1964 10 Degree Standard Observer"], SHAPE, copy=False
|
||||
)
|
||||
|
||||
# --- Illuminant E (implicit weighting in the integration) -----------
|
||||
for shape in (SPECTRAL_SHAPE_DEFAULT, SHAPE):
|
||||
E = reshape_sd(SDS_ILLUMINANTS["E"], shape, copy=False)
|
||||
assert np.all(E.values == 100.0), "illuminant E is not exactly 100"
|
||||
E2 = reshape_sd(
|
||||
reshape_sd(SDS_ILLUMINANTS["E"], SPECTRAL_SHAPE_DEFAULT, copy=False),
|
||||
SHAPE,
|
||||
copy=False,
|
||||
)
|
||||
assert np.all(E2.values == 100.0)
|
||||
|
||||
# --- CIE D-series basis functions -----------------------------------
|
||||
basis = [
|
||||
reshape_sd(SDS_BASIS_FUNCTIONS_CIE_ILLUMINANT_D_SERIES[n], SHAPE, copy=False)
|
||||
for n in ("S0", "S1", "S2")
|
||||
]
|
||||
|
||||
# --- CIE 2017 test colour samples -----------------------------------
|
||||
tcs = load_TCS_CIE2017(SHAPE)
|
||||
assert tcs.shape == SHAPE and tcs.values.shape == (401, 99)
|
||||
|
||||
wl_default = cmfs_2_default.wavelengths
|
||||
write_csv(
|
||||
"cmf_cie1931_2deg_360_780.csv",
|
||||
["wavelength_nm", "x_bar", "y_bar", "z_bar"],
|
||||
(
|
||||
[w, *v]
|
||||
for w, v in zip(wl_default, cmfs_2_default.values, strict=True)
|
||||
),
|
||||
)
|
||||
write_csv(
|
||||
"cmf_cie1931_2deg_380_780.csv",
|
||||
["wavelength_nm", "x_bar", "y_bar", "z_bar"],
|
||||
([w, *v] for w, v in zip(SHAPE.wavelengths, cmfs_2_test.values, strict=True)),
|
||||
)
|
||||
write_csv(
|
||||
"cmf_cie1964_10deg_380_780.csv",
|
||||
["wavelength_nm", "x_bar", "y_bar", "z_bar"],
|
||||
([w, *v] for w, v in zip(SHAPE.wavelengths, cmfs_10.values, strict=True)),
|
||||
)
|
||||
write_csv(
|
||||
"d_series_basis_380_780.csv",
|
||||
["wavelength_nm", "S0", "S1", "S2"],
|
||||
(
|
||||
[w, a, b, c]
|
||||
for w, a, b, c in zip(
|
||||
SHAPE.wavelengths,
|
||||
basis[0].values,
|
||||
basis[1].values,
|
||||
basis[2].values,
|
||||
strict=True,
|
||||
)
|
||||
),
|
||||
)
|
||||
write_csv(
|
||||
"tcs_cie2017_380_780.csv",
|
||||
["wavelength_nm", *[f"TCS{i:02d}" for i in range(99)]],
|
||||
([w, *v] for w, v in zip(SHAPE.wavelengths, tcs.values, strict=True)),
|
||||
)
|
||||
|
||||
# CIECAM02 matrices exactly as colour uses them: CAT02 (literal) and the
|
||||
# numpy product MATRIX_XYZ_TO_HPE @ inv(CAT02) used by RGB_to_rgb().
|
||||
hpe_cat02_inv = np.matmul(MATRIX_XYZ_TO_HPE, CAT_INVERSE_CAT02)
|
||||
path = os.path.join(DATA, "ciecam02_matrices.csv")
|
||||
with open(path, "w", newline="\n") as f:
|
||||
f.write("name,m00,m01,m02,m10,m11,m12,m20,m21,m22\n")
|
||||
for name, m in (("CAT02", CAT_CAT02), ("HPE_CAT02_INV", hpe_cat02_inv)):
|
||||
f.write(name + "," + ",".join(fmt(v) for v in np.ravel(m)) + "\n")
|
||||
print(f"wrote {path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Generate golden test vectors for the ``tm30`` crate.
|
||||
|
||||
Every case runs the production Python reference ``compute_tm30`` (HPCS
|
||||
``tm30.py`` on colour-science 0.4.7) and writes one JSON file into
|
||||
``crates/tm30/tests/golden`` holding the exact input (wavelengths, values)
|
||||
and every output field. NaN is encoded as ``null``. Floats are written with
|
||||
``repr`` so they round-trip exactly.
|
||||
|
||||
Usage (from the repository root):
|
||||
|
||||
python -W ignore crates/tm30/tools/golden.py [--tm30-dir DIR] [--lamps-dir DIR]
|
||||
|
||||
``--tm30-dir`` is the folder containing ``tm30.py`` (defaults to the HPCS
|
||||
scratch copy), ``--lamps-dir`` the buildfor.life ``comparison-data/lamps``
|
||||
folder with ``L*/spd.csv`` files (read only).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import glob
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
import numpy as np
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.normpath(os.path.join(HERE, "..", "tests", "golden"))
|
||||
|
||||
DEFAULT_TM30_DIR = os.path.join(
|
||||
os.path.expanduser("~"), "AppData", "Local", "hermes", "cache", "scratch", "hpcs-py"
|
||||
)
|
||||
DEFAULT_LAMPS_DIR = "C:/dev/buildfor_life_web/apps/web/comparison-data/lamps"
|
||||
|
||||
# HPCS 6500 grid: 350 points, 380..1050 nm.
|
||||
HPCS_GRID = np.linspace(380.0, 1050.0, 350)
|
||||
|
||||
|
||||
def clean(x):
|
||||
x = float(x)
|
||||
return None if math.isnan(x) else x
|
||||
|
||||
|
||||
def read_lamp_csv(path):
|
||||
wl, vals = [], []
|
||||
with open(path, newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for row in reader:
|
||||
wl.append(float(row["wavelength_nm"]))
|
||||
vals.append(float(row["value"]))
|
||||
return np.array(wl), np.array(vals)
|
||||
|
||||
|
||||
def build_cases(lamps_dir):
|
||||
import colour
|
||||
from colour.colorimetry import SDS_ILLUMINANTS, SpectralShape, sd_blackbody
|
||||
from colour.colorimetry import sd_CIE_illuminant_D_series
|
||||
from colour.temperature import CCT_to_xy_CIE_D
|
||||
|
||||
cases = []
|
||||
|
||||
# Real lamps measured with the HPCS 6500 (380-1050 nm, ~1.92 nm steps).
|
||||
for path in sorted(glob.glob(os.path.join(lamps_dir, "L*", "spd.csv"))):
|
||||
lamp = os.path.basename(os.path.dirname(path))
|
||||
wl, vals = read_lamp_csv(path)
|
||||
cases.append((f"lamp_{lamp}", f"{lamp}/spd.csv", wl, vals))
|
||||
|
||||
# colour-science illuminant library.
|
||||
names = (
|
||||
[f"FL{i}" for i in range(1, 13)]
|
||||
+ ["A", "D50", "D65"]
|
||||
+ [k for k in SDS_ILLUMINANTS.keys() if k.startswith("LED-")]
|
||||
+ [f"HP{i}" for i in range(1, 6)]
|
||||
)
|
||||
for name in names:
|
||||
sd = SDS_ILLUMINANTS[name]
|
||||
cases.append(
|
||||
(
|
||||
"illuminant_" + name.replace("-", "_").replace(".", "_"),
|
||||
f"colour.SDS_ILLUMINANTS[{name!r}] {sd.shape}",
|
||||
sd.wavelengths,
|
||||
sd.values,
|
||||
)
|
||||
)
|
||||
|
||||
# Planckian radiators, covering the < 4000 K (pure Planckian reference)
|
||||
# and 4000-5000 K (Planckian / daylight mixture) regimes.
|
||||
shape = SpectralShape(360, 830, 1)
|
||||
for T in (2700, 4300, 4700):
|
||||
sd = sd_blackbody(T, shape)
|
||||
cases.append((f"blackbody_{T}K", f"sd_blackbody({T}, {shape})", sd.wavelengths, sd.values))
|
||||
|
||||
# CIE daylight (> 5000 K regime).
|
||||
for T in (6500, 10000):
|
||||
xy = CCT_to_xy_CIE_D(T * 1.4388 / 1.4380)
|
||||
sd = sd_CIE_illuminant_D_series(xy)
|
||||
cases.append((f"daylight_{T}K", f"sd_CIE_illuminant_D_series(CCT_to_xy_CIE_D({T}*1.4388/1.4380))", sd.wavelengths, sd.values))
|
||||
|
||||
# Grid variations.
|
||||
# Coarse 10 nm grid.
|
||||
d50 = SDS_ILLUMINANTS["D50"]
|
||||
wl10 = np.arange(380.0, 781.0, 10.0)
|
||||
cases.append(("grid_10nm_D50", "D50 sampled every 10 nm", wl10, d50[wl10]))
|
||||
# HPCS 6500 grid (380-1050 nm, 670/349 nm steps), blackbody and LED.
|
||||
bb = sd_blackbody(3000, SpectralShape(360, 1100, 1))
|
||||
cases.append(("hpcs_grid_blackbody_3000K", "sd_blackbody(3000) on 380-1050 nm / 350 points", HPCS_GRID, bb[HPCS_GRID]))
|
||||
led = SDS_ILLUMINANTS["LED-B3"]
|
||||
cases.append(
|
||||
(
|
||||
"hpcs_grid_LED_B3",
|
||||
"LED-B3 linearly resampled on 380-1050 nm / 350 points (0 beyond 780)",
|
||||
HPCS_GRID,
|
||||
np.interp(HPCS_GRID, led.wavelengths, led.values, right=0.0),
|
||||
)
|
||||
)
|
||||
# Irregular, non-uniform grid.
|
||||
rng = np.random.default_rng(20180)
|
||||
wl_irr = np.sort(np.concatenate([[371.3], rng.uniform(372.0, 800.0, 240), [801.7]]))
|
||||
fl11 = SDS_ILLUMINANTS["FL11"]
|
||||
cases.append(
|
||||
(
|
||||
"irregular_grid_FL11",
|
||||
"FL11 linearly resampled on 242 random wavelengths 371-802 nm",
|
||||
wl_irr,
|
||||
np.interp(wl_irr, fl11.wavelengths, fl11.values),
|
||||
)
|
||||
)
|
||||
# Partial coverage (400-700 nm only): np.interp holds the end values.
|
||||
fl4 = SDS_ILLUMINANTS["FL4"]
|
||||
wl_p = np.arange(400.0, 701.0, 2.5)
|
||||
cases.append(
|
||||
(
|
||||
"partial_400_700_FL4",
|
||||
"FL4 on 400-700 nm / 2.5 nm",
|
||||
wl_p,
|
||||
np.interp(wl_p, fl4.wavelengths, fl4.values),
|
||||
)
|
||||
)
|
||||
# Negated spectrum: colour normalises by Y, so a negative scale must give
|
||||
# exactly the positive-scale result.
|
||||
fl2 = SDS_ILLUMINANTS["FL2"]
|
||||
cases.append(("negated_FL2", "-1 * FL2 (scale invariance incl. sign)", fl2.wavelengths, -fl2.values))
|
||||
# Noisy real lamp with negative values (dark-noise like).
|
||||
if cases and cases[0][0].startswith("lamp_"):
|
||||
_, _, wl, vals = cases[0]
|
||||
noise = rng.normal(0.0, 0.01 * float(np.max(vals)), vals.shape)
|
||||
cases.append(("noisy_" + cases[0][0], "first lamp + 1% gaussian noise (negative values)", wl, vals + noise))
|
||||
|
||||
return cases
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--tm30-dir", default=DEFAULT_TM30_DIR)
|
||||
ap.add_argument("--lamps-dir", default=DEFAULT_LAMPS_DIR)
|
||||
args = ap.parse_args()
|
||||
|
||||
sys.path.insert(0, args.tm30_dir)
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
import colour # noqa: F401
|
||||
from tm30 import compute_tm30
|
||||
|
||||
cases = build_cases(args.lamps_dir)
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for old in glob.glob(os.path.join(OUT, "*.json")):
|
||||
os.remove(old)
|
||||
|
||||
for name, source, wl, vals in cases:
|
||||
wl = [float(x) for x in np.asarray(wl, dtype=float)]
|
||||
vals = [float(x) for x in np.asarray(vals, dtype=float)]
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore")
|
||||
res = compute_tm30(wl, vals)
|
||||
expected = {
|
||||
"Rf": clean(res["Rf"]),
|
||||
"Rg": clean(res["Rg"]),
|
||||
"CCT_K": clean(res["CCT_K"]),
|
||||
"Duv": clean(res["Duv"]),
|
||||
"bins": [
|
||||
{k: (v if k == "bin" else clean(v)) for k, v in b.items()}
|
||||
for b in res["bins"]
|
||||
],
|
||||
}
|
||||
doc = {
|
||||
"name": name,
|
||||
"source": source,
|
||||
"generator": "crates/tm30/tools/golden.py (tm30.py + colour-science "
|
||||
+ __import__("colour").__version__
|
||||
+ ", numpy "
|
||||
+ np.__version__
|
||||
+ ")",
|
||||
"wavelengths": wl,
|
||||
"values": vals,
|
||||
"expected": expected,
|
||||
}
|
||||
path = os.path.join(OUT, name + ".json")
|
||||
with open(path, "w", newline="\n") as f:
|
||||
json.dump(doc, f, indent=1, allow_nan=False)
|
||||
print(
|
||||
f"{name:32s} CCT={expected['CCT_K']:9.2f} Duv={expected['Duv']: .5f} "
|
||||
f"Rf={expected['Rf']:6.2f} Rg={expected['Rg']:6.2f}"
|
||||
)
|
||||
print(f"{len(cases)} cases -> {OUT}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user