HSV (hue, saturation, value) and HSL (hue, saturation, lightness) are two intuitive, but computationally cumbersome, colorspaces.

The basic idea behind these colorspaces is the good ol’ color wheel, where primary colors are placed on a triangle and secondary colors between; complementary colors are to be found opposite on the circle. That’s intuitive enough, but we still have to make it into a workable colorspace.

We—computer scientists at least—think of color primarily as RGB (possibly with different gammas, like sRGB) and only occasionally in terms of other colorspaces. So let’s built the intuition on how HSV from RGB.

We know we want at least one dimension to be associated to luminance. The great diagonal of a RGB cube is just that: it passes from black to white, with all intermediary shades of gray. We also want the other dimensions to correspond to the Yellow-Blue and Red-Green differences, or to other concepts such as hue and saturation. Let’s see what happens when we make the RGB cube stand on its tail.

The RGB cube standing up with the great diagonal as principal axis casts an interesting “shadow”: an hexagon. Moreover, if we assign the colors to this hexagon with the corresponding points on the cube, we get a “color wheel”—not quite round, but that’s not that important for now.

Every point in the cube can be projected onto the hexagon:

In transparency, the parallelogram generated by a particular RGB color (here, the color (0.5,0.7,0.3), with normalized components). The arrows shows there the color is projected on the hexagon. Interestingly, the projection of the color (as a linear combination of , , and , the basis of the RGB color space onto the hexagon is the same as the color expressed as the projection of the basis vectors (because , if is the projection, the basis, and the color). In other wods, we can project the axes of the cube onto the hexagon and use those projection to compute where the color lands:

The vectors , , and are the *projected* RGB axes. Let’s call the coordinates in the hexagonal plane and . Then

projects a color onto the hexagonal plane.

Let’s come back to the above figure. In the small circle, we have the projected color (0.5,0.7,0.3). It does match its surrounding rather well. However, that’s a bit of cheating, and a also a bit misleading. Indeed, the plane is only two dimensional, and we’re missing a dimension: a lot of colors will map to the same point. The missing third dimension is brightness.

The hue is simply the angle formed by the point, the origin, and some conventional point—say pure saturated red ().

The saturation is the distance from the center. At the center, we have the grays, therefore no saturation. Saturation is maximal on the edges of the hexagon.

Brightness is perpendicular to the hexagonal plane. It may, or may not be normalized. Some use the usual weightings of 0.299, 0.587, and 0.114 on RGB to get the brightness, but we might as well just use the transform implied by rotation of the cube on its tail—more on this a bit below.

*

* *

Now, if we add the third axis, we get an hexagonal prism:

In this model, it seems that we’re using too much of that colorspace for the colors we can distinguish. Indeed, it’s impossible to have a color that is very saturated and very dark at the same time. So the bottom end should be reduced to a point. So let’s do that. We get:

But ultimately, we want a color wheel-like system. We can stretch that hexagon into a circle:

*

* *

Now, how to we map a RGB color to that lovely funnel space?

- The hue is given by the angle of the vector:
,

possibly normalized to (0,1) (as Mathematica expects), or , …

- Saturation is typically computed as instead of vector norm of .
- Brightness is given as .

The standard implementation of RGB to HSV (and back) is a bit tricky. First, we must figure out into which of the six regions of the hexagon the projection lands, find the corresponding tangent, saturation, and brightness.

RGBHSV[{r_, g_, b_}] := Module[{h, s, v, c, M = Max[r, g, b], m = Min[r, g, b]}, c = M - m; h = If[c == 0, 0, Which[ M == r, Mod[(g - b)/c, 6], M == g, (b - r)/c + 2, M == b, (r - g)/c + 4, True, 0 ] ]; s = If[M != 0, (M - m)/M, 0]; v = M; Return [{ h, s, v}] ] HSVRGB[{h_, s_, v_}] := Module[{i, f, p, q, t, th}, th = h;(* Mod[Round[h],360]/60; *) i = Floor[th]; f = th - i; p = v (1 - s); q = v (1 - s f); t = v (1 - s (1 - f)); Which[ i == 0, {v, t, p}, i == 1, {q, v, p}, i == 2, {p, v, t}, i == 3, {p, q, v}, i == 4, {t, p, v}, i == 5, {v, p, q} ] ]

*

* *

We can still refine HSV a bit, or maybe make it a bit more intuitive. As we remarked, a color can’t be very saturated and very dark at the same time. Likewise, it’s hard to have a very bright and very saturated color—it will be perceived as white. HSL takes advantage of this and use a double cone (technically a bicone):

*

* *

The above procedures aren’t that complex, but one might rightly think that they are too slow for fast colorspace translation. Imagine coding video in HSV: you would need to compute `RGBHSV` for each pixel. Certainly we could do better computationally.

Well, the very first transformation, the one that set the RGB cube on its tail, is pretty much what we need: the great diagonal onto one of the axes, and the two others perpendicular to it. Let’s derive the transformation matrix.

First, consider the rotations around the red and green axes:

and

.

To bring the cube on its tail, we must align its great diagonal onto the first axis of the new colorspace. We rotate along the green axis to bring the diagonal in the Red/Blue plane, then along the Green axis to bring it to the red axis. In other words:

.

I came up with (the essentially trivial) colorspace sometimes in the 90s, but not as a replacement for HSV, merely to get a length-preserving color transform with one axis being brightness. The fact that is a rotation has the interesting side-effect that it is orthonormal and the inverse is merely its transpose: .