Fun Projects and Art

Implementing arbitrary color correction with the myki!

In previous posts, I’ve discussed how to configure the myki light (and the arduino leonardo in general) to operate in 16-bit mode on four channels (red, green, blue, white). I also discussed at length why HSI Colorspace is the ideal colorspace for use with LED lighting. Then I moved on to derive an algorithm for how to convert from HSI to RGBW colorspace in an optimal way despite the inherent degenerency in the conversion from three to four colorspace parameters.

This post came later, because it required all three of these previous posts to explain the magic that is about to happen. That magic is arbitrary scaling while doing conversions.

With arbitrary scaling as the final step for outputting a red, green, blue, or white value it is possible to do a variety of very useful things.

  1. Compensation for the eye’s inherent variable sensitivity over a given intensity range. For instance, at low intensity a small change in intensity produces a larger perceptual change than the same change in intensity at higher intensity.
  2. Compensation for the variation in sensitivity of the eye to different colors, known as the luminosity function. In summary, your eye sees a green light as far brighter than a blue light even if both are the same actual power.
  3. Color correction based on LED variations during manufacturing. For instance, one batch of LEDs might have a variation of 5% in exactly what frequency of light they put out, or variations in intensity that change the brightness for a given power put into the LED. By monitoring the color with a carefully calibrated instrument, you can compensate for the variation in software.
  4. Color correction over time as the LED degrades. All LEDs become dimmer over time and shift in frequency. This allows periodic color correction to be done to keep the light displaying a predictable hue as it naturally gradually changes.

To find out more, please read below the break.

The code to do this conversion is actually fairly straightforward now that the groundwork is in place with 16-bit outputs, and an optimized way to convert from HSI to RGBW colorspace.

First, why do we need 16-bit PWM for this to work well? The answer boils down to resolution conversion. Let’s look at it with a little bit of math.

Boundary Conditions

What is the boundary condition we’re looking for? Well, all that we can say for sure is that the mapping function should be continuous, monotonic, should always produce an output between 0 and 1, should be 0 at 0, and should be 1 at 1. What this means in English is that the brightness shouldn’t suddenly jump around, the output should never go down when the input goes up, that the output has to be in an achievable range for the LED to produce, that the LED should be off when the input is off, and that the LED should be as on as it gets when the input is on. For the carefully reading, note that I convert to a maximum value from the 0-1 range after this function, so we do not need to compensate for luminosity variations here.

Examples – Linear Mapping

If we’re scaling from a brightness in the range [0,1] to an output intensity in the range [0,1] with 256 steps (8-bit color) let us imagine that the mapping is linear. So, 0 goes to 0, 0.5 goes to 0.5, and 1 goes to 1.

The function is simple in this case – $f(x) = x$ where $f(x)$ is the output brightness and $x$ is the perceptual desired brightness.

Unfortunately, in practice this is not a continuous function but rather a discontinuous one. For instance, if $f(x) = 1/256 + \delta$ where $\delta$ is not a multiple of $1/256$ in 8-bit mode, then $f(x)$ must be approximated as 1/256 instead. Now, this is not a problem in the linear mapping here, because $x$ and $f(x)$ are discontinuous in exactly the same way. But it is a problem for any other kind of mapping. For clarity, I am showing how this quantization would work at an even lower mapping – 4-bit PWM.

image

In this graph, you can see nicely distributed values, with no loss of resolution in the mapping.

Example – Quadratic Mapping

Now let’s explore a practical case. We know for sure that the eyes are not linear. At low brightnesses your eyes can detect a single photon. At high brightnesses, such a change will be absolutely imperceptible. So how about a mapping like using a quadratic mapping?

First, what does this mean. A quadratic mapping means mathematically that $f(x) = x^2$. So to review the boundary conditions, we have a situation where when $x = 0$, $f(x) = 0$. When $x = 1$, $f(x) = 1$. Great start! How about being monotonic and continuous? Well, if we dredge back up calculus, we can use a derivative for both of these questions. We can do the derivative here to find that $\frac{df(x)}{dx} = 2 \cdot x$, or in differential form, $df(x) = 2x \cdot dx$.

What does this mean? For a small “differential” change ($dx$) in the perceptual brightness desired, the output change is twice that differential change multiplied by the current perceptual brightness. So, if $x=0$ (in other words, the light is off), going up in brightness by a nothing at all. Great! What we’d like is a situation wherein the light barely changes the output brightness as the perceptual brightness goes up evenly because the eyes detect those small changes better. Even better, as the perceptual brightness $x$ increases, the incremental change in the actual brightness increases. Here is a graph showing this with 4-bit color.

image

Ouch. That is not what we wanted at all! For the initial values of perceptual brightness we wanted, we see no change in the output brightness because the PWM resolution is not sufficiently high. In the middle it looks like some kind of nasty approximation to linear, and then at high values it jumps around.

This is why we need 16-bit PWM in order to do a reasonable job with this kind of mapping. The basic math shows that no matter how high the resolution is, performing a mapping function will cause this kind of resolution loss. This is the critical detail. With 8-bit color the situation is a bit better.

image

Not too bad, right? Certainly better. But let’s zoom in on that area where our eyes are the most sensitive – low intensities.

image

Oof. That’s a lot of missed opportunities to nicely fade. Now, you might ask yourself, can you really tell the difference between a power output of 0.004 versus 0? Well, take a look at this example below to see just how big of a range of brightnesses you’re missing in between 0 and 1/256.

In this example, you can see the red LED in 16-bit mode gradually fading up, with the blue LED set to output an identical brightness as well as it can in 8-bit mode. What blue LED you might ask? Well, keep watching!

Okay, so yeah, your eyes can very easily detect those brightness well below 1/256 of full intensity. This is why we need 16-bit color to do this mapping well. If we were to use 8-bit mode, we would need to reach a perceptual brightness of over 6% before the light even turns on!

Okay, so now let’s look at the case of 16-bit mapping for the same quadratic function.

image

Now we’re getting somewhere. That graph shows a single dot at each valid output value – and it practically looks continuous! But let’s not get our hopes up. First let’s check out those low ranges where 8-bit mapping ended up not being all it was cracked up to be.

image

Now we’re cooking with fire!

Now for the first time, we can get an output that is very close to continuous – much, much closer than 8-bit color, and nearly imperceptibly imprecise. You can still just barely tell that the step from off to $½^{16}$ is discontinuous, but what an improvement!

And that’s the point.

With 16-bit color on every single channel, for the first time we truly have the ability to apply nearly arbitrary mapping functions to our LEDs and still have high enough output resolution to provide good perceptual brightness shifts.

On to the Code!

Here is the code to convert from HSI to RGBW as it stands as of the last post.

// This section is modified by the addition of white so that it assumes
// fully saturated colors, and then scales with white to lower saturation.
//
// Next, scale appropriately the pure color by mixing with the white channel.
// Saturation is defined as "the ratio of colorfulness to brightness" so we will
// do this by a simple ratio wherein the color values are scaled down by (1-S)
// while the white LED is placed at S.
 
// This will maintain constant brightness because in HSI, R+B+G = I. Thus, 
// S*(R+B+G) = S*I. If we add to this (1-S)*I, where I is the total intensity,
// the sum intensity stays constant while the ratio of colorfulness to brightness
// goes down by S linearly relative to total Intensity, which is constant.

#include "math.h"
#define DEG_TO_RAD(X) (M_PI*(X)/180)

void hsi2rgbw(float H, float S, float I, int* rgbw) {
  int r, g, b, w;
  float cos_h, cos_1047_h;
  H = fmod(H,360); // cycle H around to 0-360 degrees
  H = 3.14159*H/(float)180; // Convert to radians.
  S = S>0?(S<1?S:1):0; // clamp S and I to interval [0,1]
  I = I>0?(I<1?I:1):0;
  
  if(H < 2.09439) {
    cos_h = cos(H);
    cos_1047_h = cos(1.047196667-H);
    r = S*255*I/3*(1+cos_h/cos_1047_h);
    g = S*255*I/3*(1+(1-cos_h/cos_1047_h));
    b = 0;
    w = 255*(1-S)*I;
  } else if(H < 4.188787) {
    H = H - 2.09439;
    cos_h = cos(H);
    cos_1047_h = cos(1.047196667-H);
    g = S*255*I/3*(1+cos_h/cos_1047_h);
    b = S*255*I/3*(1+(1-cos_h/cos_1047_h));
    r = 0;
    w = 255*(1-S)*I;
  } else {
    H = H - 4.188787;
    cos_h = cos(H);
    cos_1047_h = cos(1.047196667-H);
    b = S*255*I/3*(1+cos_h/cos_1047_h);
    r = S*255*I/3*(1+(1-cos_h/cos_1047_h));
    g = 0;
    w = 255*(1-S)*I;
  }
  
  rgbw[0]=r;
  rgbw[1]=g;
  rgbw[2]=b;
  rgbw[3]=w;
}

From here, it is actually quite straightforward to actually implement the change. Instead of scaling up to 255 (for 8-bit color) inside the function, we pull that scaling out to make it so that at the assignment for rgbw[0] the values for r, g, b, and w are all between 0 and 1. This value will be treated as the “perceptual brightness” that we intend to display.

So, the intent is that if r and g both have values of 0.5, they will both appear perceptually to be equally bright, and half the intensity they are when at an intensity of 1.

At the same time, we will need to modify the code to allow it to generically produce 8, 12, or 16 bit values depending on the mode so that the final output value sent to the PWM controller is appropriate for the device mode. However, this may work very poorly in less than 16-bit mode for reasons described previously.

// This section is modified by the addition of white so that it assumes
// fully saturated colors, and then scales with white to lower saturation.
//
// Next, scale appropriately the pure color by mixing with the white channel.
// Saturation is defined as "the ratio of colorfulness to brightness" so we will
// do this by a simple ratio wherein the color values are scaled down by (1-S)
// while the white LED is placed at S.
 
// This will maintain constant brightness because in HSI, R+B+G = I. Thus, 
// S*(R+B+G) = S*I. If we add to this (1-S)*I, where I is the total intensity,
// the sum intensity stays constant while the ratio of colorfulness to brightness
// goes down by S linearly relative to total Intensity, which is constant.

// Finally, this function removes mapping to 8-bits from the actual conversion
// routine and instead implements a quadratic scaling. You can see this in the
// last four lines of the function. First, a scaling function is applied to the float
// value for the brightness. Next, it is multiplied by 2^16-1 to convert into a
// valid PWM value in 16-bit mode.

#include "math.h"
#define DEG_TO_RAD(X) (M_PI*(X)/180)

void hsi2rgbw(float H, float S, float I, int* rgbw) {
  int r, g, b, w;
  float cos_h, cos_1047_h;
  H = fmod(H,360); // cycle H around to 0-360 degrees
  H = 3.14159*H/(float)180; // Convert to radians.
  S = S>0?(S<1?S:1):0; // clamp S and I to interval [0,1]
  I = I>0?(I<1?I:1):0;
  
  if(H < 2.09439) {
    cos_h = cos(H);
    cos_1047_h = cos(1.047196667-H);
    r = S*I/3*(1+cos_h/cos_1047_h);
    g = S*I/3*(1+(1-cos_h/cos_1047_h));
    b = 0;
    w = (1-S)*I;
  } else if(H < 4.188787) {
    H = H - 2.09439;
    cos_h = cos(H);
    cos_1047_h = cos(1.047196667-H);
    g = S*I/3*(1+cos_h/cos_1047_h);
    b = S*I/3*(1+(1-cos_h/cos_1047_h));
    r = 0;
    w = (1-S)*I;
  } else {
    H = H - 4.188787;
    cos_h = cos(H);
    cos_1047_h = cos(1.047196667-H);
    b = S*I/3*(1+cos_h/cos_1047_h);
    r = S*I/3*(1+(1-cos_h/cos_1047_h));
    g = 0;
    w = (1-S)*I;
  }
  
  rgbw[0]=0xFFFF*r*r;
  rgbw[1]=0xFFFF*g*g;
  rgbw[2]=0xFFFF*b*b;
  rgbw[3]=0xFFFF*w*w;
}

And that, my friends, demonstrates a simple description of how you can define an arbitrary scaling function with 16-bit resolution in order to achieve high quality, color corrected, eye-sensitivity compensating, beautiful color.