Dithering in Colour
obrhubr.org
obrhubr.org
If anyone thinks their websites are too colorful, I made a pure JavaScript web component to dither images on client in real time, taking into account the real pixel size of the current display.
https://sheep.horse/2023/1/improved_web_component_for_pixel-...
The image at the bottom is an example. On some devices this interacts weirdly with the pattern of pixels or even the refresh rate when in motion due to scrolling.
12-bit per channel color might be enough to never have visible banding. Or dithering
Crysis had sky banding... Skyrim has the famous menu smoke mentioned in the PDF. All fixable, probably fixable on the hardware of the day. (I remember messing with dithering on a 2007 DX9 GPU)
Getting an extra 2bits of hue (ab) while maintaining luminance (L) is quite doable except at the chroma and brightness extremes where your eye mostly ignores them anyway. That could be done pretty high in the display stack. I'd also say that the DACs in many displays are capable of higher chroma resolution, but gamma non-linearity eats up a bit dynamic range.
Some TVs and monitors do temporal dithering for you... Accept 8-bit input, and temporal dither it for the 6-bit display doesn't look that bad. Probably extends well to 10-bit input.
Now, I'm hoping you're an old school CRT nerd, and if so then you'd also agree that the original color phosphor glow down periods were several ms and the eye's response to changing color is marginal at best. Uncorrelated subpixel scale dithering works just fine at 30Hz.
The eye's response to changing color is slow, but the response to changing luminance is very fast.
Your cones are surprisingly low bandwidth (why old color TVs even worked at
30Hz), while your rods provide danger/flicker cues outside the fovea.
Uncorrelated subpixel scale dithering works just fine at 30Hz.The linearized RGB palette is similarly awful. It clobbers a whole swath of colors, rendering them as nearly black. Purples are particularly brutalized. Yellows disappeared and became white.
On my phone, the middle palette doesn't appear too bright to my eyes, either.
Even the linearized gradient looks worse, .
Maybe linear is not best for perceptual accuracy.
var img
var pixel
var threshold
var error = [0, 0, 0]
var a0
function preload() {
img = loadImage("https://upload.wikimedia.org/wikipedia/commons/thumb/4/44/Albrecht_D%C3%BCrer_-_Hare%2C_1502_-_Google_Art_Project.jpg/1920px-Albrecht_D%C3%BCrer_-_Hare%2C_1502_-_Google_Art_Project.jpg")
}
function setup() {
// I'm just using a low discrepancy sequence for a quasirandom
// dither and diffusing the error to the right, because it's
// trivial to implement
a0 = 1/sqrt(5)
pixelDensity(2)
createCanvas(400, 400);
image(img, 0, 0, 400, 400)
loadPixels()
pixel = 0
threshold = 0
}
function draw() {
if (pixel > 400*400*16) {
return
}
for (var i = 0; i < 2000; i++) {
threshold = (threshold + a0)%1
for(var j=0; j< 3; j++) {
var c = pixels[pixel + j]
pixels[pixel + j] = c + error[j] > threshold * 255 ? 255 : 0
error[j] += c - pixels[pixel + j]
}
pixel += 4
}
updatePixels()
}
Of course this isn't trying to pick the closest colour in the palette as you're doing - it's just trying to end up with the same intensity of rgb as the original image. It does make me wonder if you should be using the manhattan distance instead of euclidean, to get the errors to add correctly.I don't know if they actually do well in dithering, though. My experience with dithering is that it actually works better in gamma space than trying to linearize anything, since the quantization is fundamentally after gamma.
Others suggest that the error is using the wrong metric for choosing the closest color, but I disagree. That wouldn't such drastic systematic darkening like this, as the palette is probably still pretty dense in the RGB cube.
Where the linearisation really matters is the arithmetic for the error diffusion, you definitely want to diffuse the error in a linear colorspace, and you are free to choose a good perceptual space for choosing the closest color at each pixel, but calculate the error in a linear space.
Visual perception is weird. But when you squint your eyes to blur the image, you are definitely mixing in a linear colorspace, as that's physical mixing of light intensities before the light even reaches your retina. So you have to match that when diffusing the error.
edit:
It also doesn't help that most (all?) browsers do color mixing wrong when the images are scaled, so if you don't view the dithered images at 100% without DPI scaling than you might get significantly distorted colors due to that too.
edit2:
For comparison this is what imageworsener does:
You really need to open the image in a viewer where each image pixel is exactly one device pixel large, otherwise the color arithmetic used for scaling by viewers is of variable quality (often very poor).
The dithered gradient shouldn't be pure black halfway through.
I have a suspicion that might be because I usually buy designer-targeted wide gamut IPS displays. I also set up low brightness on them, e.g. right now I’m looking at BenQ PD2700U display with brightness 10/100 and contrast 50/100. However, sRGB color space was developed decades ago for CRT displays.
For me, viewing the images on my phone makes them look off.
It can be better than sRGB for the color part of gradients but is awful for the brightness axis. The reason why linear color is so important for operations like blending light, antialiasing, and dithering is also why it is bad where perceptual uniformity is desired. sRGB isn't as good as Oklab or CIELAB perceptually, but a grayscale ramp rendered in linear color is so distorted towards white it's useless. Image formats also encode non-linear color for good reason. "Use linear color everywhere" is overly simplistic and bad advice.
The other issue with your code right now, is that it is using euclidean distance in RGB space to choose the nearest color, but it would be probably also more accurate to use a perceptual color difference metric, a very simple choice is euclidean distance on OKLab colors.
I think dithering is a pretty interesting area of exploration, especially as a lot of the popular dithering algorithms are quite old and optimized for ancient compute requirements. It would be nice to see some dithering that isn't using 8-bits for errors, is based on perceptual accuracy, and perhaps uses something like a neural net to diffuse things in the best way possible.
[1]: https://juliagraphics.github.io/Colors.jl/stable/colordiffer...
[2]: https://github.com/JuliaImages/DitherPunk.jl
[3]: https://juliaimages.org/DitherPunk.jl/stable/#Dithering-with...
No textures or masks, just brute computing on the CPU.
I’ll try OKLab and compare, thanks for the comment :)
If anyone's curious I've implemented this here: https://github.com/DDoS/Cadre/blob/main/encre/core/src/dithe... I use it to map images from their source colour space to the lower gamut palettes of E Ink colour displays.
They show a single example of incorrect dithering, explain it's wrong, and then don't show a corrected version. There isn't a single example of proper color dithering.
And they talk about the distance to the nearest color (RGB) but don't explain how to account for black or white -- how to trade off between accuracy of hue, brightness, and saturation, for example.
This post doesn't explain at all how to actually dither in color. I don't understand why this is on the front page with over 50 votes.
It looks far too dark, and I’m viewing it on an iPad with a high DPI screen. I also strongly suspect I can’t change the gamma on this device, nor have I ever knowingly done so. Anyone know why it looks bad?
This was nearly eight years ago, but I managed to find it this morning and uploaded it to YouTube.
Here was the resulting animation: https://youtu.be/FHrIQOWeerg
I remember I used Processing to build it, and it took so long to animate as I had to export it frame-by-frame. Fun days!
TBH both look wrong to me. If I squint, neither dithering patterns match the original gradient... but the non-linearized one looks the most similar.
What could be causing this?
Hypercorrection, in this care over-linearisation.
edit:
Reading back, viewing the gradients also not at 100% zoom level could also itself cause the mismatch, because browsers just suck at image scaling.
They have a different default gamma and they may show a different gray level.
(It bite me a long time ago. I made a gif that has the same RGB bacground than a webpage. In my pc it was fine, but in a mac they the border was very visible and the result horrible. My solution was to change the backgroung of the webpage from a RGB number to a 1 pixel gif with repetition or scale to fill the page.)
See: Dithering should happen in sRGB https://www.shadertoy.com/view/NssBRX
It gets much worse if you uncomment the SHOW_CORRECT define since the data is then being transformed back to SRGB before being quantised, which quite heavily skews the probability of which code point will be selected in favour of the lighter colour.
Increasing the number of bits hides the effect somewhat by making more code points available. But because they're distributed in gamma-encoded rather than linear-encoded space, it's still not correct to assume that a 50/50 pixel mix of two adjacent code points will appear the same as the colour numerically halfway between them, unless you're making that judgement in linear space.
The mistake the shadertoy is making is transforming the data to sRGB before quantising. Both dithering and quantising should be done in linear space (which is non-trivial since in linear space the codepoints aren't linearly distributed any more) - otherwise the dither function's triangular distribution is skewed by the sRGB transform.
I remember I had quite a bit of discussion with madshi when MadVR tried implementing it. You can do something that comes close by modifying the colour space into something that is gamma light in the integer part and linear light in the fractional part.
If the value of a pixel is x you then get something like floor(x) + (l - ginv(x)) / (l - u) with l and u the the two shades corresponding to floor(x) and ceil(x) in linear light.
Though technically error diffusion will still be incorrect, but it does handle constant shades correctly and most alternatives are worse somehow.
Edit: Oh and I see you suffer from the patterned runs of pixels that plagued madshi ;-) It's one of the reasons I prefer simple patterned dithers
But in this case I think it's just wrong. The entire first 40% of the bar is black, and I don't think it should be.
https://gabrielgambetta.com/zx-raytracer.html#fourth-iterati...
Seems that at some level it should, though perhaps not directly at the pixel level due to the high frequency of the per-pixel differences, but maybe at the more coarse "averaged" level?
One of those things I've wanted to explore but remains on my to-do list...
https://nigeltao.github.io/blog/2022/gamma-aware-ordered-dit...
https://bisqwit.iki.fi/story/howto/dither/jy/
This page focuses on ordered dithering, which tend to work better for animations compared to error diffusion based dithering schemes (like the linked article).
It seems like it could have a really nice effect.
https://scanlime.org/2013/11/fadecandy-easier-tastier-and-mo...
> Firmware that uses unique dithering and color correction algorithms to raise the bar for quality while getting out of the way of your creativity.