FastLED 3.10.8
Loading...
Searching...
No Matches
rgbw_colorimetric.h
Go to the documentation of this file.
1
36
37#pragma once
38
40#include "fl/gfx/rgbw.h"
41#include "fl/math/math.h"
42#include "fl/stl/stdint.h"
43#include "fl/stl/unique_ptr.h"
44
45namespace fl {
47
48// ===== Shared primitives — re-exported from colorimetric_response ===========
49//
50// The pure-math leaf helpers, the LUT quantization constants, and the
51// `LutInterp` enum now live in `colorimetric_response::` (#3255). They are
52// re-exported here so callers that reach into `colorimetric_detail::name`
53// (and tests using `using namespace fl::colorimetric_detail`) keep
54// compiling without churn. New code should prefer the
55// `colorimetric_response::` namespace directly.
56
57using colorimetric_response::cct_to_xy; // ok bare using
58using colorimetric_response::xyY_to_XYZ; // ok bare using
59using colorimetric_response::invert3x3; // ok bare using
60using colorimetric_response::matvec3; // ok bare using
61using colorimetric_response::barycentric_xy; // ok bare using
63using colorimetric_response::quantize_u8; // ok bare using
64using colorimetric_response::quantize_lut_cell; // ok bare using
65using colorimetric_response::nnls3; // ok bare using
66using colorimetric_response::hermite_basis; // ok bare using
67using colorimetric_response::LutInterp; // ok bare using
68
69// `constexpr` variables cannot be re-exported via using-declarations in a
70// way that preserves constant-expression status across all consumers, so
71// shadow them here as initialized constexpr aliases pointing at the
72// shared definitions. The values come from one place — the shared header —
73// and any future change there flows through automatically.
77
78// ===== RGB colorimetric layer — re-exported for backward-compat callers =====
79using colorimetric_response::EmitterProfile; // ok bare using
83using colorimetric_response::rgb_source_to_XYZ; // ok bare using
85
86// ===== RGBW-specific topology helpers =======================================
87
88// Native-gamut detection (#2748). Returns true when the source primaries
89// of `p` (input_xy_r/g/b) match the LED's measured primaries (xy_r/g/b)
90// within float tolerance — i.e. the user is feeding "native LED gamut"
91// drive coordinates rather than a foreign gamut like sRGB / Rec.2020.
92//
93// Native mode has stricter topology authority than named-gamut mode:
94//
95// * Single-channel inputs are exact drive identity:
96// (R,0,0)->(R,0,0,0), (0,G,0)->(0,G,0,0), (0,0,B)->(0,0,B,0).
97//
98// * Dual-channel outer edges are channel-set identity, not value identity.
99// For RG/RB/GB edges the inactive RGB channel and W must remain zero, but
100// the two active channels are still solved from the source target XYZ and
101// the measured diode XYZ/Y columns. This is why yellow_half is not simply
102// raw (0.5,0.5,0,0): measured R/G luminance and the D65 source matrix set
103// the correct active-channel ratio.
104//
105// * Three-channel inputs fall through to the strict RGBW sub-gamut routing
106// and may use W inside RGW/RBW/BGW.
107//
108// The W chromaticity (input_xy_w) is intentionally NOT compared here: native
109// authority is about RGB topology. Different target whites affect the source
110// matrix and full-3-channel routing, not whether a pure/edge native input is
111// allowed to introduce W.
113 constexpr float kPrimaryEps = 1e-6f;
114 auto close = [](const float a[2], const float b[2]) FL_NO_EXCEPT {
115 const float dx = a[0] - b[0];
116 const float dy = a[1] - b[1];
117 return (dx * dx + dy * dy) < kPrimaryEps;
118 };
119 return close(p.input_xy_r, p.xy_r)
120 && close(p.input_xy_g, p.xy_g)
121 && close(p.input_xy_b, p.xy_b);
122}
123
124// Topology activity classifier (#2748). Counts how many of `s_r, s_g, s_b`
125// are above the LSB-level epsilon. The solvers use this to select the native
126// authority fast path:
127// n == 1 : exact single-axis identity
128// n == 2 : fixed-topology two-emitter solve
129// n == 3 : normal strict / LP / overdrive interior solve
130inline int count_active_channels(float s_r, float s_g, float s_b) FL_NO_EXCEPT {
131 // 1 / 65535 — matches the 16-bit verifier precision used in the
132 // reference math model; anything below this is below noise floor.
133 constexpr float kTopoEps = 1.0f / 65535.0f;
134 int n = 0;
135 if (s_r > kTopoEps) ++n;
136 if (s_g > kTopoEps) ++n;
137 if (s_b > kTopoEps) ++n;
138 return n;
139}
140
141// ===== Types =================================================================
142
143// Precomputed per-profile data: emitter XYZ columns + the four matrix inverses
144// the solvers need. Built once when the active (profile, cct) pair changes.
145// xy_w is the *effective* W chromaticity — equal to profile->xy_w when no CCT
146// override is active, otherwise the cct_to_xy() shift. The strict solvers
147// must route barycentric containment against this value (not the profile's
148// raw xy_w) or they will choose the wrong sub-gamut for CCT-shifted whites.
151 float xy_w[2]; // effective W chromaticity (incl. CCT shift)
152 float P_R[3], P_G[3], P_B[3], P_W[3]; // emitter XYZ at full drive
153 float P_RGB_inv[3][3]; // [R G B]^-1, used by wx_lp_legacy
154 float P_RGW_inv[3][3]; // [R G W]^-1
155 float P_RBW_inv[3][3]; // [R B W]^-1
156 float P_BGW_inv[3][3]; // [B G W]^-1
157 float d_W[3]; // P_RGB_inv * P_W (wx_lp_legacy cache)
158
159 // Source-space → measured-device XYZ matrix (#2705).
160 //
161 // build_source_matrix() first derives the standard normalized CIE source
162 // matrix from profile.input_xy_* (source white has Y=1). build_profile_cache()
163 // then scales that matrix by the reference white-fit factor used by the
164 // math model so M_src·[1,1,1] is in the same absolute XYZ/Y domain as the
165 // measured emitter columns P_R/P_G/P_B/P_W.
166 //
167 // This scaling is essential: all runtime solves compare M_src targets
168 // directly against measured diode columns. Leaving M_src at Y=1 would make
169 // native D65 dual-edge and LP solves underpowered and would not match the
170 // reference model/cube.
171 //
172 // When the source chromaticities are degenerate (input_xy_w[1] == 0, the
173 // default for value-initialized `DiodeProfile{}`), `has_source_space` is
174 // false and solvers fall back to direct device-emitter projection.
175 float M_src[3][3];
177};
178
179// Owns its cell storage via fl::unique_ptr. Obtain via `build_lut(...)`,
180// which atomically allocates + populates. Lookup code can rely on
181// .cells.get() being non-null for the table's lifetime.
182struct LutTable {
183 int N = 0; // grid edge length
184 LutInterp interp = LutInterp::Bilinear; // storage / interp scheme
185 float xy_min[2] = {0.0f, 0.0f}; // grid origin in xy
186 float xy_max[2] = {0.0f, 0.0f}; // grid extent in xy
187 fl::unique_ptr<i16[]> cells; // size = N*N*stride for chosen interp
188};
189
190// Two-emitter (warm-W, cool-W) profile for RGBCCT layered solver.
192 DiodeProfile warm_path; // R, G, B, W=warm — typical xy_w ≈ 3000K
193 DiodeProfile cool_path; // R, G, B, W=cool — typical xy_w ≈ 6500K
194};
195
196// Convenience: derive eta from a target CCT relative to the warm/cool
197// reference CCTs. Linear interpolation in CCT space, clamped to [0, 1].
198inline float rgbcct_eta_for_cct(int target_cct, int warm_cct,
199 int cool_cct) FL_NO_EXCEPT {
200 if (cool_cct == warm_cct) return 0.5f;
201 const float t = (static_cast<float>(target_cct) - warm_cct)
202 / (static_cast<float>(cool_cct) - warm_cct);
203 return fl::clamp(t, 0.0f, 1.0f);
204}
205
206// ===== Function declarations (definitions in rgbw_colorimetric.cpp.hpp) =====
207// All gated by FASTLED_RGBW_COLORIMETRIC at the definition site. With the
208// macro off, the symbols simply don't exist in the library — the dispatch
209// in rgbw.cpp.hpp falls back to the stub path (warn-once + rgb_2_rgbw_exact).
210
211// Build the per-profile cache. If cct_override is in [1500, 15000], the W
212// vertex chromaticity is replaced with cct_to_xy(cct_override). Pass
213// cct_override = 0 to use the profile's xy_w unchanged.
214void build_profile_cache(const DiodeProfile* p, int cct_override,
216
217// Convenience overload: no CCT override.
219 ProfileCache* cache) FL_NO_EXCEPT {
220 build_profile_cache(p, 0, cache);
221}
222
223// Strict sub-gamut solver (gist sec 5).
224//
225// Runtime path:
226// 1. Black/near-black returns zero.
227// 2. Native single-axis inputs return exact identity.
228// 3. Native dual-edge inputs are locked to RG/RB/GB and solved as a measured
229// two-emitter least-squares problem — no W/third-channel bleed, but also
230// not raw passthrough.
231// 4. Interior inputs split source value from chroma, solve the full-chroma
232// endpoint in one of {RGW, RBW, BGW}, then scale by source value.
233//
234// Returns false only for numerically degenerate profiles / matrices where the
235// caller should fall back to a simpler path.
236bool solve_strict_subgamut(const ProfileCache& cache, float s_r,
237 float s_g, float s_b,
238 float out_rgbw[4]) FL_NO_EXCEPT;
239
240// Per-chromaticity variant: starts from explicit (xy, Y) instead of input RGB.
241// Used by the LUT builder where xy/Y have already been chosen by the table
242// sampling pass. This path intentionally skips native input topology rules
243// because there is no source RGB active-channel mask attached to an arbitrary
244// xy sample.
246 const float xy_t[2], float source_value,
247 float out_rgbw[4]) FL_NO_EXCEPT;
248
249// Reference wx_lp_legacy solver.
250//
251// This is the analytical runtime counterpart of the reference math-model
252// "wx_lp_legacy" cube. It is not the boosted/overdrive mode. It solves a
253// chromaticity-preserving bounded endpoint:
254// 1. honor native single/dual topology authority;
255// 2. split source value from chroma;
256// 3. project the full-chroma target into the reachable hull if needed;
257// 4. solve the xy constraints while maximizing W;
258// 5. keep a small RGB floor when the four-channel manifold can represent the
259// target, normalize the endpoint, then apply the original source value.
260//
261// Native single-axis inputs remain exact identity. Native dual edges remain
262// locked to RG/RB/GB but are solved from measured/source XYZ instead of raw
263// passthrough. This is intentionally separate from boosted overdrive below.
264bool solve_wx_lp_legacy(const ProfileCache& cache, float s_r, float s_g,
265 float s_b, float out_rgbw[4]) FL_NO_EXCEPT;
266
267// White-overdrive / boosted solver (#2706). Uses a separate visual policy
268// that pushes W past the non-overdriven residual boundary by
269// `overdrive_ratio`, accepting chromaticity drift toward the W diode in
270// exchange for higher luminance. Keep this separate from wx_lp_legacy; the LP
271// legacy reference is the bounded chromaticity-preserving solver above.
272void solve_wx_overdrive(const ProfileCache& cache, float s_r, float s_g,
273 float s_b, float overdrive_ratio,
274 float out_rgbw[4]) FL_NO_EXCEPT;
275
276// Default overdrive ratio for `kRGBWColorimetricBoosted`. 0.5 = halfway
277// between the strict vertex and full W; produces visibly brighter output
278// than the strict mode while limiting chromaticity drift.
279constexpr float kDefaultOverdriveRatio = 0.5f;
280
281// Allocate + populate a LUT for `cache` at `grid_n` edge length, using
282// `interp` (default Hermite). Hermite tables carry per-cell value + slope
283// (∂/∂t_x, ∂/∂t_y), tripling per-cell storage but typically achieving lower
284// error than bilinear at the same grid size — enabling small tables (N=8..16)
285// suitable for memory-constrained targets.
286LutTable build_lut(const ProfileCache& cache, int grid_n,
287 LutInterp interp) FL_NO_EXCEPT;
288
289inline LutTable build_lut(const ProfileCache& cache, int grid_n) FL_NO_EXCEPT {
290 return build_lut(cache, grid_n, LutInterp::Hermite);
291}
292
293// Bilinear lookup + Y multiply + normalize.
294void lookup_lut(const LutTable& lut, const float xy_t[2], float source_value,
295 float out_rgbw[4]) FL_NO_EXCEPT;
296
297// RGBCCT layered solver: solve target against each W diode separately, then
298// line-blend the resulting RGBW tuples by an eta in [0, 1].
299// Output layout: out[0..2] = RGB, out[3] = warm-W, out[4] = cool-W.
300void solve_rgbcct(const RgbcctProfile& profile, float s_r, float s_g,
301 float s_b, float eta, float out[5]) FL_NO_EXCEPT;
302
303} // namespace colorimetric_detail
304} // namespace fl
Shared colorimetric primitives reusable by the RGBW and (future) RGB colorimetric paths (issue #3255)...
Functions for red, green, blue, white (RGBW) output.
void solve_wx_overdrive(const ProfileCache &cache, float s_r, float s_g, float s_b, float overdrive_ratio, float out_rgbw[4]) FL_NO_EXCEPT
void solve_rgbcct(const RgbcctProfile &profile, float s_r, float s_g, float s_b, float eta, float out[5]) FL_NO_EXCEPT
int count_active_channels(float s_r, float s_g, float s_b) FL_NO_EXCEPT
bool is_native_input_gamut(const DiodeProfile &p) FL_NO_EXCEPT
LutTable build_lut(const ProfileCache &cache, int grid_n, LutInterp interp) FL_NO_EXCEPT
void lookup_lut(const LutTable &lut, const float xy_t[2], float source_value, float out_rgbw[4]) FL_NO_EXCEPT
constexpr float kDefaultOverdriveRatio
bool solve_strict_subgamut_xy(const ProfileCache &cache, const float xy_t[2], float source_value, float out_rgbw[4]) FL_NO_EXCEPT
float rgbcct_eta_for_cct(int target_cct, int warm_cct, int cool_cct) FL_NO_EXCEPT
bool solve_wx_lp_legacy(const ProfileCache &cache, float s_r, float s_g, float s_b, float out_rgbw[4]) FL_NO_EXCEPT
bool solve_strict_subgamut(const ProfileCache &cache, float s_r, float s_g, float s_b, float out_rgbw[4]) FL_NO_EXCEPT
void build_profile_cache(const DiodeProfile *p, int cct_override, ProfileCache *cache) FL_NO_EXCEPT
u8 quantize_u8(float v) FL_NO_EXCEPT
void hermite_basis(float t, float out[4]) FL_NO_EXCEPT
void matvec3(const float M[3][3], const float v[3], float out[3]) FL_NO_EXCEPT
bool invert3x3(const float in[3][3], float out[3][3]) FL_NO_EXCEPT
bool barycentric_xy(const float t[2], const float A[2], const float B[2], const float C[2], float bary[3]) FL_NO_EXCEPT
void nnls3(const float M[3][3], const float b[3], float t_out[3], float *residual_out) FL_NO_EXCEPT
bool build_source_matrix(const float xy_r[2], const float xy_g[2], const float xy_b[2], const float xy_w[2], float M_out[3][3]) FL_NO_EXCEPT
void rgb_source_to_XYZ(const RgbColorimetricCache &cache, float s_r, float s_g, float s_b, float X_t[3]) FL_NO_EXCEPT
void xyY_to_XYZ(float x, float y, float Y, float out[3]) FL_NO_EXCEPT
bool solve_rgb_colorimetric(const RgbColorimetricCache &cache, float s_r, float s_g, float s_b, float out_rgb[3]) FL_NO_EXCEPT
void cct_to_xy(int cct, float out[2]) FL_NO_EXCEPT
bool build_rgb_colorimetric_cache(const EmitterProfile &p, RgbColorimetricCache *cache) FL_NO_EXCEPT
i16 quantize_lut_cell(float v) FL_NO_EXCEPT
u8 u8 fract8 blur_amount FL_NO_EXCEPT
Definition blur.h:186
constexpr enable_if< is_fixed_point< T >::value, T >::type clamp(T x, T lo, T hi) FL_NO_EXCEPT
Base definition for an LED controller.
Definition crgb.hpp:179