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

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:

Tags: Photoshop, Banding, Gradients, Javascript, Dithering

View All Posts