What is the best way to average two colors that define a linear gradient?

Viewed 29284

If I have two colors defined by their RGB values, can I average the Red, Green and Blue values and then combine to define a third color that looks like a visual average of the two?

ie NewColor = (R1+R2)/2,(G1+G2)/2,(B1+B2)/2

EDIT1: Thanks for all the responses. For my current needs, I am only dealing with color pairs that are shades of the same color so I think that averaging them will work. However, I will try converting to Lab Space to make sure that assumption is true and the technique will be useful in the future.

EDIT2: Here are my results FWIW. Color1 and Color2 are my two colors and the two middle columns are the results of averaging in Lab space and averaging RGB respectively. In this case there is not a lot of difference between the two color and so the differences in the output from the averaging techniques is subtle.

visual comparison of color averaging techniques

9 Answers

Take a look at the answers to this question.

Basically, you want to convert the colors into something called Lab space, and find their average in that space.

Lab space is a way of representing colours where points that are close to each other are those that look similar to each other to humans.

I don't know whether taking a simple average of the components is the "best" from a perceptual point of view (that sounds like a question for a psychologist), but here are a couple of examples using simple component averaging.

alt text

The red-mustard-green one is ugly but the interpolation seems reasonable enough.

Yes. You can average two colors together like that (simple averaging). It's the approach used by OpenGL to blend colors together (e.g., in creating mip maps for rendering distant objects, or rendering a 50% transparent texture). It is fast, simple, and "good enough" for many situations. It isn't completely realistic, however, and probably wouldn't be used on photograph-quality images.

I made this code snippet comparing RGB-Average, RGB-root-mean-square, and CIELAB-Average of many colors. (TLDR: they're all about the same color-wise, but since lab() isn't natively supported by most browsers yet, just use the RGB² method)

Observations:

  1. The RGB-squared (quadratic mean) method does tend to produce brighter colors, is just as fast as the regular average method (in this configuration at least), and is thus the overall best choice.
  2. the LAB method timings are much worse because we can't just use lab() in CSS yet (except maybe Safari), and thus the transform chain is rgb > xyz > lab > xyz > rgb. Whenever browsers do implement lab(), this method looks to be an intriguing option.
  3. HSL Colors: I included HSL averaging at first and it doesn't work, even when accounting for revolving angle properly. (averaging two hues seems to produce a complement to the two input colors)

let names = ["RGB mean", "RGB root mean²", "LAB mean"];
let analyzers = [simpleAverage,squaredAverage,labAverage];
let elapsed = [0,0,0];

let c1,c2,r,d1,d2,sp,sp2
for(let i=0;i<50000;i++){    
  
  c1 = i==0 ? [255,0,0]: i==1 ? [0,255,0] : i==2 ? [0,0,255] : i==3 ? [255,255,0]: i==4 ? [255,0,255] : i==5 ? [0,255,255] : randRGB()
  c2 = i==0 ? [0,0,255]: i==1 ? [255,0,0] : i==2 ? [0,255,0] : i==3 ? [0,255,255]: i==4 ? [255,255,0] : i==5 ? [255,0,255]: randRGB()

  if(i<100) {
    r = document.createElement("div")
    r.classList.add("row")
    d1 = document.createElement("div")
    d2 = document.createElement("div")
    d1.style.backgroundColor = rgbStr(c1)
    sp = document.createElement("span")
    sp.style.color = d1.style.backgroundColor
    sp.innerText = `Color 1: ${rgbStr(c1)}`
    d1.appendChild(sp)
    d2.style.backgroundColor = rgbStr(c2)
    sp2 = document.createElement("span")
    sp2.style.color = d2.style.backgroundColor
    sp2.innerText = `Color 2: ${rgbStr(c2)}`
    d2.appendChild(sp2)

    r.appendChild(d1)
    r.appendChild(d2)
  }
    for(let k=0;k<3;k++){
      const t0 = performance.now()
      let ave = analyzers[k](c1,c2)
      elapsed[k] += performance.now()-t0;

      if(i<100){
        let d = document.createElement("div")
        d.style.backgroundColor = rgbStr(ave)
        let sp = document.createElement("span")
        sp.style.color = d.style.backgroundColor
        sp.innerText = `${names[k]}: ${d.style.backgroundColor}`
        d.appendChild(sp)
        r.appendChild(d)
      }

    }
    if(i<100) colorResults.appendChild(r)
  
}
elapsed.forEach((ms,i)=>{
  timingResults.insertAdjacentHTML("beforeEnd", `<div>${names[i]}: ${ms.toFixed(2)}ms</div>`)
})

function rgbStr(c){
  return `rgb(${parseInt(c[0])},${parseInt(c[1])},${parseInt(c[2])})`
}
function labAverage(c1,c2){
  let l1 = XYZtoLAB(RGBtoXYZ(c1))
  let l2 = XYZtoLAB(RGBtoXYZ(c2))
  let ave = simpleAverage(l1,l2)
  let newXYZ = LabToXYZ(ave)
  let newRGB = XYZtoRGB(newXYZ)
  return newRGB
}
function simpleAverage(c1,c2) {
  return [((c1[0]+c2[0])/2), ((c1[1]+c2[1])/2), ((c2[2]+c1[2])/2)]
}
function squaredAverage(c1,c2) {
  return [
    Math.floor(Math.sqrt((c1[0]**2+c2[0]**2)/2)), 
    Math.floor(Math.sqrt((c1[1]**2+c2[1]**2)/2)), 
    Math.floor(Math.sqrt((c2[2]**2+c1[2]**2)/2))
  ];
}
function randRGB(){    
  return [randInt(256),randInt(256), randInt(256)]
}
function randInt(max) { return Math.floor(Math.random()*max)}
function RGBtoXYZ(RGB) {
  //https://stackoverflow.com/questions/15408522/rgb-to-xyz-and-lab-colours-conversion
  let R = RGB[0];
  let G = RGB[1];
  let B = RGB[2];

  var_R = parseFloat(R / 255)        //R from 0 to 255
  var_G = parseFloat(G / 255)        //G from 0 to 255
  var_B = parseFloat(B / 255)        //B from 0 to 255

  if (var_R > 0.04045) var_R = Math.pow((var_R + 0.055) / 1.055, 2.4)
  else var_R = var_R / 12.92
  if (var_G > 0.04045) var_G = Math.pow((var_G + 0.055) / 1.055, 2.4)
  else var_G = var_G / 12.92
  if (var_B > 0.04045) var_B = Math.pow((var_B + 0.055) / 1.055, 2.4)
  else var_B = var_B / 12.92

  var_R = var_R * 100
  var_G = var_G * 100
  var_B = var_B * 100

  //Observer. = 2°, Illuminant = D65
  X = var_R * 0.4124 + var_G * 0.3576 + var_B * 0.1805
  Y = var_R * 0.2126 + var_G * 0.7152 + var_B * 0.0722
  Z = var_R * 0.0193 + var_G * 0.1192 + var_B * 0.9505
  return [X, Y, Z]
}
function XYZtoLAB(XYZ) {
  //https://stackoverflow.com/questions/15408522/rgb-to-xyz-and-lab-colours-conversion
  let x = XYZ[0];
  let y = XYZ[1];
  let z = XYZ[2];
  var ref_X = 95.047;
  var ref_Y = 100.000;
  var ref_Z = 108.883;
  var_X = x / ref_X     //ref_X =  95.047   Observer= 2°, Illuminant= D65
  var_Y = y / ref_Y     //ref_Y = 100.000
  var_Z = z / ref_Z     //ref_Z = 108.883

  if (var_X > 0.008856) var_X = Math.pow(var_X, (1 / 3))
  else var_X = (7.787 * var_X) + (16 / 116)
  if (var_Y > 0.008856) var_Y = Math.pow(var_Y, (1 / 3))
  else var_Y = (7.787 * var_Y) + (16 / 116)
  if (var_Z > 0.008856) var_Z = Math.pow(var_Z, (1 / 3))
  else var_Z = (7.787 * var_Z) + (16 / 116)

  CIE_L = (116 * var_Y) - 16
  CIE_a = 500 * (var_X - var_Y)
  CIE_b = 200 * (var_Y - var_Z)

  return [CIE_L, CIE_a, CIE_b]
}
function LabToXYZ(lab){
  //adapted from easyRGB.com
  //The tristimulus values are (X, Y, Z) = (109.85, 100.00, 35.58)
  var ref_X = 95.047;
  var ref_Y = 100.000;
  var ref_Z = 108.883;
  let l = lab[0];
  let a = lab[1];
  let b = lab[2];

  let var_Y = ( l + 16 ) / 116
  let var_X = a / 500 + var_Y
  let var_Z = var_Y - b / 200

  if ( var_Y**3  > 0.008856 ) var_Y = var_Y**3
  else                       var_Y = ( var_Y - 16 / 116 ) / 7.787
  if ( var_X**3  > 0.008856 ) var_X = var_X**3
  else                       var_X = ( var_X - 16 / 116 ) / 7.787
  if ( var_Z**3  > 0.008856 ) var_Z = var_Z**3
  else                       var_Z = ( var_Z - 16 / 116 ) / 7.787

  X = var_X * ref_X
  Y = var_Y * ref_Y
  Z = var_Z * ref_Z
  return [X, Y, Z];
}
function XYZtoRGB(xyz) {
  //adapted from easyRGB.com
  let X = xyz[0];
  let Y = xyz[1];
  let Z = xyz[2];

  var_X = X / 100
  var_Y = Y / 100
  var_Z = Z / 100

  var_R = var_X *  3.2406 + var_Y * -1.5372 + var_Z * -0.4986
  var_G = var_X * -0.9689 + var_Y *  1.8758 + var_Z *  0.0415
  var_B = var_X *  0.0557 + var_Y * -0.2040 + var_Z *  1.0570

  if ( var_R > 0.0031308 ) var_R = 1.055 * ( var_R**( 1 / 2.4 ) ) - 0.055
  else                     var_R = 12.92 * var_R
  if ( var_G > 0.0031308 ) var_G = 1.055 * ( var_G**( 1 / 2.4 ) ) - 0.055
  else                     var_G = 12.92 * var_G
  if ( var_B > 0.0031308 ) var_B = 1.055 * ( var_B**( 1 / 2.4 ) ) - 0.055
  else                     var_B = 12.92 * var_B

  sR = var_R * 255
  sG = var_G * 255
  sB = var_B * 255

  return [sR,sG,sB];
}
body {
  font-size:1.3em;
  background-color:#f5f5f5;
}
span {
  filter: invert(100%) grayscale(100%) contrast(1000);
}
.row {
  margin:20px;
}
.row div:nth-child(1), .row div:nth-child(2) {
  padding:7px;
  text-align:center;
}
.row > div {
  margin:1px;
}
50000 iterations (showing first 100 results):
<div id="timingResults"></div>
<div id="colorResults"></div>

Related