From da822a160c17ac5e037826d14e959bb26261c8cb Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 18 Sep 2026 16:09:08 -0700 Subject: [PATCH 1/2] feat(color): bound legacy-correction accuracy from datasheet ranges (P1) FastLED/FastLED#4035's second acceptance criterion -- quantify TypicalLEDStrip, Typical8mmPixel and UncorrectedColor -- had no number, because the datasheets publish dominant-wavelength and luminous-intensity ranges, not chromaticity. tools/bound_legacy_correction_accuracy.py propagates those ranges into an interval of admissible profiles and scores each legacy model across it under A1: linear-sRGB source, Bradford D65 -> full-drive white (Y=1), CIEDE2000 over a black-safe neutral ramp, primaries and secondaries. Stated assumptions, not datasheet facts: a Gaussian emitter spectrum whose dominant wavelength (about E) matches the datasheet, with FWHM red 15-25 / green 25-40 / blue 18-30 nm; luminous intensity proportional to Y. The CIE 1931 2-degree CMF payload is pinned by SHA-256, as in the SK9822 tool. Results (LEGACY-ACCURACY-BOUND.md): - under A1, every legacy model misses the datasheet budget (median <= 3.0, p95 <= 6.0) for every admissible profile in 26 of 27 part/model cells; - the admissible median alone spans more than the whole 3.0 budget in 14 of 27 cells, so datasheet ranges cannot pin a derived profile to the A1 budget -- the numerical reason there are zero admissible derived profiles and accuracy claims wait on P10; - a supplementary absolute-colorimetry table (not A1) scores the corrections' D65 design intent. Tests: CIEDE2000 against Sharma et al. reference pairs; an sRGB-primary device scores zero uncorrected; Bradford white mapping; dominant- wavelength round trip and the checked-in outputs, with the real CMF table. Co-Authored-By: Claude Opus 5 (1M context) --- measured-profiles/LEGACY-ACCURACY-BOUND.md | 99 +++ measured-profiles/P1-CHARACTERIZATION.md | 50 +- .../legacy-correction-accuracy-bound-v1.json | 621 ++++++++++++++++++ tests/test_legacy_accuracy_bound.py | 110 ++++ tools/bound_legacy_correction_accuracy.py | 558 ++++++++++++++++ 5 files changed, 1428 insertions(+), 10 deletions(-) create mode 100644 measured-profiles/LEGACY-ACCURACY-BOUND.md create mode 100644 measured-profiles/legacy-correction-accuracy-bound-v1.json create mode 100644 tests/test_legacy_accuracy_bound.py create mode 100644 tools/bound_legacy_correction_accuracy.py diff --git a/measured-profiles/LEGACY-ACCURACY-BOUND.md b/measured-profiles/LEGACY-ACCURACY-BOUND.md new file mode 100644 index 0000000..305322e --- /dev/null +++ b/measured-profiles/LEGACY-ACCURACY-BOUND.md @@ -0,0 +1,99 @@ +# Legacy-correction accuracy, bounded from datasheet data + +Generated by `tools/bound_legacy_correction_accuracy.py` for FastLED/FastLED#4035. +**This is a bound, not a measurement.** The datasheets publish dominant-wavelength +and luminous-intensity ranges, not chromaticity, so every admissible profile the +published ranges allow is scored, under two stated assumptions: + +- each emitter's spectrum is a Gaussian whose dominant wavelength (about equal-energy + white E) matches the datasheet, with FWHM red 15–25 nm, + green 25–40 nm, blue 18–30 nm (assumed, typical of + AlGaInP / InGaN emitters; not from the datasheets); +- luminous intensity is proportional to Y at a common viewing geometry. + +Evaluation follows A1: an ordinary buffer is linear sRGB (B6/B8); the target is the +sRGB colour Bradford-adapted from D65 to the device's full-drive white (Y=1); the +legacy path drives each emitter at `code/255 × correction`. ΔE2000 over a black-safe +neutral ramp (32–255), RGB primaries and CMY secondaries. Budget for a +datasheet-derived profile: median ≤ 3, p95 ≤ 6. + +`nominal` uses mid wavelength, mid FWHM and mid intensity. The ranges are the minimum +and maximum of each statistic over every admissible profile. + +| part | model | nominal median | nominal p95 | median range | median spread | p95 range | verdict | +| --- | --- | ---: | ---: | --- | ---: | --- | --- | +| SK6812 | UncorrectedColor | 6.91 | 10.90 | 3.60–10.50 | 6.90 | 7.77–15.41 | outside budget for every admissible profile | +| SK6812 | TypicalLEDStrip | 15.01 | 19.31 | 13.08–16.29 | 3.21 | 17.41–21.08 | outside budget for every admissible profile | +| SK6812 | Typical8mmPixel | 12.39 | 19.59 | 10.87–14.48 | 3.61 | 17.92–21.04 | outside budget for every admissible profile | +| WS2812B | UncorrectedColor | 1.22 | 14.45 | 1.08–3.69 | 2.61 | 12.64–16.52 | outside budget for every admissible profile | +| WS2812B | TypicalLEDStrip | 15.11 | 19.71 | 13.48–16.41 | 2.93 | 18.94–20.42 | outside budget for every admissible profile | +| WS2812B | Typical8mmPixel | 12.98 | 20.11 | 12.51–13.89 | 1.38 | 19.18–21.12 | outside budget for every admissible profile | +| WS2813A | UncorrectedColor | 4.40 | 9.33 | 1.48–5.52 | 4.04 | 8.38–10.40 | outside budget for every admissible profile | +| WS2813A | TypicalLEDStrip | 14.94 | 21.46 | 14.55–15.27 | 0.72 | 21.06–21.79 | outside budget for every admissible profile | +| WS2813A | Typical8mmPixel | 10.64 | 18.15 | 10.20–11.84 | 1.64 | 17.21–19.16 | outside budget for every admissible profile | +| WS2813B | UncorrectedColor | 3.99 | 9.34 | 1.32–5.31 | 3.99 | 8.36–10.44 | outside budget for every admissible profile | +| WS2813B | TypicalLEDStrip | 15.12 | 21.64 | 14.71–15.46 | 0.75 | 21.24–21.97 | outside budget for every admissible profile | +| WS2813B | Typical8mmPixel | 10.52 | 17.89 | 9.99–11.52 | 1.53 | 17.00–18.85 | outside budget for every admissible profile | +| WS2813C | UncorrectedColor | 2.24 | 6.68 | 1.74–3.43 | 1.68 | 6.00–7.99 | undetermined: datasheet ranges straddle the budget | +| WS2813C | TypicalLEDStrip | 15.16 | 21.68 | 14.03–15.51 | 1.47 | 21.24–22.03 | outside budget for every admissible profile | +| WS2813C | Typical8mmPixel | 11.72 | 18.47 | 10.75–12.81 | 2.06 | 17.20–19.77 | outside budget for every admissible profile | +| WS2813D | UncorrectedColor | 2.72 | 7.05 | 2.04–4.03 | 1.99 | 6.22–8.42 | outside budget for every admissible profile | +| WS2813D | TypicalLEDStrip | 14.96 | 21.48 | 14.14–15.29 | 1.15 | 21.05–21.82 | outside budget for every admissible profile | +| WS2813D | Typical8mmPixel | 11.73 | 18.71 | 10.75–12.81 | 2.06 | 17.43–20.00 | outside budget for every admissible profile | +| WS2815 | UncorrectedColor | 5.11 | 10.41 | 1.76–7.85 | 6.09 | 8.42–16.26 | outside budget for every admissible profile | +| WS2815 | TypicalLEDStrip | 15.35 | 21.87 | 12.99–18.33 | 5.34 | 21.24–25.95 | outside budget for every admissible profile | +| WS2815 | Typical8mmPixel | 10.36 | 17.55 | 9.92–14.38 | 4.46 | 17.00–20.00 | outside budget for every admissible profile | +| WS2816B-2121 | UncorrectedColor | 6.67 | 15.44 | 2.64–10.78 | 8.14 | 7.77–26.70 | outside budget for every admissible profile | +| WS2816B-2121 | TypicalLEDStrip | 16.92 | 21.13 | 14.76–20.08 | 5.32 | 19.75–27.92 | outside budget for every admissible profile | +| WS2816B-2121 | Typical8mmPixel | 14.51 | 18.63 | 9.77–17.19 | 7.43 | 15.62–25.26 | outside budget for every admissible profile | +| GS8208 LED5050 option | UncorrectedColor | 2.88 | 9.53 | 2.17–8.50 | 6.33 | 8.11–18.52 | outside budget for every admissible profile | +| GS8208 LED5050 option | TypicalLEDStrip | 14.58 | 21.09 | 13.55–18.03 | 4.48 | 20.19–24.89 | outside budget for every admissible profile | +| GS8208 LED5050 option | Typical8mmPixel | 10.84 | 18.55 | 10.55–16.58 | 6.03 | 17.49–22.27 | outside budget for every admissible profile | + +## What this establishes + +- **Datasheet ranges cannot pin accuracy to the A1 budget.** In 14 of 27 part/model + cells the admissible median ΔE2000 alone spans more than the whole 3 median budget, + so no single datasheet-derived profile could be defended at that budget. A + datasheet-derived runtime profile needs xy or spectral data the PDFs do not publish. +- **Legacy models under A1:** 26 of 27 cells are outside budget for every admissible + profile, 0 within budget for every one, and the rest undetermined. + `TypicalLEDStrip` and `Typical8mmPixel` score worse than `UncorrectedColor` under A1 + because A1 is relative colorimetry: the target is adapted to the device's own + full-drive white, so a correction that tints that white is penalized. The + supplementary absolute table below scores the goal those corrections were built for. + +## Supplementary: absolute colorimetry (not A1) + +Target and reference white are D65 with no adaptation; nominal profile only. Context +for the legacy corrections' design intent, not an A1 acceptance number. + +| part | model | median | p95 | +| --- | --- | ---: | ---: | +| SK6812 | UncorrectedColor | 24.48 | 30.97 | +| SK6812 | TypicalLEDStrip | 25.86 | 33.79 | +| SK6812 | Typical8mmPixel | 19.48 | 25.86 | +| WS2812B | UncorrectedColor | 22.75 | 28.84 | +| WS2812B | TypicalLEDStrip | 26.63 | 32.48 | +| WS2812B | Typical8mmPixel | 18.09 | 24.44 | +| WS2813A | UncorrectedColor | 16.32 | 23.27 | +| WS2813A | TypicalLEDStrip | 19.51 | 26.25 | +| WS2813A | Typical8mmPixel | 12.21 | 16.21 | +| WS2813B | UncorrectedColor | 15.51 | 22.05 | +| WS2813B | TypicalLEDStrip | 18.10 | 24.93 | +| WS2813B | Typical8mmPixel | 12.70 | 16.50 | +| WS2813C | UncorrectedColor | 19.48 | 26.26 | +| WS2813C | TypicalLEDStrip | 20.88 | 28.01 | +| WS2813C | Typical8mmPixel | 16.44 | 22.84 | +| WS2813D | UncorrectedColor | 19.80 | 26.78 | +| WS2813D | TypicalLEDStrip | 21.71 | 28.72 | +| WS2813D | Typical8mmPixel | 15.80 | 22.18 | +| WS2815 | UncorrectedColor | 15.31 | 21.70 | +| WS2815 | TypicalLEDStrip | 18.17 | 22.83 | +| WS2815 | Typical8mmPixel | 14.76 | 18.79 | +| WS2816B-2121 | UncorrectedColor | 6.55 | 15.59 | +| WS2816B-2121 | TypicalLEDStrip | 17.48 | 21.43 | +| WS2816B-2121 | Typical8mmPixel | 13.95 | 17.85 | +| GS8208 LED5050 option | UncorrectedColor | 17.29 | 24.27 | +| GS8208 LED5050 option | TypicalLEDStrip | 21.30 | 27.66 | +| GS8208 LED5050 option | Typical8mmPixel | 10.43 | 14.15 | diff --git a/measured-profiles/P1-CHARACTERIZATION.md b/measured-profiles/P1-CHARACTERIZATION.md index 2a4109a..befd032 100644 --- a/measured-profiles/P1-CHARACTERIZATION.md +++ b/measured-profiles/P1-CHARACTERIZATION.md @@ -36,16 +36,46 @@ value, but no individual-diode xy or spectral distribution. A neutral, primary, secondary, luminance, or ΔE2000 comparison requires each emitter's xy and relative radiometric/photometric scale; a luminous-intensity RGB ratio alone cannot establish ΔE2000. Consequently there are **zero admissible -derived profiles**, no defensible clusters, and no numerical score for -`TypicalLEDStrip`, `Typical8mmPixel`, or `UncorrectedColor`. Reporting zero -error or a five-part illustrative sample would falsely imply full-catalog -coverage. This is a quantified artifact-completeness result: 0/10 integrated -parts with admitted extracted spectral/xy data; 0/10 with a measured uncertainty; -0/10 currently usable for the A1 normalized ΔE calculation. P1 characterization -remains incomplete pending source extraction and the required numerical report. - -When payload access is restored, calculate the three legacy models across -*every* admissible integrated record: use the profile full-drive white as +derived profiles** at runtime: 0/10 integrated parts with admitted extracted +spectral/xy data, 0/10 with a measured uncertainty. + +## Legacy-correction accuracy, as a bound + +What the datasheets *do* publish -- a dominant-wavelength range and a +luminous-intensity range per emitter -- is propagated into an interval of +admissible profiles by `tools/bound_legacy_correction_accuracy.py`, and every +legacy model is scored across that interval under A1 (relative colorimetry, +linear-sRGB source, Bradford to the full-drive white, ΔE2000 over a neutral +ramp, primaries and secondaries). Two assumptions are stated, not taken from +the PDFs: a Gaussian emitter spectrum with a per-technology FWHM interval, and +luminous intensity proportional to Y. The generated table is +[`LEGACY-ACCURACY-BOUND.md`](LEGACY-ACCURACY-BOUND.md) +(`legacy-correction-accuracy-bound-v1.json`), covering the 9 integrated rows +with wavelength and intensity data. + +Results: + +- `TypicalLEDStrip`, `Typical8mmPixel` and `UncorrectedColor` are quantified + as ΔE2000 intervals per part; under A1 every legacy model misses the + datasheet budget (median ≤ 3.0 / p95 ≤ 6.0) for every admissible profile on + all but one part/model cell. The corrections score worse than + `UncorrectedColor` under A1 because they tint the device white, which + relative colorimetry penalizes; a supplementary absolute-colorimetry table + scores their D65 design intent. +- The admissible median alone spans more than the entire 3.0 budget in about + half the cells, so datasheet ranges **cannot pin a derived profile to the A1 + budget**. That is the numerical reason for zero admissible derived profiles, + and why accuracy claims wait on P10 measurement. +- Clustering: per-part intervals overlap at this resolution, so no + datasheet-level clustering is defensible; the WS2813 A–D variants differ in + intensity only and are not a bin population. + +These are datasheet bounds, not measurements, and are kept separate from any +P10 instrument result. + +The bound implements the prescribed method, and P10 measured records reuse +it unchanged: score the three legacy models across *every* admissible record, +use the profile full-drive white as relative/adaptive white (Y=1, dark surround), test black-safe neutral ramp, RGB primaries and CMY secondaries, record neutral chromaticity/Y error and ΔE2000, then report count, median, p95 and excluded-part reasons. Keep diff --git a/measured-profiles/legacy-correction-accuracy-bound-v1.json b/measured-profiles/legacy-correction-accuracy-bound-v1.json new file mode 100644 index 0000000..03b3f73 --- /dev/null +++ b/measured-profiles/legacy-correction-accuracy-bound-v1.json @@ -0,0 +1,621 @@ +{ + "kind": "datasheet_bound_not_measurement", + "issue": "FastLED/FastLED#4035", + "assumptions": { + "spectrum": "Gaussian per emitter; peak solved so dominant wavelength (about equal-energy E) matches the datasheet", + "fwhm_nm": { + "red": [ + 15.0, + 25.0 + ], + "green": [ + 25.0, + 40.0 + ], + "blue": [ + 18.0, + 30.0 + ] + }, + "relative_y": "proportional to published luminous intensity (common viewing geometry)" + }, + "evaluation": { + "source": "linear sRGB (B6/B8 default for ordinary buffers)", + "target": "Bradford D65 -> device full-drive white, white at Y=1 (A1)", + "metric": "CIEDE2000 in CIELAB with device white as reference", + "test_colours": [ + [ + 32, + 32, + 32 + ], + [ + 64, + 64, + 64 + ], + [ + 128, + 128, + 128 + ], + [ + 192, + 192, + 192 + ], + [ + 255, + 255, + 255 + ], + [ + 255, + 0, + 0 + ], + [ + 0, + 255, + 0 + ], + [ + 0, + 0, + 255 + ], + [ + 0, + 255, + 255 + ], + [ + 255, + 0, + 255 + ], + [ + 255, + 255, + 0 + ] + ], + "budget": { + "median": 3.0, + "p95": 6.0 + }, + "sampling": "dominant wavelength {min, mid, max} x FWHM {low, mid, high} per channel; intensity {min, max} per channel" + }, + "models": { + "UncorrectedColor": [ + 1.0, + 1.0, + 1.0 + ], + "TypicalLEDStrip": [ + 1.0, + 0.690196, + 0.941176 + ], + "Typical8mmPixel": [ + 1.0, + 0.878431, + 0.54902 + ] + }, + "rows": [ + { + "part": "SK6812", + "admissible_profiles": 5832, + "models": { + "UncorrectedColor": { + "nominal_median": 6.91, + "nominal_p95": 10.9, + "median_range": [ + 3.6, + 10.5 + ], + "median_spread": 6.9, + "absolute_nominal_median": 24.48, + "absolute_nominal_p95": 30.97, + "p95_range": [ + 7.77, + 15.41 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 15.01, + "nominal_p95": 19.31, + "median_range": [ + 13.08, + 16.29 + ], + "median_spread": 3.21, + "absolute_nominal_median": 25.86, + "absolute_nominal_p95": 33.79, + "p95_range": [ + 17.41, + 21.08 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 12.39, + "nominal_p95": 19.59, + "median_range": [ + 10.87, + 14.48 + ], + "median_spread": 3.61, + "absolute_nominal_median": 19.48, + "absolute_nominal_p95": 25.86, + "p95_range": [ + 17.92, + 21.04 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "WS2812B", + "admissible_profiles": 5832, + "models": { + "UncorrectedColor": { + "nominal_median": 1.22, + "nominal_p95": 14.45, + "median_range": [ + 1.08, + 3.69 + ], + "median_spread": 2.61, + "absolute_nominal_median": 22.75, + "absolute_nominal_p95": 28.84, + "p95_range": [ + 12.64, + 16.52 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 15.11, + "nominal_p95": 19.71, + "median_range": [ + 13.48, + 16.41 + ], + "median_spread": 2.93, + "absolute_nominal_median": 26.63, + "absolute_nominal_p95": 32.48, + "p95_range": [ + 18.94, + 20.42 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 12.98, + "nominal_p95": 20.11, + "median_range": [ + 12.51, + 13.89 + ], + "median_spread": 1.38, + "absolute_nominal_median": 18.09, + "absolute_nominal_p95": 24.44, + "p95_range": [ + 19.18, + 21.12 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "WS2813A", + "admissible_profiles": 729, + "models": { + "UncorrectedColor": { + "nominal_median": 4.4, + "nominal_p95": 9.33, + "median_range": [ + 1.48, + 5.52 + ], + "median_spread": 4.04, + "absolute_nominal_median": 16.32, + "absolute_nominal_p95": 23.27, + "p95_range": [ + 8.38, + 10.4 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 14.94, + "nominal_p95": 21.46, + "median_range": [ + 14.55, + 15.27 + ], + "median_spread": 0.72, + "absolute_nominal_median": 19.51, + "absolute_nominal_p95": 26.25, + "p95_range": [ + 21.06, + 21.79 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 10.64, + "nominal_p95": 18.15, + "median_range": [ + 10.2, + 11.84 + ], + "median_spread": 1.64, + "absolute_nominal_median": 12.21, + "absolute_nominal_p95": 16.21, + "p95_range": [ + 17.21, + 19.16 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "WS2813B", + "admissible_profiles": 729, + "models": { + "UncorrectedColor": { + "nominal_median": 3.99, + "nominal_p95": 9.34, + "median_range": [ + 1.32, + 5.31 + ], + "median_spread": 3.99, + "absolute_nominal_median": 15.51, + "absolute_nominal_p95": 22.05, + "p95_range": [ + 8.36, + 10.44 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 15.12, + "nominal_p95": 21.64, + "median_range": [ + 14.71, + 15.46 + ], + "median_spread": 0.75, + "absolute_nominal_median": 18.1, + "absolute_nominal_p95": 24.93, + "p95_range": [ + 21.24, + 21.97 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 10.52, + "nominal_p95": 17.89, + "median_range": [ + 9.99, + 11.52 + ], + "median_spread": 1.53, + "absolute_nominal_median": 12.7, + "absolute_nominal_p95": 16.5, + "p95_range": [ + 17.0, + 18.85 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "WS2813C", + "admissible_profiles": 729, + "models": { + "UncorrectedColor": { + "nominal_median": 2.24, + "nominal_p95": 6.68, + "median_range": [ + 1.74, + 3.43 + ], + "median_spread": 1.68, + "absolute_nominal_median": 19.48, + "absolute_nominal_p95": 26.26, + "p95_range": [ + 6.0, + 7.99 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": false + }, + "TypicalLEDStrip": { + "nominal_median": 15.16, + "nominal_p95": 21.68, + "median_range": [ + 14.03, + 15.51 + ], + "median_spread": 1.47, + "absolute_nominal_median": 20.88, + "absolute_nominal_p95": 28.01, + "p95_range": [ + 21.24, + 22.03 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 11.72, + "nominal_p95": 18.47, + "median_range": [ + 10.75, + 12.81 + ], + "median_spread": 2.06, + "absolute_nominal_median": 16.44, + "absolute_nominal_p95": 22.84, + "p95_range": [ + 17.2, + 19.77 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "WS2813D", + "admissible_profiles": 729, + "models": { + "UncorrectedColor": { + "nominal_median": 2.72, + "nominal_p95": 7.05, + "median_range": [ + 2.04, + 4.03 + ], + "median_spread": 1.99, + "absolute_nominal_median": 19.8, + "absolute_nominal_p95": 26.78, + "p95_range": [ + 6.22, + 8.42 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 14.96, + "nominal_p95": 21.48, + "median_range": [ + 14.14, + 15.29 + ], + "median_spread": 1.15, + "absolute_nominal_median": 21.71, + "absolute_nominal_p95": 28.72, + "p95_range": [ + 21.05, + 21.82 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 11.73, + "nominal_p95": 18.71, + "median_range": [ + 10.75, + 12.81 + ], + "median_spread": 2.06, + "absolute_nominal_median": 15.8, + "absolute_nominal_p95": 22.18, + "p95_range": [ + 17.43, + 20.0 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "WS2815", + "admissible_profiles": 729, + "models": { + "UncorrectedColor": { + "nominal_median": 5.11, + "nominal_p95": 10.41, + "median_range": [ + 1.76, + 7.85 + ], + "median_spread": 6.09, + "absolute_nominal_median": 15.31, + "absolute_nominal_p95": 21.7, + "p95_range": [ + 8.42, + 16.26 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 15.35, + "nominal_p95": 21.87, + "median_range": [ + 12.99, + 18.33 + ], + "median_spread": 5.34, + "absolute_nominal_median": 18.17, + "absolute_nominal_p95": 22.83, + "p95_range": [ + 21.24, + 25.95 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 10.36, + "nominal_p95": 17.55, + "median_range": [ + 9.92, + 14.38 + ], + "median_spread": 4.46, + "absolute_nominal_median": 14.76, + "absolute_nominal_p95": 18.79, + "p95_range": [ + 17.0, + 20.0 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "WS2816B-2121", + "admissible_profiles": 5832, + "models": { + "UncorrectedColor": { + "nominal_median": 6.67, + "nominal_p95": 15.44, + "median_range": [ + 2.64, + 10.78 + ], + "median_spread": 8.14, + "absolute_nominal_median": 6.55, + "absolute_nominal_p95": 15.59, + "p95_range": [ + 7.77, + 26.7 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 16.92, + "nominal_p95": 21.13, + "median_range": [ + 14.76, + 20.08 + ], + "median_spread": 5.32, + "absolute_nominal_median": 17.48, + "absolute_nominal_p95": 21.43, + "p95_range": [ + 19.75, + 27.92 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 14.51, + "nominal_p95": 18.63, + "median_range": [ + 9.77, + 17.19 + ], + "median_spread": 7.43, + "absolute_nominal_median": 13.95, + "absolute_nominal_p95": 17.85, + "p95_range": [ + 15.62, + 25.26 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + }, + { + "part": "GS8208 LED5050 option", + "admissible_profiles": 729, + "models": { + "UncorrectedColor": { + "nominal_median": 2.88, + "nominal_p95": 9.53, + "median_range": [ + 2.17, + 8.5 + ], + "median_spread": 6.33, + "absolute_nominal_median": 17.29, + "absolute_nominal_p95": 24.27, + "p95_range": [ + 8.11, + 18.52 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "TypicalLEDStrip": { + "nominal_median": 14.58, + "nominal_p95": 21.09, + "median_range": [ + 13.55, + 18.03 + ], + "median_spread": 4.48, + "absolute_nominal_median": 21.3, + "absolute_nominal_p95": 27.66, + "p95_range": [ + 20.19, + 24.89 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + }, + "Typical8mmPixel": { + "nominal_median": 10.84, + "nominal_p95": 18.55, + "median_range": [ + 10.55, + 16.58 + ], + "median_spread": 6.03, + "absolute_nominal_median": 10.43, + "absolute_nominal_p95": 14.15, + "p95_range": [ + 17.49, + 22.27 + ], + "within_budget_everywhere": false, + "within_budget_nowhere": true + } + } + } + ] +} diff --git a/tests/test_legacy_accuracy_bound.py b/tests/test_legacy_accuracy_bound.py new file mode 100644 index 0000000..d1d06e2 --- /dev/null +++ b/tests/test_legacy_accuracy_bound.py @@ -0,0 +1,110 @@ +import importlib.util +import os +import tempfile +import unittest +from pathlib import Path + + +TOOL = Path(__file__).parents[1] / "tools" / "bound_legacy_correction_accuracy.py" +SPEC = importlib.util.spec_from_file_location("bound_legacy_correction_accuracy", TOOL) +assert SPEC and SPEC.loader +BOUND = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(BOUND) + +# Optional: path to the pinned CIE 1931 2-degree CSV, for the tests that need +# real colour-matching functions. The payload is CC BY-SA and not vendored. +CIE_CSV = os.environ.get("DATASHEETS_CIE_CSV") + + +class DeltaE2000Test(unittest.TestCase): + # Sharma, Wu & Dalal (2005), Table 1, pairs 1, 7, 17 and 34. + PAIRS = ( + ((50.0, 2.6772, -79.7751), (50.0, 0.0, -82.7485), 2.0425), + ((50.0, 0.0, 0.0), (50.0, -1.0, 2.0), 2.3669), + ((50.0, 2.5, 0.0), (73.0, 25.0, -18.0), 27.1492), + ((2.0776, 0.0795, -1.1350), (0.9033, -0.0636, -0.5514), 0.9082), + ) + + def test_matches_the_published_reference_pairs(self) -> None: + for lab1, lab2, expected in self.PAIRS: + self.assertAlmostEqual(BOUND.delta_e_2000(lab1, lab2), expected, places=4) + + def test_is_zero_for_identical_colours(self) -> None: + self.assertEqual(BOUND.delta_e_2000((50, 10, -20), (50, 10, -20)), 0.0) + + +class AdaptationTest(unittest.TestCase): + def test_bradford_maps_source_white_onto_destination_white(self) -> None: + dst = (1.0985, 1.0, 0.3558) # illuminant A + mapped = BOUND.mat_vec(BOUND.bradford(BOUND.D65_XYZ, dst), BOUND.D65_XYZ) + for got, want in zip(mapped, dst): + self.assertAlmostEqual(got, want, places=9) + + def test_srgb_primaries_device_scores_zero_uncorrected(self) -> None: + # A device whose emitters are exactly the sRGB primaries has a D65 + # full-drive white, so the relative target needs no adaptation and the + # uncorrected legacy drive reproduces every test colour. + xys = [(0.64, 0.33), (0.30, 0.60), (0.15, 0.06)] + lums = [0.2126729, 0.7151522, 0.0721750] + matrix = BOUND.emitter_matrix(xys, lums) + for error in BOUND.score(matrix, BOUND.MODELS["UncorrectedColor"]): + self.assertLess(error, 0.05) + + def test_a_white_tinting_correction_is_penalized_under_a1(self) -> None: + xys = [(0.64, 0.33), (0.30, 0.60), (0.15, 0.06)] + lums = [0.2126729, 0.7151522, 0.0721750] + matrix = BOUND.emitter_matrix(xys, lums) + errors = BOUND.score(matrix, BOUND.MODELS["TypicalLEDStrip"]) + self.assertGreater(max(errors), 5.0) + + +class StatisticsTest(unittest.TestCase): + def test_p95_interpolates_between_order_statistics(self) -> None: + self.assertAlmostEqual(BOUND.p95(list(range(21))), 19.0) + self.assertAlmostEqual(BOUND.p95([1.0, 2.0]), 1.95) + + +class CliTest(unittest.TestCase): + def test_rejects_an_unpinned_cie_payload(self) -> None: + with tempfile.TemporaryDirectory() as directory: + bad = Path(directory) / "cie.csv" + bad.write_text("500,1,1,1\n") + with self.assertRaises(SystemExit): + BOUND.main([str(bad)]) + + def test_usage_error_without_arguments(self) -> None: + self.assertEqual(BOUND.main([]), 2) + + +@unittest.skipUnless( + CIE_CSV, "set DATASHEETS_CIE_CSV to the pinned CIE 1931 2-degree CSV" +) +class RealColourMatchingTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + BOUND.verify_cie(Path(CIE_CSV)) + cls.cmf = BOUND.load_cmf(Path(CIE_CSV)) + cls.locus = BOUND.locus(cls.cmf) + + def test_emitter_xy_round_trips_its_dominant_wavelength(self) -> None: + for dominant, fwhm in ((466.0, 20.0), (523.5, 30.0), (622.5, 18.0)): + xy = BOUND.emitter_xy(self.cmf, self.locus, dominant, fwhm) + self.assertAlmostEqual( + BOUND.dominant_wavelength(xy, self.locus), dominant, places=3 + ) + + def test_a_wider_emitter_is_less_saturated_at_the_same_dominant_wavelength( + self, + ) -> None: + narrow = BOUND.emitter_xy(self.cmf, self.locus, 523.5, 20.0) + wide = BOUND.emitter_xy(self.cmf, self.locus, 523.5, 40.0) + e = BOUND.EQUAL_ENERGY + distance = lambda xy: ((xy[0] - e[0]) ** 2 + (xy[1] - e[1]) ** 2) ** 0.5 + self.assertGreater(distance(narrow), distance(wide)) + + def test_checked_in_outputs_are_current(self) -> None: + self.assertEqual(BOUND.main([CIE_CSV, "--check"]), 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/bound_legacy_correction_accuracy.py b/tools/bound_legacy_correction_accuracy.py new file mode 100644 index 0000000..c094078 --- /dev/null +++ b/tools/bound_legacy_correction_accuracy.py @@ -0,0 +1,558 @@ +"""Bound the colour accuracy of FastLED's legacy corrections from datasheet data. + +FastLED/FastLED#4035 asks P1 to quantify ``TypicalLEDStrip``, +``Typical8mmPixel`` and ``UncorrectedColor`` against datasheet-derived +profiles. The integrated-LED datasheets publish a *dominant-wavelength range* +and a *luminous-intensity range* per emitter, not chromaticity, so a single +derived profile would manufacture precision the PDFs lack. This tool instead +propagates what the datasheets do state into an *interval* of admissible +profiles, and reports the ΔE2000 each legacy model scores across that +interval. + +Stated assumptions (not datasheet facts, and named in every output): + +* Each emitter's spectrum is a Gaussian in wavelength whose FWHM lies in a + per-technology interval (``FWHM_NM``). Its peak is solved so that its + dominant wavelength (relative to the equal-energy white E) equals the + datasheet value. +* Luminous intensity is proportional to Y at a common viewing geometry, so + the mcd ratio is the relative Y of the three emitters. + +Evaluation (A1 normalization, #4033): an ordinary buffer is linear sRGB +(B6/B8). The intended colour of code ``c`` is ``c/255`` through the sRGB +primaries, Bradford-adapted from D65 to the device's full-drive white, with +that white at Y=1. The legacy path drives each emitter linearly at +``c/255 * correction``. ΔE2000 is computed in CIELAB with the device white as +reference, over a black-safe neutral ramp, the primaries and the secondaries. + +The CIE 1931 2-degree CMF table is the CIE 2019 dataset (CC BY-SA 4.0), not +redistributed here; the CLI refuses any payload whose SHA-256 differs. + + uv run python tools/bound_legacy_correction_accuracy.py CIE_xyz_1931_2deg.csv + uv run python tools/bound_legacy_correction_accuracy.py CIE_xyz_1931_2deg.csv --check +""" + +from __future__ import annotations + +import hashlib +import itertools +import json +import math +import statistics +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +OUTPUT = ROOT / "measured-profiles" / "legacy-correction-accuracy-bound-v1.json" +REPORT = ROOT / "measured-profiles" / "LEGACY-ACCURACY-BOUND.md" +CIE_SHA256 = "fa663e3535a7e0763a745993a1f0a192eb0275ac46ad2d1befd7626841e713c1" + +# A1 datasheet-derived profile budget (#4033): median / p95 ΔE2000. +BUDGET_MEDIAN = 3.0 +BUDGET_P95 = 6.0 + +# Assumed FWHM intervals, nm (low, high): AlGaInP red, InGaN green, InGaN blue. +FWHM_NM = {"red": (15.0, 25.0), "green": (25.0, 40.0), "blue": (18.0, 30.0)} + +# Legacy correction coefficients, FastLED src/color.h. +MODELS = { + "UncorrectedColor": (1.0, 1.0, 1.0), + "TypicalLEDStrip": (1.0, 176 / 255, 240 / 255), + "Typical8mmPixel": (1.0, 224 / 255, 140 / 255), +} + +# Datasheet rows transcribed in P1-CHARACTERIZATION.md ("Extracted +# integrated-package inventory"). Each channel: dominant wavelength range nm, +# luminous-intensity range mcd (equal bounds where one value is published). +PARTS = { + "SK6812": { + "red": ((620, 625), (700, 1000)), + "green": ((522.5, 525), (1500, 2200)), + "blue": ((467.5, 470), (700, 1000)), + }, + "WS2812B": { + "red": ((620, 625), (390, 420)), + "green": ((522, 525), (660, 720)), + "blue": ((465, 467), (180, 200)), + }, + "WS2813A": { + "red": ((620, 622), (480, 480)), + "green": ((522, 525), (1500, 1500)), + "blue": ((467, 470), (320, 320)), + }, + "WS2813B": { + "red": ((620, 622), (360, 360)), + "green": ((522, 525), (1150, 1150)), + "blue": ((467, 470), (220, 220)), + }, + "WS2813C": { + "red": ((620, 622), (120, 120)), + "green": ((522, 525), (540, 540)), + "blue": ((467, 470), (130, 130)), + }, + "WS2813D": { + "red": ((620, 622), (100, 100)), + "green": ((522, 525), (420, 420)), + "blue": ((467, 470), (110, 110)), + }, + "WS2815": { + "red": ((620, 625), (360, 360)), + "green": ((515, 525), (1150, 1150)), + "blue": ((465, 475), (220, 220)), + }, + "WS2816B-2121": { + "red": ((620, 625), (210, 360)), + "green": ((522, 527), (420, 720)), + "blue": ((470, 475), (70, 120)), + }, + "GS8208 LED5050 option": { + "red": ((615, 630), (450, 450)), + "green": ((520, 535), (1300, 1300)), + "blue": ((460, 475), (280, 280)), + }, +} +CHANNELS = ("red", "green", "blue") + +# Test colours as 8-bit codes: black-safe neutral ramp, primaries, secondaries. +NEUTRALS = [(v, v, v) for v in (32, 64, 128, 192, 255)] +CHROMATICS = [ + (255, 0, 0), + (0, 255, 0), + (0, 0, 255), + (0, 255, 255), + (255, 0, 255), + (255, 255, 0), +] +TEST_COLOURS = NEUTRALS + CHROMATICS + +SRGB_TO_XYZ = ( + (0.4124564, 0.3575761, 0.1804375), + (0.2126729, 0.7151522, 0.0721750), + (0.0193339, 0.1191920, 0.9503041), +) +D65_XYZ = (0.95047, 1.0, 1.08883) +BRADFORD = ( + (0.8951, 0.2664, -0.1614), + (-0.7502, 1.7135, 0.0367), + (0.0389, -0.0685, 1.0296), +) +EQUAL_ENERGY = (1 / 3, 1 / 3) + + +# ---------------------------------------------------------------- linear algebra + + +def mat_vec(m, v): + return tuple(sum(m[r][c] * v[c] for c in range(3)) for r in range(3)) + + +def mat_mul(a, b): + return tuple( + tuple(sum(a[r][k] * b[k][c] for k in range(3)) for c in range(3)) + for r in range(3) + ) + + +def mat_inv(m): + (a, b, c), (d, e, f), (g, h, i) = m + det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g) + if abs(det) < 1e-12: + raise ValueError("singular matrix") + return ( + ((e * i - f * h) / det, (c * h - b * i) / det, (b * f - c * e) / det), + ((f * g - d * i) / det, (a * i - c * g) / det, (c * d - a * f) / det), + ((d * h - e * g) / det, (b * g - a * h) / det, (a * e - b * d) / det), + ) + + +def bradford(src_white, dst_white): + """Bradford chromatic-adaptation matrix taking src_white to dst_white.""" + s = mat_vec(BRADFORD, src_white) + d = mat_vec(BRADFORD, dst_white) + scale = ((d[0] / s[0], 0, 0), (0, d[1] / s[1], 0), (0, 0, d[2] / s[2])) + return mat_mul(mat_inv(BRADFORD), mat_mul(scale, BRADFORD)) + + +# ------------------------------------------------------------------- CIE colour + + +def load_cmf(path: Path) -> dict[int, tuple[float, float, float]]: + cmf: dict[int, tuple[float, float, float]] = {} + for line_number, line in enumerate(path.read_text().splitlines(), 1): + if not line.strip(): + continue + parts = line.split(",") + if len(parts) != 4: + raise ValueError(f"CIE CMF row {line_number} must have four columns") + wavelength = int(float(parts[0])) + values = tuple( + float(p) if p.strip().lower() != "nan" else 0.0 for p in parts[1:] + ) + if not all(math.isfinite(v) for v in values): + raise ValueError(f"CIE CMF row {line_number} is not finite") + if wavelength in cmf: + raise ValueError(f"CIE CMF duplicate wavelength {wavelength}") + cmf[wavelength] = values + return cmf + + +def gaussian_xy(cmf, peak_nm: float, fwhm_nm: float) -> tuple[float, float]: + sigma = fwhm_nm / (2 * math.sqrt(2 * math.log(2))) + x = y = z = 0.0 + for wavelength, (xb, yb, zb) in cmf.items(): + s = math.exp(-0.5 * ((wavelength - peak_nm) / sigma) ** 2) + x += s * xb + y += s * yb + z += s * zb + total = x + y + z + if total <= 0: + raise ValueError("spectrum integrates to zero") + return x / total, y / total + + +def locus(cmf) -> list[tuple[int, float]]: + """(wavelength, hue angle about E) along the spectral locus, 380–700 nm.""" + points = [] + for wavelength in range(380, 701): + xb, yb, zb = cmf[wavelength] + total = xb + yb + zb + x, y = xb / total, yb / total + points.append( + (wavelength, math.atan2(y - EQUAL_ENERGY[1], x - EQUAL_ENERGY[0])) + ) + return points + + +def dominant_wavelength(xy, locus_points) -> float: + """Dominant wavelength of a spectral (non-purple) chromaticity about E.""" + angle = math.atan2(xy[1] - EQUAL_ENERGY[1], xy[0] - EQUAL_ENERGY[0]) + # Hue angle decreases monotonically with wavelength over 380–700 nm, with + # one wrap from -pi to +pi in the blue; unwrap so the list is monotonic. + unwrapped = [] + previous = None + offset = 0.0 + for wavelength, a in locus_points: + if previous is not None and a - previous > math.pi: + offset -= 2 * math.pi + unwrapped.append((wavelength, a + offset)) + previous = a + for candidate in (angle, angle - 2 * math.pi, angle + 2 * math.pi): + for (w0, a0), (w1, a1) in zip(unwrapped, unwrapped[1:]): + if min(a0, a1) <= candidate <= max(a0, a1) and a0 != a1: + return w0 + (candidate - a0) / (a1 - a0) * (w1 - w0) + raise ValueError("chromaticity is on the purple line; no dominant wavelength") + + +def emitter_xy( + cmf, locus_points, dominant_nm: float, fwhm_nm: float +) -> tuple[float, float]: + """xy of the Gaussian emitter of the given FWHM whose dominant wavelength is dominant_nm.""" + lo, hi = dominant_nm - 40.0, dominant_nm + 40.0 + for _ in range(60): + mid = 0.5 * (lo + hi) + if ( + dominant_wavelength(gaussian_xy(cmf, mid, fwhm_nm), locus_points) + < dominant_nm + ): + lo = mid + else: + hi = mid + return gaussian_xy(cmf, 0.5 * (lo + hi), fwhm_nm) + + +# --------------------------------------------------------------- CIELAB / ΔE2000 + + +def xyz_to_lab(xyz, white): + def f(t): + return t ** (1 / 3) if t > (6 / 29) ** 3 else t / (3 * (6 / 29) ** 2) + 4 / 29 + + fx, fy, fz = (f(xyz[i] / white[i]) for i in range(3)) + return 116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz) + + +def delta_e_2000(lab1, lab2) -> float: + """CIEDE2000 (Sharma, Wu & Dalal 2005), kL = kC = kH = 1.""" + l1, a1, b1 = lab1 + l2, a2, b2 = lab2 + c1, c2 = math.hypot(a1, b1), math.hypot(a2, b2) + c_bar = (c1 + c2) / 2 + g = 0.5 * (1 - math.sqrt(c_bar**7 / (c_bar**7 + 25**7))) + a1p, a2p = (1 + g) * a1, (1 + g) * a2 + c1p, c2p = math.hypot(a1p, b1), math.hypot(a2p, b2) + h1p = math.degrees(math.atan2(b1, a1p)) % 360 if c1p else 0.0 + h2p = math.degrees(math.atan2(b2, a2p)) % 360 if c2p else 0.0 + dlp = l2 - l1 + dcp = c2p - c1p + if c1p * c2p == 0: + dhp = 0.0 + elif abs(h2p - h1p) <= 180: + dhp = h2p - h1p + elif h2p - h1p > 180: + dhp = h2p - h1p - 360 + else: + dhp = h2p - h1p + 360 + dhp_big = 2 * math.sqrt(c1p * c2p) * math.sin(math.radians(dhp / 2)) + lp_bar = (l1 + l2) / 2 + cp_bar = (c1p + c2p) / 2 + if c1p * c2p == 0: + hp_bar = h1p + h2p + elif abs(h1p - h2p) <= 180: + hp_bar = (h1p + h2p) / 2 + elif h1p + h2p < 360: + hp_bar = (h1p + h2p + 360) / 2 + else: + hp_bar = (h1p + h2p - 360) / 2 + t = ( + 1 + - 0.17 * math.cos(math.radians(hp_bar - 30)) + + 0.24 * math.cos(math.radians(2 * hp_bar)) + + 0.32 * math.cos(math.radians(3 * hp_bar + 6)) + - 0.20 * math.cos(math.radians(4 * hp_bar - 63)) + ) + d_theta = 30 * math.exp(-(((hp_bar - 275) / 25) ** 2)) + rc = 2 * math.sqrt(cp_bar**7 / (cp_bar**7 + 25**7)) + sl = 1 + 0.015 * (lp_bar - 50) ** 2 / math.sqrt(20 + (lp_bar - 50) ** 2) + sc = 1 + 0.045 * cp_bar + sh = 1 + 0.015 * cp_bar * t + rt = -math.sin(math.radians(2 * d_theta)) * rc + return math.sqrt( + (dlp / sl) ** 2 + + (dcp / sc) ** 2 + + (dhp_big / sh) ** 2 + + rt * (dcp / sc) * (dhp_big / sh) + ) + + +# ---------------------------------------------------------------- evaluation + + +def emitter_matrix(xys, luminances): + """3x3 matrix whose columns are the emitters' XYZ, device white at Y=1.""" + total = sum(luminances) + cols = [] + for (x, y), lum in zip(xys, luminances): + big_y = lum / total + cols.append((x / y * big_y, big_y, (1 - x - y) / y * big_y)) + return tuple(tuple(cols[c][r] for c in range(3)) for r in range(3)) + + +def score(matrix, correction) -> list[float]: + """ΔE2000 of the legacy path for every test colour against the relative-colorimetric target.""" + device_white = mat_vec(matrix, (1.0, 1.0, 1.0)) + adapt = bradford(D65_XYZ, device_white) + errors = [] + for code in TEST_COLOURS: + linear = tuple(v / 255 for v in code) + target = mat_vec(adapt, mat_vec(SRGB_TO_XYZ, linear)) + drive = tuple(linear[i] * correction[i] for i in range(3)) + actual = mat_vec(matrix, drive) + errors.append( + delta_e_2000( + xyz_to_lab(target, device_white), xyz_to_lab(actual, device_white) + ) + ) + return errors + + +def score_absolute(matrix, correction) -> list[float]: + """Supplementary, not A1: ΔE2000 against the sRGB colour with no adaptation. + + Target and reference white are D65 (white at Y=1); the device's full-drive + white is also Y=1. This is the goal the legacy corrections were designed + for -- pulling the device white toward D65 -- which A1's relative + colorimetry deliberately does not reward. + """ + errors = [] + for code in TEST_COLOURS: + linear = tuple(v / 255 for v in code) + target = mat_vec(SRGB_TO_XYZ, linear) + drive = tuple(linear[i] * correction[i] for i in range(3)) + actual = mat_vec(matrix, drive) + errors.append(delta_e_2000(xyz_to_lab(target, D65_XYZ), xyz_to_lab(actual, D65_XYZ))) + return errors + + +def p95(values) -> float: + ordered = sorted(values) + rank = 0.95 * (len(ordered) - 1) + low = math.floor(rank) + high = min(low + 1, len(ordered) - 1) + return ordered[low] + (rank - low) * (ordered[high] - ordered[low]) + + +def evaluate(cmf) -> dict: + locus_points = locus(cmf) + rows = [] + for part, channels in PARTS.items(): + # Per channel: every (dominant wavelength, FWHM) corner -> xy. + xy_options = {} + for name in CHANNELS: + (d_lo, d_hi), _ = channels[name] + w_lo, w_hi = FWHM_NM[name] + xy_options[name] = [ + emitter_xy(cmf, locus_points, d, w) + for d in (d_lo, (d_lo + d_hi) / 2, d_hi) + for w in (w_lo, (w_lo + w_hi) / 2, w_hi) + ] + lum_options = { + name: sorted({channels[name][1][0], channels[name][1][1]}) + for name in CHANNELS + } + nominal_xy = [ + xy_options[name][4] for name in CHANNELS + ] # mid wavelength, mid FWHM + nominal_lum = [sum(channels[name][1]) / 2 for name in CHANNELS] + nominal = emitter_matrix(nominal_xy, nominal_lum) + per_model = {} + for model, correction in MODELS.items(): + medians, p95s = [], [] + for xys in itertools.product(*(xy_options[n] for n in CHANNELS)): + for lums in itertools.product(*(lum_options[n] for n in CHANNELS)): + errors = score(emitter_matrix(xys, lums), correction) + medians.append(statistics.median(errors)) + p95s.append(p95(errors)) + nominal_errors = score(nominal, correction) + absolute_errors = score_absolute(nominal, correction) + per_model[model] = { + "nominal_median": round(statistics.median(nominal_errors), 2), + "nominal_p95": round(p95(nominal_errors), 2), + "median_range": [round(min(medians), 2), round(max(medians), 2)], + "median_spread": round(max(medians) - min(medians), 2), + "absolute_nominal_median": round(statistics.median(absolute_errors), 2), + "absolute_nominal_p95": round(p95(absolute_errors), 2), + "p95_range": [round(min(p95s), 2), round(max(p95s), 2)], + "within_budget_everywhere": max(medians) <= BUDGET_MEDIAN + and max(p95s) <= BUDGET_P95, + "within_budget_nowhere": min(medians) > BUDGET_MEDIAN + or min(p95s) > BUDGET_P95, + } + rows.append( + { + "part": part, + "admissible_profiles": len(xy_options["red"]) ** 3 + * math.prod(len(v) for v in lum_options.values()), + "models": per_model, + } + ) + return { + "kind": "datasheet_bound_not_measurement", + "issue": "FastLED/FastLED#4035", + "assumptions": { + "spectrum": "Gaussian per emitter; peak solved so dominant wavelength (about equal-energy E) matches the datasheet", + "fwhm_nm": {k: list(v) for k, v in FWHM_NM.items()}, + "relative_y": "proportional to published luminous intensity (common viewing geometry)", + }, + "evaluation": { + "source": "linear sRGB (B6/B8 default for ordinary buffers)", + "target": "Bradford D65 -> device full-drive white, white at Y=1 (A1)", + "metric": "CIEDE2000 in CIELAB with device white as reference", + "test_colours": [list(c) for c in TEST_COLOURS], + "budget": {"median": BUDGET_MEDIAN, "p95": BUDGET_P95}, + "sampling": "dominant wavelength {min, mid, max} x FWHM {low, mid, high} per channel; intensity {min, max} per channel", + }, + "models": {k: [round(v, 6) for v in c] for k, c in MODELS.items()}, + "rows": rows, + } + + +def render_report(data: dict) -> str: + lines = [ + "# Legacy-correction accuracy, bounded from datasheet data", + "", + "Generated by `tools/bound_legacy_correction_accuracy.py` for FastLED/FastLED#4035.", + "**This is a bound, not a measurement.** The datasheets publish dominant-wavelength", + "and luminous-intensity ranges, not chromaticity, so every admissible profile the", + "published ranges allow is scored, under two stated assumptions:", + "", + "- each emitter's spectrum is a Gaussian whose dominant wavelength (about equal-energy", + f" white E) matches the datasheet, with FWHM red {FWHM_NM['red'][0]:g}–{FWHM_NM['red'][1]:g} nm,", + f" green {FWHM_NM['green'][0]:g}–{FWHM_NM['green'][1]:g} nm, blue {FWHM_NM['blue'][0]:g}–{FWHM_NM['blue'][1]:g} nm (assumed, typical of", + " AlGaInP / InGaN emitters; not from the datasheets);", + "- luminous intensity is proportional to Y at a common viewing geometry.", + "", + "Evaluation follows A1: an ordinary buffer is linear sRGB (B6/B8); the target is the", + "sRGB colour Bradford-adapted from D65 to the device's full-drive white (Y=1); the", + "legacy path drives each emitter at `code/255 × correction`. ΔE2000 over a black-safe", + "neutral ramp (32–255), RGB primaries and CMY secondaries. Budget for a", + f"datasheet-derived profile: median ≤ {BUDGET_MEDIAN:g}, p95 ≤ {BUDGET_P95:g}.", + "", + "`nominal` uses mid wavelength, mid FWHM and mid intensity. The ranges are the minimum", + "and maximum of each statistic over every admissible profile.", + "", + "| part | model | nominal median | nominal p95 | median range | median spread | p95 range | verdict |", + "| --- | --- | ---: | ---: | --- | ---: | --- | --- |", + ] + for row in data["rows"]: + for model, r in row["models"].items(): + if r["within_budget_everywhere"]: + verdict = "within budget for every admissible profile" + elif r["within_budget_nowhere"]: + verdict = "outside budget for every admissible profile" + else: + verdict = "undetermined: datasheet ranges straddle the budget" + lines.append( + f"| {row['part']} | {model} | {r['nominal_median']:.2f} | {r['nominal_p95']:.2f} | " + f"{r['median_range'][0]:.2f}–{r['median_range'][1]:.2f} | {r['median_spread']:.2f} | " + f"{r['p95_range'][0]:.2f}–{r['p95_range'][1]:.2f} | {verdict} |" + ) + cells = [(row["part"], m, r) for row in data["rows"] for m, r in row["models"].items()] + wide = sum(1 for _, _, r in cells if r["median_spread"] > BUDGET_MEDIAN) + outside = sum(1 for _, _, r in cells if r["within_budget_nowhere"]) + inside = sum(1 for _, _, r in cells if r["within_budget_everywhere"]) + lines += [ + "", + "## What this establishes", + "", + f"- **Datasheet ranges cannot pin accuracy to the A1 budget.** In {wide} of {len(cells)} part/model", + f" cells the admissible median ΔE2000 alone spans more than the whole {BUDGET_MEDIAN:g} median budget,", + " so no single datasheet-derived profile could be defended at that budget. A", + " datasheet-derived runtime profile needs xy or spectral data the PDFs do not publish.", + f"- **Legacy models under A1:** {outside} of {len(cells)} cells are outside budget for every admissible", + f" profile, {inside} within budget for every one, and the rest undetermined.", + " `TypicalLEDStrip` and `Typical8mmPixel` score worse than `UncorrectedColor` under A1", + " because A1 is relative colorimetry: the target is adapted to the device's own", + " full-drive white, so a correction that tints that white is penalized. The", + " supplementary absolute table below scores the goal those corrections were built for.", + "", + "## Supplementary: absolute colorimetry (not A1)", + "", + "Target and reference white are D65 with no adaptation; nominal profile only. Context", + "for the legacy corrections' design intent, not an A1 acceptance number.", + "", + "| part | model | median | p95 |", + "| --- | --- | ---: | ---: |", + ] + for part, model, r in cells: + lines.append(f"| {part} | {model} | {r['absolute_nominal_median']:.2f} | {r['absolute_nominal_p95']:.2f} |") + lines.append("") + return "\n".join(lines) + + +def verify_cie(path: Path) -> None: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if digest != CIE_SHA256: + raise SystemExit(f"CIE CMF payload SHA-256 {digest} != pinned {CIE_SHA256}") + + +def main(argv: list[str]) -> int: + if not argv or len(argv) > 2 or (len(argv) == 2 and argv[1] != "--check"): + print(__doc__, file=sys.stderr) + return 2 + cie = Path(argv[0]) + verify_cie(cie) + data = evaluate(load_cmf(cie)) + rendered = json.dumps(data, indent=2) + "\n" + report = render_report(data) + if len(argv) == 2: + ok = OUTPUT.read_text() == rendered and REPORT.read_text() == report + print("up to date" if ok else "stale: regenerate", file=sys.stderr) + return 0 if ok else 1 + OUTPUT.write_text(rendered) + REPORT.write_text(report) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) From 597a3b9f5dda7921d20fb174838da1b6d55fbb68 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Fri, 18 Sep 2026 16:26:32 -0700 Subject: [PATCH 2/2] fix(color): state the bound as a grid, and run the tests in CI Review follow-up on #12: - The tool scores a grid (each range's ends and midpoint), not the continuous datasheet ranges. Wording is now "at every sampled profile" and says the grid is an inner bound, not a proof over the continuum. The spread claim is unaffected in direction: a sampled spread can only understate the continuous one. Intensity is now sampled at min/mid/max, so the nominal profile is one of the grid points. Headline counts are unchanged (14/27 spreads exceed the budget; 26/27 cells outside budget at every sample). - Add .github/workflows/tests.yml. No test in this repo ran in CI; it now fetches the CIE table, verifies its pinned SHA-256, runs the full suite with DATASHEETS_CIE_CSV set, and --checks every checked-in artifact. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/tests.yml | 46 +++++ measured-profiles/LEGACY-ACCURACY-BOUND.md | 78 +++++---- measured-profiles/P1-CHARACTERIZATION.md | 20 ++- .../legacy-correction-accuracy-bound-v1.json | 158 +++++++++--------- tools/bound_legacy_correction_accuracy.py | 62 ++++--- 5 files changed, 216 insertions(+), 148 deletions(-) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..4c191b7 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,46 @@ +name: tests + +# Runs the Python test suite and the checked-in artifact checks on every push +# to main and every pull request. The colour tools compare against the +# CIE 1931 2-degree CMF table, which is CC BY-SA 4.0 and not redistributed +# here: it is fetched at run time and rejected unless its SHA-256 matches the +# pin the tools themselves enforce. + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v6 + + - name: Fetch the pinned CIE 1931 2-degree CMF table + run: | + set -euo pipefail + curl -sSfL --retry 3 -o "$RUNNER_TEMP/CIE_xyz_1931_2deg.csv" \ + https://files.cie.co.at/Publications-datasets/CIE_xyz_1931_2deg.csv + echo "fa663e3535a7e0763a745993a1f0a192eb0275ac46ad2d1befd7626841e713c1 $RUNNER_TEMP/CIE_xyz_1931_2deg.csv" \ + | sha256sum -c - + + - name: Unit tests + env: + DATASHEETS_CIE_CSV: ${{ runner.temp }}/CIE_xyz_1931_2deg.csv + run: uv run --no-project --with jsonschema python -m unittest discover -s tests -v + + - name: Checked-in artifacts are current + env: + CIE: ${{ runner.temp }}/CIE_xyz_1931_2deg.csv + run: | + set -euo pipefail + uv run --no-project --with jsonschema python tools/check_profile_artifacts.py + uv run --no-project python tools/generate_intensity_sensitivity.py --check + uv run --no-project python tools/bound_legacy_correction_accuracy.py "$CIE" --check diff --git a/measured-profiles/LEGACY-ACCURACY-BOUND.md b/measured-profiles/LEGACY-ACCURACY-BOUND.md index 305322e..a08ddcb 100644 --- a/measured-profiles/LEGACY-ACCURACY-BOUND.md +++ b/measured-profiles/LEGACY-ACCURACY-BOUND.md @@ -2,8 +2,9 @@ Generated by `tools/bound_legacy_correction_accuracy.py` for FastLED/FastLED#4035. **This is a bound, not a measurement.** The datasheets publish dominant-wavelength -and luminous-intensity ranges, not chromaticity, so every admissible profile the -published ranges allow is scored, under two stated assumptions: +and luminous-intensity ranges, not chromaticity, so the ranges are sampled on a grid +(each channel's dominant wavelength min/mid/max × FWHM low/mid/high × intensity +min/mid/max) and every grid profile is scored, under two stated assumptions: - each emitter's spectrum is a Gaussian whose dominant wavelength (about equal-energy white E) matches the datasheet, with FWHM red 15–25 nm, @@ -17,47 +18,52 @@ legacy path drives each emitter at `code/255 × correction`. ΔE2000 over a blac neutral ramp (32–255), RGB primaries and CMY secondaries. Budget for a datasheet-derived profile: median ≤ 3, p95 ≤ 6. -`nominal` uses mid wavelength, mid FWHM and mid intensity. The ranges are the minimum -and maximum of each statistic over every admissible profile. +`nominal` is the grid's centre point: mid wavelength, mid FWHM, mid intensity. The +ranges are the minimum and maximum of each statistic over the sampled grid. They are +not a proof over the continuous ranges: a grid can miss a worse or better interior +point, so each range is an inner bound on the true one. | part | model | nominal median | nominal p95 | median range | median spread | p95 range | verdict | | --- | --- | ---: | ---: | --- | ---: | --- | --- | -| SK6812 | UncorrectedColor | 6.91 | 10.90 | 3.60–10.50 | 6.90 | 7.77–15.41 | outside budget for every admissible profile | -| SK6812 | TypicalLEDStrip | 15.01 | 19.31 | 13.08–16.29 | 3.21 | 17.41–21.08 | outside budget for every admissible profile | -| SK6812 | Typical8mmPixel | 12.39 | 19.59 | 10.87–14.48 | 3.61 | 17.92–21.04 | outside budget for every admissible profile | -| WS2812B | UncorrectedColor | 1.22 | 14.45 | 1.08–3.69 | 2.61 | 12.64–16.52 | outside budget for every admissible profile | -| WS2812B | TypicalLEDStrip | 15.11 | 19.71 | 13.48–16.41 | 2.93 | 18.94–20.42 | outside budget for every admissible profile | -| WS2812B | Typical8mmPixel | 12.98 | 20.11 | 12.51–13.89 | 1.38 | 19.18–21.12 | outside budget for every admissible profile | -| WS2813A | UncorrectedColor | 4.40 | 9.33 | 1.48–5.52 | 4.04 | 8.38–10.40 | outside budget for every admissible profile | -| WS2813A | TypicalLEDStrip | 14.94 | 21.46 | 14.55–15.27 | 0.72 | 21.06–21.79 | outside budget for every admissible profile | -| WS2813A | Typical8mmPixel | 10.64 | 18.15 | 10.20–11.84 | 1.64 | 17.21–19.16 | outside budget for every admissible profile | -| WS2813B | UncorrectedColor | 3.99 | 9.34 | 1.32–5.31 | 3.99 | 8.36–10.44 | outside budget for every admissible profile | -| WS2813B | TypicalLEDStrip | 15.12 | 21.64 | 14.71–15.46 | 0.75 | 21.24–21.97 | outside budget for every admissible profile | -| WS2813B | Typical8mmPixel | 10.52 | 17.89 | 9.99–11.52 | 1.53 | 17.00–18.85 | outside budget for every admissible profile | -| WS2813C | UncorrectedColor | 2.24 | 6.68 | 1.74–3.43 | 1.68 | 6.00–7.99 | undetermined: datasheet ranges straddle the budget | -| WS2813C | TypicalLEDStrip | 15.16 | 21.68 | 14.03–15.51 | 1.47 | 21.24–22.03 | outside budget for every admissible profile | -| WS2813C | Typical8mmPixel | 11.72 | 18.47 | 10.75–12.81 | 2.06 | 17.20–19.77 | outside budget for every admissible profile | -| WS2813D | UncorrectedColor | 2.72 | 7.05 | 2.04–4.03 | 1.99 | 6.22–8.42 | outside budget for every admissible profile | -| WS2813D | TypicalLEDStrip | 14.96 | 21.48 | 14.14–15.29 | 1.15 | 21.05–21.82 | outside budget for every admissible profile | -| WS2813D | Typical8mmPixel | 11.73 | 18.71 | 10.75–12.81 | 2.06 | 17.43–20.00 | outside budget for every admissible profile | -| WS2815 | UncorrectedColor | 5.11 | 10.41 | 1.76–7.85 | 6.09 | 8.42–16.26 | outside budget for every admissible profile | -| WS2815 | TypicalLEDStrip | 15.35 | 21.87 | 12.99–18.33 | 5.34 | 21.24–25.95 | outside budget for every admissible profile | -| WS2815 | Typical8mmPixel | 10.36 | 17.55 | 9.92–14.38 | 4.46 | 17.00–20.00 | outside budget for every admissible profile | -| WS2816B-2121 | UncorrectedColor | 6.67 | 15.44 | 2.64–10.78 | 8.14 | 7.77–26.70 | outside budget for every admissible profile | -| WS2816B-2121 | TypicalLEDStrip | 16.92 | 21.13 | 14.76–20.08 | 5.32 | 19.75–27.92 | outside budget for every admissible profile | -| WS2816B-2121 | Typical8mmPixel | 14.51 | 18.63 | 9.77–17.19 | 7.43 | 15.62–25.26 | outside budget for every admissible profile | -| GS8208 LED5050 option | UncorrectedColor | 2.88 | 9.53 | 2.17–8.50 | 6.33 | 8.11–18.52 | outside budget for every admissible profile | -| GS8208 LED5050 option | TypicalLEDStrip | 14.58 | 21.09 | 13.55–18.03 | 4.48 | 20.19–24.89 | outside budget for every admissible profile | -| GS8208 LED5050 option | Typical8mmPixel | 10.84 | 18.55 | 10.55–16.58 | 6.03 | 17.49–22.27 | outside budget for every admissible profile | +| SK6812 | UncorrectedColor | 6.91 | 10.90 | 3.60–10.50 | 6.91 | 7.69–15.41 | outside budget at every sample | +| SK6812 | TypicalLEDStrip | 15.01 | 19.31 | 12.74–16.29 | 3.55 | 17.32–21.08 | outside budget at every sample | +| SK6812 | Typical8mmPixel | 12.39 | 19.59 | 10.57–14.48 | 3.91 | 17.92–21.06 | outside budget at every sample | +| WS2812B | UncorrectedColor | 1.22 | 14.45 | 1.07–3.69 | 2.62 | 12.64–16.52 | outside budget at every sample | +| WS2812B | TypicalLEDStrip | 15.11 | 19.71 | 13.48–16.41 | 2.93 | 18.94–20.42 | outside budget at every sample | +| WS2812B | Typical8mmPixel | 12.98 | 20.11 | 12.51–13.89 | 1.38 | 19.18–21.12 | outside budget at every sample | +| WS2813A | UncorrectedColor | 4.40 | 9.33 | 1.48–5.52 | 4.04 | 8.38–10.40 | outside budget at every sample | +| WS2813A | TypicalLEDStrip | 14.94 | 21.46 | 14.55–15.27 | 0.72 | 21.06–21.79 | outside budget at every sample | +| WS2813A | Typical8mmPixel | 10.64 | 18.15 | 10.20–11.84 | 1.64 | 17.21–19.16 | outside budget at every sample | +| WS2813B | UncorrectedColor | 3.99 | 9.34 | 1.32–5.31 | 3.99 | 8.36–10.44 | outside budget at every sample | +| WS2813B | TypicalLEDStrip | 15.12 | 21.64 | 14.71–15.46 | 0.75 | 21.24–21.97 | outside budget at every sample | +| WS2813B | Typical8mmPixel | 10.52 | 17.89 | 9.99–11.52 | 1.53 | 17.00–18.85 | outside budget at every sample | +| WS2813C | UncorrectedColor | 2.24 | 6.68 | 1.74–3.43 | 1.68 | 6.00–7.99 | samples straddle the budget | +| WS2813C | TypicalLEDStrip | 15.16 | 21.68 | 14.03–15.51 | 1.47 | 21.24–22.03 | outside budget at every sample | +| WS2813C | Typical8mmPixel | 11.72 | 18.47 | 10.75–12.81 | 2.06 | 17.20–19.77 | outside budget at every sample | +| WS2813D | UncorrectedColor | 2.72 | 7.05 | 2.04–4.03 | 1.99 | 6.22–8.42 | outside budget at every sample | +| WS2813D | TypicalLEDStrip | 14.96 | 21.48 | 14.14–15.29 | 1.15 | 21.05–21.82 | outside budget at every sample | +| WS2813D | Typical8mmPixel | 11.73 | 18.71 | 10.75–12.81 | 2.06 | 17.43–20.00 | outside budget at every sample | +| WS2815 | UncorrectedColor | 5.11 | 10.41 | 1.76–7.85 | 6.09 | 8.42–16.26 | outside budget at every sample | +| WS2815 | TypicalLEDStrip | 15.35 | 21.87 | 12.99–18.33 | 5.34 | 21.24–25.95 | outside budget at every sample | +| WS2815 | Typical8mmPixel | 10.36 | 17.55 | 9.92–14.38 | 4.46 | 17.00–20.00 | outside budget at every sample | +| WS2816B-2121 | UncorrectedColor | 6.67 | 15.44 | 2.64–11.21 | 8.57 | 7.77–26.70 | outside budget at every sample | +| WS2816B-2121 | TypicalLEDStrip | 16.92 | 21.13 | 14.24–20.08 | 5.84 | 19.09–27.92 | outside budget at every sample | +| WS2816B-2121 | Typical8mmPixel | 14.51 | 18.63 | 9.77–17.19 | 7.43 | 15.62–25.26 | outside budget at every sample | +| GS8208 LED5050 option | UncorrectedColor | 2.88 | 9.53 | 2.17–8.50 | 6.33 | 8.11–18.52 | outside budget at every sample | +| GS8208 LED5050 option | TypicalLEDStrip | 14.58 | 21.09 | 13.55–18.03 | 4.48 | 20.19–24.89 | outside budget at every sample | +| GS8208 LED5050 option | Typical8mmPixel | 10.84 | 18.55 | 10.55–16.58 | 6.03 | 17.49–22.27 | outside budget at every sample | ## What this establishes - **Datasheet ranges cannot pin accuracy to the A1 budget.** In 14 of 27 part/model - cells the admissible median ΔE2000 alone spans more than the whole 3 median budget, - so no single datasheet-derived profile could be defended at that budget. A - datasheet-derived runtime profile needs xy or spectral data the PDFs do not publish. -- **Legacy models under A1:** 26 of 27 cells are outside budget for every admissible - profile, 0 within budget for every one, and the rest undetermined. + cells the median ΔE2000 over the sampled grid alone spans more than the whole + 3 median budget. A sampled spread can only understate the continuous one, + so this holds for the ranges themselves: no single datasheet-derived profile could + be defended at that budget, and a runtime profile needs xy or spectral data the + PDFs do not publish. +- **Legacy models under A1:** 26 of 27 cells are outside budget at every + sampled profile, 0 within budget at every one, and the rest straddle it. These + verdicts are over the grid, not proven over the continuous ranges. `TypicalLEDStrip` and `Typical8mmPixel` score worse than `UncorrectedColor` under A1 because A1 is relative colorimetry: the target is adapted to the device's own full-drive white, so a correction that tints that white is penalized. The diff --git a/measured-profiles/P1-CHARACTERIZATION.md b/measured-profiles/P1-CHARACTERIZATION.md index befd032..7d30938 100644 --- a/measured-profiles/P1-CHARACTERIZATION.md +++ b/measured-profiles/P1-CHARACTERIZATION.md @@ -42,9 +42,9 @@ spectral/xy data, 0/10 with a measured uncertainty. ## Legacy-correction accuracy, as a bound What the datasheets *do* publish -- a dominant-wavelength range and a -luminous-intensity range per emitter -- is propagated into an interval of -admissible profiles by `tools/bound_legacy_correction_accuracy.py`, and every -legacy model is scored across that interval under A1 (relative colorimetry, +luminous-intensity range per emitter -- is sampled into a grid of profiles +(each range's ends and midpoint) by `tools/bound_legacy_correction_accuracy.py`, +and every legacy model is scored across that grid under A1 (relative colorimetry, linear-sRGB source, Bradford to the full-drive white, ΔE2000 over a neutral ramp, primaries and secondaries). Two assumptions are stated, not taken from the PDFs: a Gaussian emitter spectrum with a per-technology FWHM interval, and @@ -56,17 +56,19 @@ with wavelength and intensity data. Results: - `TypicalLEDStrip`, `Typical8mmPixel` and `UncorrectedColor` are quantified - as ΔE2000 intervals per part; under A1 every legacy model misses the - datasheet budget (median ≤ 3.0 / p95 ≤ 6.0) for every admissible profile on - all but one part/model cell. The corrections score worse than + as ΔE2000 ranges per part; under A1 legacy models miss the datasheet budget + (median ≤ 3.0 / p95 ≤ 6.0) at every sampled profile in nearly every + part/model cell (see the generated table for the count). The grid is an + inner bound on the continuous ranges, not a proof over them. The corrections score worse than `UncorrectedColor` under A1 because they tint the device white, which relative colorimetry penalizes; a supplementary absolute-colorimetry table scores their D65 design intent. -- The admissible median alone spans more than the entire 3.0 budget in about - half the cells, so datasheet ranges **cannot pin a derived profile to the A1 +- The median over the sampled grid alone spans more than the entire 3.0 + budget in about half the cells. A sampled spread can only understate the + continuous one, so datasheet ranges **cannot pin a derived profile to the A1 budget**. That is the numerical reason for zero admissible derived profiles, and why accuracy claims wait on P10 measurement. -- Clustering: per-part intervals overlap at this resolution, so no +- Clustering: per-part ranges overlap at this resolution, so no datasheet-level clustering is defensible; the WS2813 A–D variants differ in intensity only and are not a bin population. diff --git a/measured-profiles/legacy-correction-accuracy-bound-v1.json b/measured-profiles/legacy-correction-accuracy-bound-v1.json index 03b3f73..4f06ba4 100644 --- a/measured-profiles/legacy-correction-accuracy-bound-v1.json +++ b/measured-profiles/legacy-correction-accuracy-bound-v1.json @@ -84,7 +84,7 @@ "median": 3.0, "p95": 6.0 }, - "sampling": "dominant wavelength {min, mid, max} x FWHM {low, mid, high} per channel; intensity {min, max} per channel" + "sampling": "grid, not the continuum: dominant wavelength {min, mid, max} x FWHM {low, mid, high} x intensity {min, mid, max} per channel" }, "models": { "UncorrectedColor": [ @@ -106,7 +106,7 @@ "rows": [ { "part": "SK6812", - "admissible_profiles": 5832, + "sampled_profiles": 19683, "models": { "UncorrectedColor": { "nominal_median": 6.91, @@ -115,72 +115,72 @@ 3.6, 10.5 ], - "median_spread": 6.9, + "median_spread": 6.91, "absolute_nominal_median": 24.48, "absolute_nominal_p95": 30.97, "p95_range": [ - 7.77, + 7.69, 15.41 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 15.01, "nominal_p95": 19.31, "median_range": [ - 13.08, + 12.74, 16.29 ], - "median_spread": 3.21, + "median_spread": 3.55, "absolute_nominal_median": 25.86, "absolute_nominal_p95": 33.79, "p95_range": [ - 17.41, + 17.32, 21.08 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 12.39, "nominal_p95": 19.59, "median_range": [ - 10.87, + 10.57, 14.48 ], - "median_spread": 3.61, + "median_spread": 3.91, "absolute_nominal_median": 19.48, "absolute_nominal_p95": 25.86, "p95_range": [ 17.92, - 21.04 + 21.06 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "WS2812B", - "admissible_profiles": 5832, + "sampled_profiles": 19683, "models": { "UncorrectedColor": { "nominal_median": 1.22, "nominal_p95": 14.45, "median_range": [ - 1.08, + 1.07, 3.69 ], - "median_spread": 2.61, + "median_spread": 2.62, "absolute_nominal_median": 22.75, "absolute_nominal_p95": 28.84, "p95_range": [ 12.64, 16.52 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 15.11, @@ -196,8 +196,8 @@ 18.94, 20.42 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 12.98, @@ -213,14 +213,14 @@ 19.18, 21.12 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "WS2813A", - "admissible_profiles": 729, + "sampled_profiles": 729, "models": { "UncorrectedColor": { "nominal_median": 4.4, @@ -236,8 +236,8 @@ 8.38, 10.4 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 14.94, @@ -253,8 +253,8 @@ 21.06, 21.79 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 10.64, @@ -270,14 +270,14 @@ 17.21, 19.16 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "WS2813B", - "admissible_profiles": 729, + "sampled_profiles": 729, "models": { "UncorrectedColor": { "nominal_median": 3.99, @@ -293,8 +293,8 @@ 8.36, 10.44 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 15.12, @@ -310,8 +310,8 @@ 21.24, 21.97 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 10.52, @@ -327,14 +327,14 @@ 17.0, 18.85 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "WS2813C", - "admissible_profiles": 729, + "sampled_profiles": 729, "models": { "UncorrectedColor": { "nominal_median": 2.24, @@ -350,8 +350,8 @@ 6.0, 7.99 ], - "within_budget_everywhere": false, - "within_budget_nowhere": false + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": false }, "TypicalLEDStrip": { "nominal_median": 15.16, @@ -367,8 +367,8 @@ 21.24, 22.03 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 11.72, @@ -384,14 +384,14 @@ 17.2, 19.77 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "WS2813D", - "admissible_profiles": 729, + "sampled_profiles": 729, "models": { "UncorrectedColor": { "nominal_median": 2.72, @@ -407,8 +407,8 @@ 6.22, 8.42 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 14.96, @@ -424,8 +424,8 @@ 21.05, 21.82 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 11.73, @@ -441,14 +441,14 @@ 17.43, 20.0 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "WS2815", - "admissible_profiles": 729, + "sampled_profiles": 729, "models": { "UncorrectedColor": { "nominal_median": 5.11, @@ -464,8 +464,8 @@ 8.42, 16.26 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 15.35, @@ -481,8 +481,8 @@ 21.24, 25.95 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 10.36, @@ -498,48 +498,48 @@ 17.0, 20.0 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "WS2816B-2121", - "admissible_profiles": 5832, + "sampled_profiles": 19683, "models": { "UncorrectedColor": { "nominal_median": 6.67, "nominal_p95": 15.44, "median_range": [ 2.64, - 10.78 + 11.21 ], - "median_spread": 8.14, + "median_spread": 8.57, "absolute_nominal_median": 6.55, "absolute_nominal_p95": 15.59, "p95_range": [ 7.77, 26.7 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 16.92, "nominal_p95": 21.13, "median_range": [ - 14.76, + 14.24, 20.08 ], - "median_spread": 5.32, + "median_spread": 5.84, "absolute_nominal_median": 17.48, "absolute_nominal_p95": 21.43, "p95_range": [ - 19.75, + 19.09, 27.92 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 14.51, @@ -555,14 +555,14 @@ 15.62, 25.26 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } }, { "part": "GS8208 LED5050 option", - "admissible_profiles": 729, + "sampled_profiles": 729, "models": { "UncorrectedColor": { "nominal_median": 2.88, @@ -578,8 +578,8 @@ 8.11, 18.52 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "TypicalLEDStrip": { "nominal_median": 14.58, @@ -595,8 +595,8 @@ 20.19, 24.89 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true }, "Typical8mmPixel": { "nominal_median": 10.84, @@ -612,8 +612,8 @@ 17.49, 22.27 ], - "within_budget_everywhere": false, - "within_budget_nowhere": true + "within_budget_at_every_sample": false, + "outside_budget_at_every_sample": true } } } diff --git a/tools/bound_legacy_correction_accuracy.py b/tools/bound_legacy_correction_accuracy.py index c094078..e300497 100644 --- a/tools/bound_legacy_correction_accuracy.py +++ b/tools/bound_legacy_correction_accuracy.py @@ -5,9 +5,10 @@ profiles. The integrated-LED datasheets publish a *dominant-wavelength range* and a *luminous-intensity range* per emitter, not chromaticity, so a single derived profile would manufacture precision the PDFs lack. This tool instead -propagates what the datasheets do state into an *interval* of admissible -profiles, and reports the ΔE2000 each legacy model scores across that -interval. +samples what the datasheets do state -- each range's ends and midpoint -- into +a grid of profiles, and reports the ΔE2000 each legacy model scores across +that grid. A grid is an inner bound on the continuous ranges, not a proof over +them. Stated assumptions (not datasheet facts, and named in every output): @@ -396,8 +397,15 @@ def evaluate(cmf) -> dict: for d in (d_lo, (d_lo + d_hi) / 2, d_hi) for w in (w_lo, (w_lo + w_hi) / 2, w_hi) ] + # min, mid and max, so the nominal (mid) profile is one of the samples. lum_options = { - name: sorted({channels[name][1][0], channels[name][1][1]}) + name: sorted( + { + channels[name][1][0], + sum(channels[name][1]) / 2, + channels[name][1][1], + } + ) for name in CHANNELS } nominal_xy = [ @@ -423,15 +431,15 @@ def evaluate(cmf) -> dict: "absolute_nominal_median": round(statistics.median(absolute_errors), 2), "absolute_nominal_p95": round(p95(absolute_errors), 2), "p95_range": [round(min(p95s), 2), round(max(p95s), 2)], - "within_budget_everywhere": max(medians) <= BUDGET_MEDIAN + "within_budget_at_every_sample": max(medians) <= BUDGET_MEDIAN and max(p95s) <= BUDGET_P95, - "within_budget_nowhere": min(medians) > BUDGET_MEDIAN + "outside_budget_at_every_sample": min(medians) > BUDGET_MEDIAN or min(p95s) > BUDGET_P95, } rows.append( { "part": part, - "admissible_profiles": len(xy_options["red"]) ** 3 + "sampled_profiles": len(xy_options["red"]) ** 3 * math.prod(len(v) for v in lum_options.values()), "models": per_model, } @@ -450,7 +458,7 @@ def evaluate(cmf) -> dict: "metric": "CIEDE2000 in CIELAB with device white as reference", "test_colours": [list(c) for c in TEST_COLOURS], "budget": {"median": BUDGET_MEDIAN, "p95": BUDGET_P95}, - "sampling": "dominant wavelength {min, mid, max} x FWHM {low, mid, high} per channel; intensity {min, max} per channel", + "sampling": "grid, not the continuum: dominant wavelength {min, mid, max} x FWHM {low, mid, high} x intensity {min, mid, max} per channel", }, "models": {k: [round(v, 6) for v in c] for k, c in MODELS.items()}, "rows": rows, @@ -463,8 +471,9 @@ def render_report(data: dict) -> str: "", "Generated by `tools/bound_legacy_correction_accuracy.py` for FastLED/FastLED#4035.", "**This is a bound, not a measurement.** The datasheets publish dominant-wavelength", - "and luminous-intensity ranges, not chromaticity, so every admissible profile the", - "published ranges allow is scored, under two stated assumptions:", + "and luminous-intensity ranges, not chromaticity, so the ranges are sampled on a grid", + "(each channel's dominant wavelength min/mid/max × FWHM low/mid/high × intensity", + "min/mid/max) and every grid profile is scored, under two stated assumptions:", "", "- each emitter's spectrum is a Gaussian whose dominant wavelength (about equal-energy", f" white E) matches the datasheet, with FWHM red {FWHM_NM['red'][0]:g}–{FWHM_NM['red'][1]:g} nm,", @@ -478,20 +487,22 @@ def render_report(data: dict) -> str: "neutral ramp (32–255), RGB primaries and CMY secondaries. Budget for a", f"datasheet-derived profile: median ≤ {BUDGET_MEDIAN:g}, p95 ≤ {BUDGET_P95:g}.", "", - "`nominal` uses mid wavelength, mid FWHM and mid intensity. The ranges are the minimum", - "and maximum of each statistic over every admissible profile.", + "`nominal` is the grid's centre point: mid wavelength, mid FWHM, mid intensity. The", + "ranges are the minimum and maximum of each statistic over the sampled grid. They are", + "not a proof over the continuous ranges: a grid can miss a worse or better interior", + "point, so each range is an inner bound on the true one.", "", "| part | model | nominal median | nominal p95 | median range | median spread | p95 range | verdict |", "| --- | --- | ---: | ---: | --- | ---: | --- | --- |", ] for row in data["rows"]: for model, r in row["models"].items(): - if r["within_budget_everywhere"]: - verdict = "within budget for every admissible profile" - elif r["within_budget_nowhere"]: - verdict = "outside budget for every admissible profile" + if r["within_budget_at_every_sample"]: + verdict = "within budget at every sample" + elif r["outside_budget_at_every_sample"]: + verdict = "outside budget at every sample" else: - verdict = "undetermined: datasheet ranges straddle the budget" + verdict = "samples straddle the budget" lines.append( f"| {row['part']} | {model} | {r['nominal_median']:.2f} | {r['nominal_p95']:.2f} | " f"{r['median_range'][0]:.2f}–{r['median_range'][1]:.2f} | {r['median_spread']:.2f} | " @@ -499,18 +510,21 @@ def render_report(data: dict) -> str: ) cells = [(row["part"], m, r) for row in data["rows"] for m, r in row["models"].items()] wide = sum(1 for _, _, r in cells if r["median_spread"] > BUDGET_MEDIAN) - outside = sum(1 for _, _, r in cells if r["within_budget_nowhere"]) - inside = sum(1 for _, _, r in cells if r["within_budget_everywhere"]) + outside = sum(1 for _, _, r in cells if r["outside_budget_at_every_sample"]) + inside = sum(1 for _, _, r in cells if r["within_budget_at_every_sample"]) lines += [ "", "## What this establishes", "", f"- **Datasheet ranges cannot pin accuracy to the A1 budget.** In {wide} of {len(cells)} part/model", - f" cells the admissible median ΔE2000 alone spans more than the whole {BUDGET_MEDIAN:g} median budget,", - " so no single datasheet-derived profile could be defended at that budget. A", - " datasheet-derived runtime profile needs xy or spectral data the PDFs do not publish.", - f"- **Legacy models under A1:** {outside} of {len(cells)} cells are outside budget for every admissible", - f" profile, {inside} within budget for every one, and the rest undetermined.", + f" cells the median ΔE2000 over the sampled grid alone spans more than the whole", + f" {BUDGET_MEDIAN:g} median budget. A sampled spread can only understate the continuous one,", + " so this holds for the ranges themselves: no single datasheet-derived profile could", + " be defended at that budget, and a runtime profile needs xy or spectral data the", + " PDFs do not publish.", + f"- **Legacy models under A1:** {outside} of {len(cells)} cells are outside budget at every", + f" sampled profile, {inside} within budget at every one, and the rest straddle it. These", + " verdicts are over the grid, not proven over the continuous ranges.", " `TypicalLEDStrip` and `Typical8mmPixel` score worse than `UncorrectedColor` under A1", " because A1 is relative colorimetry: the target is adapted to the device's own", " full-drive white, so a correction that tints that white is penalized. The",