FastLED 3.9.15
Loading...
Searching...
No Matches
rgbw_colorimetric.cpp.hpp
Go to the documentation of this file.
1
5
7
8#if FASTLED_RGBW_COLORIMETRIC
9
10#include "fl/log/log.h"
11#include "fl/math/math.h"
12#include "fl/stl/unique_ptr.h"
13
14namespace fl {
15namespace colorimetric_detail {
16
17// =============================================================================
18// Implementation map
19// =============================================================================
20//
21// The colorimetric path is deliberately small-matrix analytical math rather
22// than a baked 3D LUT. The main implementation pieces are:
23//
24// build_profile_cache()
25// Converts measured xy/Y emitters into absolute XYZ columns, caches the
26// three strict sub-gamut inverses, caches the RGB inverse used by W/LP
27// solvers, and scales the source RGB->XYZ matrix into the same absolute
28// device-Y domain.
29//
30// solve_strict_subgamut()
31// Reference strict RGBW solve. Native singles are exact identity; native
32// dual edges are fixed-topology measured two-emitter solves; interiors
33// solve a full-chroma endpoint in RGW/RBW/BGW and then apply value.
34//
35// solve_wx_lp_legacy()
36// Reference white-extraction solve. It maximizes W while preserving target
37// chromaticity, then applies value. This is separate from boosted mode.
38//
39// solve_wx_overdrive()
40// Visual boosted policy that intentionally pushes W beyond the LP/strict
41// residual boundary and therefore may drift toward the W diode.
42//
43// The most important invariant is value/chroma separation. If we solve a
44// value-scaled RGB node directly and then normalize, several value levels can
45// collapse to the same saturated tuple. Solving the full-chroma endpoint
46// first, normalizing/projection there, and applying the original value last
47// preserves fixed-hue HSV ramps and matches the Python reference model.
48
49// Build all per-profile quantities once so the per-pixel runtime path is just
50// a few matrix-vector operations and tiny fixed-size solves.
51//
52// Important scale convention:
53// * P_R/P_G/P_B/P_W are measured emitter columns in absolute device XYZ/Y.
54// * build_source_matrix() returns a normalized source matrix where white
55// has Y=1.
56// * We scale M_src by the same white-fit factor used by the reference math
57// model so source targets and measured emitter columns share units.
58//
59// Without the M_src scale, native/D65 dual edges and LP legacy would compare
60// a Y=1 source target against hundreds/thousands of device-Y emitter units.
61// The result is underpowered solves and the wrong active-channel ratios.
62void build_profile_cache(const DiodeProfile* p, int cct_override,
64 cache->profile = p;
65 xyY_to_XYZ(p->xy_r[0], p->xy_r[1], p->lum_r, cache->P_R);
66 xyY_to_XYZ(p->xy_g[0], p->xy_g[1], p->lum_g, cache->P_G);
67 xyY_to_XYZ(p->xy_b[0], p->xy_b[1], p->lum_b, cache->P_B);
68 cache->xy_w[0] = p->xy_w[0];
69 cache->xy_w[1] = p->xy_w[1];
70 if (cct_override >= 1500 && cct_override <= 15000) {
71 cct_to_xy(cct_override, cache->xy_w);
72 }
73 xyY_to_XYZ(cache->xy_w[0], cache->xy_w[1], p->lum_w, cache->P_W);
74
75 auto pack = [](const float* a, const float* b, const float* c,
76 float out[3][3]) FL_NOEXCEPT {
77 out[0][0] = a[0]; out[0][1] = b[0]; out[0][2] = c[0];
78 out[1][0] = a[1]; out[1][1] = b[1]; out[1][2] = c[1];
79 out[2][0] = a[2]; out[2][1] = b[2]; out[2][2] = c[2];
80 };
81
82 float P_RGB[3][3], P_RGW[3][3], P_RBW[3][3], P_BGW[3][3];
83 pack(cache->P_R, cache->P_G, cache->P_B, P_RGB);
84 pack(cache->P_R, cache->P_G, cache->P_W, P_RGW);
85 pack(cache->P_R, cache->P_B, cache->P_W, P_RBW);
86 pack(cache->P_B, cache->P_G, cache->P_W, P_BGW);
87
88 // Inverting a singular matrix leaves the destination with whatever
89 // invert3x3 wrote — solvers reading it would silently produce garbage.
90 // Warn once if any inversion fails so the user knows their profile has
91 // degenerate primaries (colinear chromaticities, near-zero luminance, etc).
92 const bool ok_rgb = invert3x3(P_RGB, cache->P_RGB_inv);
93 const bool ok_rgw = invert3x3(P_RGW, cache->P_RGW_inv);
94 const bool ok_rbw = invert3x3(P_RBW, cache->P_RBW_inv);
95 const bool ok_bgw = invert3x3(P_BGW, cache->P_BGW_inv);
96 if (!(ok_rgb && ok_rgw && ok_rbw && ok_bgw)) {
97 FL_WARN_ONCE("RGBW colorimetric: profile has degenerate primaries — "
98 "one or more sub-gamut matrix inversions failed. Output "
99 "colors will be incorrect. Check DiodeProfile xy/lum values.");
100 // Zero-init any failed inverse so downstream matvec3() output is
101 // deterministic (zero) rather than UB-valued garbage.
102 auto zero3x3 = [](float m[3][3]) FL_NOEXCEPT {
103 for (int i = 0; i < 3; ++i)
104 for (int j = 0; j < 3; ++j) m[i][j] = 0.0f;
105 };
106 if (!ok_rgb) zero3x3(cache->P_RGB_inv);
107 if (!ok_rgw) zero3x3(cache->P_RGW_inv);
108 if (!ok_rbw) zero3x3(cache->P_RBW_inv);
109 if (!ok_bgw) zero3x3(cache->P_BGW_inv);
110 }
111
112 matvec3(cache->P_RGB_inv, cache->P_W, cache->d_W);
113
114 // Source-space matrix (#2705). When input_xy_w is degenerate (the
115 // legacy value-initialized case `DiodeProfile{}`), leave M_src empty
116 // and signal the solvers to fall back to the device-emitter projection.
117 cache->has_source_space = (p->input_xy_w[1] > 1e-6f)
118 && build_source_matrix(p->input_xy_r, p->input_xy_g,
119 p->input_xy_b, p->input_xy_w,
120 cache->M_src);
121 if (cache->has_source_space) {
122 // build_source_matrix() produces the standard normalized CIE source
123 // matrix with source white at Y=1. The solver matrices below are in
124 // measured-device absolute XYZ units, so scale M_src by the same
125 // white-fit factor used by the reference math model:
126 // K = 1 / max(solve_subgamut(source_white_Y1))
127 // This makes M_src·[1,1,1] land at the brightest achievable device
128 // white instead of an underpowered Y=1 target. Without this scale,
129 // native/D65 dual-edge and LP solves cannot match the Python model.
130 float X_w[3];
131 xyY_to_XYZ(p->input_xy_w[0], p->input_xy_w[1], 1.0f, X_w);
132
133 const float (*invs[3])[3] = {
134 cache->P_RGW_inv, cache->P_RBW_inv, cache->P_BGW_inv,
135 };
136 float best_max_t = 0.0f;
137 bool found_scale = false;
138 constexpr float kScaleEps = 1e-6f;
139 for (int k = 0; k < 3; ++k) {
140 float t[3];
141 matvec3(invs[k], X_w, t);
142 if (t[0] < -kScaleEps || t[1] < -kScaleEps || t[2] < -kScaleEps) {
143 continue;
144 }
145 const float mt = fl::max(fl::max(t[0], t[1]), t[2]);
146 if (mt > kScaleEps && (!found_scale || mt < best_max_t)) {
147 best_max_t = mt;
148 found_scale = true;
149 }
150 }
151 if (!found_scale) {
152 float t[3];
153 matvec3(cache->P_RGB_inv, X_w, t);
154 const float mt = fl::max(fl::max(fl::fabs(t[0]), fl::fabs(t[1])),
155 fl::fabs(t[2]));
156 if (mt > kScaleEps) {
157 best_max_t = mt;
158 found_scale = true;
159 }
160 }
161 const float scale_k = found_scale ? (1.0f / best_max_t) : 1.0f;
162 for (int i = 0; i < 3; ++i) {
163 for (int j = 0; j < 3; ++j) {
164 cache->M_src[i][j] *= scale_k;
165 }
166 }
167 } else {
168 for (int i = 0; i < 3; ++i)
169 for (int j = 0; j < 3; ++j) cache->M_src[i][j] = 0.0f;
170 }
171}
172
173// Project an out-of-hull target XYZ onto the achievable LED gamut (#2708,
174// math-model gist §3). Returns true if a projection was applied, in which
175// case `X_t` and `xy_t` are updated to the projected point at unit Y. When
176// the target is already inside the full RGB triangle, returns false and
177// leaves `X_t` / `xy_t` untouched.
178//
179// Algorithm matches the reference's `_strict_project_target_xyz_to_led_hull`:
180// run NNLS in each of the three sub-gamuts (RGW / RBW / BGW), cap drive at
181// 1.0 per sub-gamut, then pick the sub-gamut with smallest residual. Use
182// the achieved chromaticity as the new target xy.
183static bool project_to_hull(const ProfileCache& cache,
184 float X_t[3], float xy_t[2]) FL_NOEXCEPT {
185 const float sum = X_t[0] + X_t[1] + X_t[2];
186 if (sum < 1e-12f) return false;
187 xy_t[0] = X_t[0] / sum;
188 xy_t[1] = X_t[1] / sum;
189
190 const DiodeProfile& p = *cache.profile;
191 float bary[3];
192 // Test the full RGB triangle (not the sub-gamut triangles) — a target
193 // can be outside a single sub-gamut yet still inside the full hull.
194 if (barycentric_xy(xy_t, p.xy_r, p.xy_g, p.xy_b, bary)
195 && bary[0] >= -1e-9f && bary[1] >= -1e-9f && bary[2] >= -1e-9f) {
196 return false;
197 }
198
199 struct Tri { const float* a; const float* b; const float* c; };
200 const Tri tris[3] = {
201 { cache.P_R, cache.P_G, cache.P_W },
202 { cache.P_R, cache.P_B, cache.P_W },
203 { cache.P_B, cache.P_G, cache.P_W },
204 };
205 float best_xyz[3] = {0, 0, 0};
206 float best_residual = 1e30f;
207 for (int k = 0; k < 3; ++k) {
208 const Tri& tri = tris[k];
209 const float M[3][3] = {
210 { tri.a[0], tri.b[0], tri.c[0] },
211 { tri.a[1], tri.b[1], tri.c[1] },
212 { tri.a[2], tri.b[2], tri.c[2] },
213 };
214 float t[3], residual;
215 nnls3(M, X_t, t, &residual);
216 // Cap drive at full-scale per sub-gamut, matching the reference.
217 float mt = t[0];
218 if (t[1] > mt) mt = t[1];
219 if (t[2] > mt) mt = t[2];
220 if (mt > 1.0f) { const float inv = 1.0f / mt; t[0] *= inv; t[1] *= inv; t[2] *= inv; }
221 float xyz[3];
222 xyz[0] = M[0][0]*t[0] + M[0][1]*t[1] + M[0][2]*t[2];
223 xyz[1] = M[1][0]*t[0] + M[1][1]*t[1] + M[1][2]*t[2];
224 xyz[2] = M[2][0]*t[0] + M[2][1]*t[1] + M[2][2]*t[2];
225 if (xyz[1] <= 1e-12f) continue;
226 if (residual < best_residual) {
227 best_residual = residual;
228 best_xyz[0] = xyz[0]; best_xyz[1] = xyz[1]; best_xyz[2] = xyz[2];
229 }
230 }
231 if (best_xyz[1] <= 1e-12f) {
232 // All sub-gamut projections collapsed to zero — pathological
233 // profile or extreme target. Leave the original X_t alone; the
234 // strict solver will return false and the dispatch falls back.
235 return false;
236 }
237 const float s2 = best_xyz[0] + best_xyz[1] + best_xyz[2];
238 xy_t[0] = best_xyz[0] / s2;
239 xy_t[1] = best_xyz[1] / s2;
240 // Per reference, normalize Y to 1.0 for downstream routing. The full-chroma
241 // topology is re-solved from this projected XYZ in the caller.
242 xyY_to_XYZ(xy_t[0], xy_t[1], 1.0f, X_t);
243 return true;
244}
245
246
247// Small leaf helpers used by the solvers below. They are kept local to this
248// translation unit so the public header stays focused on reusable math helpers
249// and type declarations.
250static float max3f(float a, float b, float c) FL_NOEXCEPT {
251 return fl::max(fl::max(a, b), c);
252}
253
254static void zero4(float out[4]) FL_NOEXCEPT {
255 out[0] = out[1] = out[2] = out[3] = 0.0f;
256}
257
258static void normalize4_if_needed(float out[4]) FL_NOEXCEPT {
259 const float m = fl::max(fl::max(out[0], out[1]), fl::max(out[2], out[3]));
260 if (m > 1.0f) {
261 const float inv_m = 1.0f / m;
262 out[0] *= inv_m; out[1] *= inv_m; out[2] *= inv_m; out[3] *= inv_m;
263 }
264}
265
266static const float* column_for_idx(const ProfileCache& cache, int idx) FL_NOEXCEPT {
267 switch (idx) {
268 case 0: return cache.P_R;
269 case 1: return cache.P_G;
270 case 2: return cache.P_B;
271 default: return cache.P_W;
272 }
273}
274
275// Convert linear source RGB into measured-device absolute XYZ.
276//
277// In normal operation this uses cache.M_src, which has already been scaled into
278// the measured emitter domain by build_profile_cache(). The fallback is for
279// legacy / partially initialized profiles where input_xy_* is not populated:
280// then the caller's RGB values are interpreted as direct measured RGB emitter
281// drive fractions.
282static void source_rgb_to_XYZ(const ProfileCache& cache, float s_r,
283 float s_g, float s_b,
284 float X_t[3]) FL_NOEXCEPT {
285 if (cache.has_source_space) {
286 const float s[3] = { s_r, s_g, s_b };
287 matvec3(cache.M_src, s, X_t);
288 } else {
289 X_t[0] = cache.P_R[0] * s_r + cache.P_G[0] * s_g + cache.P_B[0] * s_b;
290 X_t[1] = cache.P_R[1] * s_r + cache.P_G[1] * s_g + cache.P_B[1] * s_b;
291 X_t[2] = cache.P_R[2] * s_r + cache.P_G[2] * s_g + cache.P_B[2] * s_b;
292 }
293}
294
295// Native single-axis authority. For true native input primaries, a pure
296// R/G/B source coordinate is already a request for that exact LED channel.
297// Do not route it through RGW/RBW/BGW or W/LP paths, since that can introduce
298// W or another primary due to white-point/source-matrix differences.
299static bool native_single_identity(float s_r, float s_g, float s_b,
300 float out[4]) FL_NOEXCEPT {
301 out[0] = fl::clamp(s_r, 0.0f, 1.0f);
302 out[1] = fl::clamp(s_g, 0.0f, 1.0f);
303 out[2] = fl::clamp(s_b, 0.0f, 1.0f);
304 out[3] = 0.0f;
305 return true;
306}
307
308// Solve a target XYZ using only a requested physical channel set.
309//
310// This is used for native outer edges. It is intentionally not a generic
311// four-channel optimizer: RG/RB/GB authority means the inactive primary and W
312// must remain exactly zero. For n==2 we solve the 3x2 least-squares normal
313// equations with a tiny non-negative active-set fallback. That preserves the
314// edge topology while still respecting measured diode XYZ/Y ratios.
315static bool solve_fixed_topology_least_squares(const ProfileCache& cache,
316 const float X[3],
317 const int* idx,
318 int n,
319 float out[4]) FL_NOEXCEPT {
320 zero4(out);
321 constexpr float kEps = 1e-9f;
322
323 if (n == 1) {
324 const float* A = column_for_idx(cache, idx[0]);
325 const float aa = A[0]*A[0] + A[1]*A[1] + A[2]*A[2];
326 const float ax = A[0]*X[0] + A[1]*X[1] + A[2]*X[2];
327 out[idx[0]] = (aa > kEps) ? fl::max(ax / aa, 0.0f) : 0.0f;
328 return true;
329 }
330
331 if (n == 2) {
332 const float* A = column_for_idx(cache, idx[0]);
333 const float* B = column_for_idx(cache, idx[1]);
334 const float aa = A[0]*A[0] + A[1]*A[1] + A[2]*A[2];
335 const float ab = A[0]*B[0] + A[1]*B[1] + A[2]*B[2];
336 const float bb = B[0]*B[0] + B[1]*B[1] + B[2]*B[2];
337 const float ax = A[0]*X[0] + A[1]*X[1] + A[2]*X[2];
338 const float bx = B[0]*X[0] + B[1]*X[1] + B[2]*X[2];
339 const float det = aa * bb - ab * ab;
340 if (fl::fabs(det) < kEps) {
341 return false;
342 }
343
344 float t0 = ( ax * bb - bx * ab) / det;
345 float t1 = (-ax * ab + bx * aa) / det;
346
347 // Non-negative active-set fallback for the 2-column case. If one
348 // coefficient goes negative, project onto the remaining column.
349 if (t0 < 0.0f && t1 >= 0.0f) {
350 t0 = 0.0f;
351 t1 = (bb > kEps) ? fl::max(bx / bb, 0.0f) : 0.0f;
352 } else if (t1 < 0.0f && t0 >= 0.0f) {
353 t1 = 0.0f;
354 t0 = (aa > kEps) ? fl::max(ax / aa, 0.0f) : 0.0f;
355 } else if (t0 < 0.0f && t1 < 0.0f) {
356 t0 = 0.0f;
357 t1 = 0.0f;
358 }
359
360 out[idx[0]] = fl::max(t0, 0.0f);
361 out[idx[1]] = fl::max(t1, 0.0f);
362 return true;
363 }
364
365 return false;
366}
367
368// Native dual-edge authority. A native RG/RB/GB input must not introduce W or
369// the inactive RGB primary, but it also must not be raw passthrough. The
370// target is first reduced to full-chroma edge coordinates, solved using only
371// the active measured emitters, normalized at the endpoint, and finally scaled
372// by the original source value. This is the path that fixes yellow_half /
373// cyan_half / magenta_half without reintroducing illegal topology.
374static bool solve_native_dual_edge_fixed_topology(const ProfileCache& cache,
375 float s_r, float s_g,
376 float s_b,
377 float out[4]) FL_NOEXCEPT {
378 zero4(out);
379 constexpr float kEps = 1.0f / 65535.0f;
380 const float value = max3f(s_r, s_g, s_b);
381 if (value <= kEps) {
382 return true;
383 }
384
385 // Native dual authority means edge-lock the active channel set, not raw
386 // input passthrough. Solve the full-chroma edge target against the active
387 // two measured emitter columns, normalize at the endpoint, then apply the
388 // original value scale so yellow_half/cyan_half/etc remain granular.
389 const float c_r = s_r / value;
390 const float c_g = s_g / value;
391 const float c_b = s_b / value;
392
393 float X_full[3];
394 source_rgb_to_XYZ(cache, c_r, c_g, c_b, X_full);
395
396 int active[2];
397 int n = 0;
398 if (s_r > kEps) active[n++] = 0;
399 if (s_g > kEps) active[n++] = 1;
400 if (s_b > kEps) active[n++] = 2;
401 if (n != 2) {
402 return false;
403 }
404
405 float full[4];
406 if (!solve_fixed_topology_least_squares(cache, X_full, active, n, full)) {
407 return false;
408 }
409 normalize4_if_needed(full);
410
411 out[0] = full[0] * value;
412 out[1] = full[1] * value;
413 out[2] = full[2] * value;
414 out[3] = 0.0f;
415 normalize4_if_needed(out);
416 return true;
417}
418
419// Strict full-chroma endpoint solve from an absolute XYZ target.
420//
421// Callers pass the full-chroma target here, not the original value-scaled RGB
422// node. This function owns hull projection, xy routing into RGW/RBW/BGW, the
423// cached 3x3 inverse solve, and endpoint normalization. Value scaling happens
424// in the public solve_strict_subgamut() wrapper after this endpoint is found.
425static bool solve_strict_subgamut_from_XYZ(const ProfileCache& cache,
426 float X_t[3],
427 float out_rgbw[4]) FL_NOEXCEPT {
428 zero4(out_rgbw);
429 const float sum_xyz = X_t[0] + X_t[1] + X_t[2];
430 if (sum_xyz < 1e-9f) {
431 return true;
432 }
433 float xy_t[2] = { X_t[0] / sum_xyz, X_t[1] / sum_xyz };
434
435 // Project full-chroma endpoints before topology routing. This keeps
436 // value-scaled nodes from repeatedly normalizing to the same saturated
437 // tuple and mirrors the reference model's endpoint-first structure.
438 project_to_hull(cache, X_t, xy_t);
439
440 struct SubGamut {
441 const float* xy_a;
442 const float* xy_b;
443 const float* xy_c;
444 const float (*Pinv)[3];
445 int idx_a, idx_b, idx_c;
446 };
447 const SubGamut sgs[3] = {
448 { cache.profile->xy_r, cache.profile->xy_g, cache.xy_w,
449 cache.P_RGW_inv, 0, 1, 3 },
450 { cache.profile->xy_r, cache.profile->xy_b, cache.xy_w,
451 cache.P_RBW_inv, 0, 2, 3 },
452 { cache.profile->xy_b, cache.profile->xy_g, cache.xy_w,
453 cache.P_BGW_inv, 2, 1, 3 },
454 };
455
456 constexpr float kEps = 1e-4f;
457 for (int k = 0; k < 3; ++k) {
458 const SubGamut& sg = sgs[k];
459 float bary[3];
460 if (!barycentric_xy(xy_t, sg.xy_a, sg.xy_b, sg.xy_c, bary)) continue;
461 if (bary[0] < -kEps || bary[1] < -kEps || bary[2] < -kEps) continue;
462 float t[3];
463 matvec3(sg.Pinv, X_t, t);
464 if (t[0] < -kEps || t[1] < -kEps || t[2] < -kEps) continue;
465 t[0] = fl::max(t[0], 0.0f);
466 t[1] = fl::max(t[1], 0.0f);
467 t[2] = fl::max(t[2], 0.0f);
468 const float m = max3f(t[0], t[1], t[2]);
469 if (m > 1.0f) {
470 const float inv_m = 1.0f / m;
471 t[0] *= inv_m; t[1] *= inv_m; t[2] *= inv_m;
472 }
473 out_rgbw[sg.idx_a] = t[0];
474 out_rgbw[sg.idx_b] = t[1];
475 out_rgbw[sg.idx_c] = t[2];
476 return true;
477 }
478 return false;
479}
480
481bool solve_strict_subgamut(const ProfileCache& cache, float s_r,
482 float s_g, float s_b,
483 float out_rgbw[4]) FL_NOEXCEPT {
484 zero4(out_rgbw);
485
486 const float value = max3f(s_r, s_g, s_b);
487 if (value <= 1e-9f) {
488 return true;
489 }
490
491 // Native topology authority is split by case:
492 // n == 1: exact single-axis identity is correct.
493 // n == 2: keep the active RG/RB/GB edge locked, but still solve that
494 // two-emitter topology colorimetrically from measured XYZ/Y.
495 // The previous n<=2 passthrough fixed W/third-channel bleed, but regressed
496 // dual edges into raw 1:1 input->output tuples.
497 if (is_native_input_gamut(*cache.profile)) {
498 const int n_active = count_active_channels(s_r, s_g, s_b);
499 if (n_active == 1) {
500 return native_single_identity(s_r, s_g, s_b, out_rgbw);
501 }
502 if (n_active == 2) {
503 return solve_native_dual_edge_fixed_topology(cache, s_r, s_g,
504 s_b, out_rgbw);
505 }
506 }
507
508 // Interior colors must preserve value separately from chroma. Solving the
509 // already value-scaled node and then normalizing is what collapsed several
510 // HSV v055/v075/v100 rows to identical saturated tuples. Instead, solve
511 // the full-chroma endpoint once, normalize/project there, then apply the
512 // original value scale.
513 const float c_r = s_r / value;
514 const float c_g = s_g / value;
515 const float c_b = s_b / value;
516
517 float X_full[3];
518 source_rgb_to_XYZ(cache, c_r, c_g, c_b, X_full);
519
520 float full[4];
521 if (!solve_strict_subgamut_from_XYZ(cache, X_full, full)) {
522 return false;
523 }
524
525 out_rgbw[0] = full[0] * value;
526 out_rgbw[1] = full[1] * value;
527 out_rgbw[2] = full[2] * value;
528 out_rgbw[3] = full[3] * value;
529 normalize4_if_needed(out_rgbw);
530 return true;
531}
532
533bool solve_strict_subgamut_xy(const ProfileCache& cache,
534 const float xy_t[2], float Y_t,
535 float out_rgbw[4]) FL_NOEXCEPT {
536 out_rgbw[0] = out_rgbw[1] = out_rgbw[2] = out_rgbw[3] = 0.0f;
537 if (Y_t < 1e-9f) {
538 return true;
539 }
540 float X_t[3];
541 xyY_to_XYZ(xy_t[0], xy_t[1], Y_t, X_t);
542
543 // Out-of-hull projection (#2708). xy_t arrives directly (this variant is
544 // called by the LUT builder), so we must project here before routing.
545 // `project_to_hull` updates `X_t` and `local_xy` in place when projection
546 // fires; the local copy is needed because the parameter is const.
547 float local_xy[2] = { xy_t[0], xy_t[1] };
548 project_to_hull(cache, X_t, local_xy);
549
550 struct SubGamut {
551 const float* xy_a;
552 const float* xy_b;
553 const float* xy_c;
554 const float (*Pinv)[3];
555 int idx_a, idx_b, idx_c;
556 };
557 // See note in solve_strict_subgamut: use cache.xy_w, not profile->xy_w.
558 const SubGamut sgs[3] = {
559 { cache.profile->xy_r, cache.profile->xy_g, cache.xy_w,
560 cache.P_RGW_inv, 0, 1, 3 },
561 { cache.profile->xy_r, cache.profile->xy_b, cache.xy_w,
562 cache.P_RBW_inv, 0, 2, 3 },
563 { cache.profile->xy_b, cache.profile->xy_g, cache.xy_w,
564 cache.P_BGW_inv, 2, 1, 3 },
565 };
566
567 constexpr float kEps = 1e-4f;
568 for (int k = 0; k < 3; ++k) {
569 const SubGamut& sg = sgs[k];
570 float bary[3];
571 if (!barycentric_xy(local_xy, sg.xy_a, sg.xy_b, sg.xy_c, bary)) continue;
572 if (bary[0] < -kEps || bary[1] < -kEps || bary[2] < -kEps) continue;
573 float t[3];
574 matvec3(sg.Pinv, X_t, t);
575 if (t[0] < -kEps || t[1] < -kEps || t[2] < -kEps) continue;
576 t[0] = fl::max(t[0], 0.0f);
577 t[1] = fl::max(t[1], 0.0f);
578 t[2] = fl::max(t[2], 0.0f);
579 out_rgbw[sg.idx_a] = t[0];
580 out_rgbw[sg.idx_b] = t[1];
581 out_rgbw[sg.idx_c] = t[2];
582 return true;
583 }
584 return false;
585}
586
587
588// Reference wx_lp_legacy endpoint helper.
589//
590// Given a target xy, search the bounded four-channel manifold for a tuple that
591// preserves chromaticity while maximizing W. The constraints are linear in
592// drive fractions when written as:
593//
594// X_i - x_target * (X_i + Y_i + Z_i)
595// Y_i - y_target * (X_i + Y_i + Z_i)
596//
597// so each candidate fixes two channels at low/high bounds and solves the other
598// two channels from the 2x2 xy residual system. A small RGB floor is tried
599// before allowing channels to collapse, matching the reference LP legacy model
600// better for white-like / neutral values in wall-reflected profiles.
601static bool solve_wx_balanced_fraction_for_xy(const ProfileCache& cache,
602 const float target_xy[2],
603 float out[4]) FL_NOEXCEPT {
604 zero4(out);
605 if (!(target_xy[0] == target_xy[0]) || !(target_xy[1] == target_xy[1])) {
606 return false;
607 }
608
609 const float* cols[4] = { cache.P_R, cache.P_G, cache.P_B, cache.P_W };
610 float A[2][4];
611 const float x = target_xy[0];
612 const float y = target_xy[1];
613 for (int i = 0; i < 4; ++i) {
614 const float* P = cols[i];
615 const float S = P[0] + P[1] + P[2];
616 A[0][i] = P[0] - x * S;
617 A[1][i] = P[1] - y * S;
618 }
619
620 const float y_max = fl::max(fl::max(cache.P_R[1], cache.P_G[1]),
621 fl::max(cache.P_B[1], cache.P_W[1]));
622 const float y_weight[4] = {
623 (y_max > 1e-12f) ? cache.P_R[1] / y_max : 0.0f,
624 (y_max > 1e-12f) ? cache.P_G[1] / y_max : 0.0f,
625 (y_max > 1e-12f) ? cache.P_B[1] / y_max : 0.0f,
626 (y_max > 1e-12f) ? cache.P_W[1] / y_max : 0.0f,
627 };
628
629 bool found = false;
630 float best_obj = -1e30f;
631 float best[4] = {0, 0, 0, 0};
632 const float floors[4] = { 1.0f / 1024.0f, 1.0f / 2048.0f,
633 1.0f / 4096.0f, 0.0f };
634 constexpr float kEps = 1e-5f;
635
636 for (int fidx = 0; fidx < 4 && !found; ++fidx) {
637 const float lo = floors[fidx];
638 const float hi = 1.0f;
639 for (int free0 = 0; free0 < 4; ++free0) {
640 for (int free1 = free0 + 1; free1 < 4; ++free1) {
641 int fixed[2];
642 int nf = 0;
643 for (int i = 0; i < 4; ++i) {
644 if (i != free0 && i != free1) fixed[nf++] = i;
645 }
646 for (int b0 = 0; b0 < 2; ++b0) {
647 for (int b1 = 0; b1 < 2; ++b1) {
648 float cand[4] = {0, 0, 0, 0};
649 cand[fixed[0]] = b0 ? hi : lo;
650 cand[fixed[1]] = b1 ? hi : lo;
651
652 const float rhs0 = -(A[0][fixed[0]] * cand[fixed[0]]
653 + A[0][fixed[1]] * cand[fixed[1]]);
654 const float rhs1 = -(A[1][fixed[0]] * cand[fixed[0]]
655 + A[1][fixed[1]] * cand[fixed[1]]);
656 const float a00 = A[0][free0];
657 const float a01 = A[0][free1];
658 const float a10 = A[1][free0];
659 const float a11 = A[1][free1];
660 const float det = a00 * a11 - a01 * a10;
661 if (fl::fabs(det) < 1e-12f) {
662 continue;
663 }
664 cand[free0] = (rhs0 * a11 - a01 * rhs1) / det;
665 cand[free1] = (a00 * rhs1 - rhs0 * a10) / det;
666
667 bool ok = true;
668 for (int i = 0; i < 4; ++i) {
669 if (cand[i] < lo - kEps || cand[i] > hi + kEps) {
670 ok = false;
671 break;
672 }
673 cand[i] = fl::clamp(cand[i], lo, hi);
674 }
675 if (!ok) continue;
676
677 const float residual0 = A[0][0]*cand[0] + A[0][1]*cand[1]
678 + A[0][2]*cand[2] + A[0][3]*cand[3];
679 const float residual1 = A[1][0]*cand[0] + A[1][1]*cand[1]
680 + A[1][2]*cand[2] + A[1][3]*cand[3];
681 if (fl::fabs(residual0) > 5e-4f || fl::fabs(residual1) > 5e-4f) {
682 continue;
683 }
684
685 const float obj = cand[3]
686 + 1e-6f * (y_weight[0]*cand[0]
687 + y_weight[1]*cand[1]
688 + y_weight[2]*cand[2]
689 + y_weight[3]*cand[3]);
690 if (!found || obj > best_obj) {
691 found = true;
692 best_obj = obj;
693 best[0] = cand[0]; best[1] = cand[1];
694 best[2] = cand[2]; best[3] = cand[3];
695 }
696 }
697 }
698 }
699 }
700 }
701
702 if (!found) {
703 return false;
704 }
705
706 const float m = fl::max(fl::max(best[0], best[1]), fl::max(best[2], best[3]));
707 if (m <= 1e-12f) {
708 return false;
709 }
710 const float inv_m = 1.0f / m;
711 out[0] = fl::clamp(best[0] * inv_m, 0.0f, 1.0f);
712 out[1] = fl::clamp(best[1] * inv_m, 0.0f, 1.0f);
713 out[2] = fl::clamp(best[2] * inv_m, 0.0f, 1.0f);
714 out[3] = fl::clamp(best[3] * inv_m, 0.0f, 1.0f);
715 return true;
716}
717
718// Chromaticity-preserving max-W solver.
719//
720// This is the FastLED analytical implementation of the math-model
721// wx_lp_legacy path. It should not be merged with solve_wx_overdrive():
722// LP legacy's objective is "as much W as possible without moving xy", whereas
723// overdrive's objective is "more W/luminance, accepting controlled xy drift".
724bool solve_wx_lp_legacy(const ProfileCache& cache, float s_r,
725 float s_g, float s_b,
726 float out_rgbw[4]) FL_NOEXCEPT {
727 zero4(out_rgbw);
728
729 const float value = max3f(s_r, s_g, s_b);
730 if (value <= 1e-9f) {
731 return true;
732 }
733
734 // LP legacy shares the native topology contract. Single-axis inputs are
735 // exact identity. Dual edges are locked to RG/RB/GB but still solved from
736 // measured/source XYZ; they are not W-overdrive candidates.
737 if (is_native_input_gamut(*cache.profile)) {
738 const int n_active = count_active_channels(s_r, s_g, s_b);
739 if (n_active == 1) {
740 return native_single_identity(s_r, s_g, s_b, out_rgbw);
741 }
742 if (n_active == 2) {
743 return solve_native_dual_edge_fixed_topology(cache, s_r, s_g,
744 s_b, out_rgbw);
745 }
746 }
747
748 // Reference wx_lp_legacy uses the current math model's bounded LP endpoint:
749 // project to the strict reachable hull, solve xy constraints while
750 // maximizing W, use a small four-channel floor when feasible, normalize the
751 // endpoint, then apply source value. This matches the LP legacy cube much
752 // better than the old direct residual formula, which collapses D65-like
753 // values toward W plus a tiny RGB residual.
754 const float c_r = s_r / value;
755 const float c_g = s_g / value;
756 const float c_b = s_b / value;
757
758 float X_t[3];
759 source_rgb_to_XYZ(cache, c_r, c_g, c_b, X_t);
760
761 float xy_t[2];
762 project_to_hull(cache, X_t, xy_t);
763
764 float full[4];
765 if (!solve_wx_balanced_fraction_for_xy(cache, xy_t, full)) {
766 // Degenerate edge fallback: strict projected endpoint. This mirrors the
767 // Python reference's fallback when the balanced four-channel manifold
768 // cannot represent the requested xy.
769 if (!solve_strict_subgamut_from_XYZ(cache, X_t, full)) {
770 return false;
771 }
772 normalize4_if_needed(full);
773 }
774
775 out_rgbw[0] = full[0] * value;
776 out_rgbw[1] = full[1] * value;
777 out_rgbw[2] = full[2] * value;
778 out_rgbw[3] = full[3] * value;
779 normalize4_if_needed(out_rgbw);
780 return true;
781}
782
783// Boosted / overdrive solver.
784//
785// This remains useful as an optional visual policy, but it is deliberately
786// distinct from wx_lp_legacy. The non-overdriven residual boundary gives the
787// chromaticity-preserving W limit; overdrive_ratio then moves W toward 1.0 and
788// accepts the corresponding drift toward the W diode.
789void solve_wx_overdrive(const ProfileCache& cache, float s_r, float s_g,
790 float s_b, float overdrive_ratio,
791 float out_rgbw[4]) FL_NOEXCEPT {
792 zero4(out_rgbw);
793
794 const float value = max3f(s_r, s_g, s_b);
795 if (value <= 1e-9f) {
796 return;
797 }
798
799 // Boosted/overdrive is a distinct visual policy, but it still must keep
800 // native single/dual topology authority. Single axes stay exact; dual
801 // edges stay locked to RG/RB/GB through the measured two-emitter solve.
802 if (is_native_input_gamut(*cache.profile)) {
803 const int n_active = count_active_channels(s_r, s_g, s_b);
804 if (n_active == 1) {
805 native_single_identity(s_r, s_g, s_b, out_rgbw);
806 return;
807 }
808 if (n_active == 2) {
809 solve_native_dual_edge_fixed_topology(cache, s_r, s_g,
810 s_b, out_rgbw);
811 return;
812 }
813 }
814
815 // Solve the full-chroma endpoint first and scale value later. This keeps
816 // boosted mode from flattening fixed-hue HSV value ramps into repeated
817 // saturated endpoint tuples.
818 const float c_r = s_r / value;
819 const float c_g = s_g / value;
820 const float c_b = s_b / value;
821
822 float X_t[3];
823 source_rgb_to_XYZ(cache, c_r, c_g, c_b, X_t);
824
825 float xy_t[2];
826 project_to_hull(cache, X_t, xy_t);
827
828 float a[3];
829 matvec3(cache.P_RGB_inv, X_t, a);
830 const float* d = cache.d_W;
831
832 float w_strict = 1.0f;
833 for (int i = 0; i < 3; ++i) {
834 if (d[i] > 1e-9f && a[i] >= 0.0f) {
835 const float lim = a[i] / d[i];
836 if (lim < w_strict) {
837 w_strict = lim;
838 }
839 }
840 }
841 w_strict = fl::clamp(w_strict, 0.0f, 1.0f);
842
843 const float rho = fl::clamp(overdrive_ratio, 0.0f, 1.0f);
844 float w = w_strict + rho * (1.0f - w_strict);
845 w = fl::clamp(w, 0.0f, 1.0f);
846
847 float full[4];
848 full[0] = fl::max(a[0] - w * d[0], 0.0f);
849 full[1] = fl::max(a[1] - w * d[1], 0.0f);
850 full[2] = fl::max(a[2] - w * d[2], 0.0f);
851 full[3] = w;
852 normalize4_if_needed(full);
853
854 out_rgbw[0] = full[0] * value;
855 out_rgbw[1] = full[1] * value;
856 out_rgbw[2] = full[2] * value;
857 out_rgbw[3] = full[3] * value;
858 normalize4_if_needed(out_rgbw);
859}
860
861LutTable build_lut(const ProfileCache& cache, int grid_n,
862 LutInterp interp) FL_NOEXCEPT {
863 LutTable lut;
864 // Guard against degenerate grids: N < 2 would divide-by-zero in the
865 // 1/(N-1) step below, and negative N would overflow the allocation size.
866 // Return an empty LutTable so callers can detect failure via .N == 0.
867 if (grid_n < 2) {
868 return lut;
869 }
870 const fl::size_t n = static_cast<fl::size_t>(grid_n);
871 lut.N = grid_n;
872 lut.interp = interp;
873 const fl::size_t stride =
875 lut.cells = fl::make_unique<i16[]>(n * n * stride);
876
877 const float* R = cache.profile->xy_r;
878 const float* G = cache.profile->xy_g;
879 const float* B = cache.profile->xy_b;
880 const float xmin = fl::min(fl::min(R[0], G[0]), B[0]);
881 const float xmax = fl::max(fl::max(R[0], G[0]), B[0]);
882 const float ymin = fl::min(fl::min(R[1], G[1]), B[1]);
883 const float ymax = fl::max(fl::max(R[1], G[1]), B[1]);
884 const float xpad = (xmax - xmin) * 0.02f;
885 const float ypad = (ymax - ymin) * 0.02f;
886 lut.xy_min[0] = xmin - xpad;
887 lut.xy_min[1] = ymin - ypad;
888 lut.xy_max[0] = xmax + xpad;
889 lut.xy_max[1] = ymax + ypad;
890
891 i16* cells = lut.cells.get();
892 const int N = grid_n;
893 const float inv_Nm1 = 1.0f / static_cast<float>(N - 1);
894 const float cell_dx = (lut.xy_max[0] - lut.xy_min[0]) * inv_Nm1;
895 const float cell_dy = (lut.xy_max[1] - lut.xy_min[1]) * inv_Nm1;
896
897 auto sample = [&](float x, float y, float out[4]) FL_NOEXCEPT {
898 const float xy[2] = {x, y};
899 solve_strict_subgamut_xy(cache, xy, 1.0f, out);
900 };
901
902 for (int j = 0; j < N; ++j) {
903 const float y = lut.xy_min[1] + cell_dy * j;
904 for (int i = 0; i < N; ++i) {
905 const float x = lut.xy_min[0] + cell_dx * i;
906 float v[4];
907 sample(x, y, v);
908 i16* cell = &cells[(j * N + i) * stride];
909 cell[0] = quantize_lut_cell(v[0]);
910 cell[1] = quantize_lut_cell(v[1]);
911 cell[2] = quantize_lut_cell(v[2]);
912 cell[3] = quantize_lut_cell(v[3]);
913
914 if (interp != LutInterp::Hermite) continue;
915
916 // Numerical partials in cell-parameter (t) units. Sample
917 // half-a-cell ahead / behind in world coords, clamped to the LUT
918 // domain so edges fall back to one-sided differences. With
919 // eps_world = cell_dx / 2 and step_world = (clamped_+ - clamped_-),
920 // df/dt = (v_xp - v_xn) / step_world * cell_dx.
921 const float eps_x = cell_dx * 0.5f;
922 const float eps_y = cell_dy * 0.5f;
923 const float x_hi = fl::clamp(x + eps_x, lut.xy_min[0], lut.xy_max[0]);
924 const float x_lo = fl::clamp(x - eps_x, lut.xy_min[0], lut.xy_max[0]);
925 const float y_hi = fl::clamp(y + eps_y, lut.xy_min[1], lut.xy_max[1]);
926 const float y_lo = fl::clamp(y - eps_y, lut.xy_min[1], lut.xy_max[1]);
927
928 float v_xp[4] = {0}, v_xn[4] = {0}, v_yp[4] = {0}, v_yn[4] = {0};
929 sample(x_hi, y, v_xp);
930 sample(x_lo, y, v_xn);
931 sample(x, y_hi, v_yp);
932 sample(x, y_lo, v_yn);
933
934 const float step_x = x_hi - x_lo;
935 const float step_y = y_hi - y_lo;
936 const float scale_x = (step_x > 1e-9f) ? (cell_dx / step_x) : 0.0f;
937 const float scale_y = (step_y > 1e-9f) ? (cell_dy / step_y) : 0.0f;
938 for (int k = 0; k < 4; ++k) {
939 cell[4 + k] = quantize_lut_cell((v_xp[k] - v_xn[k]) * scale_x);
940 cell[8 + k] = quantize_lut_cell((v_yp[k] - v_yn[k]) * scale_y);
941 }
942 }
943 }
944 return lut;
945}
946
947void lookup_lut(const LutTable& lut, const float xy_t[2], float Y_t,
948 float out_rgbw[4]) FL_NOEXCEPT {
949 // Defend against degenerate LUTs (empty table, unbuilt cells, zero span).
950 // build_lut() returns an empty LutTable on bad input — without these
951 // guards the divide-by-zero and OOB reads would corrupt random memory.
952 out_rgbw[0] = out_rgbw[1] = out_rgbw[2] = out_rgbw[3] = 0.0f;
953 if (lut.N < 2 || lut.cells.get() == nullptr) {
954 return;
955 }
956 const int N = lut.N;
957 const float dx = lut.xy_max[0] - lut.xy_min[0];
958 const float dy = lut.xy_max[1] - lut.xy_min[1];
959 if (dx <= 0.0f || dy <= 0.0f) {
960 return;
961 }
962 float x_norm = (xy_t[0] - lut.xy_min[0]) / dx * (N - 1);
963 float y_norm = (xy_t[1] - lut.xy_min[1]) / dy * (N - 1);
964 x_norm = fl::clamp(x_norm, 0.0f, static_cast<float>(N - 1) - 1e-4f);
965 y_norm = fl::clamp(y_norm, 0.0f, static_cast<float>(N - 1) - 1e-4f);
966 const int i = static_cast<int>(x_norm);
967 const int j = static_cast<int>(y_norm);
968 const float fx = x_norm - i;
969 const float fy = y_norm - j;
970
971 const i16* base = lut.cells.get();
972 const int stride =
974 const i16* c00 = &base[(j * N + i) * stride];
975 const i16* c10 = &base[(j * N + i + 1) * stride];
976 const i16* c01 = &base[((j + 1) * N + i) * stride];
977 const i16* c11 = &base[((j + 1) * N + i + 1) * stride];
978
979 const float inv_Q = 1.0f / static_cast<float>(kLutQ);
980
981 if (lut.interp == LutInterp::Hermite) {
982 // Bicubic Hermite with no fxy cross term. Basis is computed via the
983 // shared header helper `hermite_basis` so the test suite exercises the
984 // same evaluator (CodeRabbit #2707).
985 float bx[4], by[4];
986 hermite_basis(fx, bx);
987 hermite_basis(fy, by);
988 const float h00x = bx[0], h01x = bx[1], h10x = bx[2], h11x = bx[3];
989 const float h00y = by[0], h01y = by[1], h10y = by[2], h11y = by[3];
990
991 for (int k = 0; k < 4; ++k) {
992 const float v00 = c00[k] * inv_Q;
993 const float v10 = c10[k] * inv_Q;
994 const float v01 = c01[k] * inv_Q;
995 const float v11 = c11[k] * inv_Q;
996 const float dx00 = c00[4 + k] * inv_Q;
997 const float dx10 = c10[4 + k] * inv_Q;
998 const float dx01 = c01[4 + k] * inv_Q;
999 const float dx11 = c11[4 + k] * inv_Q;
1000 const float dy00 = c00[8 + k] * inv_Q;
1001 const float dy10 = c10[8 + k] * inv_Q;
1002 const float dy01 = c01[8 + k] * inv_Q;
1003 const float dy11 = c11[8 + k] * inv_Q;
1004
1005 const float per_Y =
1006 v00 * h00x * h00y + v10 * h01x * h00y
1007 + v01 * h00x * h01y + v11 * h01x * h01y
1008 + dx00 * h10x * h00y + dx10 * h11x * h00y
1009 + dx01 * h10x * h01y + dx11 * h11x * h01y
1010 + dy00 * h00x * h10y + dy10 * h01x * h10y
1011 + dy01 * h00x * h11y + dy11 * h01x * h11y;
1012
1013 float t = per_Y * Y_t;
1014 if (t < 0.0f) t = 0.0f;
1015 out_rgbw[k] = t;
1016 }
1017 } else {
1018 const float w00 = (1 - fx) * (1 - fy);
1019 const float w10 = fx * (1 - fy);
1020 const float w01 = (1 - fx) * fy;
1021 const float w11 = fx * fy;
1022 for (int k = 0; k < 4; ++k) {
1023 const float per_Y = (w00 * c00[k] + w10 * c10[k]
1024 + w01 * c01[k] + w11 * c11[k]) * inv_Q;
1025 float t = per_Y * Y_t;
1026 if (t < 0.0f) t = 0.0f;
1027 out_rgbw[k] = t;
1028 }
1029 }
1030
1031 const float m = fl::max(fl::max(out_rgbw[0], out_rgbw[1]),
1032 fl::max(out_rgbw[2], out_rgbw[3]));
1033 if (m > 1.0f) {
1034 const float inv_m = 1.0f / m;
1035 out_rgbw[0] *= inv_m;
1036 out_rgbw[1] *= inv_m;
1037 out_rgbw[2] *= inv_m;
1038 out_rgbw[3] *= inv_m;
1039 }
1040}
1041
1042void solve_rgbcct(const RgbcctProfile& profile, float s_r, float s_g,
1043 float s_b, float eta, float out[5]) FL_NOEXCEPT {
1044 ProfileCache warm_cache;
1045 ProfileCache cool_cache;
1046 build_profile_cache(&profile.warm_path, 0, &warm_cache);
1047 build_profile_cache(&profile.cool_path, 0, &cool_cache);
1048
1049 float warm_rgbw[4] = {0};
1050 float cool_rgbw[4] = {0};
1051 solve_strict_subgamut(warm_cache, s_r, s_g, s_b, warm_rgbw);
1052 solve_strict_subgamut(cool_cache, s_r, s_g, s_b, cool_rgbw);
1053
1054 eta = fl::clamp(eta, 0.0f, 1.0f);
1055 const float w_warm = 1.0f - eta;
1056 const float w_cool = eta;
1057
1058 out[0] = w_warm * warm_rgbw[0] + w_cool * cool_rgbw[0];
1059 out[1] = w_warm * warm_rgbw[1] + w_cool * cool_rgbw[1];
1060 out[2] = w_warm * warm_rgbw[2] + w_cool * cool_rgbw[2];
1061 out[3] = w_warm * warm_rgbw[3];
1062 out[4] = w_cool * cool_rgbw[3];
1063
1064 const float m = fl::max(
1065 fl::max(fl::max(out[0], out[1]), fl::max(out[2], out[3])), out[4]);
1066 if (m > 1.0f) {
1067 const float inv_m = 1.0f / m;
1068 for (int k = 0; k < 5; ++k) out[k] *= inv_m;
1069 }
1070}
1071
1072} // namespace colorimetric_detail
1073} // namespace fl
1074
1075#endif // FASTLED_RGBW_COLORIMETRIC
uint32_t scale_y[NUM_LAYERS]
Definition Fire2023.h:95
uint32_t scale_x[NUM_LAYERS]
Definition Fire2023.h:94
unsigned int xy(unsigned int x, unsigned int y)
#define FL_WARN_ONCE(X)
Definition log.h:278
Centralized logging categories for FastLED hardware interfaces and subsystems.
bool solve_wx_lp_legacy(const ProfileCache &cache, float s_r, float s_g, float s_b, float out_rgbw[4]) FL_NOEXCEPT
void xyY_to_XYZ(float x, float y, float Y, float out[3]) FL_NOEXCEPT
void cct_to_xy(int cct, float out[2]) FL_NOEXCEPT
i16 quantize_lut_cell(float v) FL_NOEXCEPT
LutTable build_lut(const ProfileCache &cache, int grid_n, LutInterp interp) FL_NOEXCEPT
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_NOEXCEPT
bool solve_strict_subgamut_xy(const ProfileCache &cache, const float xy_t[2], float Y_t, float out_rgbw[4]) FL_NOEXCEPT
void matvec3(const float M[3][3], const float v[3], float out[3]) FL_NOEXCEPT
void solve_wx_overdrive(const ProfileCache &cache, float s_r, float s_g, float s_b, float overdrive_ratio, float out_rgbw[4]) FL_NOEXCEPT
void build_profile_cache(const DiodeProfile *p, int cct_override, ProfileCache *cache) FL_NOEXCEPT
void lookup_lut(const LutTable &lut, const float xy_t[2], float Y_t, float out_rgbw[4]) FL_NOEXCEPT
void nnls3(const float M[3][3], const float b[3], float t_out[3], float *residual_out) FL_NOEXCEPT
bool invert3x3(const float in[3][3], float out[3][3]) FL_NOEXCEPT
bool barycentric_xy(const float t[2], const float A[2], const float B[2], const float C[2], float bary[3]) FL_NOEXCEPT
bool solve_strict_subgamut(const ProfileCache &cache, float s_r, float s_g, float s_b, float out_rgbw[4]) FL_NOEXCEPT
bool is_native_input_gamut(const DiodeProfile &p) FL_NOEXCEPT
void solve_rgbcct(const RgbcctProfile &profile, float s_r, float s_g, float s_b, float eta, float out[5]) FL_NOEXCEPT
int count_active_channels(float s_r, float s_g, float s_b) FL_NOEXCEPT
void hermite_basis(float t, float out[4]) FL_NOEXCEPT
__SIZE_TYPE__ size_t
Definition s16x16x4.h:16
FL_DISABLE_WARNING_PUSH U constexpr common_type_t< T, U > min(T a, U b) FL_NOEXCEPT
Definition math.h:71
double fabs(double value) FL_NOEXCEPT
Definition math.h:509
constexpr int type_rank< T >::value
constexpr common_type_t< T, U > max(T a, U b) FL_NOEXCEPT
Definition math.h:75
FL_DISABLE_WARNING_PUSH unsigned char * B
fl::enable_if<!fl::is_array< T >::value, unique_ptr< T > >::type make_unique(Args &&... args) FL_NOEXCEPT
Definition unique_ptr.h:261
CRGB sample(const CRGB *grid, const XYMap &xyMap, float x, float y, SampleMode mode)
Sample a pixel from a 2D CRGB grid at floating-point coordinates.
Definition sample.cpp.hpp:9
FASTLED_FORCE_INLINE fl::u8 P(fl::u8 x)
constexpr enable_if< is_fixed_point< T >::value, T >::type clamp(T x, T lo, T hi) FL_NOEXCEPT
Base definition for an LED controller.
Definition crgb.hpp:179
Chromaticity-aware RGBW solvers — strict sub-gamut + wx_lp_legacy white extraction + boosted overdriv...
#define FL_NOEXCEPT