Photoshop, Banding, Gradients
How to Fix Banding in Gradients
Photoshop’s gradient algorithm is quite disappointing. It is notorious for creating gradients with
banding. Here is an example, attempting to create a gradient from #222
to #333
:
Photoshop’s Gradient with Banding
Eeeww!
Can you see it? If you look closely, there are vertical “lines” in the image where the color changes. This is incredibly easy to fix algorithmically - but very difficult to fix any other way.
Gradients are basically linear interpolation algorithms:
function lerp(a, b, amount){
return a + (b - a) * amount;
}
function lerpColor(color1, color2, amount){
return {
r: Math.round(lerp(color1.r, color2.r, amount)),
g: Math.round(lerp(color1.g, color2.g, amount)),
b: Math.round(lerp(color1.b, color2.b, amount))
};
}
Where does this fail?
Actually, the linear interpolation (lerp
) is perfectly correct. It’s the Math.round
where
things go askew.
Most color channels are represented in 8-bits, which means they range from 0 to 255. This is good
enough most of the time, and humans have a really hard time detecting the difference between colors
that are 1 unit apart (i.e., #445599
and #445699
). However - for certain colors, under certain
conditions, humans can actually see the difference. Gradients are one instance where humans can
detect minute changes.
So it seems we are at a loss - how can we fix gradient banding if we actually need better hardware that supports more than 8-bits per channel? It turns out this problem was already solved, back when we had even worse hardware restrictions. How do you display a 24-bit image on a 8-bit display?
The answer: Dithering.
In fact, searching through Google’s results on how to fix banding results in a bunch of non-algorithmic attempts at faking dithering. Let’s settle this once and for all, and just perform true dithering on a higher-than-8-bit channel image in memory.
The following implementation is using roughly 64-bits per channel, and dithers back to 8-bit channels to display the target image.
var color1 = {
// color1 (default: #222222)
r: 0x22 / 0xFF,
g: 0x22 / 0xFF,
b: 0x22 / 0xFF
};
var color2 = {
// color2 (default: #333333)
r: 0x33 / 0xFF,
g: 0x33 / 0xFF,
b: 0x33 / 0xFF
};
// output image size (default: [320, 240])
var imageSize = [320, 240];
// enable or disable dithering (default: true)
var dithering = true;
// resolution of output colors (default: 256)
var maxVal = 256;
var cnv = document.createElement('canvas');
cnv.width = imageSize[0];
cnv.height = imageSize[1];
document.body.appendChild(cnv);
var ctx = cnv.getContext('2d');
var imd = ctx.createImageData(imageSize[0], imageSize[1]);
var ditherError = [];
function getError(x, y, chan){
if (!dithering)
return 0;
var k = (x + y * imageSize[0]) * 3 + chan;
if (typeof ditherError[k] == 'undefined')
return 0;
return ditherError[k];
}
function addError(x, y, chan, val){
var k = (x + y * imageSize[0]) * 3 + chan;
ditherError[k] = getError(x, y, chan) + val;
}
function lerp(a, b, amount){
return a + (b - a) * amount;
}
function doLine(y){
for (var x = 0; x < imageSize[0]; x++){
for (var chan = 0; chan < 3; chan++){
var amount = x / (imageSize[0] - 1);
var cmp = chan == 0 ? 'r' : (chan == 1 ? 'g' : 'b');
var target = lerp(color1[cmp], color2[cmp], amount) +
getError(x, y, chan);
var actual = Math.floor(target * maxVal);
if (actual < 0)
actual = 0;
if (actual >= maxVal)
actual = maxVal - 1;
var err = target - actual / (maxVal - 1);
addError(x + 1, y + 0, chan, err * 7 / 16);
addError(x - 1, y + 1, chan, err * 3 / 16);
addError(x + 0, y + 1, chan, err * 5 / 16);
addError(x + 1, y + 1, chan, err * 1 / 16);
imd.data[(x + y * imageSize[0]) * 4 + chan] =
Math.floor(actual * 255 / (maxVal - 1));
}
imd.data[(x + y * imageSize[0]) * 4 + 3] = 255; // alpha
}
}
var y = 0;
function tick(){
doLine(y);
y++;
ctx.putImageData(imd, 0, 0);
if (y < imageSize[1])
setTimeout(tick, 1);
}
tick();
Sample rendering: