FastLED 3.10.8
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_NO_EXCEPT {
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_NO_EXCEPT {
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_NO_EXCEPT {
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_NO_EXCEPT {
251 return fl::max(fl::max(a, b), c);
252}
253
254static void zero4(float out[4]) FL_NO_EXCEPT {
255 out[0] = out[1] = out[2] = out[3] = 0.0f;
256}
257
258static void normalize4_if_needed(float out[4]) FL_NO_EXCEPT {
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_NO_EXCEPT {
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_NO_EXCEPT {
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_NO_EXCEPT {
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_NO_EXCEPT {
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_NO_EXCEPT {
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_NO_EXCEPT {
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 > 1e-9f) {
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_NO_EXCEPT {
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 source_value,
535 float out_rgbw[4]) FL_NO_EXCEPT {
536 out_rgbw[0] = out_rgbw[1] = out_rgbw[2] = out_rgbw[3] = 0.0f;
537 if (source_value < 1e-9f) {
538 return true;
539 }
540 float X_t[3];
541 xyY_to_XYZ(xy_t[0], xy_t[1], 1.0f, 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 const float m = max3f(t[0], t[1], t[2]);
580 if (m > 1e-9f) {
581 const float endpoint_scale = source_value / m;
582 t[0] *= endpoint_scale;
583 t[1] *= endpoint_scale;
584 t[2] *= endpoint_scale;
585 }
586 out_rgbw[sg.idx_a] = t[0];
587 out_rgbw[sg.idx_b] = t[1];
588 out_rgbw[sg.idx_c] = t[2];
589 return true;
590 }
591 return false;
592}
593
594
595// Reference wx_lp_legacy endpoint helper.
596//
597// Given a target xy, search the bounded four-channel manifold for a tuple that
598// preserves chromaticity while maximizing W. The constraints are linear in
599// drive fractions when written as:
600//
601// X_i - x_target * (X_i + Y_i + Z_i)
602// Y_i - y_target * (X_i + Y_i + Z_i)
603//
604// so each candidate fixes two channels at low/high bounds and solves the other
605// two channels from the 2x2 xy residual system. A small RGB floor is tried
606// before allowing channels to collapse, matching the reference LP legacy model
607// better for white-like / neutral values in wall-reflected profiles.
608static bool solve_wx_balanced_fraction_for_xy(const ProfileCache& cache,
609 const float target_xy[2],
610 float out[4]) FL_NO_EXCEPT {
611 zero4(out);
612 if (!(target_xy[0] == target_xy[0]) || !(target_xy[1] == target_xy[1])) {
613 return false;
614 }
615
616 const float* cols[4] = { cache.P_R, cache.P_G, cache.P_B, cache.P_W };
617 float A[2][4];
618 const float x = target_xy[0];
619 const float y = target_xy[1];
620 for (int i = 0; i < 4; ++i) {
621 const float* P = cols[i];
622 const float S = P[0] + P[1] + P[2];
623 A[0][i] = P[0] - x * S;
624 A[1][i] = P[1] - y * S;
625 }
626
627 const float y_max = fl::max(fl::max(cache.P_R[1], cache.P_G[1]),
628 fl::max(cache.P_B[1], cache.P_W[1]));
629 const float y_weight[4] = {
630 (y_max > 1e-12f) ? cache.P_R[1] / y_max : 0.0f,
631 (y_max > 1e-12f) ? cache.P_G[1] / y_max : 0.0f,
632 (y_max > 1e-12f) ? cache.P_B[1] / y_max : 0.0f,
633 (y_max > 1e-12f) ? cache.P_W[1] / y_max : 0.0f,
634 };
635
636 bool found = false;
637 float best_obj = -1e30f;
638 float best[4] = {0, 0, 0, 0};
639 const float floors[4] = { 1.0f / 1024.0f, 1.0f / 2048.0f,
640 1.0f / 4096.0f, 0.0f };
641 constexpr float kEps = 1e-5f;
642
643 for (int fidx = 0; fidx < 4 && !found; ++fidx) {
644 const float lo = floors[fidx];
645 const float hi = 1.0f;
646 for (int free0 = 0; free0 < 4; ++free0) {
647 for (int free1 = free0 + 1; free1 < 4; ++free1) {
648 int fixed[2];
649 int nf = 0;
650 for (int i = 0; i < 4; ++i) {
651 if (i != free0 && i != free1) fixed[nf++] = i;
652 }
653 for (int b0 = 0; b0 < 2; ++b0) {
654 for (int b1 = 0; b1 < 2; ++b1) {
655 float cand[4] = {0, 0, 0, 0};
656 cand[fixed[0]] = b0 ? hi : lo;
657 cand[fixed[1]] = b1 ? hi : lo;
658
659 const float rhs0 = -(A[0][fixed[0]] * cand[fixed[0]]
660 + A[0][fixed[1]] * cand[fixed[1]]);
661 const float rhs1 = -(A[1][fixed[0]] * cand[fixed[0]]
662 + A[1][fixed[1]] * cand[fixed[1]]);
663 const float a00 = A[0][free0];
664 const float a01 = A[0][free1];
665 const float a10 = A[1][free0];
666 const float a11 = A[1][free1];
667 const float det = a00 * a11 - a01 * a10;
668 if (fl::fabs(det) < 1e-12f) {
669 continue;
670 }
671 cand[free0] = (rhs0 * a11 - a01 * rhs1) / det;
672 cand[free1] = (a00 * rhs1 - rhs0 * a10) / det;
673
674 bool ok = true;
675 for (int i = 0; i < 4; ++i) {
676 if (cand[i] < lo - kEps || cand[i] > hi + kEps) {
677 ok = false;
678 break;
679 }
680 cand[i] = fl::clamp(cand[i], lo, hi);
681 }
682 if (!ok) continue;
683
684 const float residual0 = A[0][0]*cand[0] + A[0][1]*cand[1]
685 + A[0][2]*cand[2] + A[0][3]*cand[3];
686 const float residual1 = A[1][0]*cand[0] + A[1][1]*cand[1]
687 + A[1][2]*cand[2] + A[1][3]*cand[3];
688 if (fl::fabs(residual0) > 5e-4f || fl::fabs(residual1) > 5e-4f) {
689 continue;
690 }
691
692 const float obj = cand[3]
693 + 1e-6f * (y_weight[0]*cand[0]
694 + y_weight[1]*cand[1]
695 + y_weight[2]*cand[2]
696 + y_weight[3]*cand[3]);
697 if (!found || obj > best_obj) {
698 found = true;
699 best_obj = obj;
700 best[0] = cand[0]; best[1] = cand[1];
701 best[2] = cand[2]; best[3] = cand[3];
702 }
703 }
704 }
705 }
706 }
707 }
708
709 if (!found) {
710 return false;
711 }
712
713 const float m = fl::max(fl::max(best[0], best[1]), fl::max(best[2], best[3]));
714 if (m <= 1e-12f) {
715 return false;
716 }
717 const float inv_m = 1.0f / m;
718 out[0] = fl::clamp(best[0] * inv_m, 0.0f, 1.0f);
719 out[1] = fl::clamp(best[1] * inv_m, 0.0f, 1.0f);
720 out[2] = fl::clamp(best[2] * inv_m, 0.0f, 1.0f);
721 out[3] = fl::clamp(best[3] * inv_m, 0.0f, 1.0f);
722 return true;
723}
724
725// Chromaticity-preserving max-W solver.
726//
727// This is the FastLED analytical implementation of the math-model
728// wx_lp_legacy path. It should not be merged with solve_wx_overdrive():
729// LP legacy's objective is "as much W as possible without moving xy", whereas
730// overdrive's objective is "more W/luminance, accepting controlled xy drift".
731bool solve_wx_lp_legacy(const ProfileCache& cache, float s_r,
732 float s_g, float s_b,
733 float out_rgbw[4]) FL_NO_EXCEPT {
734 zero4(out_rgbw);
735
736 const float value = max3f(s_r, s_g, s_b);
737 if (value <= 1e-9f) {
738 return true;
739 }
740
741 // LP legacy shares the native topology contract. Single-axis inputs are
742 // exact identity. Dual edges are locked to RG/RB/GB but still solved from
743 // measured/source XYZ; they are not W-overdrive candidates.
744 if (is_native_input_gamut(*cache.profile)) {
745 const int n_active = count_active_channels(s_r, s_g, s_b);
746 if (n_active == 1) {
747 return native_single_identity(s_r, s_g, s_b, out_rgbw);
748 }
749 if (n_active == 2) {
750 return solve_native_dual_edge_fixed_topology(cache, s_r, s_g,
751 s_b, out_rgbw);
752 }
753 }
754
755 // Reference wx_lp_legacy uses the current math model's bounded LP endpoint:
756 // project to the strict reachable hull, solve xy constraints while
757 // maximizing W, use a small four-channel floor when feasible, normalize the
758 // endpoint, then apply source value. This matches the LP legacy cube much
759 // better than the old direct residual formula, which collapses D65-like
760 // values toward W plus a tiny RGB residual.
761 const float c_r = s_r / value;
762 const float c_g = s_g / value;
763 const float c_b = s_b / value;
764
765 float X_t[3];
766 source_rgb_to_XYZ(cache, c_r, c_g, c_b, X_t);
767
768 float xy_t[2];
769 project_to_hull(cache, X_t, xy_t);
770
771 float full[4];
772 if (!solve_wx_balanced_fraction_for_xy(cache, xy_t, full)) {
773 // Degenerate edge fallback: strict projected endpoint. This mirrors the
774 // Python reference's fallback when the balanced four-channel manifold
775 // cannot represent the requested xy.
776 if (!solve_strict_subgamut_from_XYZ(cache, X_t, full)) {
777 return false;
778 }
779 normalize4_if_needed(full);
780 }
781
782 out_rgbw[0] = full[0] * value;
783 out_rgbw[1] = full[1] * value;
784 out_rgbw[2] = full[2] * value;
785 out_rgbw[3] = full[3] * value;
786 normalize4_if_needed(out_rgbw);
787 return true;
788}
789
790// Boosted / overdrive solver.
791//
792// This remains useful as an optional visual policy, but it is deliberately
793// distinct from wx_lp_legacy. The non-overdriven residual boundary gives the
794// chromaticity-preserving W limit; overdrive_ratio then moves W toward 1.0 and
795// accepts the corresponding drift toward the W diode.
796void solve_wx_overdrive(const ProfileCache& cache, float s_r, float s_g,
797 float s_b, float overdrive_ratio,
798 float out_rgbw[4]) FL_NO_EXCEPT {
799 zero4(out_rgbw);
800
801 const float value = max3f(s_r, s_g, s_b);
802 if (value <= 1e-9f) {
803 return;
804 }
805
806 // Boosted/overdrive is a distinct visual policy, but it still must keep
807 // native single/dual topology authority. Single axes stay exact; dual
808 // edges stay locked to RG/RB/GB through the measured two-emitter solve.
809 if (is_native_input_gamut(*cache.profile)) {
810 const int n_active = count_active_channels(s_r, s_g, s_b);
811 if (n_active == 1) {
812 native_single_identity(s_r, s_g, s_b, out_rgbw);
813 return;
814 }
815 if (n_active == 2) {
816 solve_native_dual_edge_fixed_topology(cache, s_r, s_g,
817 s_b, out_rgbw);
818 return;
819 }
820 }
821
822 // Solve the full-chroma endpoint first and scale value later. This keeps
823 // boosted mode from flattening fixed-hue HSV value ramps into repeated
824 // saturated endpoint tuples.
825 const float c_r = s_r / value;
826 const float c_g = s_g / value;
827 const float c_b = s_b / value;
828
829 float X_t[3];
830 source_rgb_to_XYZ(cache, c_r, c_g, c_b, X_t);
831
832 float xy_t[2];
833 project_to_hull(cache, X_t, xy_t);
834
835 float a[3];
836 matvec3(cache.P_RGB_inv, X_t, a);
837 const float* d = cache.d_W;
838
839 float w_strict = 1.0f;
840 for (int i = 0; i < 3; ++i) {
841 if (d[i] > 1e-9f && a[i] >= 0.0f) {
842 const float lim = a[i] / d[i];
843 if (lim < w_strict) {
844 w_strict = lim;
845 }
846 }
847 }
848 w_strict = fl::clamp(w_strict, 0.0f, 1.0f);
849
850 const float rho = fl::clamp(overdrive_ratio, 0.0f, 1.0f);
851 float w = w_strict + rho * (1.0f - w_strict);
852 w = fl::clamp(w, 0.0f, 1.0f);
853
854 float full[4];
855 full[0] = fl::max(a[0] - w * d[0], 0.0f);
856 full[1] = fl::max(a[1] - w * d[1], 0.0f);
857 full[2] = fl::max(a[2] - w * d[2], 0.0f);
858 full[3] = w;
859 normalize4_if_needed(full);
860
861 out_rgbw[0] = full[0] * value;
862 out_rgbw[1] = full[1] * value;
863 out_rgbw[2] = full[2] * value;
864 out_rgbw[3] = full[3] * value;
865 normalize4_if_needed(out_rgbw);
866}
867
868LutTable build_lut(const ProfileCache& cache, int grid_n,
869 LutInterp interp) FL_NO_EXCEPT {
870 LutTable lut;
871 // Guard against degenerate grids: N < 2 would divide-by-zero in the
872 // 1/(N-1) step below, and negative N would overflow the allocation size.
873 // Return an empty LutTable so callers can detect failure via .N == 0.
874 if (grid_n < 2) {
875 return lut;
876 }
877 const fl::size_t n = static_cast<fl::size_t>(grid_n);
878 lut.N = grid_n;
879 lut.interp = interp;
880 const fl::size_t stride =
881 (interp == LutInterp::Hermite) ? kLutStrideHermite : kLutStrideBilinear;
882 lut.cells = fl::make_unique<i16[]>(n * n * stride);
883
884 const float* R = cache.profile->xy_r;
885 const float* G = cache.profile->xy_g;
886 const float* B = cache.profile->xy_b;
887 const float xmin = fl::min(fl::min(R[0], G[0]), B[0]);
888 const float xmax = fl::max(fl::max(R[0], G[0]), B[0]);
889 const float ymin = fl::min(fl::min(R[1], G[1]), B[1]);
890 const float ymax = fl::max(fl::max(R[1], G[1]), B[1]);
891 const float xpad = (xmax - xmin) * 0.02f;
892 const float ypad = (ymax - ymin) * 0.02f;
893 lut.xy_min[0] = xmin - xpad;
894 lut.xy_min[1] = ymin - ypad;
895 lut.xy_max[0] = xmax + xpad;
896 lut.xy_max[1] = ymax + ypad;
897
898 i16* cells = lut.cells.get();
899 const int N = grid_n;
900 const float inv_Nm1 = 1.0f / static_cast<float>(N - 1);
901 const float cell_dx = (lut.xy_max[0] - lut.xy_min[0]) * inv_Nm1;
902 const float cell_dy = (lut.xy_max[1] - lut.xy_min[1]) * inv_Nm1;
903
904 auto sample = [&](float x, float y, float out[4]) FL_NO_EXCEPT {
905 const float xy[2] = {x, y};
906 solve_strict_subgamut_xy(cache, xy, 1.0f, out);
907 };
908
909 for (int j = 0; j < N; ++j) {
910 const float y = lut.xy_min[1] + cell_dy * j;
911 for (int i = 0; i < N; ++i) {
912 const float x = lut.xy_min[0] + cell_dx * i;
913 float v[4];
914 sample(x, y, v);
915 i16* cell = &cells[(j * N + i) * stride];
916 cell[0] = quantize_lut_cell(v[0]);
917 cell[1] = quantize_lut_cell(v[1]);
918 cell[2] = quantize_lut_cell(v[2]);
919 cell[3] = quantize_lut_cell(v[3]);
920
921 if (interp != LutInterp::Hermite) continue;
922
923 // Numerical partials in cell-parameter (t) units. Sample
924 // half-a-cell ahead / behind in world coords, clamped to the LUT
925 // domain so edges fall back to one-sided differences. With
926 // eps_world = cell_dx / 2 and step_world = (clamped_+ - clamped_-),
927 // df/dt = (v_xp - v_xn) / step_world * cell_dx.
928 const float eps_x = cell_dx * 0.5f;
929 const float eps_y = cell_dy * 0.5f;
930 const float x_hi = fl::clamp(x + eps_x, lut.xy_min[0], lut.xy_max[0]);
931 const float x_lo = fl::clamp(x - eps_x, lut.xy_min[0], lut.xy_max[0]);
932 const float y_hi = fl::clamp(y + eps_y, lut.xy_min[1], lut.xy_max[1]);
933 const float y_lo = fl::clamp(y - eps_y, lut.xy_min[1], lut.xy_max[1]);
934
935 float v_xp[4] = {0}, v_xn[4] = {0}, v_yp[4] = {0}, v_yn[4] = {0};
936 sample(x_hi, y, v_xp);
937 sample(x_lo, y, v_xn);
938 sample(x, y_hi, v_yp);
939 sample(x, y_lo, v_yn);
940
941 const float step_x = x_hi - x_lo;
942 const float step_y = y_hi - y_lo;
943 const float scale_x = (step_x > 1e-9f) ? (cell_dx / step_x) : 0.0f;
944 const float scale_y = (step_y > 1e-9f) ? (cell_dy / step_y) : 0.0f;
945 for (int k = 0; k < 4; ++k) {
946 cell[4 + k] = quantize_lut_cell((v_xp[k] - v_xn[k]) * scale_x);
947 cell[8 + k] = quantize_lut_cell((v_yp[k] - v_yn[k]) * scale_y);
948 }
949 }
950 }
951 return lut;
952}
953
954void lookup_lut(const LutTable& lut, const float xy_t[2], float source_value,
955 float out_rgbw[4]) FL_NO_EXCEPT {
956 // Defend against degenerate LUTs (empty table, unbuilt cells, zero span).
957 // build_lut() returns an empty LutTable on bad input — without these
958 // guards the divide-by-zero and OOB reads would corrupt random memory.
959 out_rgbw[0] = out_rgbw[1] = out_rgbw[2] = out_rgbw[3] = 0.0f;
960 if (lut.N < 2 || lut.cells.get() == nullptr) {
961 return;
962 }
963 const int N = lut.N;
964 const float dx = lut.xy_max[0] - lut.xy_min[0];
965 const float dy = lut.xy_max[1] - lut.xy_min[1];
966 if (dx <= 0.0f || dy <= 0.0f) {
967 return;
968 }
969 float x_norm = (xy_t[0] - lut.xy_min[0]) / dx * (N - 1);
970 float y_norm = (xy_t[1] - lut.xy_min[1]) / dy * (N - 1);
971 x_norm = fl::clamp(x_norm, 0.0f, static_cast<float>(N - 1) - 1e-4f);
972 y_norm = fl::clamp(y_norm, 0.0f, static_cast<float>(N - 1) - 1e-4f);
973 const int i = static_cast<int>(x_norm);
974 const int j = static_cast<int>(y_norm);
975 const float fx = x_norm - i;
976 const float fy = y_norm - j;
977
978 const i16* base = lut.cells.get();
979 const int stride =
980 (lut.interp == LutInterp::Hermite) ? kLutStrideHermite : kLutStrideBilinear;
981 const i16* c00 = &base[(j * N + i) * stride];
982 const i16* c10 = &base[(j * N + i + 1) * stride];
983 const i16* c01 = &base[((j + 1) * N + i) * stride];
984 const i16* c11 = &base[((j + 1) * N + i + 1) * stride];
985
986 const float inv_Q = 1.0f / static_cast<float>(kLutQ);
987
988 if (lut.interp == LutInterp::Hermite) {
989 // Bicubic Hermite with no fxy cross term. Basis is computed via the
990 // shared header helper `hermite_basis` so the test suite exercises the
991 // same evaluator (CodeRabbit #2707).
992 float bx[4], by[4];
993 hermite_basis(fx, bx);
994 hermite_basis(fy, by);
995 const float h00x = bx[0], h01x = bx[1], h10x = bx[2], h11x = bx[3];
996 const float h00y = by[0], h01y = by[1], h10y = by[2], h11y = by[3];
997
998 for (int k = 0; k < 4; ++k) {
999 const float v00 = c00[k] * inv_Q;
1000 const float v10 = c10[k] * inv_Q;
1001 const float v01 = c01[k] * inv_Q;
1002 const float v11 = c11[k] * inv_Q;
1003 const float dx00 = c00[4 + k] * inv_Q;
1004 const float dx10 = c10[4 + k] * inv_Q;
1005 const float dx01 = c01[4 + k] * inv_Q;
1006 const float dx11 = c11[4 + k] * inv_Q;
1007 const float dy00 = c00[8 + k] * inv_Q;
1008 const float dy10 = c10[8 + k] * inv_Q;
1009 const float dy01 = c01[8 + k] * inv_Q;
1010 const float dy11 = c11[8 + k] * inv_Q;
1011
1012 const float endpoint =
1013 v00 * h00x * h00y + v10 * h01x * h00y
1014 + v01 * h00x * h01y + v11 * h01x * h01y
1015 + dx00 * h10x * h00y + dx10 * h11x * h00y
1016 + dx01 * h10x * h01y + dx11 * h11x * h01y
1017 + dy00 * h00x * h10y + dy10 * h01x * h10y
1018 + dy01 * h00x * h11y + dy11 * h01x * h11y;
1019
1020 float t = endpoint * source_value;
1021 if (t < 0.0f) t = 0.0f;
1022 out_rgbw[k] = t;
1023 }
1024 } else {
1025 const float w00 = (1 - fx) * (1 - fy);
1026 const float w10 = fx * (1 - fy);
1027 const float w01 = (1 - fx) * fy;
1028 const float w11 = fx * fy;
1029 for (int k = 0; k < 4; ++k) {
1030 const float endpoint = (w00 * c00[k] + w10 * c10[k]
1031 + w01 * c01[k] + w11 * c11[k]) * inv_Q;
1032 float t = endpoint * source_value;
1033 if (t < 0.0f) t = 0.0f;
1034 out_rgbw[k] = t;
1035 }
1036 }
1037
1038 const float m = fl::max(fl::max(out_rgbw[0], out_rgbw[1]),
1039 fl::max(out_rgbw[2], out_rgbw[3]));
1040 if (m > 1.0f) {
1041 const float inv_m = 1.0f / m;
1042 out_rgbw[0] *= inv_m;
1043 out_rgbw[1] *= inv_m;
1044 out_rgbw[2] *= inv_m;
1045 out_rgbw[3] *= inv_m;
1046 }
1047}
1048
1049void solve_rgbcct(const RgbcctProfile& profile, float s_r, float s_g,
1050 float s_b, float eta, float out[5]) FL_NO_EXCEPT {
1051 ProfileCache warm_cache;
1052 ProfileCache cool_cache;
1053 build_profile_cache(&profile.warm_path, 0, &warm_cache);
1054 build_profile_cache(&profile.cool_path, 0, &cool_cache);
1055
1056 float warm_rgbw[4] = {0};
1057 float cool_rgbw[4] = {0};
1058 solve_strict_subgamut(warm_cache, s_r, s_g, s_b, warm_rgbw);
1059 solve_strict_subgamut(cool_cache, s_r, s_g, s_b, cool_rgbw);
1060
1061 eta = fl::clamp(eta, 0.0f, 1.0f);
1062 const float w_warm = 1.0f - eta;
1063 const float w_cool = eta;
1064
1065 out[0] = w_warm * warm_rgbw[0] + w_cool * cool_rgbw[0];
1066 out[1] = w_warm * warm_rgbw[1] + w_cool * cool_rgbw[1];
1067 out[2] = w_warm * warm_rgbw[2] + w_cool * cool_rgbw[2];
1068 out[3] = w_warm * warm_rgbw[3];
1069 out[4] = w_cool * cool_rgbw[3];
1070
1071 const float m = fl::max(
1072 fl::max(fl::max(out[0], out[1]), fl::max(out[2], out[3])), out[4]);
1073 if (m > 1.0f) {
1074 const float inv_m = 1.0f / m;
1075 for (int k = 0; k < 5; ++k) out[k] *= inv_m;
1076 }
1077}
1078
1079} // namespace colorimetric_detail
1080} // namespace fl
1081
1082#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(...)
Definition log.h:439
Centralized logging categories for FastLED hardware interfaces and subsystems.
fl::enable_if<!fl::is_array< T >::value, unique_ptr< T > >::type make_unique(Args &&... args) FL_NO_EXCEPT
Definition unique_ptr.h:263
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
void hermite_basis(float t, float out[4]) FL_NO_EXCEPT
bool is_native_input_gamut(const DiodeProfile &p) 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
LutTable build_lut(const ProfileCache &cache, int grid_n, LutInterp interp) 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 lookup_lut(const LutTable &lut, const float xy_t[2], float source_value, float out_rgbw[4]) 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
bool solve_strict_subgamut_xy(const ProfileCache &cache, const float xy_t[2], float source_value, float out_rgbw[4]) 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 xyY_to_XYZ(float x, float y, float Y, float out[3]) FL_NO_EXCEPT
void build_profile_cache(const DiodeProfile *p, int cct_override, ProfileCache *cache) FL_NO_EXCEPT
void cct_to_xy(int cct, float out[2]) FL_NO_EXCEPT
i16 quantize_lut_cell(float v) FL_NO_EXCEPT
__SIZE_TYPE__ size_t
Definition s16x16x4.h:16
constexpr common_type_t< T, U > max(T a, U b) FL_NO_EXCEPT
Definition math.h:75
u8 u8 fract8 blur_amount FL_NO_EXCEPT
Definition blur.h:186
constexpr int type_rank< T >::value
CRGB sample(const CRGB *grid, const XYMap &xyMap, float x, float y, SampleMode mode) FL_NO_EXCEPT
Sample a pixel from a 2D CRGB grid at floating-point coordinates.
FASTLED_FORCE_INLINE fl::u8 P(fl::u8 x) FL_NO_EXCEPT
FL_DISABLE_WARNING_PUSH U constexpr common_type_t< T, U > min(T a, U b) FL_NO_EXCEPT
Definition math.h:71
constexpr enable_if< is_fixed_point< T >::value, T >::type clamp(T x, T lo, T hi) FL_NO_EXCEPT
double fabs(double value) FL_NO_EXCEPT
Definition math.h:517
Base definition for an LED controller.
Definition crgb.hpp:179
Chromaticity-aware RGBW solvers — strict sub-gamut + wx_lp_legacy white extraction + boosted overdriv...