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.

Note that this isn't for converting between RGB and HSV; this is only about applying an HSV-space modification to an RGB value and getting another RGB value out. There is no affine transformation to convert between RGB and HSV, as HSV is not a linear color space.

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.

A note about response curves

The math involved assumes that we are dealing with a linear color space. However, most displays use an exponential curve. For this to behave completely accurately, you'll have to convert your colors to linear color before, and to exponential color afterwards. A pretty close approximation (assuming a display gamma of 2.2) is as follows, where L is the linear-space value, G is the gamma-space value, and M is the maximum value (i.e. the channel value for the white point); for traditional 8-bit/channel images this is 255.

L = M(G/M)2.2
G = M(L/M)0.455

Note that if you are going to do this on a per-pixel basis in real time you will almost certainly want to precompute this as a lookup table.

For the remainder of the math, we don't care about the scale of the values, as long as they start at 0.

Step 1: Convert RGB → YIQ

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 YIQ is very easy; YIQ is a color space which uses the perceptive-weighted brightness of the red, green and blue channels to provide a luminance (Y) channel, and places the chroma values for red, green and blue roughly 120 degrees apart in the I-Q plane.

Note that there are many color spaces that you can use for this transform which have different hue-mapping characteristics; strictly-speaking, there is no single natural "angle" between any given colors, and different effects can be achieved by using different color spaces such as YPbPr or YUV. (An earlier version of this page used YPbPr for simplicity, but that led to much confusion when people expected the 180-degree rotation of red to be cyan, which is the case in YIQ but not in YPbPr.)

The transformation from RGB to YIQ is best expressed as a matrix, with the RGB value multiplied as a 1x3 vector on the right:

TYIQ =
0.2990.5870.114
0.596-0.274-0.321
0.211-0.5230.311

One convenient property of the YIQ color space is that it is essentially unit-independent, so we don't have to care about what our range of colors is, as long as it starts at 0.

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):

U = cos(H*π/180)
W = sin(H*π/180)

Th =

100
0U-W
0WU
=
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 I and Q 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 YIQ back to RGB, it's also pretty simple; we just use the inverse of the first matrix:

Trgb =
10.9560.621
1-0.272-0.647
1-1.1071.705

Pulling it all together

The final composed transform Thsv is TrgbTHTSTVTYIQ (we compose the matrices from right-to-left since the original color is on the right - matrix multiplication is non-commutative), 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
0VSU-VSW
0VSWVSU

where U and W are the same as in TH, above.

From here we can more conveniently get the master transform:

0.2990.5870.114
0.596-0.274-0.321
0.211-0.5230.311
*
V00
0VSU-VSW
0VSWVSU
*
10.9560.621
1-0.272-0.647
1-1.1071.705
=
.299V+.701VSU+.168VSW .587V-.587VSU+.330VSW .114V-.114VSU-.497VSW
.299V-.299VSU-.328VSW .587V+.413VSU+.035VSW .114V-.114VSU+.292VSW
.299V-.3VSU+1.25VSW .587V-.588VSU-1.05VSW .114V+.886VSU-.203VSW

As a sanity check, we test the matrix with V=S=1 and H=0 (meaning U=1 and W=0) and as a result we get something very close to the identity matrix (with a little divergence due to roundoff error).

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 multiplier (scalar)
        float V           // value multiplier (scalar)
    )
{
    float VSU = V*S*cos(H*M_PI/180);
    float VSW = V*S*sin(H*M_PI/180);

    Color ret;
    ret.r = (.299*V+.701*VSU+.168*VSW)*in.r
        + (.587*V-.587*VSU+.330*VSW)*in.g
        + (.114*V-.114*VSU-.497*VSW)*in.b;
    ret.g = (.299*V-.299*VSU-.328*VSW)*in.r
        + (.587*V+.413*VSU+.035*VSW)*in.g
        + (.114*V-.114*VSU+.292*VSW)*in.b;
    ret.b = (.299*V-.3*VSU+1.25*VSW)*in.r
        + (.587*V-.588*VSU-1.05*VSW)*in.g
        + (.114*V+.886*VSU-.203*VSW)*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
        float m  // Maximum value for a channel
    )
{
    Color ret;
    ret.r = (in.r*r.r + in.g*r.g + in.b*r.b)/m;
    ret.g = (in.r*g.r + in.g*g.g + in.b*g.b)/m;
    ret.b = (in.r*b.r + in.g*b.g + in.b*b.b)/m;
    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.)

A note about gamut and response

The YIQ color space as used here (as well as in NTSC) attempts to keep the perceptive brightness the same, regardless of channel. This leads to some fairly major problems with the response range; for example, since green is about twice as bright as red, rotating pure green to red would produce a super-red - and meanwhile, the value on some channels can also be pulled down past 0. This is not a flaw in the algorithm, so much as a fundamental flaw in how color works to begin with, and a disconnect between the intuitive notion of how "hue" works vs. the actual physics involved. (In a sense, a hue "rotation" is a pretty ridiculous thing to even try to do to begin with; while red and blue shift are very real phenomena, there is no actual fundamental property of light or color which places red, green and blue at 120-degree intervals apart from each other.)

The easy solution to this issue is to always clamp the color values between 0 and 1. A more correct solution is to actually keep track of where the colors are beyond the range and spread that excess energy (or lack thereof) across the image (i.e. to neighboring pixels), similar to HDR rendering. As more display devices move to floating-point-based color representation, this will become easier to deal with. However, at present (November 2009), no major image formats support anything other than a clamped integer color space (although some high-definition video formats support so-called "deep color" and at least the concept of superwhite/superblack).

In many cases, if you're making heavy use of hue transformation (in e.g. a game), you actually want to be dealing with your art assets stored as HSV and then just use that to produce your RGB values 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, float max) { return max*pow(value/max, 1/gamma); }
float GammaToLinear(float value, float gamma, float max) { return max*pow(value/max, gamma); }

In this case, both the linear and gamma colors will be stored in the same range (0..max). In real-world real-time implementations you will probably do this transformation using either a pair of lookup tables or a polynomial approximation so you don't have to do multiple pow() calls per pixel.

What about Photoshop's HSL adjustments?

As far as I can tell, Photoshop does not use a colorspace rotation to do its HSL adjustments. The best I can tell is that it does some sort of ad-hoc "intuitive" approach where it likely uses the lowest channel value to determine the saturation, and uses the ratio between the remaining two channels to determine the "angle" on the traditional 6-spoke print color wheel (red/yellow/green/cyan/blue/magenta). This provides perfect numeric values based on peoples' expectations of hues vs. RGB channels, but it actually makes for some pretty terrible chroma adjustment which tends to only work well on solid colors. Even smooth gradients between two colors are completely fouled up by this approach.

So, what I'm saying is that Photoshop's HSL transform mechanism isn't really something that should be emulated since its utility is limited to begin with (at least for general-purpose image processing; it's great for graphic design and number-oriented pixel art, I suppose).

I haven't verified this for myself, but apparently the YIQ-based algorithm described here is what Flash, as well as video editing tools such as Final Cut, Premiere, and so on, use for their color processing; given that it originates from real-life NTSC video processing equipment (which encode all color information in YIQ), this makes sense.

Acknowledgments

Wikipedia's article on YIQ provided the transformation matrices to convert between RGB and YIQ.

Ricky Haggett had used the previous YPbPr version of the algorithm which caused me to notice a major error in the math, and also inspired me to correct this to use YIQ, even though the math involved is much more complex but comes closer to what people expect from hue transformation, even if a 180-degree rotation still isn't the same as just doing (255-r,255-g,255-b).

Chris Healer found a mistake in my transcription of TYIQ.