Neltner Labs

Fun Projects and Art

Why every LED light should be using HSI colorspace.

So this topic is going to be a bit controversial. For one, almost no one knows what HSI Colorspace is, and fewer know why it’s the best choice for LED lighting. Basically, there are two very common colorspaces. RGB and HSV, neither of which make sense for LED lighting.

RGB is the colorspace widely used on the web – the first byte is the intensity of red, the second green, the third blue. The advantage is simplicity in a RGB LED based system. The downside is that to do very simple intuitive things like “rotate the color around a color wheel” is actually very complex in RGB computationally. You have to turn on green and red off, and then blue on and green off, and then red on and blue off. Try implementing it in a program and you’ll see how non-intuitive it can be.

HSV is a much more human-centric colorspace standing for “hue, saturation, value”. As hue changes from 0 degrees to 360 degrees, it goes around the color wheel, something that humans can grasp easily. Saturation represents whether the color is “colorful” or “white”, similarly simple to grasp. The problem with HSV is in the V for “value”. Value is defined not as the sum or average of RG and B, but instead as the maximum.

To see why this is problematic, continue below the break.

Consider the case of going from HSV = (0,1,1) to HSV = (60,1,1). In human-speak, this means going from hue of 0 degrees (red) to a hue of 60 degrees (yellow) at full saturation (no white) and full value (which is meaningless).

Okay, so you start with the red LED at full power. MAX(R, G, B) = 1. Great.

Now you start fading. What happens? Intuitively for a LED light we’d like for green to turn on while red turns off. But no, that’s not how MAX(R, G, B) works! Instead of trading red for green, green turns on and red stays on, so that yellow is actually emitting twice as much power as pure red.

Now, that makes no sense from a perceptual standpoint. But it makes perfect sense historically. Historically, it was less computationally intensive to calculate MAX than to calculate AVG or SUM. So that’s how it ended up defined. Oops.

So for a display this isn’t so bad. You calibrate, it’s not the end of the world. But for LED lighting? Ick. Say you have a 12W LED like in the MyKi, with 3W per color plus 3W for white. Now when you’re trying to emit red light you consume 3W, but when you’re trying to emit yellow you emit 6W (RGB = 1,1,0)! Ouch. Needless to say, you’re going to see the light getting brighter and dimmer.

Enter the HSI colorspace. HSI defines H (hue) and S (saturation) identically to HSV, in an intuitive human-centric fashion, but also defines I (intensity) as the total power output of the light. So instead of V = max(R, G, B), you use I = avg(R, G, B). In this fashion, when you implement a color fade in HSI colorspace at full intensity and full saturation, red, green, and blue are constantly shifting into one another but the total power output of the light stays constant and the perceptual brightness is more constant.

Here is an example function for doing a HSI -> RGB colorspace conversion on an arduino. I highly recommend using this type of algorithm for making smooth color fades. A fade can be implemented as simply as incrementing hue while leaving saturation and intensity constant, calling this function to convert the HSI value to RGB, and then sending these out as PWM values.

// Function example takes H, S, I, and a pointer to the 
// returned RGB colorspace converted vector. It should
// be initialized with:
//
// int rgb[3];
//
// in the calling function. After calling hsi2rgb
// the vector rgb will contain red, green, and blue
// calculated values.

void hsi2rgb(float H, float S, float I, int* rgb) {
  int r, g, b;
  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;
    
  // Math! Thanks in part to Kyle Miller.
  if(H < 2.09439) {
    r = 255*I/3*(1+S*cos(H)/cos(1.047196667-H));
    g = 255*I/3*(1+S*(1-cos(H)/cos(1.047196667-H)));
    b = 255*I/3*(1-S);
  } else if(H < 4.188787) {
    H = H - 2.09439;
    g = 255*I/3*(1+S*cos(H)/cos(1.047196667-H));
    b = 255*I/3*(1+S*(1-cos(H)/cos(1.047196667-H)));
    r = 255*I/3*(1-S);
  } else {
    H = H - 4.188787;
    b = 255*I/3*(1+S*cos(H)/cos(1.047196667-H));
    r = 255*I/3*(1+S*(1-cos(H)/cos(1.047196667-H)));
    g = 255*I/3*(1-S);
  }
  rgb[0]=r;
  rgb[1]=g;
  rgb[2]=b;
}

In a future post, I will explain how to produce an optimized mapping of HSI onto RGBW colorspace (more complex than it sounds because the solution is fully degenerate for you math people). I will also talk about how you can easily incorporate arbitrary scaling to account for perceptual brightness into your color conversion routine.