1

What would be the best way to display the most faithfully graphics that have more than 8 bits per channel on a regular 24 bpp display?

Michel Rouzic
  • 1,013
  • 1
  • 9
  • 22

1 Answers1

6

The best solution I can think of is based on a random dithering that changes every frame. This combines the advantage of dithering with not having a fixed dithering pattern, and since a given pixel changes values many times a second what you perceive is closer to the average of those various values, which is closer to the original "deep color" value than any given 24 bpp value.

How it looks

A gradient of green, undithered, dithered (10 frames are shown), then both enhanced in the same way for visibility:

Banded gradient

Dithered gradient

Enhanced banded gradient

Enhanced dithered gradient

The dithering

The dithering is achieved by adding the gamma-compressed deep color value for each channel with a random value, then rounding to the nearest 8-bit value. It would seem natural to use random numbers with a uniform distribution between -0.5 and 0.5 (I'm talking in units that are equivalent to 1 in 8-bit gamma-compressed values, like the difference between 0 and 1 or 254 and 255), however this would result in a sort of banding artifact where the values of a gradient close to an 8-bit value would have little noise whereas values the furthest from any 8-bit value would show a lot more noise. A Gaussian noise is much more suitable as it gives a much smoother noise level. I chose a sigma of 1.0, but for less noise a sigma of 0.8 might do.

You can create a Gaussian PRNG by taking two random numbers, n1 and n2, fitting them each in the [-1 , 1] range, and if they represent a point within the unit circle (if the sum sum of their squares is inferior or equal to 1, otherwise start again) return sqrt(-2. * log(sum) / sum) * n1.

Practical implementation

I chose to implement this by converting a 15 bit per channel linear RGB framebuffer into an 8 bit per channel sRGB framebuffer. The linear to sRGB part is just a detail, I use a lookup table to transform the linear values into gamma-compressed values (I chose to make those intermediate values use 13 bits, you can see it as an 8.5 fixed point notation for sRGB values).

It should go without saying that you're not going to generate a new random Gaussian number for each pixel, you'll want to precalculate a bunch of them and put them in a circular buffer. I chose to make 16384 of them, yes, only 16384, I avoid any repeating patterns by choosing a random entry point in this buffer, a random length to go through (between 100 and 1123, this is pretty arbitrary), and when I reach the end of the length I chose a new random starting point and a new random length. This way I get pretty random non-repeating patterns out of a relatively small buffer of numbers. The numbers in the buffer are stored in 2.5 fixed point format, this way they are all between -4.0 and 4.0 which covers for the range of Gaussian random numbers I want to have. Just make sure to add 0.5 to your random numbers as this will take care of the rounding to the nearest integer later.

Here's basically how it works for each pixel and each channel:

15-bit linear value --via LUT--> 13-bit (8.5 fixed point) gamma-compressed value then ADD 2.5 fixed point random number then SHIFT 5 bits to the right.

Now you get an integer value between -4 and 260, you can use if()s to limit those, but it's much faster to use a 264 element LUT that returns 0 for negative numbers (you can use negative numbers as the index by allocating your buffer then doing buffer = &buffer[4], saves you an addition I guess) and that returns 255 for numbers above 255. Also I use the same random number for each of the three color channels, this avoids chromatic noise, though arguably the result might look somewhat less noisy if those three use independent numbers.

For a single pixel's red channel my code looks like this: sfb[i].r = bytecheck_l.lutb[lsrgb_l.lutint[fb[i].r] + dither_l.lutint[id] >> 5]; sfb being the sRGB 24 bpp buffer, fb being the 45 bpp linear RGB buffer, lsrgb_l.lutint[] being the linear to gamma-compressed LUT, dither_l.lutint[] the LUT containing the random Gaussian numbers in 2.5 fixed point format and bytecheck_l.lutb[] returning values clipped to [0 , 255].

Performance

I get over 50 FPS in a 1400x820 SDL window with my test gradient using just one core of a 2.4 GHz Core 2 Quad Q6600 and dual channel 800 MHz DDR2 memory, a somewhat mediocre machine by current standards, so this solution seems definitely suitable for modern computers.

Please let me know if any of my explanations require clarifications.

Michel Rouzic
  • 1,013
  • 1
  • 9
  • 22
  • Adjacent levels in a 24-bit system are between very difficult and impossible to distinguish - why the need for dithering at all? And if you use dithering why isn't static dithering good enough? – Mark Ransom Apr 03 '13 at 17:25
  • I disagree, it's a real problem, you can see by yourself if you play around with gradients in Photoshop's 16-bit per channel RGB mode (because unlike in 8-bit it doesn't dither what you see, and there's a reason why Photoshop dithers everything in 8 bit mode, because you can see the banding). And clearing a 'dynamic' dithering gives a better result than a static dithering, and doesn't fuse what it does to make your image better with what your image really contains, as in it doesn't mislead you into believe your image really contains that noise. – Michel Rouzic Apr 03 '13 at 17:58
  • By the way the question is about how to best display "deep color" content, I can see why one would think that "true colour" is good enough for most things, but this isn't a question about whether higher depths have any use or not, they do, and since most computer displays are limited to 24 bpp I think it's useful to try and identify THE BEST way to display deep colour content, not "the most good enough" way. – Michel Rouzic Apr 03 '13 at 18:04
  • How about just adding a number to the gaussian buffer index that is relatively prime to the length of the gaussian buffer and not one? (say, 1009) Not good enough? Would be slightly simpler if it's good enough – harold Apr 03 '13 at 19:37
  • You mean always 1009 instead of anything between 100 and 1123? I'm not sure, it might be good enough or it might make apparent repeated patterns appear more often. I didn't think of it as a performance issue given that with my values you have to calculate a new index and length in average every ~350 pixels (and there's 3 channels for each, that's a whole bunch of lookups, adds and shifts), so doing `stop = (id + 100 + (rand() & 0x03FF)) & 0x3FFF;` instead of `stop = (id + 1009) & 0x03FF;` shouldn't make much of a difference anyway. Also I'm not sure it being a prime or not would matter. – Michel Rouzic Apr 03 '13 at 19:50
  • Making it prime relative to the table size makes sure it hits every element eventually. Not having that property would be kind of bad, but it's not a very strong property so having it may not be sufficient. – harold Apr 03 '13 at 20:23
  • Oh, so you want to remove any randomness in the index/length calculation? I'm not sure I fully understand your scheme to be honest. Anyway I guess there's no one way to do it, my scheme is just one way to avoid the problem of repetition, that's the best one I could think of. Years ago I tried doing kind of the same thing (except 24 bpp to 16 bpp at 200 MHz, it actually worked quite smoothly) but I just used a very large random numbers buffer that repeated itself and it was hard to find a size that didn't seem like the noise pattern was slowly moving in a direction, so that wasn't a good way. – Michel Rouzic Apr 03 '13 at 20:53
  • @harold I think that visually speaking you'd be best off having a noise sequence that is exactly a multiple of the frame size, but many many frames long. That would avoid the "moving pattern" problem. – Mark Ransom Apr 03 '13 at 22:49
  • @MichelRouzic, I've *never* seen banding in an 8-bit gradient, dithered or not. As I said I think that 8 bits is sufficient to make the boundaries invisible, assuming proper gamma, and I've never seen any evidence to the contrary. I wish I had Photoshop to try your experiment. I can see that more bits would be valuable to preserve more of the information for later processing but that's not what we're talking about here. – Mark Ransom Apr 03 '13 at 22:52
  • 1
    Mark Ransom, really? You've never seen banding in an 8-bit gradient of your life? Ah well, it's not like you're the first person I hear saying that, but it's always a bit surprising, mostly from programmers well versed in graphics programming, although I too used to assume that 8 bits was enough. Well, there's a first time for everything, so I made you one, and it's hard not to see, you can even count the bands quite easily , at least on a calibrated screen: [obvious_banding.png](http://i.imgur.com/GTUR2af.png). Now I hope you can see why 8 bits might be inadequate sometimes. – Michel Rouzic Apr 03 '13 at 23:43
  • 1
    @MichelRouzic, thank you for that simple yet effective example! I will never question the need for more than 8 bits/channel again. It's interesting that I can see the transition from most of the bands but not all of them, it makes me wonder if my display is doing something tricky. I still question the need for motion dithering as I've found static dithering to be enough, even when going to 16 bit vs. 24 bit - see my examples at http://stackoverflow.com/a/3963150/5987 and http://stackoverflow.com/a/11650801/5987. I hope I can find time to duplicate those results with your example. – Mark Ransom Apr 04 '13 at 04:26
  • Looking at the obvious_banding.png, I'm wondering if there's more to it than meets the eye, pun pun. At least on this craptop monitor, I'm not just seeing banding, but dithering in 4 or more steps, making me think that the actual color channel depth is 5-6 bits or so, despite a claimed 24 bit color mode (well 32, word alignment, different story). Maybe the banding is actually a contrast effect created by the relatively sharp edges between differently dithered regions. Are you sure *your* monitor is actually 8 bits per channel? – nitro2k01 Apr 04 '13 at 08:16
  • Mach banding is the name of the effect I was looking for, accentuated by dithering performed by the monitor circuitry to compensate for lacking bitness of the LCD. That's my theory anyway. – nitro2k01 Apr 04 '13 at 08:23
  • Mark you're right, static dithering is adequate for most things, even in 16 bpp, I just think we can do a bit better, and by having it move I just think that it's more like actually having deep colour than just dithering. I like your butterfly example, but photographs are pretty forgiving of slight noise/grain. Btw I use my dithering technique in my game engine, since everything always moves I figured it's better if the dithering moves too, it looks more alive and not like there's a texture overlaying everything. As for your display maybe its LUT is 8 bits so some colours are fused together? – Michel Rouzic Apr 04 '13 at 13:18
  • nitro haha I knew someone would think that. I checked (and you can check too) that each visible band matched to a separate adjacent 8 bit value. You can even count the bands and see that it matches to the top value minus the lowest value. If you do see dithering maybe you have one of those 18 bits display that does dithering, in which case it makes sense for you to see dithering in 4 steps. Now if 18 bit displays do that, maybe 24 bit displays can/should do it too! :D And if you see Mach banding that still means your eyes can perceive the difference! – Michel Rouzic Apr 04 '13 at 13:28