Stannum/blog/

On RGB CCT LED controllers

2025-02-13 Permalink

I have recently came to install some RGB LEDs. Since CRI of my lights is of an importance to me, and RGB bulbs have horrible CRI, I’ve always thought of RGB bulbs as a gimmick.

Nowadays, however, RGB CCT LEDs are readily available. These are strips/bulbs that include five channels: red, green, blue, as well as warm and cold whites. This allows a no-compromise solution: achieving good CRI while enabling colored light for the special occasions that it is needed, as well as some other cool effects.[1]

Even though the focus of this article is on RGB CCT lights, some of it is applicable to RGBW or plain RGB to an extent.

A macro shot of an RGB+CCT single-chip LED.

Expectations

Being untainted by the previous art, I started with the expectation of the following features from an RGB CCT setup:

There are other requirements too, but they are not relevant to this discussion.

A mockup of the desired controls.

Survey of existing solutions

During my research I tried out Shelly, Tasmota, and WLED, as well as checked out screenshots of other products.[3]

First thing that stands out is that none of them allow color calibration. Tasmota comes close with the RGBWWTable and the VirtualCT settings, but neither provide a complete solution:

Next, both the UIs and the firmware are designed around the wrong idea of controlling RGB and white channels independently. By default, Shelly and Tasmota allow setting either RGB or white, but can be switched to allowing both at the same time—which is a high degree of useless.

Left: WLED interface. The sliders are, top to bottom: master brightness, RGB brightness, red, green, blue, white brightness, color temperature. Right: Tasmota interface. The sliders are, top to bottom: color temperature, hue, saturation, brightness.

Tasmota and WLED have a ‘WhiteBlend’ mode: when enabled, it drives the white channels with the minimum of the target RGB, subtracts that base value, then drives the RGB channels with the remaining RGB components. While it’s a step in the right direction, it still falls short: it decides between warm white and cold white based on the current CT setting rather than the desired RGB color.

Proper color animation is lacking in all of the products. They focus on animating short fades, short color cycles, or longer but discrete schedules rather than enabling a day-long color ramp. In either case, the animations are done in either linear or gamma-compressed RGB, not in anything perceptually uniform.

The precision is only 8-bits in gamma, and 10-bits in linear. The math that combines master brightness with a set color also appears to be only 8-bit. As a result the lights don’t produce correct colors when dimmed down. Combine it with animations, and you get some very janky fades.

Note that 10-bit linear precision is not even a hardware limitation. ESP8266 has a 14-bit PWM duty cycle, and ESP32 has a 20-bit one. Their ALUs have a whopping 32-bits of precision.

Solving for x

Let A be a 3x5 matrix whose columns are the measured CIEXYZ coordinates of each channel. Let b be the desired color in CIEXYZ. We need to find a vector x* of intensities of each channel s.t.

x* = argminx { ∥Ax − b∥2 | 0 ≤ x ≤ 1 }

I.e. we look for feasible channel intensities that reproduce the closest in-gamut color. This is a box-constrained quadratic program, and can be easily solved with a projected conjugate gradient descent.

Next, notice that the conditions thus set are under-determined. With five channels we have two extra degrees of freedom: for all but the fully saturated colors (laying on the gamut boundary), we can mix in some combinations of whites while compensating with the RGB channels.

To guarantee uniqueness, observe that white channels should be given higher priority over RGB. That is because a white LED will have a better CRI as opposed to producing that white with individual RGB lights. Additionally, at least with the LEDs I have at hand, the white LEDs are much brighter at the same power draw compared to RGB.

So let c be the power drawn by each channel at full duty cycle. In my case c = (1, 1, 1, 1, 1)T. Once we found the solution x* to the previous problem, we now look for

x = argminx { cTx | 0 ≤ x ≤ 1, Ax = Ax* }

This is a typical linear program.[4] It can be solved rather easily with the simplex algorithm. As stated, though, it is not in the standard form, and it’s best to keep it that way to avoid bumping the dimensionality. Instead, we modify the simplex algorithm to handle these box-constraints directly. This is achieved by allowing basic feasible solutions with either xi = 0 or xi = 1. Depending on which one it is, the sign of the reduced gradient is flipped accordingly, and the step size θ is limited by θ ≤ −xi/di or θ ≤ (1 − xi)/di, depending on the sign of the moving direction di.

Finally, it’s desirable to use the perceptual ΔE in CIELUV coordinates for gamut mapping rather than the linear CIEXYZ norm. This complicates the computation of the gradient in the 1st sub-problem, but doesn’t substantially change anything else. If switched to this norm, it might be beneficial to also switch from CG to L-BFGS.

Calibration

There are various ways to determine the calibration matrix.

The preferred way would be to use a quality colorimeter or a spectrometer to acquire direct measurements.

If such equipment isn’t available, a decent approximation can be made using a pre-calibrated interchangeable-lens camera: with the camera fixed and the lens removed, photograph each of the channels shining directly onto the sensor, then average the raw values and apply the color matrix to get the CIEXYZ coordinates. This works better for the wider spectrum whites, but has considerably worse accuracy with the near-monochromatic LEDs.

Notice that whatever method is being used, the relative intensity of the channels needs to be preserved.

Additionally, manual color matching can be incorporated to the equation. For example, say we determined the approximate CIEXYZ coordinates of each channel through measurements. Let Bm be the matrix whose columns are the CIEXYZ measurements, and A be the calibration matrix we are solving for. Then:

A I = Bm

Next, manually determine the RGB channel intensities (xw,0, xw,1, xw,2) to match the warm white channel at intensity xw,3, and do the same for cold white. This can be incorporated as additional two equations:

A (xw,0, xw,1, xw,2, −xw,3, 0)T = (0, 0, 0)T
A (xc,0, xc,1, xc,2, 0, −xc,4)T = (0, 0, 0)T

These two equations are stacked to the previous one forming an over-determined system:

A X = B

A can then be obtained as a LLS solution to this system.

Implementation

For start I implemented the proposed method on a PC with floating point math, then tested it by applying the computed intensities with Tasmota’s PWM command.

The out-takes are: my calibration is good but not perfect, and Tasmota’s bit-depth is insufficient for dim lights. Otherwise it works great! Setting sRGB values mostly match the color on my (uncalibrated) monitor, whereas dim-to-red gives a genuine feel of glowing metal.

A light at color temperature of 800K, 1300K, 2400K, 4000K, 6500K and 9320K, as the brightness varies accordingly.

For the next step I intend to refactor it to use 16-bit integer state with 32-bit integer math, then incorporate it directly into Tasmota.

In case performance becomes an issue, the necessary calculations can be amortized over time. I.e. a single gradient or linear-programming step can be done at each animation step, so the convergence will be perceived as a quick fade. Additionally, since the target color is usually smoothly varied by the user or an animation, the previously computed intensities x serve us a good guess for the next iteration. Empirical tests show convergence in just 5 CG steps, and 1 LP step.

Footnotes

  1. There are CCT dimmable bulbs on the market that dim-to-warm: i.e. as they dim down they shift their color temperature to warmer whites. A natural extension of this that I wanted to experience is a dim-to-red. That is as the light dims all the way to off, it would mix in some greens and reds to simulate an actual black body at low temperatures: from 2500K all the way down to 800K.

  2. I wish it was standard practice for manufacturers to publish calibration data of their devices. I knew, however, that that is too much to expect from consumer-grade products.

  3. I stayed away from any cloud-only solutions, like Tuya. It’s unfortunate that people are willing to depend on some Chinese-run server in order to control a simple light in their room.

  4. It might be tempting to simply add the cTx term to the 1st problem. But that would interfere with reproduction of fully saturated colors.