Perceptually uniform colorspaces for Cocoa programmers

As a Cocoa programmer, you might spend a lot of time creating your own NSView subclasses, often resorting to Core Graphics, Core Image and other powerful graphics frameworks to do your own drawing.

A particularly challenging aspect of Cocoa view programming is the recreation of the "Aqua" look, which often uses gradients or rich colors to create the candy-like, "lickable" interface elements Mac users have grown to love. Examples of these are the backgrounds of selected items in source lists such as Mail's mailbox list or iCal's calendar list, but also extreme elements such as the top bar in the pzizz application.

To create such a view, you basically have 3 choices of how to deal with the color values, each having particular advantages and disadvantages.

  • hardcode the color values
  • specify separate values for every single color
  • define a "fundamental" color and calculate the rest

Hardcoding the color values makes your view inflexible as it only supports a single look, but is the fastest and easiest approach. Specifying separate values for every occurrence of a new color (such as the starting and ending color of a gradient) provides flexibility, but it is a hassle to manually find good color combinations for every single look you want to create. Also, it's a lot of code to assign every single color a specific value in complex views.

Defining a fundamental color is the most convenient approach: In this case, you basically want to tell your view something like "Ok, I want you to look red now. Or green.", i.e. you only specify a single color value and let the view calculate the nuances. In the case of a subtle gradient, this means you'll either have to calculate a lighter or darker version of the fundamental color before drawing the gradient. The more complex your view becomes, the more color values you have to calculate from the fundamental color, but wouldn't it be cool to just say "Oh, I want this in red now" and then get it by simply changing a single fundamental color?

As an example, let's take a look at the navigation bar in iPhone applications (a deliberate choice since I'm currently playing a lot with the SDK).

iPhone Navigation Bar

The standard navigation bar can be seen as consisting of 4 elements: A very light line at the top (1 pixel tall), a 21 pixel gradient, a darker 21 pixel gradient and a rather dark line at the bottom, also 1 pixel tall. More than that, we can see that there is actually a very strong correlation between the 6 colors used here, in the sense that they all seem to be derived from this "grey-blue" hue that seems to be the fundamental color of the view.

Now let's assume we want to create our own navigation bar subclass that supports an arbitrary fundamental color, so that we can spice up our iPhone application a bit while staying true to the overall look-and-feel. For example, we might want a red navigation bar that otherwise resembles the original one.

Let's pick the darkest color of the bottom gradient as our "fundamental" color, since it is the richest color in the view.

A naive approach to calculating the other 5 colors would now be to simply measure the R, G and B values of the other colors in the original navigation bar and simply add these differences to our fundamental color in order to create the 5 other colors. If we set our fundamental color to the original "grey-blue", this will obviously work. But what about arbitrary fundamental colors? After all, that's the whole point of our excercise.

Playing with different colors, you'll notice that with this approach, you fail to recreate the look of the original navigation bar well with certain colors: Sometimes, some of the derived colors will be way too bright or too dark. Sometimes, they might seem too little or too much saturated, etc... Why is that?

It happens because RGB, being designed from a device perspective and not a human perspective, is not a perceptually uniform colorspace. That means that the difference of two RGB values does not correspond to the perceptual difference. For example, when adding (0.1, 0.1, 0.1) to certain colors (especially bright ones), you will see little change, but adding this to others creates much more of a brightening effect. In math-talk, we could say that the Euclidian distance between two RGB color values does not correspond to the perceived difference.

What about HSV then? Couldn't we just create our calculated colors by manipulating HSV values, which are obviously more "human-like" to wield?

Unfortunately not. HSV is also a perceptually non-uniform colorspace (which you can easily guess by looking at how symmetric it is). Same problem, even though the values are definitely more intuitive.

So what to do? Fortunately, there is a colorspace that we can easily convert our RGB values into that is also perceptually uniform. It's called Lab, CIELAB or Lab*. CIELAB is the one colorspace that most closely resembles the way color information is encoded in our eyes. L* pertains to the lightness value, whereas a* and b* are the color information (much like how the eye sends information back to the brain, a* is basically the red/green channel, and b* is the blue-yellow channel). If you want to learn more about CIELAB, I'd recommend starting with the CIELAB article on Wikipedia.

If you've worked with colorspaces on the Mac before, you might have stumbled across the ICC profiles used for color matching (Apple calls this ColorSync) before: What they do is basically this: An input profile transforms from the input color space to CIELAB, an output profile transforms back to RGB (or CMYK or whatever other space an output device uses). Basically, this is exactly how we want to calculate our sub-colors from the fundamental color, too. The process will be to

  1. Convert our fundamental RGB color to CIELAB
  2. Do our relative color manipulation, such as adding or subtracting in Lab* space.
  3. Convert back from CIELAB to RGB

Converting to CIELAB is pretty straight-forward. Usually, the transformation is done by converting from RGB to XYZ (the Tristimulus colorspace, don't worry about details) first, then from XYZ to CIELAB. The conversion back to RGB follows the reverse route.

There is a good collection of colorspace conversion algorithms written in language-agnostic pseudo-code at EasyRGB. Their code uses standard illuminants, so if you want to do heavy color lifting, be sure to convert your RGB values to the appropriate RGB colorspace before using their conversion formulae to XYZ and CIELAB, but for the scope of this article, this is not so important.

Once you have the code to convert from RGB to CIELAB and back in place (stick it into an NSColor or UIColor category, for example), it's time to find out the relative Lab* values to use in your code: What do we need to add/subtract to/from the fundamental color in order to achieve our sub-colors.

The easiest way to do this is to either design a mockup of your view in Photoshop or taking a screenshot of the view you want to copy (such as the iPhone navigation bar in our example &mdash But be careful with colorspaces when taking a screenshot, especially if your monitor is not color-calibrated) and using Photoshop's color selection dialog to see the CIELAB values of each color. Next, simply compute the relative offset of this color from the fundamental color and plug it into your code, creating a new color from the fundamental color in Lab* space. You can now convert back to RGB and create an NSColor, CGColorRef or UIColor object out of the RGB values. Pretty straight-forward, huh?

Using this approach, you'll see that you are getting much more perceptually uniform versions of your graphics artwork when plugging in different fundamental colors than what you were getting when you were manipulating RGB or HSV values directly! Obviously, the overall look/composition of your view is now much more stable in relation to the fundamental color (for which you can now choose very bright or dark colors without getting too much of a deviation from the look you're aiming for).

As an example, I've created a custom subclass of the iPhone navigation bar that accepts a single fundamental color via an Objective-C property and calculates the rest of the colors internally. Here's a collection of some custom navigation bar colorings.

iPhone Navigation Bars with custom colors

Not bad, but also not perfect. Especially the very bright 1-pixel line at the top of the menu bar seems to get lost when using a bright fundamental color (such as can be seen in the lime-colored example). Why is that?

The problem is that the CIELAB color gamut is much larger than that of RGB, HSV and even the human perception. The color gamut basically corresponds to the amount of colors that can be expressed in a given colorspace.

That means that when we have done our color manipulation in Lab* space and convert back to RGB, clipping might occur. Thusly, we might lose color information, which explains the lost highlights (i.e. bright colors) in the top 1-pixel line in some of the examples.

Another issue is that, while being much more perceptually uniform than RGB or HSV, CIELAB is not 100% perceptually uniform either. While definitely being the color space of choice for perceptual uniformity (trivia: Luv* could also be used), it is questionable if a 100% percent perceptually uniform colorspace is even possible, since different people have slightly different sensitivity when perceiving certain colors. Thusly, you will still not be able to recreate a certain "look" 100% at all times, but it is much better than with RGB and HSV.

Naturally, the material presented here is not restricted to Cocoa programming, but plays a big role here because of the general look-and-feel of MacOS X, which uses a lot of gradients etc... in its interface, to which this technique lends itself perfectly. Nevertheless, a basic understanding of colorspaces (especially from a perceptual point of view) is an important asset for every programmer that has to deal with graphics.

Links
CIELAB page on Wikipedia
Pseudo-code for colorspace conversions