Search code examples
colorsconvertersrgbhsl

RGB to HSL conversion


I'm creating a Color Picker tool and for the HSL slider, I need to be able to convert RGB to HSL. When I searched SO for a way to do the conversion, I found this question HSL to RGB color conversion.

While it provides a function to do conversion from RGB to HSL, I see no explanation to what's really going on in the calculation. To understand it better, I've read the HSL and HSV on Wikipedia.

Later, I've rewritten the function from the "HSL to RGB color conversion" using the calculations from the "HSL and HSV" page.

I'm stuck at the calculation of hue if the R is the max value. See the calculation from the "HSL and HSV" page:

enter image description here

This is from another wiki page that's in Dutch:

enter image description here

and this is from the answers to "HSL to RGB color conversion":

case r: h = (g - b) / d + (g < b ? 6 : 0); break; // d = max-min = c

I've tested all three with a few RGB values and they seem to produce similar (if not exact) results. What I'm wondering is are they performing the same thing? Will get I different results for some specific RGB values? Which one should I be using?

hue = (g - b) / c;                   // dutch wiki
hue = ((g - b) / c) % 6;             // eng wiki
hue = (g - b) / c + (g < b ? 6 : 0); // SO answer
function rgb2hsl(r, g, b) {
    // see https://en.wikipedia.org/wiki/HSL_and_HSV#Formal_derivation
    // convert r,g,b [0,255] range to [0,1]
    r = r / 255,
    g = g / 255,
    b = b / 255;
    // get the min and max of r,g,b
    var max = Math.max(r, g, b);
    var min = Math.min(r, g, b);
    // lightness is the average of the largest and smallest color components
    var lum = (max + min) / 2;
    var hue;
    var sat;
    if (max == min) { // no saturation
        hue = 0;
        sat = 0;
    } else {
        var c = max - min; // chroma
        // saturation is simply the chroma scaled to fill
        // the interval [0, 1] for every combination of hue and lightness
        sat = c / (1 - Math.abs(2 * lum - 1));
        switch(max) {
            case r:
                // hue = (g - b) / c;
                // hue = ((g - b) / c) % 6;
                // hue = (g - b) / c + (g < b ? 6 : 0);
                break;
            case g:
                hue = (b - r) / c + 2;
                break;
            case b:
                hue = (r - g) / c + 4;
                break;
        }
    }
    hue = Math.round(hue * 60); // °
    sat = Math.round(sat * 100); // %
    lum = Math.round(lum * 100); // %
    return [hue, sat, lum];
}

Solution

  • I've been reading several wiki pages and checking different calculations, and creating visualizations of RGB cube projection onto a hexagon. And I'd like to post my understanding of this conversion. Since I find this conversion (representations of color models using geometric shapes) interesting, I'll try to be as thorough as I can be. First, let's start with RGB.

    RGB

    Well, this doesn't really need much explanation. In its simplest form, you have 3 values, R, G, and B in the range of [0,255]. For example, 51,153,204. We can represent it using a bar graph:

    RGB Bar Graph

    RGB Cube

    We can also represent a color in a 3D space. We have three values R, G, B that corresponds to X, Y, and Z. All three values are in the [0,255] range, which results in a cube. But before creating the RGB cube, let's work on 2D space first. Two combinations of R,G,B gives us: RG, RB, GB. If we were to graph these on a plane, we'd get the following:

    RGB 2D Graphs

    These are the first three sides of the RGB cube. If we place them on a 3D space, it results in a half cube:

    RGB Cube Sides

    If you check the above graph, by mixing two colors, we get a new color at (255,255), and these are Yellow, Magenta, and Cyan. Again, two combinations of these gives us: YM, YC, and MC. These are the missing sides of the cube. Once we add them, we get a complete cube:

    RGB Cube

    And the position of 51,153,204 in this cube:

    RGB Cube Color Position

    Projection of RGB Cube onto a hexagon

    Now that we have the RGB Cube, let's project it onto a hexagon. First, we tilt the cube by 45° on the x, and then 35.264° on the y. After the second tilt, black corner is at the bottom and the white corner is at the top, and they both pass through the z axis.

    RGB Cube Tilt

    As you can see, we get the hexagon look we want with the correct hue order when we look at the cube from the top. But we need to project this onto a real hexagon. What we do is draw a hexagon that is in the same size with the cube top view. All the corners of the hexagon corresponds to the corners of the cube and the colors, and the top corner of the cube that is white, is projected onto the center of the hexagon. Black is omitted. And if we map every color onto the hexagon, we get the look at right.

    Cube to Hexagon Projection

    And the position of 51,153,204 on the hexagon would be:

    Hue Color Position

    Calculating the Hue

    Before we make the calculation, let's define what hue is.

    Hue is roughly the angle of the vector to a point in the projection, with red at 0°.

    ... hue is how far around that hexagon’s edge the point lies.

    This is the calculation from the HSL and HSV wiki page. We'll be using it in this explanation.

    Wiki calc

    Examine the hexagon and the position of 51,153,204 on it.

    Hexagon basics

    First, we scale the R, G, B values to fill the [0,1] interval.

    R = R / 255    R =  51 / 255 = 0.2
    G = G / 255    G = 153 / 255 = 0.6
    B = B / 255    B = 204 / 255 = 0.8
    

    Next, find the max and min values of R, G, B

    M = max(R, G, B)    M = max(0.2, 0.6, 0.8) = 0.8
    m = min(R, G, B)    m = min(0.2, 0.6, 0.8) = 0.2
    

    Then, calculate C (chroma). Chroma is defined as:

    ... chroma is roughly the distance of the point from the origin.

    Chroma is the relative size of the hexagon passing through a point ...

    C = OP / OP'
    C = M - m
    C = 0.8- 0.2 = 0.6
    

    Now, we have the R, G, B, and C values. If we check the conditions, if M = B returns true for 51,153,204. So, we'll be using H'= (R - G) / C + 4.

    Let's check the hexagon again. (R - G) / C gives us the length of BP segment.

    segment = (R - G) / C = (0.2 - 0.6) / 0.6 = -0.6666666666666666
    

    We'll place this segment on the inner hexagon. Starting point of the hexagon is R (red) at 0°. If the segment length is positive, it should be on RY, if negative, it should be on RM. In this case, it is negative -0.6666666666666666, and is on the RM edge.

    Segment position & shift

    Next, we need to shift the position of the segment, or rather P₁ towars the B (because M = B). Blue is at 240°. Hexagon has 6 sides. Each side corresponds to 60°. 240 / 60 = 4. We need to shift (increment) the P₁ by 4 (which is 240°). After the shift, P₁ will be at P and we'll get the length of RYGCP.

    segment = (R - G) / C = (0.2 - 0.6) / 0.6 = -0.6666666666666666
    RYGCP   = segment + 4 = 3.3333333333333335
    

    Circumference of the hexagon is 6 which corresponds to 360°. 53,151,204's distance to is 3.3333333333333335. If we multiply 3.3333333333333335 by 60, we'll get its position in degrees.

    H' = 3.3333333333333335
    H  = H' * 60 = 200°
    

    In the case of if M = R, since we place one end of the segment at R (0°), we don't need to shift the segment to R if the segment length is positive. The position of P₁ will be positive. But if the segment length is negative, we need to shift it by 6, because negative value means that the angular position is greater than 180° and we need to do a full rotation.

    So, neither the Dutch wiki solution hue = (g - b) / c; nor the Eng wiki solution hue = ((g - b) / c) % 6; will work for negative segment length. Only the SO answer hue = (g - b) / c + (g < b ? 6 : 0); works for both negative and positive values.

    JSFiddle: Test all three methods for rgb(255,71,99)


    JSFiddle: Find a color's position in RGB Cube and hue hexagon visually

    Working hue calculation:

    console.log(rgb2hue(51,153,204));
    console.log(rgb2hue(255,71,99));
    console.log(rgb2hue(255,0,0));
    console.log(rgb2hue(255,128,0));
    console.log(rgb2hue(124,252,0));
    
    function rgb2hue(r, g, b) {
      r /= 255;
      g /= 255;
      b /= 255;
      var max = Math.max(r, g, b);
      var min = Math.min(r, g, b);
      var c   = max - min;
      var hue;
      if (c == 0) {
        hue = 0;
      } else {
        switch(max) {
          case r:
            var segment = (g - b) / c;
            var shift   = 0 / 60;       // R° / (360° / hex sides)
            if (segment < 0) {          // hue > 180, full rotation
              shift = 360 / 60;         // R° / (360° / hex sides)
            }
            hue = segment + shift;
            break;
          case g:
            var segment = (b - r) / c;
            var shift   = 120 / 60;     // G° / (360° / hex sides)
            hue = segment + shift;
            break;
          case b:
            var segment = (r - g) / c;
            var shift   = 240 / 60;     // B° / (360° / hex sides)
            hue = segment + shift;
            break;
        }
      }
      return hue * 60; // hue is in [0,6], scale it up
    }