Add lamp comparison export with TM-30 computation
lamp_export.py writes the per-lamp bundle consumed by the buildfor.life comparison pages: spd.csv (full 380-1050 nm spectrum), tm30.csv (ANSI/IES TM-30-18 hue-bin data for the color vector graphic), and metrics.json (photometric, colorimetric, CRI R1-R15, TM-30 Rf/Rg, electrical, supply settings). - tm30.py computes TM-30-18 via colour-science from the measured spectrum, resampled to a uniform 1 nm grid; validated against CIE FL2 (Rf 70, Rg 86) - Optional built-in PSU control (--mode/--voltage/--frequency/--current), switched on for the measurement and off afterwards, with --settle warm-up and --interval between readings - Readings use the vendor single-shot cycle verified from USB captures: test config -> trigger (8C 0E 02) -> poll -> read -> reset per reading - Also supports --passive and --parse <pcap> sources
This commit is contained in:
@@ -16,5 +16,8 @@ uv.lock
|
|||||||
# Captures (binary data, not tracked)
|
# Captures (binary data, not tracked)
|
||||||
captures/
|
captures/
|
||||||
|
|
||||||
|
# Lamp export bundles (published to the website repo instead)
|
||||||
|
lamps/
|
||||||
|
|
||||||
# Claude Code
|
# Claude Code
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ vendor software for measurement automation and data extraction.
|
|||||||
- **Power supply**: Control the built-in AC (100-240V, 50/60Hz) and DC
|
- **Power supply**: Control the built-in AC (100-240V, 50/60Hz) and DC
|
||||||
(1-60V, 0-5A) power supply
|
(1-60V, 0-5A) power supply
|
||||||
- **Export**: CSV output for data logging
|
- **Export**: CSV output for data logging
|
||||||
|
- **TM-30**: ANSI/IES TM-30-18 Rf, Rg, and hue-bin data computed from the
|
||||||
|
measured spectrum (via colour-science)
|
||||||
|
- **Lamp bundles**: one-command export of spectrum + TM-30 + metrics for the
|
||||||
|
[buildfor.life lamp comparison](https://buildfor.life/comparisons)
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -58,6 +62,34 @@ uv run hpcs6500.py --psu-off
|
|||||||
uv run hpcs6500.py --integration 500
|
uv run hpcs6500.py --integration 500
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Lamp Comparison Export
|
||||||
|
|
||||||
|
Produces the per-lamp data bundle consumed by the buildfor.life comparison
|
||||||
|
pages: `spd.csv` (full 380-1050 nm spectrum), `tm30.csv` (TM-30-18 hue-bin
|
||||||
|
data for the color vector graphic), and `metrics.json` (photometric,
|
||||||
|
colorimetric, CRI R1-R15, TM-30 Rf/Rg, electrical).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Single reading from the device
|
||||||
|
uv run lamp_export.py --name philips-a60-8w --manufacturer Philips --model "A60 8W 927"
|
||||||
|
|
||||||
|
# Average several readings
|
||||||
|
uv run lamp_export.py --name philips-a60-8w --readings 5
|
||||||
|
|
||||||
|
# Power the lamp from the built-in supply: 230 V / 50 Hz, 60 s warm-up,
|
||||||
|
# PSU switches on before the readings and off afterwards
|
||||||
|
uv run lamp_export.py --name philips-a60-8w --voltage 230 --frequency 50 --settle 60
|
||||||
|
|
||||||
|
# From an existing pcap capture
|
||||||
|
uv run lamp_export.py --name some-lamp --parse captures/run.pcap
|
||||||
|
```
|
||||||
|
|
||||||
|
Output lands in `lamps/<name>/`. TM-30 is computed from the measured spectrum
|
||||||
|
with [colour-science](https://www.colour-science.org/); the spectrum is
|
||||||
|
relative, which TM-30 is invariant to. Sanity check of the implementation:
|
||||||
|
`uv run tm30.py` reproduces the published values for the CIE FL2 illuminant
|
||||||
|
(Rf 70, Rg 86).
|
||||||
|
|
||||||
## Offline Parsing
|
## Offline Parsing
|
||||||
|
|
||||||
Parse previously captured USB traffic (pcap files from USBPcap):
|
Parse previously captured USB traffic (pcap files from USBPcap):
|
||||||
@@ -72,6 +104,8 @@ uv run hpcs6500.py --parse captures/some_capture.pcap --quick
|
|||||||
| File | Description |
|
| File | Description |
|
||||||
|------------------|----------------------------------------------------|
|
|------------------|----------------------------------------------------|
|
||||||
| `hpcs6500.py` | Driver class (`HPCS6500`) and CLI |
|
| `hpcs6500.py` | Driver class (`HPCS6500`) and CLI |
|
||||||
|
| `lamp_export.py` | Lamp comparison bundle export (spd/tm30/metrics) |
|
||||||
|
| `tm30.py` | ANSI/IES TM-30-18 computation from a spectrum |
|
||||||
| `usb_capture.py` | USB traffic capture tool (requires USBPcap) |
|
| `usb_capture.py` | USB traffic capture tool (requires USBPcap) |
|
||||||
| `PROTOCOL.md` | Complete protocol reference (byte-level) |
|
| `PROTOCOL.md` | Complete protocol reference (byte-level) |
|
||||||
| `pyproject.toml` | Project metadata and dependencies |
|
| `pyproject.toml` | Project metadata and dependencies |
|
||||||
@@ -86,6 +120,7 @@ uv run hpcs6500.py --parse captures/some_capture.pcap --quick
|
|||||||
|
|
||||||
- Python 3.11+
|
- Python 3.11+
|
||||||
- `pyserial` (serial communication)
|
- `pyserial` (serial communication)
|
||||||
|
- `colour-science` (TM-30 computation)
|
||||||
- USBPcap (only for `usb_capture.py`, not needed for normal operation)
|
- USBPcap (only for `usb_capture.py`, not needed for normal operation)
|
||||||
|
|
||||||
## Protocol
|
## Protocol
|
||||||
|
|||||||
+268
@@ -0,0 +1,268 @@
|
|||||||
|
"""
|
||||||
|
Lamp comparison export — one measurement, one publishable data bundle.
|
||||||
|
|
||||||
|
Takes a reading from the HPCS 6500 (or an existing pcap capture) and writes
|
||||||
|
the per-lamp files consumed by the buildfor.life comparison pages:
|
||||||
|
|
||||||
|
<out>/<name>/spd.csv full spectrum, wavelength_nm,value (380-1050 nm)
|
||||||
|
<out>/<name>/tm30.csv TM-30-18 hue-bin data for the color vector graphic
|
||||||
|
<out>/<name>/metrics.json photometric / colorimetric / CRI / TM-30 /
|
||||||
|
electrical summary
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
uv run lamp_export.py --name philips-a60-8w
|
||||||
|
uv run lamp_export.py --name x --manufacturer Philips --model "A60 8W 927"
|
||||||
|
uv run lamp_export.py --name x --readings 5 # average 5 readings
|
||||||
|
uv run lamp_export.py --name x --voltage 230 --frequency 50 --settle 60
|
||||||
|
uv run lamp_export.py --name x --passive # vendor SW drives the instrument
|
||||||
|
uv run lamp_export.py --name x --parse captures/run.pcap
|
||||||
|
|
||||||
|
With --voltage/--frequency/--current/--mode (or --psu), the built-in supply is
|
||||||
|
configured, switched on for the measurement, and switched off afterwards.
|
||||||
|
--settle waits after power-on so the lamp stabilizes before the first reading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from hpcs6500 import HPCS6500, find_hpcs_port, parse_pcap_messages
|
||||||
|
|
||||||
|
SCALAR_GROUPS = {
|
||||||
|
"photometric": ["Phi_lm", "eta_lm_W", "CCT_K", "Duv", "SDCM", "TLCI"],
|
||||||
|
"chromaticity": ["x", "y", "u", "v", "u_prime", "v_prime", "CIE_X", "CIE_Y", "CIE_Z"],
|
||||||
|
"radiometric": [
|
||||||
|
"Phi_e_mW", "Phi_euv_mW", "Phi_eb_mW", "Phi_ey_mW",
|
||||||
|
"Phi_er_mW", "Phi_efr_mW", "Phi_eir_mW", "Phi_e_total",
|
||||||
|
],
|
||||||
|
"electrical": ["Voltage_V", "Current_A", "Power_W", "Freq_Hz", "PF", "UThd", "AThd"],
|
||||||
|
"sensor": ["PeakSignal", "DarkSignal", "Compensate"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def average_readings(readings):
|
||||||
|
"""Element-wise average of scalars and spectra across readings."""
|
||||||
|
result = dict(readings[0])
|
||||||
|
n = len(readings)
|
||||||
|
if n == 1:
|
||||||
|
return result
|
||||||
|
for key, value in readings[0].items():
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
result[key] = sum(r.get(key, 0.0) for r in readings) / n
|
||||||
|
elif key == "spectrum":
|
||||||
|
result[key] = [
|
||||||
|
sum(r["spectrum"][i] for r in readings) / n
|
||||||
|
for i in range(len(value))
|
||||||
|
]
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def readings_from_pcap(path):
|
||||||
|
"""Extract parsed readings from a pcap capture."""
|
||||||
|
messages = parse_pcap_messages(path)
|
||||||
|
blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 3904
|
||||||
|
and m["data"][:8] == b"HPCS6500"]
|
||||||
|
elec_blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 1584]
|
||||||
|
dev = HPCS6500.__new__(HPCS6500)
|
||||||
|
readings = []
|
||||||
|
for i, block in enumerate(blocks):
|
||||||
|
r = dev._parse_measurement(block["data"])
|
||||||
|
if i < len(elec_blocks):
|
||||||
|
r.update(dev._parse_electrical(elec_blocks[i]["data"]))
|
||||||
|
readings.append(r)
|
||||||
|
return readings
|
||||||
|
|
||||||
|
|
||||||
|
def readings_from_device(port, count, passive, psu):
|
||||||
|
dev = HPCS6500(port)
|
||||||
|
name = dev.identify()
|
||||||
|
if name:
|
||||||
|
print(f"Device: {name}")
|
||||||
|
readings = []
|
||||||
|
supply = None
|
||||||
|
try:
|
||||||
|
if psu["mode"]:
|
||||||
|
ok = dev.set_mode(psu["mode"])
|
||||||
|
print(f"Mode {psu['mode'].upper()}: {'OK' if ok else 'FAILED'}")
|
||||||
|
if psu["voltage"] is not None:
|
||||||
|
if psu["mode"] == "dc":
|
||||||
|
ok = dev.set_dc_voltage(psu["voltage"])
|
||||||
|
else:
|
||||||
|
ok = dev.set_ac_voltage(psu["voltage"])
|
||||||
|
print(f"Voltage {psu['voltage']:g} V: {'OK' if ok else 'FAILED'}")
|
||||||
|
if psu["frequency"] is not None:
|
||||||
|
ok = dev.set_ac_frequency(psu["frequency"])
|
||||||
|
print(f"Frequency {psu['frequency']:g} Hz: {'OK' if ok else 'FAILED'}")
|
||||||
|
if psu["current"] is not None:
|
||||||
|
ok = dev.set_dc_current(psu["current"])
|
||||||
|
print(f"DC current limit {psu['current']:g} A: {'OK' if ok else 'FAILED'}")
|
||||||
|
|
||||||
|
supply = dev.read_psu_settings()
|
||||||
|
|
||||||
|
if psu["enable"]:
|
||||||
|
if not dev.psu_on():
|
||||||
|
print("ERROR: failed to turn PSU on")
|
||||||
|
sys.exit(1)
|
||||||
|
print("PSU on")
|
||||||
|
if psu["settle"] > 0:
|
||||||
|
print(f"Settling {psu['settle']:g} s ...")
|
||||||
|
time.sleep(psu["settle"])
|
||||||
|
else:
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
for i in range(count):
|
||||||
|
print(f"Reading {i + 1}/{count} ...")
|
||||||
|
if passive:
|
||||||
|
r = dev.read_current()
|
||||||
|
else:
|
||||||
|
# Vendor single-shot cycle (verified from USB captures):
|
||||||
|
# test config -> trigger (8C 0E 02) -> poll -> read -> reset,
|
||||||
|
# repeated per reading. auto_psu stays off; we hold the PSU.
|
||||||
|
dev.send_test_config(auto_psu=False)
|
||||||
|
r = dev.take_single_reading()
|
||||||
|
if r is None:
|
||||||
|
print("ERROR: failed to get reading")
|
||||||
|
sys.exit(1)
|
||||||
|
readings.append(r)
|
||||||
|
if i < count - 1:
|
||||||
|
if psu["enable"] and not passive:
|
||||||
|
# take_single_reading() ends with an instrument reset;
|
||||||
|
# make sure the lamp stays powered for the next reading.
|
||||||
|
dev.psu_on()
|
||||||
|
if psu["interval"] > 0:
|
||||||
|
time.sleep(psu["interval"])
|
||||||
|
finally:
|
||||||
|
if psu["enable"]:
|
||||||
|
dev.psu_off()
|
||||||
|
print("PSU off")
|
||||||
|
dev.close()
|
||||||
|
return readings, supply
|
||||||
|
|
||||||
|
|
||||||
|
def write_bundle(reading, out_dir, meta, n_readings, supply=None):
|
||||||
|
# Deferred: importing colour-science takes a few seconds, so it happens
|
||||||
|
# after the readings rather than at startup.
|
||||||
|
from tm30 import compute_tm30
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
spectrum = reading.get("spectrum") or []
|
||||||
|
nm = reading.get("spectrum_nm") or []
|
||||||
|
if not spectrum or max(spectrum) <= 0:
|
||||||
|
print("ERROR: reading contains no spectrum data")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# spd.csv — full measured range; consumers trim to visible as needed.
|
||||||
|
with open(out_dir / "spd.csv", "w", newline="") as f:
|
||||||
|
w = csv.writer(f)
|
||||||
|
w.writerow(["wavelength_nm", "value"])
|
||||||
|
for wl, val in zip(nm, spectrum):
|
||||||
|
w.writerow([f"{wl:.2f}", f"{val:.6g}"])
|
||||||
|
|
||||||
|
# TM-30 from the same spectrum.
|
||||||
|
tm30 = compute_tm30(nm, spectrum)
|
||||||
|
bin_fields = list(tm30["bins"][0].keys())
|
||||||
|
with open(out_dir / "tm30.csv", "w", newline="") as f:
|
||||||
|
w = csv.DictWriter(f, fieldnames=bin_fields)
|
||||||
|
w.writeheader()
|
||||||
|
for b in tm30["bins"]:
|
||||||
|
w.writerow({k: (f"{v:.6g}" if isinstance(v, float) else v) for k, v in b.items()})
|
||||||
|
|
||||||
|
# metrics.json — grouped scalars plus identification.
|
||||||
|
metrics = {
|
||||||
|
"name": meta["name"],
|
||||||
|
"manufacturer": meta["manufacturer"],
|
||||||
|
"model": meta["model"],
|
||||||
|
"notes": meta["notes"],
|
||||||
|
"instrument": reading.get("device", "HPCS6500"),
|
||||||
|
"measured_at": datetime.now().astimezone().isoformat(timespec="seconds"),
|
||||||
|
"instrument_date": reading.get("test_date"),
|
||||||
|
"instrument_time": reading.get("test_time"),
|
||||||
|
"readings_averaged": n_readings,
|
||||||
|
"tm30": {"Rf": round(tm30["Rf"], 1), "Rg": round(tm30["Rg"], 1)},
|
||||||
|
"cri": {"Ra": reading.get("Ra")}
|
||||||
|
| {f"R{i}": reading.get(f"R{i}") for i in range(1, 16)},
|
||||||
|
}
|
||||||
|
if supply:
|
||||||
|
metrics["supply"] = supply
|
||||||
|
for group, keys in SCALAR_GROUPS.items():
|
||||||
|
metrics[group] = {k: reading[k] for k in keys if k in reading}
|
||||||
|
with open(out_dir / "metrics.json", "w") as f:
|
||||||
|
json.dump(metrics, f, indent=2)
|
||||||
|
|
||||||
|
return tm30
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Export a lamp measurement bundle")
|
||||||
|
parser.add_argument("--name", required=True,
|
||||||
|
help="Lamp slug, becomes the output directory name")
|
||||||
|
parser.add_argument("--manufacturer", default="")
|
||||||
|
parser.add_argument("--model", default="")
|
||||||
|
parser.add_argument("--notes", default="")
|
||||||
|
parser.add_argument("--out", default="lamps", help="Output base directory")
|
||||||
|
parser.add_argument("--port", help="COM port (auto-detect if omitted)")
|
||||||
|
parser.add_argument("--readings", type=int, default=1,
|
||||||
|
help="Number of readings to average (default 1)")
|
||||||
|
parser.add_argument("--passive", action="store_true",
|
||||||
|
help="Read without controlling the instrument")
|
||||||
|
parser.add_argument("--parse", metavar="PCAP",
|
||||||
|
help="Export from a pcap capture instead of the device")
|
||||||
|
|
||||||
|
psu_group = parser.add_argument_group("power supply")
|
||||||
|
psu_group.add_argument("--mode", choices=["ac", "dc"],
|
||||||
|
help="Supply mode (default ac when --voltage is given)")
|
||||||
|
psu_group.add_argument("--voltage", type=float, help="Supply voltage (V)")
|
||||||
|
psu_group.add_argument("--frequency", type=float, help="AC frequency (Hz)")
|
||||||
|
psu_group.add_argument("--current", type=float, help="DC current limit (A)")
|
||||||
|
psu_group.add_argument("--psu", action="store_true",
|
||||||
|
help="Power the lamp from the built-in supply "
|
||||||
|
"(implied by --mode/--voltage/--frequency/--current)")
|
||||||
|
psu_group.add_argument("--settle", type=float, default=0,
|
||||||
|
help="Seconds to wait after PSU on before the first reading")
|
||||||
|
parser.add_argument("--interval", type=float, default=1.0,
|
||||||
|
help="Seconds to wait between readings (default 1)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
psu = {
|
||||||
|
"mode": args.mode,
|
||||||
|
"voltage": args.voltage,
|
||||||
|
"frequency": args.frequency,
|
||||||
|
"current": args.current,
|
||||||
|
"settle": args.settle,
|
||||||
|
"interval": args.interval,
|
||||||
|
"enable": args.psu or args.mode is not None or args.voltage is not None
|
||||||
|
or args.frequency is not None or args.current is not None,
|
||||||
|
}
|
||||||
|
|
||||||
|
supply = None
|
||||||
|
if args.parse:
|
||||||
|
readings = readings_from_pcap(args.parse)
|
||||||
|
if not readings:
|
||||||
|
print(f"ERROR: no measurement blocks in {args.parse}")
|
||||||
|
sys.exit(1)
|
||||||
|
print(f"Using {len(readings)} reading(s) from capture")
|
||||||
|
else:
|
||||||
|
port = args.port or find_hpcs_port()
|
||||||
|
if not port:
|
||||||
|
print("ERROR: HPCS 6500 not found. Connect the device or specify --port.")
|
||||||
|
sys.exit(1)
|
||||||
|
readings, supply = readings_from_device(port, args.readings, args.passive, psu)
|
||||||
|
|
||||||
|
reading = average_readings(readings)
|
||||||
|
out_dir = Path(args.out) / args.name
|
||||||
|
meta = {k: getattr(args, k) for k in ("name", "manufacturer", "model", "notes")}
|
||||||
|
tm30 = write_bundle(reading, out_dir, meta, len(readings), supply)
|
||||||
|
|
||||||
|
print(f"\nWrote {out_dir}/spd.csv, tm30.csv, metrics.json")
|
||||||
|
print(f" {reading.get('Phi_lm', 0):.0f} lm {reading.get('eta_lm_W', 0):.1f} lm/W "
|
||||||
|
f"{reading.get('CCT_K', 0):.0f} K Ra {reading.get('Ra', 0):.1f}")
|
||||||
|
print(f" TM-30: Rf {tm30['Rf']:.1f} Rg {tm30['Rg']:.1f}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -6,4 +6,5 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"pyserial>=3.5",
|
"pyserial>=3.5",
|
||||||
|
"colour-science>=0.4.4",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"""
|
||||||
|
ANSI/IES TM-30-18 computation from an HPCS 6500 spectrum.
|
||||||
|
|
||||||
|
Wraps colour-science to derive the fidelity index Rf, gamut index Rg, and the
|
||||||
|
per-hue-bin data needed to draw a TM-30 color vector graphic (CVG): the test
|
||||||
|
and reference chromaticity averages in CAM02-UCS a'b' (raw and normalized so
|
||||||
|
the reference gamut is the unit circle), plus per-bin chroma shift, hue shift,
|
||||||
|
and local fidelity.
|
||||||
|
|
||||||
|
The instrument spectrum is relative; TM-30 is scale-invariant, so no absolute
|
||||||
|
calibration is required. Only the 380-780 nm range is used.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
try:
|
||||||
|
# colour warns at import time about optional scipy/matplotlib features;
|
||||||
|
# the TM-30 path used here needs neither.
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
import colour
|
||||||
|
from colour.quality import colour_fidelity_index_ANSIIESTM3018
|
||||||
|
except ImportError as e: # pragma: no cover
|
||||||
|
raise ImportError(
|
||||||
|
"TM-30 computation requires colour-science; run `uv sync`"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
VISIBLE_MIN_NM = 380
|
||||||
|
VISIBLE_MAX_NM = 780
|
||||||
|
N_BINS = 16
|
||||||
|
|
||||||
|
|
||||||
|
def compute_tm30(wavelengths_nm, values):
|
||||||
|
"""Compute TM-30-18 quantities from a measured spectrum.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
wavelengths_nm: sequence of wavelengths in nm (any step; 380-1050
|
||||||
|
from the HPCS 6500 is fine, the IR tail is discarded).
|
||||||
|
values: spectral power at each wavelength (relative units).
|
||||||
|
|
||||||
|
Returns dict:
|
||||||
|
Rf, Rg: floats
|
||||||
|
CCT_K, Duv: floats (as derived by the TM-30 reference selection)
|
||||||
|
bins: list of 16 dicts, hue bin 1..16, each with
|
||||||
|
ref_a, ref_b, test_a, test_b raw CAM02-UCS bin averages
|
||||||
|
ref_a_norm ... test_b_norm normalized (reference = unit circle)
|
||||||
|
Rcs_pct chroma shift, percent
|
||||||
|
Rhs hue shift (rad, CAM02-UCS)
|
||||||
|
Rf_h local fidelity for the bin
|
||||||
|
"""
|
||||||
|
wl = np.asarray(wavelengths_nm, dtype=float)
|
||||||
|
vals = np.asarray(values, dtype=float)
|
||||||
|
mask = (wl >= VISIBLE_MIN_NM) & (wl <= VISIBLE_MAX_NM)
|
||||||
|
if mask.sum() < 10:
|
||||||
|
raise ValueError("spectrum does not cover the visible range")
|
||||||
|
|
||||||
|
# Resample onto a uniform 1 nm grid: the instrument's ~1.92 nm grid is
|
||||||
|
# non-uniform in colour's eyes and triggers interpolator paths that need
|
||||||
|
# scipy; a uniform grid keeps the pipeline on plain numpy.
|
||||||
|
grid = np.arange(VISIBLE_MIN_NM, VISIBLE_MAX_NM + 1, 1, dtype=float)
|
||||||
|
resampled = np.interp(grid, wl[mask], vals[mask])
|
||||||
|
|
||||||
|
sd = colour.SpectralDistribution(dict(zip(grid, resampled)))
|
||||||
|
# colour emits informational "aligning shape" runtime warnings while it
|
||||||
|
# adapts observers/illuminants to our 1 nm grid; not actionable.
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("ignore")
|
||||||
|
spec = colour_fidelity_index_ANSIIESTM3018(sd, additional_data=True)
|
||||||
|
|
||||||
|
averages_test = np.asarray(spec.averages_test)
|
||||||
|
averages_reference = np.asarray(spec.averages_reference)
|
||||||
|
average_norms = np.asarray(spec.average_norms)
|
||||||
|
R_cs = np.asarray(spec.R_cs)
|
||||||
|
R_hs = np.asarray(spec.R_hs)
|
||||||
|
|
||||||
|
# Local fidelity per hue bin: mean of the per-CES R_f over the samples
|
||||||
|
# assigned to the bin (spec.bins holds the bin index of each CES).
|
||||||
|
sample_bins = np.asarray(spec.bins)
|
||||||
|
R_s = np.asarray(spec.R_s)
|
||||||
|
Rf_h = np.full(N_BINS, np.nan)
|
||||||
|
for j in range(N_BINS):
|
||||||
|
members = R_s[sample_bins == j]
|
||||||
|
if members.size:
|
||||||
|
Rf_h[j] = members.mean()
|
||||||
|
|
||||||
|
bins = []
|
||||||
|
for j in range(N_BINS):
|
||||||
|
norm = average_norms[j] if average_norms[j] else 1.0
|
||||||
|
bins.append(
|
||||||
|
{
|
||||||
|
"bin": j + 1,
|
||||||
|
"ref_a": float(averages_reference[j, 0]),
|
||||||
|
"ref_b": float(averages_reference[j, 1]),
|
||||||
|
"test_a": float(averages_test[j, 0]),
|
||||||
|
"test_b": float(averages_test[j, 1]),
|
||||||
|
"ref_a_norm": float(averages_reference[j, 0] / norm),
|
||||||
|
"ref_b_norm": float(averages_reference[j, 1] / norm),
|
||||||
|
"test_a_norm": float(averages_test[j, 0] / norm),
|
||||||
|
"test_b_norm": float(averages_test[j, 1] / norm),
|
||||||
|
"Rcs_pct": float(R_cs[j]),
|
||||||
|
"Rhs": float(R_hs[j]),
|
||||||
|
"Rf_h": float(Rf_h[j]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Rf": float(spec.R_f),
|
||||||
|
"Rg": float(spec.R_g),
|
||||||
|
"CCT_K": float(spec.CCT),
|
||||||
|
"Duv": float(spec.D_uv),
|
||||||
|
"bins": bins,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Sanity check against a known illuminant.
|
||||||
|
sd = colour.SDS_ILLUMINANTS["FL2"]
|
||||||
|
wl = sd.wavelengths
|
||||||
|
result = compute_tm30(wl, sd.values)
|
||||||
|
print(f"FL2: Rf={result['Rf']:.1f} Rg={result['Rg']:.1f} "
|
||||||
|
f"CCT={result['CCT_K']:.0f} K Duv={result['Duv']:.4f}")
|
||||||
|
for b in result["bins"][:4]:
|
||||||
|
print(b)
|
||||||
Reference in New Issue
Block a user