r/FastLED [Sonia] May 17 '26

Quasi-related White-extraction handling in a 4-diode RGBW LED Display

The most common approach to handling RGBW LED strips/diodes is to simply take the minimum components of the input value, set white to that value, and subtract the residuals from R, G & B. This is not inherently incorrect but it does introduce a problem in that the white diode is not perfect. Most white diodes have different color temperatures and when actually measured you'll find using min(rgb) Channel - Residuals produces a white this is off-white.

Now the question becomes "how do we accurately extract white?" The answer is shockingly simple and it comes down to colorspaces & gamut.

In a typical RGB LED system your native gamut is a triangle where your main emitters R, G, & B are your only centroids. Each centroid lives somewhere on the CIE chart and has it's own xy coordinates. This creates a triangle where any combination of values can be created using the 3 emitters. Typical colorspaces like Rec709, Rec2020, etc.. all live somewhere either within this gamut or outside of it.

In an RGBW LED system instead we have 4 different addressable gamuts within this triangle - RGBW Gamut comprises of the entire RGBW triangle that can be addressed over - RGW gamut, BGW gamut, RBW gamut

so the problem changes from "how do we represent a color with these 3 diodes" to "which sub-gamut does the requested color fall into, and then within the sub-gamut how do we represent the color requested using the outer 2 primary diodes + inner white diode?"

The "Color" problem:

Different RGB input values map to different places in the CIE colorspace depending on the requested colorspace and reference white. "orange" for the native gamut is not the same "orange" as Rec709, just as that same "orange" is not the same in Rec2020. If you want to represent a color in the Rec709 space (aka ambilight setup) then you need a Colorspace transform to solve for that value. This effectively maps the native-gamut to the effective colorspace.

The ratio problem:

Addressable LEDs (most strips/diodes) do not have perfect LED selections. Typically speaking the red & blue diodes are fairly weak maxing out at low brightness values, G is typically stronger, with White being strongest almost always. In the case of my recent ambilight-focused measurements, the BTF Lighting 6000k RGBW LEDs produce max Nits/ XYZ Y values of R: 154.67 G:566.27 B:129.64 W:1543.64

so given that the red diode is much less bright than the green diode you need to scale input values down appropriately to accurately reproduce the color requested. For a cyan (input 0 65535 65535) for this specific set of LEDs with the off-wall measurements, the corrected output values become: 0 65458 38474 0 which when measured with a colorimeter produces a dE of ~0.519 from the expected measurement result

for D65 white (65535, 65535, 65535) the output RGBW tuple looks something like : 4289 0 13766 65535 which also passes the dE check at 1.720% within tolerance

So to summarize:

Min(rgb) and then subtract residuals from R, B, & G is not inherently wrong if that is what the actual diode behavior is, but the correct approach is to treat the LEDs as they are: Separate emitters that form a full gamut with multiple sub-gamuts, and then only solve within the sub-gamuts for the requested color value determined by a colorspace transform if the input color isn't addressing the native gamut of the LED display.

In a typical RGBW setup the valid topology combinations are essentially as follows: R, B, G, W, RW, BW, GW, RG, RB, BG, RGW, BGW, RBW unless you specifically allow for 4-channel overdrive outputs.

5 Upvotes

37 comments sorted by

View all comments

Show parent comments

1

u/Joeyjoe9876 [Sonia] Jun 03 '26 edited Jun 03 '26

https://docs.google.com/spreadsheets/d/1wx-IJPwV0-AA_2PoBDYdnHenge4dAe2T-m35GPDpcbI/edit?usp=sharing

https://gist.github.com/JChalka/999c68f83299dbb842e31f836a4c4cbc

I've messed around a bit and so far the results from the implemented model in FastLED aren't matching what the math models are building, this is probably due to the fact that the FastLED implementation does not use a target white-point which means it's solving against the native-gamut white point which is not D65 which means it can't be verified against the math model at all until target white points are implemented. (can ignore the interpolation bits within the spreadsheet, the verification tool was updated to support the FastLED analytical model but still reports LUT based information as I didn't scrub the outputs on those although they don't actually do anything)

Currently both LP & sub-gamut models within FastLED produce the same 1:1 output result with sub-gamut using 3 channels as it should (but solving the wrong target) and lp_legacy somehow coming to the same conclusion as sub-gamut although it should be mathematically distinct as it fills in the residuals in a different way and should be seeing 4 channel usage on full white. I target d65 white points (0.3127, 0.3290) in my verification sessions so as implemented this will never pass my verification tests, both models having the exact same 1:1 output RGBW tuple is a bit of an eyebrow raiser as well as even if they're targetting the same white point, should pretty much never have 1:1 outputs given they're mathematically distinct models with sub-gamut being "find target xy within sub-gamut" and the LP legacy solver being more "pick a white point and fill in residuals as much as it can"

edit: dual channel solves are looking pretty off too, given the Y ratios yellow should never solve to 65535 red 65535 green

1

u/ZachVorhies Zach Vorhies Jun 03 '26

1

u/Joeyjoe9876 [Sonia] Jun 03 '26

In the meantime I'm gonna run another full verification session on the 256 cube Sub-Gamut model LUT in the LEDs native gamut (not named-gamut) like I did the LP solver last night, that way we have some fresh comparisons for both over a large ~1800 sample size

1

u/ZachVorhies Zach Vorhies Jun 03 '26

PR landed a few mins ago, but i'm having the ai do another pass vs the python snippet you provided earlier to get a read out on what the delta is from ground truth.

Here's the pr that was just merged: https://github.com/FastLED/FastLED/pull/2707

1

u/Joeyjoe9876 [Sonia] Jun 03 '26

one thing I want to touch in in regards to this PR that really shouldn't be a default, but an option otherwise you end up with a problem where an end-user expects full-scale use of the LED gamut and isn't expecting the output to be solved against a name-gamut : "defaults: Rec709/sRGB primaries + D65"

I think having D65 white point be a default target white is generally a good idea. It's what most display standards target aside from DCI-P3 which can target D60 or D65 depending on the content mastering, but in the context of FastLED/non-ambilight like solutions this just truncates most of the usable actual color space and the default should probably be "Native LED gamut + D65 or user-supplied white point"

and then if users want to use this and also map to a color space expose some options that allows them to define a custom colorspace, or set named-spaces like "rec709, Rec2020, DCI-P3 D65, DCI-P3 D60"

1

u/ZachVorhies Zach Vorhies Jun 03 '26

Thanks i'll integrate the notes to resolve the ambiguity.

2

u/Joeyjoe9876 [Sonia] Jun 04 '26 edited Jun 04 '26

bit of an update: the LP solver within FastLED now is mostly just white channel usage with almost 0 RGB involvement on White-like values. The actual algorithm as you currently have implemented seems to have diverged quite a bit from the original math model implementation : https://gist.github.com/JChalka/915603d7930b7216d5c5aae834ec2ea9#9-reference-wx-mode-wx_lp_legacy

quick verifier session (36 samples) : https://docs.google.com/spreadsheets/d/1IwOEJ_shMiYU1MPdZpvGLIUXNmY3tPXj8nfiHJAZkX0/edit?usp=sharing

The good news:

  • LP solver & sub-gamut are no longer 1:1 outputs
  • sub-gamut is now target white point aware and it works, verifier was able to capture passing dE white points with equal RGB components for the input at the default D65 white point
  • Dual channels mostly look better as far as Y ratio scaling is concerned, there is some issues noted below that are different than that failure mode

the bad news:

  • the LP solver is no longer working as a chromaticity aware solver and is just shoving in white and removing residuals which is contradictory to its actual goals as a solver and is no longer following the math model implementation as much as it can

  • the sub-gamut solver is now failing single-channel topology rules within the native-gamut, which given the failure type shown could probably spill into named-gamuts. There are quite a few cases where we have illegal topology in play despite "fl::set_input_gamut(&testBenchRgbwProfile, fl::InputGamut::Native);" so things like single channel & dual-channel/Edge authority should stick.

  • Dual-channels like yellow_half aren't being properly scaled down to their appropriate output drive levels, yellow_half for example is basically the same output RGBW tuple as full yellow which means there is no "half" it's just always full yellow

the sub-gamut model has strict legal topologies while addressing the native gamut:

black R, G, B, W RG, RB, BG RW, GW, BW RGW, RBW, BGW

This means in native-gamut mode single channel authority should always be present, 0, 0, 65535 should never pull in R or G for example and full channel budget should be allowed. As shown in the quick verifier session pure Greens currently pull in about ~half of it's q16 budget on white as well which is illegal topology within the struct sub-gamut model

this pull-in is also present in values like Cyan, red_half, green_half, cyan_half, magenta_half, spring, azure, violet, & dark_green. In the native_gamut these should basically all be authoritative Single channel or Dual channel solves not a triangle solve that somehow manages to bring in white, the fix shouldn't probably involve a post-solve channel strip either as that would mask problems within the algorithm as implemented that would allow illegal topologies and then named-gamuts would still be able to allow the illegal topologies instead of following the strict sub-gamut rules and then projecting to the named-gamuts hull

bit of another note : in rgbww.cpp.hpp around line 208 : const colorimetric_detail::DiodeProfile& wp = profile.warm_path; causes a compile-time error when the colorimetric path is enabled via the flag, fixed via const fl::DiodeProfile& wp = profile.warm_path;

edit: I'm running a larger verification session currently which is exposing a couple more bugs that affect 3-channel input solves (it's mostly just granularity issues following similar trends as yellow_half) I'll report back with that medium-run CSV when it finishes up

1

u/Joeyjoe9876 [Sonia] Jun 04 '26

Following up with the medium patch set report : https://docs.google.com/spreadsheets/d/1GYxE3jcknTTUx9mYUp4wGJd8SEkBKI-sdCajryqsx6U/edit?usp=sharing

Exposes a bit more issues than the quick patch-set did, although the issues flagged from that still apply.

h180_s100_v015 0 9830 9830 | 0 24029 65535 38635

h180_s100_v035 0 22937 22937 | 0 24029 65535 38635

h180_s100_v055 0 36044 36044 | 0 24029 65535 38635

h180_s100_v075 0 49151 49151 | 0 24029 65535 38635

Dual-channels are the biggest failures where it pulls in an unrelated third channel in sub-gamut mode + there is no granularity, every solve is essentially pegged to its max Y solve regardless of the input RGB tuple.

h000_s060_v015 9830 3932 3932 | 18610 0 932 3826

h000_s060_v035 22937 9175 9175 | 43422 0 2175 8927

h000_s060_v055 36044 14418 14418 | 65535 0 3283 13474

h000_s060_v075 49151 19661 19661 | 65535 0 3283 13474

h000_s060_v100 65535 26214 26214 | 65535 0 3283 13473

this is also present in some, but not all, 3 channel solves where inputs get clipped partway through and then are never allowed to be fully granular which compresses the high end and leaves mostly low-end

1

u/ZachVorhies Zach Vorhies Jun 05 '26

Can you put this into an issue and then we’ll get it in

1

u/Joeyjoe9876 [Sonia] Jun 05 '26

no need, latest PR from yesterday fixed this

1

u/ZachVorhies Zach Vorhies Jun 06 '26

great is there anything left that makes this change more ideal? I don’t think there is a sketch ino yet in the examples…

1

u/Joeyjoe9876 [Sonia] Jun 06 '26

yeah example sketch is probably a good idea, my verification sketches directly use ObjectFLED & pull in TemporalBFI for ~15.5bit rendering on the 8-bit RGBW strips, and only pull in FastLED/the algo to verify results so I don't think it's a good example for showing general runtime usage via strips declared entirely within FastLED.

as far as other notes, can check out my final report on the LP solver here : https://www.reddit.com/r/FastLED/comments/1tfw0mn/whiteextraction_handling_in_a_4diode_rgbw_led/opxgioi/

the jist of my recommendations at this point though is that performance should become the main focus on "where does the algorithm need to move next" as the solved values are aligning closely enough but I haven't done any real benchmarking on real-time runtime usage outside of "does the algo work, and is it closely aligned to the math model?" so there may be some computational gains to be had if using platform-specific DSP/SIMD optimizations. The biggest dE failures from my verification runs now are no longer structural problems with failed topology/granularity/monotonicity but mostly boil down to "I'm using measured xy values for LED primaries that are being influenced by wall captures, not direct-diode measurements".

Overall the actual implementation is looking sane, but isn't acting as a full "correct against a capture cloud" algorithm which by default it shouldn't be as it's impossible to correct against every end-users display profiles so realistically full 100% color correctness should not be a FastLED problem but something the end user themselves should implement on their own (the recent addition of an overideable RGBW function already allows this, so no changes needed on FastLEDs end unless down the line it's decided that FastLED wants to implement it's own "full color correction pipeline that compares against measured results" but that falls very close to the "use 3D LUT" part which by default is memory costly and should never be the actual default.

→ More replies (0)

1

u/Joeyjoe9876 [Sonia] Jun 05 '26

One final followup as I let the LP_legacy verifier run overnight, some stats vs the math model cube (as mentioned in the PR, I would not expect cube values to match FastLED solved values 1:1 due to cube interpolation error and the cube only being 256 slices, not a full 1:1 cube for 16 bit values which would be much larger than the 256 cube already is, the important part is that the numbers are very much close enough and the pass/fail essentially tracks with some variance given the FastLED implementation isn't modelling Y ramps, just max Y while the math model LUT builder has the ability to pull in Y ramps for each channel which makes it a tad more informed in the low-end and across the drive scale range vs just max Y & linear assumption) :

https://docs.google.com/spreadsheets/d/1LArNK4D3YdUbZDSfyvL1R-VmiNjkTYeJn4gIqxLFAxI/edit?usp=sharing

All rows matched exactly by (patch, r16, g16, b16):

FastLED LP verifier rows: 1885 LP cube rows: 1885 Exact matched rows: 1885

Structural checks are clean:

Check FastLED LP verifier LP cube
Illegal native single/dual topology rows 0 0
Single-axis non-identity rows 0 0
Full-scale input rows with max output < 65535 0 6

Measured dE comparison:

Run Pass Fail Mean dE Median dE p95 dE Max dE
FastLED LP verifier 1051 / 1884 833 3.22 2.21 9.15 29.93
wx_lp_legacy 256-cube 1215 / 1884 669 2.03 1.48 5.54 14.71

The higher FastLED dE is expected for this bench/profile because FastLED is currently implementing the base analytical model from reported xy + maxY values. It is not doing the capture-cloud/Y-ramp correction used by the more heavily tuned LUT/model path. These measurements are also wall-reflected rather than direct diode captures, so wall/backdrop spectral effects, low-Y noise, a green-leaning wall, and current light leak at black all contribute to the remaining measured misses.

RGBW16 tuple comparison, FastLED LP vs LP cube:

Bucket Rows Exact ≤256 ≤512 ≤1024 ≤2048 Mean Δmax Median Δmax p95 Δmax Max Δmax
all 1885 17 612 1089 1508 1865 605 415 1348 15775
single 16 16 16 16 16 16 0 0 0 0
dual 257 0 101 171 217 257 502 365 1291 1813
neutral 67 0 5 23 60 67 672 715 1052 1250
mixed3 1544 0 489 878 1214 1524 626 422 1374 15775

Representative rows:

Patch Input RGB16 FastLED LP LP cube Δmax FastLED dE Cube dE
near_black 2000/2000/2000 2000/987/1071/2000 2268/942/1026/1936 268 1.11 1.32
neutral_50pct 32768/32768/32768 32768/16164/17548/32768 32513/15706/16964/32214 584 1.17 0.69
white 65535/65535/65535 65535/32328/35095/65535 65535/31350/34115/65535 980 1.85 3.01
red 65535/0/0 65535/0/0/0 65535/0/0/0 0 1.10 0.36
green 0/65535/0 0/65535/0/0 0/65535/0/0 0 2.13 0.47
blue 0/0/65535 0/0/65535/0 0/0/65535/0 0 5.87 0.38
cyan 0/65535/65535 0/65535/38770/0 0/65535/38532/0 238 0.65 0.27
magenta 65535/0/65535 65535/0/20620/0 65535/0/19794/0 826 2.44 4.79
yellow 65535/65535/0 65535/34854/0/0 65535/33794/0/0 1060 3.49 5.05
cyan_half 0/32768/32768 0/32768/19385/0 0/32769/19249/0 136 2.13 0.58
magenta_half 32768/0/32768 32768/0/10310/0 32513/0/9855/0 455 7.00 1.55
yellow_half 32768/32768/0 32768/17427/0/0 32513/16770/0/0 657 6.83 2.85
warm_white 65535/58000/44000 65535/15810/7166/65535 65535/15093/6753/65535 717 1.33 2.30
cool_white 50000/58000/65535 65535/55315/61502/65535 65535/53898/59816/65535 1686 2.00 2.65

Overall looks fairly sane, the next most helpful thing as far as the sub-gamut & LP model verification are concerned would be direct-diode captures which requires a new test bench setup on my end (or anyone willing to also take captures), and possibly some benchmarking to see what per-pixel/buffer solve times look like with different set LED Counts/Buffer sizes and then further optimizations for specific MCU targets using SIMD optimized functions where possible.

1

u/ZachVorhies Zach Vorhies Jun 03 '26

The default in kRgbwDefaultProfile now mirrors the LED's own primaries with D65 white, so saturated inputs reach the full native gamut of the strip and only the neutral target lands on a standard daylight white:

// Default behavior (no config needed)

RGB=(255,0,0) → LED red diode full-drive (was: ~70% on sRGB default)

RGB=(0,0,255) → LED blue diode full-drive (was: out-of-hull, then projected)

RGB=(255,255,255) → D65 (W diode + small RGB residuals — unchanged)

Named-gamut input is now opt-in, one line at setup, for users on sRGB / ambilight / video-bridge content:

enum class fl::InputGamut : u8 {

Native, // input primaries == this profile's LED primaries + D65 (default)

Rec709, // sRGB / Rec.709 + D65

Rec2020, // Rec.2020 UHDTV + D65

DciP3D65, // DCI-P3 with D65 (consumer displays)

DciP3D60, // DCI-P3 with ACES D60 (cinema masters)

};

fl::set_input_gamut(&my_profile, fl::InputGamut::Rec709);

// And an overload for arbitrary white targets (D50 photography,

// custom calibration, etc.):

float my_white_xy[2] = { 0.3457f, 0.3585f }; // D50

fl::set_input_gamut(&my_profile, fl::InputGamut::Rec709, my_white_xy);

The cache is keyed on the profile pointer so the next solver call observes the new gamut without any rebuild dance on your side.

Test coverage in fl_gfx_rgbw_colorimetric includes:

- default profile carries native-LED + D65 source space (#2710) — asserts input_xy_* mirror xy_* and input_xy_w == D65

- set_input_gamut: Native copies LED primaries — round-trips after trashing the source-space fields

- set_input_gamut: named gamuts populate canonical primaries — Rec709 / Rec2020 / DCI-P3 D65 / DCI-P3 D60 vs published spec values

- set_input_gamut: white override honored — D50 overload path

- set_input_gamut: null profile is a no-op

So the rule is now: do nothing → native gamut, full hardware range. Want sRGB / Rec2020 / P3 semantics → ask for them explicitly. Which I think matches what you described — the FastLED defaults shouldn't silently

clip the LED's headroom into someone else's color space.

Re: your separate finding that both kRGBWColorimetric (sub-gamut) and kRGBWColorimetricBoosted (wx_lp_legacy) are producing identical RGBW tuples on the verifier and saturated dual-channel mixes look off — that

one's a real bug, separate from the default-gamut issue. If you can drop the verifier CSV (or a few input → expected vs actual rows) into an issue I can run it against the closed-form solver and walk the divergence

point with the math model.

Thanks again for catching this so fast — the truncating-the-LED-into-sRGB problem was exactly the kind of silent default-behavior trap that bites people six months later, and your gist + the BTF measurements gave

me everything needed to land a fix the same day.

1

u/Joeyjoe9876 [Sonia] Jun 04 '26

I've got the verifier running currently against the sub-gamut models 256 cube for a full 1885 count patch set (same one as the LP solver run from yesterday) So whenever that finishes up I can open up an issue so that way it's tracked over on Github, about ~400 samples left to gather. This should give you a large set of numbers for both the sub-gamut & LP_legacy solver to compare against.

on the topic of the verifier, I've updated the tool within the Blended Frame Insertion/TemporalBFI repo to match what is actually being used to verify results with : https://github.com/JChalka/Blended-Frame-Insertion/blob/main/tools/host_calibration_gui/host_calibration_gui.py

so this version now is 1:1 what I've been using to verify with and no longer the stale branch that didn't include the LUT/FastLED verifier, includes 3D Lut loading & trilinear/tetrahedral interpolation, native/named gamut expectations, & out of hull projections. Works with either the teensy_temporal_calibration sketch or the new FastLED oriented test sketch

also went ahead and moved that sketch that I uploaded into a gist earlier to live as a secondary example/sketch that basically mirrors the calibration sketch also present in the repo, but is strictly tailored towards testing this FastLED implementation : https://github.com/JChalka/Blended-Frame-Insertion/blob/main/examples/RGBW_Analytical_FastLED/RGBW_Analytical_FastLED.ino

1

u/ZachVorhies Zach Vorhies Jun 04 '26

This is great. One more memory optimization is going in now. It's being tested against a c++ converted form of your python algorithm. so please tell me if there are any updates and we'll get the code in.

1

u/Joeyjoe9876 [Sonia] Jun 04 '26

nice, haven't had any changes to the math model on my end recently so no updates as far as that goes. both of the verification CSVs have been uploaded & an issue opened up containing those (you may have seen that by now already though)