HSV color transforms

Because the need for color manipulation comes up fairly often in computer graphics, particularly transformations of hue, saturation, and value, and because some of this math is a bit tricky, here's how to do HSV color transforms on RGB data using simple matrix operations.

Preliminary

All of the operations here require multiplication of matrices and vectors. Since MathML isn't very universal and I hate using images to represent things, I'll just use standard HTML tables to lay them out. Hopefully your web browser supports the inline-table layout, and a simple multiplication of a matrix and a vector will look like this:
Ax Ay Az
Bx By Bz
Cx Cy Cz
*
Vx
Vy
Vz
=
AxVx+ AyVy + AzVz
BxVx+ ByVy + BzVz
CxVx+ CyVy + CzVz
Conveniently, that also serves as the crash course on how to multiply a matrix and a vector. (To multiply two matrices, you treat each column of the right-hand matrix as a separate vector and multiply each of them by the left-hand matrix, and then stack the result vectors horizontally.)

(If each thing ends up on its own line, just pretend they line up horizontally. Maybe in the future I'll just replace them with images. Incidentally, Safari is the only browser I've seen which does this right, and even then it still stacks some tables vertically for some reason.)

Matrix multiplication, like numerical multiplication, is associative, but unlike numerical mutiplication, is not commutative. This means that if you have multiple matrix operations to perform to a single vector, like v'=ABCDv, you can combine them together into a single master matrix where Z=ABCD and then v'=Zv. This will be useful later on.

Step 1: Convert RGB → YPbPr

RGB values aren't very convenient for doing complex transforms on, especially hue. The math for doing a hue rotation on RGB is nasty. However, the math for doing a hue rotation on YPbPr is very easy. So what's YPbPr? It's a simple color space like YUV, where the primary axis is the the average of the three channels, and the other two axes are the offsets of the blue and red channels from the brightness channel.

Strictly-speaking, Y isn't the luminance, as this model doesn't account for the perceptual differences between red, green, and blue; as such this isn't the same Y as in the YUV color space. On that note, you can use YUV instead of YPbPr and the math will work out nearly the same (although the hue transform will generate slightly different results); I'll leave it up to the reader to find the appropriate transforms for that.

The conversion is pretty straightforward, with

Y=(r+g+b)/3 = r/3 + g/3 + b/3

Pb = b - Y = 2b/3 - r/3 - g/3

Pr = r - Y = 2r/3 - g/3 - b/3

Or, as a matrix,
TYPbPr =
1/31/31/3
-1/3-1/32/3
2/3-1/3-1/3
One very useful property of this transform is that since Y is relative to the range of the colors you'll be using and Pb/Pr are offsets from Y, we don't really care what units are used for r, g and b; i.e. you don't have to map them to another range of values, as long as they start at 0 (actually they can start at something other than 0 for the hue and saturation transforms as well, though the value transform will be affected).

Step 2: Hue

Now that our color is in this simple format, doing a hue transform is pretty simple - we're just rotating the color around the Y axis. The math for this is as follows (where H is the hue transform amount, in degrees):
C = cos(H*π/180)
S = sin(H*π/180)

Th=

100
0C-S
0SC
=
100
0cos(H*π/180)-sin(H*π/180)
0sin(H*π/180)cos(H*π/180)

Step 3: Saturation

Saturation is just the distance between the color and the gray (Y) axis; you just scale the Pb and Pr channels. So its matrix is:
Ts=
100
0S0
00S

Step 4: Value

Finally, the value transformation is a simple scaling of the color as a whole:
Tv=
V00
0V0
00V

Step 5: Convert back to RGB

To convert YPbPr back to RGB, it's also pretty simple; we just reverse the equations from above:
r=Y+Pr
b=Y+Pb
g=3Y-r-b=Y-Pr-Pb
or as a matrix,
Trgb=
101
1-1-1
110
As a sanity check, multiply TYPbPr with Trgb — the result is the identity matrix, so we at least know that conversion to and from YPbPr is correct.

Pulling it all together

The sum total transform Thsv is TYPbPrTHTSTVTrgb, which you get by multiplying all the above matrices together. Because matrix multiplication is associative, it's easiest to work out THTSTV first, which is
V00
0VScos(H*π/180)-VSsin(H*π/180)
0VSsin(H*π/180)VScos(H*π/180)
From here on out, we'll use the following equivalence to make the equations easier to work with:
k = V/3

a = V*S*cos(H*π/180)/3

b = V*S*sin(H*π/180)/3

(we divide them by 3 because that allows us to cancel out all the fractions in TYPbPr) so we can rewrite the above as:
3k00
03a-3b
03b3a
and from here we can more conveniently get the master transform:
k=V/3
a=V*S*cos(H*π/180)/3
b=V*S*sin(H*π/180)/3
Thsv =
k+2a -2b k-a-b
-k+a+3b 3a-b -k+a-2b
2k-2a 2b 2k+a+b
As a sanity check, we test the matrix with V=S=1 and H=0 (meaning k=1/3, a=1/3, and b=0) and as a result we get the identity matrix.

So show me the code already!

Okay, fine. Here's some simple C++ code to do an HSV transformation on a single Color (where Color is a struct containing three members, r, g and b with obvious meanings, in whatever data format you want):
Color TransformHSV(
        const Color &in,  // color to transform
        float H,          // hue shift (in degrees)
        float S,          // saturation shift (scalar)
        float V           // value multiplier (scalar)
    )
{
    float k = V/3;
    float a = V*S*cos(H*M_PI/180)/3;
    float b = V*S*sin(H*M_PI/180)/3;

    Color ret;
    ret.r =    (k+2*a)*in.r -     2*b*in.g +    (k-a-b)*in.b;
    ret.g = (-k+a+3*b)*in.r + (3*a-b)*in.g + (-k+a+2*b)*in.b;
    ret.b =  (2*k-2*a)*in.r +     2*b*in.g +  (2*k+a+b)*in.b;
    return ret;
}

What if I want to use someone else's color transform matrix?

Let's say you want to perfectly replicate the color transformation done by some other library or image-manipulation package (say, Flash, for example) but don't know the exact colorspace they use for their intermediate transformation. Well, for any affine transformation (i.e. not involving gamma correction or whatnot), this is actually pretty simple. First, just run the unit colors of red, green, and blue through that color filter, and then those become the rows of your transformation matrix. How convenient is that?

Code for that would look something like this:

Color TransformByExample(
        const Color &in,  // color to transform
        const Color &r,   // pre-transformed red
        const Color &g,   // pre-transformed green
        const Color &b    // pre-transformed blue
    )
{
    Color ret;
    ret.r = in.r*r.r + in.g*r.g + in.b*r.b;
    ret.g = in.r*g.r + in.g*g.g + in.b*g.b;
    ret.rb= in.r*b.r + in.g*b.g + in.b*b.b;
    return ret;
}
(Really, I only bring it up because it was part of the conversation which led me to write up this guide to begin with.)

And what about gamma correction?

Okay, the various code and equations from above assume linear gamma. Dealing with a non-linear gamma isn't too hard, though; just convert the colors to linear, then convert them back to gamma-space values when you're done. (If you're doing this, hopefully you're storing your values as floats.) For reference, here's how you do that:
float LinearToGamma(float value, float gamma) { return pow(value, 1/gamma); }
float GammaToLinear(float value, float gamma) { return pow(value, gamma); }
Not difficult or anything, but it's nice to keep this in mind, especially since high-gamma images can undergo some pretty weird effects when dealt with in a linear color space.