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 interp(a, b, amount){
  return a + (b - a) * amount;
}

function interpColor(color1, color2, amount){
  return {
    r: Math.round(interp(color1.r, color2.r, amount)),
    g: Math.round(interp(color1.g, color2.g, amount)),
    b: Math.round(interp(color1.b, color2.b, amount))
  };
}

Where does this fail?

Actually, the linear interpolation (interp) 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.

/* Proper Dithered Gradients :: Public Domain */
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, channel){
  if (!dithering)
    return 0;
  var k = (x + y * imageSize[0]) * 3 + channel;
  if (typeof ditherError[k] == 'undefined')
    return 0;
  return ditherError[k];
}

function addError(x, y, channel, val){
  var k = (x + y * imageSize[0]) * 3 + channel;
  ditherError[k] = getError(x, y, channel) + val;
}

function interp(a, b, amount){
  return a + (b - a) * amount;
}

function doLine(y){
  for (var x = 0; x < imageSize[0]; x++){
    for (var channel = 0; channel < 3; channel++){
      var amount = x / (imageSize[0] - 1);
      var cmp = channel == 0 ? 'r' : (channel == 1 ? 'g' : 'b');
      var target = interp(color1[cmp], color2[cmp], amount) +
        getError(x, y, channel);
      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, channel, err * 7 / 16);
      addError(x - 1, y + 1, channel, err * 3 / 16);
      addError(x + 0, y + 1, channel, err * 5 / 16);
      addError(x + 1, y + 1, channel, err * 1 / 16);
      imd.data[(x + y * imageSize[0]) * 4 + channel] =
        Math.floor(actual * 255 / (maxVal - 1));
    }
    imd.data[(x + y * imageSize[0]) * 4 + 3] = 255; // set alpha
  }
}

var y = 0;
function tick(){
  doLine(y);
  y++;
  ctx.putImageData(imd, 0, 0);
  if (y < imageSize[1])
    setTimeout(tick, 1);
}

tick();

Sample rendering:

permanent link
back to top

posted 3 Jun 2013 by Sean
tags: photoshop, banding, gradients, javascript, dithering

View All Articles