Correct sRGB Dithering

This is a brain-dump inspired by a thread on twit­ter about cor­rect™ dither in sRGB, mean­ing, to choose the dither pat­tern in such a way as to pre­serve the phys­i­cal bright­ness of the orig­i­nal pix­els. This is in prin­ci­ple a solved prob­lem, but the dev­il is in the details that are eas­i­ly over­looked, espe­cial­ly when dither­ing to only a few quan­ti­za­tion lev­els.

So, this top­ic came up on twit­ter:

I had pre­vi­ous­ly spent some time to wrap my head around this exact prob­lem, so I shot from the hip with some pseu­do code that I used in Space Glid­er on Shader­toy. Code post­ings on twit­ter are nev­er a good idea, so here is a cleaned up ver­sion wrapped up in a prop­er func­tion:

vec3 oetf( vec3 );    // = pow( .4545 )  (*)
vec3 eotf( vec3 );    // = pow( 2.2 )    (*)
 
vec3 dither( vec3 linear_color, vec3 noise, float quant )
{
    vec3 c0 = floor( oetf( linear_color ) / quant ) * quant;
    vec3 c1 = c0 + quant;
    vec3 discr = mix( eotf( c0 ), eotf( c1 ), noise );
    return mix( c0, c1, lessThan( discr, linear_color ) );
}

How the code works

The linear_color is the val­ue that is going to be writ­ten out to the ren­der tar­get. This is the val­ue to be dithered and is sup­posed to be in lin­ear RGB. The noise argu­ment can be any uni­form noise in the range 0 to 1 (prefer­ably some form of blue noise, or it could be an ordered Bay­er-pat­tern). Last­ly, the quant argu­ment is the quan­ti­za­tion inter­val, which is “one over one minus the num­ber of lev­els”; for exam­ple: 1/255 for quan­ti­za­tion to 256 lev­els, or 51/255 so emu­late the palette of web-safe col­ors (6 lev­els). The free func­tion oetf is used here to stand for an arbi­trary opto-elec­tron­ic trans­fer func­tion, which for sRGB is noth­ing more than the good old gam­ma curve (marked with an aster­isk, because you should also not neglect the lin­ear seg­ment if you out­put for sRGB).

EDIT: In a relat­ed blog post, I explain how the gam­ma curve  is real­ly → noth­ing more than a µ‑law for pix­els.

Here is how the dither func­tion works: It first com­putes the quan­tized low­er and upper bounds, c0 and c1, that brack­et the input val­ue. The out­put is then select­ed as either c0 or c1 based on a com­par­i­son of the input against a dis­crim­i­nant, discr. The salient point is that this com­par­i­son is per­formed in lin­ear space!

So why is it nec­es­sary to com­pute the dis­crim­i­nant in lin­ear space? Because what mat­ters is phys­i­cal bright­ness, which is lin­ear in the num­ber of pix­els (at least it should be, on a sane dis­play), but it is not in gen­er­al lin­ear in the RGB val­ue ifself.

Why the code works

To illus­trate fur­ther lets con­tin­ue with the web palette exam­ple where there are 6 quan­ti­za­tion lev­els. The fol­low­ing table shows how these 6 lev­els should map to phys­i­cal lumi­nance, accord­ing to the sRGB-stan­dard:

val­ue
(PERCENT)
val­ue
(8 bit)
Val­ue
(HEX)
lumi­nance
(cD per sq meter)
exam­ple
0% 0 #00 0         
20% 51 #33 3,31         
40% 102 #66 13,3         
60% 153 #99 31,9         
80% 204 #CC 60,4         
100% 255 #FF 80         

The lumi­nance val­ues here were cal­cu­lat­ed by fol­low­ing the sRGB trans­fer func­tion and under the assump­tion of the stan­dard 80 cd/m² dis­play bright­ness. Now con­sid­er for exam­ple that we want to match the lumi­nance of the #33 grey val­ue (3,31 cd/m²) with a dither pat­tern. Accord­ing to table we should choose a 25% pat­tern when using the #66 pix­els (3,31 into 13,3), a 10% pat­tern for the #99 pix­els (3,31 into 31,9), a 5,4% pat­tern for the #CC pix­els (3,31 into 60,4) or a 4,1% pat­tern for the #FF pix­els (3,31 into 80). This has been real­ized in the fol­low­ing image:

All tiles in this image should appear approx­i­mate­ly with the same bright­ness. They may not match per­fect­ly on your dis­play, but they should do at least ok. Make sure the image is viewed at its orig­i­nal size. To min­i­mize resiz­ing errors I have includ­ed a 2× ver­sion for reti­na dis­plays that should get auto­mat­i­cal­ly select­ed on Mac­Books and the like.

In con­trast, using the raw RGB val­ue as the basis for the dither pat­tern as shown above does not pro­duce a match­ing appear­ance. In this case I used a 50% pat­tern with the #66 pix­els (20 into 40), a 33% pat­tern with #99 pix­els (20 into 60), a 25% pat­tern with #CC pix­els (20 into 80) and a 20% pat­tern with #FF pix­els (20 into 100). See for your­self how that does not match!

A real world example

As I said in the begin­ning, I came up with the above dither­ing code as a side effect of the con­tin­ued tin­ker­ing with Space Glid­er, as I want­ed to have a some­what faith­ful ren­di­tion of twi­light and night sit­u­a­tions, and that means that with­out dither­ing, the sky gra­di­ent would pro­duce very not­i­ca­ble band­ing, espe­cial­ly so in VR.

To illius­trate, a took a screen­shot of a twi­light scene, stand­ing in the moun­tains with the land­ing lights on. The dark­est pix­el in this image is #020204, which is some­where in the low­er left cor­ner. With a VR head­set on, and with the eyes dark-adapt­ed, the jumps between #02, #03 and #04 are clear­ly vis­i­ble and prop­er dither­ing is a must.

I will now show how the code shown in the begin­ning is work­ing as intend­ed by dither­ing this image to 2, 3, 4, 6 and 8 quan­ti­za­tion lev­els by sim­ply adjust­ing the quant vari­able. Again, all images should match phys­i­cal bright­ness impres­sion (and again, on the con­di­tion that your brows­er does not mess with the pix­els). The noise input used here is just the Shader­toy builtin blue noise tex­ture, but 2 copies were added togeth­er at dif­fer­ent scales to make it effec­tive­ly one 16-bit noise tex­ture.

Conclusion

So that’s it as this is only a quick reac­tion post. To recap, the dither­ing prob­lem is com­pli­cat­ed by the fact that dis­play bright­ness in lin­ear in the num­ber of pix­els, but non-lin­ear in the RGB val­ue. Get­ting it right mat­ters for the low­est quan­ti­za­tion lev­els, be it either the dark parts of an image with many quan­ti­za­tion lev­els, or if there are only a few quan­ti­za­tion lev­els over­all.

3 thoughts on “Correct sRGB Dithering

    • Hi Thomas,
      thanks for get­ting involved! Yes, I was first to write eotf() and eotf_inv(), but I felt that eotf() and oetf() are visu­al­ly bet­ter dis­tin­guish­able in code. Also screen space is at a pre­mi­um in the small code sec­tions, so I went for the most con­cise option. In any case, your link clears it up. Cheers.

  1. Pingback: Correct SRGB Dithering – Hacker News Robot

Leave a Reply

Your email address will not be published.