Gustav Westling

@zegl

TinyGo LEDs: 6-bit colors and Gamma Correction

zegl

Last time I covered how to use TinyGo on the Cosmic Unicorn. Where the previous post ended, we could successfully draw to all pixels on the display, with a poor mans 2-bit color encoding.

Lots of colors


In this post we'll cover the process of increasing the color rendering support to 6 bits, with "Gamma Correction" to be able to draw realistic images.

To start, we're increasing the frame count to 6.

- const FRAME_COUNT = 2
+ const FRAME_COUNT = 6

But this is not enough, we also need to update how we're encoding color data into our frames, and how we're rendering those frames.

We also have to take "gamma correction" into account. Gamma correction is the process of converting "light intensity" to "perceived light intensity".

We're controlling "brightness" by rapidly powering the LED on and off, and the ratio of on to off will be it's brightness.

A LED that's on for 100% of the time will be twice as bright as one that's on 50%... or will it? No, it turns out that our eyes do not perceive lightness linearly, which we need to correct for.

Gamma table

This is not an exact science, as the "perceived brightness" cannot be measured. The perceived brightness versus luminance seems to follow a power law, and an exponent of 1/gamma is often used, where gamma is somewhere between 1.5 and 3.0.

corrected = value1/gamma

A exponent of 1/2.2 is the most popular option, and is also the gamma correction value that's used by image formats like sRGB.

Why do we need to "correct" anything in the first place? Because the RGB values have already been messed with!

In digital photography, the camera will apply "gamma encoding" when encoding the light of the physical world to a digital file. The gamma encoding is used optimise the file size, as by default a lot of storage would be used to save light information that the eye can't differentiate between.

value = x2.2

And when you want to display the image again on a monitor, the gamma is "decoded" by applying the inverse power.

Which means that what we're doing here is the inverse of the encoding that a camera would do. And they do indeed cancel each other out: x = xg1/g, or in our example: x = x2.21/2.2

..... anyway! That's a lot of text for explaining that we've created a pre-computed lookup table of our gamma correction.

6-bit gamma correction

var GAMMA_6BIT = [256]uint8{
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2,
    2, 2, 2, 3, 3, 3, 3, 3, 3, 3, 3, 3, 4, 4, 4, 4,
    4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6,
    6, 6, 7, 7, 7, 7, 7, 7, 8, 8, 8, 8, 8, 9, 9, 9,
    9, 9, 9, 10, 10, 10, 10, 10, 11, 11, 11, 11, 11, 12, 12, 12,
    12, 13, 13, 13, 13, 13, 14, 14, 14, 14, 15, 15, 15, 15, 16, 16,
    16, 16, 17, 17, 17, 17, 18, 18, 18, 18, 19, 19, 19, 20, 20, 20,
    20, 21, 21, 21, 22, 22, 22, 22, 23, 23, 23, 24, 24, 24, 25, 25,
    25, 26, 26, 26, 26, 27, 27, 27, 28, 28, 28, 29, 29, 29, 30, 30,
    30, 31, 31, 32, 32, 32, 33, 33, 33, 34, 34, 34, 35, 35, 36, 36,
    36, 37, 37, 37, 38, 38, 39, 39, 39, 40, 40, 41, 41, 41, 42, 42,
    43, 43, 43, 44, 44, 45, 45, 45, 46, 46, 47, 47, 48, 48, 49, 49,
    49, 50, 50, 51, 51, 52, 52, 53, 53, 53, 54, 54, 55, 55, 56, 56,
    57, 57, 58, 58, 59, 59, 60, 60, 61, 61, 62, 62, 63, 63, 63, 64,
}

Using this gamma table, we can replace the on(val int, frame int) bool function from the 2-bit implementation, and binary-encode the 6-bit number into our frames.

// Apply gamma correction
gammaR := GAMMA_6BIT[r]
gammaG := GAMMA_6BIT[g]
gammaB := GAMMA_6BIT[b]

for frame := 0; frame < FRAME_COUNT; frame++ {
    // Set color bits in each frame
    c.frames[frame][y*FRAME_COL_SIZE+(x*3+0)] = gammaB&0b1 == 1
    c.frames[frame][y*FRAME_COL_SIZE+(x*3+1)] = gammaG&0b1 == 1
    c.frames[frame][y*FRAME_COL_SIZE+(x*3+2)] = gammaR&0b1 == 1

    gammaR = gammaR >> 1
    gammaG = gammaG >> 1
    gammaB = gammaB >> 1

}

When rendering, we allow each frame to be "on" for as long as its numerical value. Each frame is on for twice as long as the previous one! This effectively "decodes" our binary representation into light again!

// How long to render each frame
for k := uint8(0); k < (uint8(1) << frame); k++ {
    tick()
}

And look! With these changes we can now render colors with 6-bit precision! That's 192 different colors!

Lots of colors!