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 levels.

So, this top­ic came up on twitter:

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 function:

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 the num­ber of lev­els minus one”; for exam­ple: 1/255 for quan­ti­za­tion to 256 lev­els, or 1/5 (= 51/255) to 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-standard:

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 texture.

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 overall.

9 Gedanken zu „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

  2. I’ve just spend two weeks try­ing to come up with a prop­er dither­ing for sav­ing to lin­ear R11G11B10F for­mat, and I’ve kin­da giv­en up on it, because with float­ing-point val­ues you encounter so many addi­tion­al dif­fi­cul­ties, it just did­n’t seem worth the has­sle. The only thing I got out of it was to notice burn-in on the OLED screen. But your post gave insight on how to dis­trib­ute the RNG cor­rect­ly to the phys­i­cal lumi­nance scale for the final sRGB output!

    Why does it always have to be this com­pli­cat­ed? Like, it’s prob­a­bly very hard in the aver­age game for any­one to notice between sRGB or lin­ear dither­ing. And what makes it even worse: Many games use no dither­ing at all! You straight-up see the band­ing. Witch­er 3, ArmA 3, just to name a few. But I want to do it cor­rect­ly, so now it’s time to update it…

    • Hi Man­gu­dai,
      thanks for your thoughts!

      For the pur­pose of dither­ing, the non-lin­ear­i­ty intro­duced by the float­ing point for­mat should be han­dled in the same way as the non-lin­ear­i­ty of the sRGB encoding. 

      You need to write some­thing akin to an “FPOTD” (float­ing-point-opti­cal-trans­fer-func­tion) and its inverse. The func­tion is to answer the ques­tion, how much phys­i­cal bright­ness the indi­vid­ual code points of the fp-for­mat map to. If you do +1 in the fp-for­mat, how much more bright­ness is it going to gain on the dis­play? Where on the phys­i­cal bright­ness lev­el are the next and pre­vi­ous code points (the ones to dither between) locat­ed? This would be a piece­wise lin­ear func­tion approx­i­mat­ing an expo­nen­tial function. 

      The usu­al FP behav­ior: a lin­ear man­tis­sa ramp until the expo­nent increas­es by one, then anoth­er man­tis­sa ramp twice as steep, and so on. 

      Since the fp-for­mat itself is lin­ear, you would­n’t need to wor­ry about sRGB on top of it, just the fp nonlinearity.

      Does that make sense?

  3. Hey, so one more thought, i for­got to add: The usu­al dither algo­rithms go with a noise span of 2, while in your algo­rithm it’s just the two adja­cent col­ors ( span 1). I test­ed your approach vs the shader­toy imple­men­ta­tion here: https://www.shadertoy.com/view/NssBRX
    And i must say, your’s looks much bet­ter except for the noise dis­con­ti­nu­ities on mov­ing noise. Because then the inter­val of 1 cre­ates areas of high noise and areas of low noise. I ask myself if it would be pos­si­ble to some­how adapt the inter­val to e.g. 2, but involv­ing 4 col­ors already cre­ates issues, because there isn’t a unique solu­tion. I’m cur­rent­ly build­ing an inter­po­la­tion, but it’s get­ting too late and i need to fin­ish the com­ment. Hope­ful­ly it works out!

    • I think, the way for­ward with a broad­er dither pat­tern would be thus:

      Take the set of neigh­bor­ing code points that you want to dither between. In the orig­i­nal arti­cle there only was c_0 and c_1, but you can take any num­ber you like c_2, c_3, c_4 and so on.

      Map these val­ues back to the lin­ear scale. In gen­er­al they will no longer be spaced uniformly.

      Make inter­po­la­tion weights, so that for each posi­tion of the dis­crim­i­na­tor, you have weights for c_0, c_1, c_2 and so on such that their lin­ear com­bi­na­tion gives the expect­ed brightness.

      Then draw a ran­dom sam­ple from c_0, c_1, c_2 and so on with prob­a­bil­i­ties pro­por­tion­al to the inter­po­la­tion weights.

      Does that make sense?

  4. Heyo, one last com­ment! I’ve thought about dis­trib­ut­ing the noise a bit more even­ly, but it’s not as easy as thought. I explic­it­ly have to quan­tize adja­cent col­ors too and it quick­ly gets very noisy. If you want to take a look at the approach, I’ve post­ed it here: https://www.shadertoy.com/view/t3BXWW

    • Looks as if this it is a cor­rect imple­men­ta­tion of the above idea.
      See my com­ment on shadertoy!

      Now the ques­tion is open if that par­tic­u­lar choice of inter­po­la­tion weights is in any way good, or ‘opti­mal’ (for any mea­sure of ‘opti­mal’). I think one such opti­mal­i­ty cri­te­ri­on could be that the vari­ance of the result­ing dither is kept constant.

Schreibe einen Kommentar zu Mangudai Antworten abbrechen

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Please answer the following anti-spam test

Which thing makes "tick-tock" and if it falls down, the clock is broken?

  1.    ruler
  2.    pencil
  3.    chair
  4.    watch
  5.    table