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,134 @@
|
||||
# tm30
|
||||
|
||||
ANSI/IES TM-30-18 colour rendition metrics (fidelity index R<sub>f</sub>,
|
||||
gamut index R<sub>g</sub>, CCT/Duv and the 16 hue-bin values for the colour
|
||||
vector graphic) from a measured spectrum, in pure Rust (std only).
|
||||
|
||||
This is a numerically faithful port of the HPCS reference script `tm30.py`
|
||||
(`compute_tm30`), which wraps
|
||||
`colour.quality.colour_fidelity_index_ANSIIESTM3018` from
|
||||
[colour-science](https://www.colour-science.org/) 0.4.7. The goal is the same
|
||||
numbers, not just the same method: every array colour uses on this path is
|
||||
embedded as it is after colour's own Sprague re-alignment, and the arithmetic
|
||||
follows colour/numpy's evaluation order.
|
||||
|
||||
```rust
|
||||
let r = tm30::compute(&wavelengths_nm, &values)?;
|
||||
println!("Rf {:.1} Rg {:.1} CCT {:.0} K Duv {:.4}", r.rf, r.rg, r.cct_k, r.duv);
|
||||
for b in &r.bins {
|
||||
// b.bin (1..=16), b.ref_a/b, b.test_a/b, *_norm, b.rcs_pct, b.rhs, b.rf_h
|
||||
}
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
```rust
|
||||
pub const N_BINS: usize = 16;
|
||||
pub struct HueBin { bin, ref_a, ref_b, test_a, test_b,
|
||||
ref_a_norm, ref_b_norm, test_a_norm, test_b_norm,
|
||||
rcs_pct, rhs, rf_h } // all f64 except bin: u32
|
||||
pub struct Tm30 { rf, rg, cct_k, duv, bins: Vec<HueBin> }
|
||||
pub enum Error { NotVisible, InvalidInput(String) }
|
||||
pub fn compute(wavelengths_nm: &[f64], values: &[f64]) -> Result<Tm30, Error>;
|
||||
// extras
|
||||
pub fn compute_detailed(..) -> Result<(Tm30, SampleData), Error>; // + per-sample J'a'b', ΔE, R_f,i
|
||||
pub fn warm_up(); // build caches up front
|
||||
pub const VISIBLE_MIN_NM, VISIBLE_MAX_NM, MIN_VISIBLE_POINTS;
|
||||
```
|
||||
|
||||
Semantics (same as `tm30.py`):
|
||||
|
||||
* Only samples with 380 <= λ <= 780 nm are used; fewer than 10 such samples
|
||||
gives `Error::NotVisible` ("spectrum does not cover the visible range").
|
||||
* They are resampled with `np.interp` semantics (linear, ends held constant)
|
||||
onto 380..=780 nm / 1 nm. The input may have any (ascending) grid, e.g. the
|
||||
HPCS 6500's 350 points over 380-1050 nm.
|
||||
* CCT/Duv: CIE 1931 2° XYZ -> CIE 1960 uv -> Ohno (2013) with colour's
|
||||
1000-25000 K Planckian table (spacing 1.001, 360-780 nm CMFs).
|
||||
* Reference illuminant: Planckian below 4000 K, CIE D-series above 5000 K
|
||||
(M1/M2 rounded to 3 decimals, no 1.4388/1.4380 correction), Y-normalised
|
||||
linear mixture in between.
|
||||
* 99 CIE 2017 colour evaluation samples, CIE 1964 10° observer, CIECAM02
|
||||
(L<sub>A</sub> = 100, Y<sub>b</sub> = 20, average surround, illuminant
|
||||
discounted), CAM02-UCS.
|
||||
* `rcs_pct` is colour's `R_cs` (already in percent), `rhs` is `R_hs`,
|
||||
`rf_h` is the mean of the per-sample R<sub>f,i</sub> of the samples whose
|
||||
reference hue falls into the bin (NaN if none) - exactly what `tm30.py`
|
||||
reports, which is *not* colour's `R_fs` (R<sub>f</sub> of the mean ΔE).
|
||||
* `*_norm` divide by the norm of the bin's reference average (1.0 if that
|
||||
norm is exactly 0, as in `tm30.py`).
|
||||
|
||||
Deviations from the Python behaviour, on inputs the Python code does not
|
||||
handle meaningfully: length mismatch, non-finite values inside 380-780 nm,
|
||||
descending wavelengths and a spectrum with zero luminance return
|
||||
`Error::InvalidInput` (Python raises, silently produces garbage, or returns
|
||||
NaN respectively). Negative scale factors behave exactly as in Python.
|
||||
|
||||
## Accuracy
|
||||
|
||||
`tests/golden.rs` compares every output field of 52 cases generated by the
|
||||
Python reference (11 HPCS lamp measurements, FL1-FL12, A, D50, D65, LED-B1..5,
|
||||
LED-BH1, LED-RGB1, LED-V1/V2, HP1-HP5, blackbodies 2700/4300/4700 K, daylight
|
||||
6500/10000 K, 10 nm, HPCS 380-1050 nm, irregular and partial grids, a noisy
|
||||
lamp with negative values and a negated spectrum; all three reference regimes
|
||||
are covered). Max deviations (this machine):
|
||||
|
||||
| field | max abs | max rel |
|
||||
|---|---|---|
|
||||
| Rf | 8.1e-11 | 1.1e-12 |
|
||||
| Rg | 2.2e-11 | 1.9e-13 |
|
||||
| CCT_K | 1.9e-8 K | 4.5e-12 |
|
||||
| Duv | 1.2e-11 | 2.5e-10 |
|
||||
| ref_a / ref_b | 2.1e-11 | 4.7e-12 |
|
||||
| test_a / test_b | 2.7e-13 | 1.8e-13 |
|
||||
| *_norm | 1.0e-12 | 4.6e-12 |
|
||||
| Rcs_pct | 4.9e-11 | 4.0e-8 (values ~1e-3 %) |
|
||||
| Rhs | 1.1e-12 | 7.7e-8 (values ~1e-5) |
|
||||
| Rf_h | 1.4e-10 | 2.3e-12 |
|
||||
|
||||
The test requires, per value, relative error <= 1e-9 *or* absolute error
|
||||
<= 1e-11 (the latter only matters for quantities that are themselves close to
|
||||
zero: Duv of Planckian sources, tiny chroma/hue shifts). NaN must match NaN.
|
||||
|
||||
Bit-exact agreement is not possible: numpy computes the tristimulus
|
||||
integrals of 2-D batches (Planckian table, 99 samples) with OpenBLAS `dgemm`,
|
||||
whose blocked FMA kernel order depends on the CPU (`DYNAMIC_ARCH`), and uses
|
||||
SIMD `sin`/`cos`/`exp`. Single-spectrum integrals reproduce OpenBLAS'
|
||||
`dgemv` order and match bit for bit. The remaining ulp-level differences in
|
||||
the Planckian table are amplified ~1000x by Ohno's parabolic/triangular
|
||||
interpolation into the CCT (still ~1e-12 relative).
|
||||
|
||||
## Performance
|
||||
|
||||
`cargo run -p tm30 --release --example bench` (HPCS-like 350-point input):
|
||||
~20 ms for the first call (parsing the embedded tables and building the
|
||||
3438-row Planckian table, cached in `OnceLock`s), ~0.25 ms per call after
|
||||
that. Call `tm30::warm_up()` at start-up to move the first cost out of the
|
||||
measurement path.
|
||||
|
||||
## Data provenance and regeneration
|
||||
|
||||
`data/*.csv` are extracted from colour-science 0.4.7 (BSD-3-Clause,
|
||||
Copyright 2013 Colour Developers) by `tools/extract_data.py`; the underlying
|
||||
data are CIE publications:
|
||||
|
||||
* `cmf_cie1931_2deg_360_780.csv`, `cmf_cie1931_2deg_380_780.csv` - CIE 1931
|
||||
2° standard observer (CIE 015:2004), as colour aligns it to 360-780 nm and
|
||||
then 380-780 nm (both are needed, the realignment is not bit-exact).
|
||||
* `cmf_cie1964_10deg_380_780.csv` - CIE 1964 10° standard observer.
|
||||
* `d_series_basis_380_780.csv` - CIE D-series S0/S1/S2 (CIE 015:2004),
|
||||
Sprague-interpolated from 5 nm to 1 nm by colour.
|
||||
* `tcs_cie2017_380_780.csv` - the 99 CIE 2017 colour evaluation samples
|
||||
(CIE 224:2017; identical to the ANSI/IES TM-30-18 CES), 1 nm.
|
||||
* `ciecam02_matrices.csv` - CAT02 and `M_HPE · CAT02⁻¹` as numpy computes it.
|
||||
|
||||
Regenerate (needs a Python with colour-science 0.4.7 + numpy):
|
||||
|
||||
```sh
|
||||
python -W ignore crates/tm30/tools/extract_data.py
|
||||
python -W ignore crates/tm30/tools/golden.py --tm30-dir <dir with tm30.py> \
|
||||
--lamps-dir <buildfor_life_web>/apps/web/comparison-data/lamps
|
||||
```
|
||||
|
||||
`tools/golden.py` writes `tests/golden/*.json` (input wavelengths/values and
|
||||
all outputs of `compute_tm30`; NaN as `null`).
|
||||
Reference in new issue
Block a user