Gamma-correct rendering, part 1

Anti-Grain Geometry (AGG) is an exceptionally flexible C++ library for high quality rendering of vector graphics. However, by default it operates in linear RGB space, whereas perhaps the majority of users of AGG are (often unknowingly) working in the sRGB colour space. Any graphics operation involving pixel blending will produce incorrect results unless the pixel data is first transformed to linear RGB.

What the heck is gamma anyway?

I'll try not to go into this in too much detail as there are already many web pages out there that discuss the issue. Basically, the situation is that the display devices commonly in use don't have a linear response. That is to say, the numerical value of a pixel is not directly proportional to the intensity of light emitted by the display device for that pixel. This originally came about because of the physical properties of the cathode ray tube - there is a power relationship between the input voltage and the number of photons emitted by the phosphors being hit by the electrons from the electron gun. The sRGB colour space standardises this non-linear response, effectively fixing the exponent at 2.2. It comes as a surprise to many (myself included, when I first learned it) that a pixel with value 128 is not half the intensity of a pixel with value 255. In fact, a half-intensity pixel would have a value of 186.

Why does it matter?

The problem comes when pixels are blended for the purposes of operations such as image rescaling or anti-aliasing. Taking the extreme case of a 50/50 blend between a black pixel and a white pixel, in the sRGB colour space we end up with a pixel value of 128 - quite a bit darker than the actual mid-grey value of 186. When this error is made, it results in jagged-looking edges when anti-aliasing, and a systematic darkening of images when rescaling or blending. Maybe you've noticed that web graphics (e.g. Flash animations) often look ugly and jaggy - that's probably because they aren't gamma-correctly rendered. The problem is widespread - even hideously expensive photo editing packages get it wrong.

How to solve the problem?

Maxim (the creator of AGG) was well aware of the issue, but even he was initially confused by it, as can be seen from several of the online samples, such as aa_demo.cpp. The error Maxim originally made was to attempt gamma correction in the polygon rasteriser, instead of in the pixel format blender. Maxim clearly realised his mistake, as some of the samples (e.g. gamma_correction.cpp) were corrected in the source code, though not necessarily in the compiled binaries available from the download page. The blender_rgb_gamma class was added, which is similar to the standard RGB blender, except that all pixel values are converted to linear RGB before blending, then converted back again afterwards. Sadly, Maxim stopped working on AGG before correcting all the example code.

Starting with the example code in aa_demo.cpp from AGG 2.4, I'll demonstrate how to incorporate gamma-correct rendering. First, we're going to create a gamma lookup table, so we need to include agg_gamma_lut.h:

Now we define a type definition for the gamma lookup table. The gamma_lut template implements a bidirectional lookup table between two different integral types. This is necessary because sRGB has a high granularity at low luminances: because of the gamma curve, the values are bunched up towards the top of the scale, leaving relatively few values to represent the lower luminances. To avoid losing precision, we need to convert the 8-bit sRGB values to 16-bit linear RGB. As a compromise we can use a 12-bit reverse lookup table, which yields reasonable results yet requires only 4096 table entries instead of the 65536 entries required by a full 16-bit table.

#include "agg_gamma_lut.h"

typedef agg::gamma_lut<agg::int8u, agg::int16u, 8, 12> gamma_type;

Next, in the_application, we add a member variable of type gamma_type. In the constructor, we'll initialise m_gamma with the standard value of 2.2. Also, we'll make the triangle bigger to make it a little easier to see what's going on.

Next, we add a checkbox control to the_application to allow us to quickly switch between gamma-correct and gamma-incorrect rendering:

agg::cbox_ctrl<agg::rgba8> m_cbox1;

We'll move the on_draw() code into the template function render() to facilitate rendering into either a gamma-corrected or non-gamma-corrected pixel format. Then, on_draw() is implemented like this:

virtual void on_draw()

{

if (m_cbox1.status())

{

typedef agg::pixfmt_bgr24_gamma<gamma_type> pixfmt;

pixfmt pixf(rbuf_window(), m_gamma);

agg::renderer_base<pixfmt> ren(pixf);

render(ren);

}

else

{

typedef agg::pixfmt_bgr24 pixfmt;

pixfmt pixf(rbuf_window());

agg::renderer_base<pixfmt> ren(pixf);

render(ren);

}

}

That's about it for the code - now for some screenshots. First, here's the original gamma-incorrect rendering:

Here's the gamma-correct rendering:

Notice how the shades of grey are more evenly spaced, resulting in smoother edges of the 1:1 rendering in the bottom left corner. Notice also how the gamma now applies to the checkbox/slider controls and the blue triangle outline.

Well, that's it for now. In the next article I'll consider an alternative implementation of gamma_lut.

Posted by Jim Barry, 2010-06-20.