Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Programmatically determine best foreground color to be placed onto an image

I'm working on a node module that will return the color that will look best onto a background image which of course will have multiple colors.

Here's what I have so far:

'use strict';

var randomcolor = require('randomcolor');
var tinycolor = require('tinycolor2');

module.exports = function(colors, tries) {
  var topColor, data = {};

  if (typeof colors == 'string') { colors = [colors]; }
  if (!tries) { tries = 10000; }

  for (var t = 0; t < tries; t++) {
    var score = 0, color = randomcolor(); //tinycolor.random();

    for (var i = 0; i < colors.length; i++) {
      score += tinycolor.readability(colors[i], color);
    }

    data[color] = (score / colors.length);

    if (!topColor || data[color] > data[topColor]) {
      topColor = color;
    }
  }

  return tinycolor(topColor);
};

So the way it works is first I provide this script with the 6 most dominant colors in an image like this:

[ { r: 44, g: 65, b: 54 },
  { r: 187, g: 196, b: 182 },
  { r: 68, g: 106, b: 124 },
  { r: 126, g: 145, b: 137 },
  { r: 147, g: 176, b: 169 },
  { r: 73, g: 138, b: 176 } ]

and then it will generate 10,000 different random colors and then pick the one that has the best average contrast ratio with the 6 given colors.

The problem is that depending on which script I use to generate the random colors, I'll basically get the same results regardless of the image given.

With tinycolor2 I'll always end up with either a very dark gray (almost black) or a very light gray (almost white). And with randomcolor I'll either end up with a dark blue or a light peach color.

My script might not be the best way of going about this but does anybody have any ideas?

Thank you

like image 786
Kodie Grantham Avatar asked Mar 20 '17 20:03

Kodie Grantham


People also ask

Which color is used for foreground color?

Using the Foreground / Background Tool The basic idea of this tool is simple. The foreground controls what color your brush or pencil will be, while the background color erases any added color and replaces it with the background color, which is white by default.

Which tool is used for foreground and background Colour?

Answer. You can designate a new foreground or background color using the Eyedropper tool, the Color panel, the Swatches panel, or the Adobe Color Picker.

What methods can be used to change the current foreground color?

You can change foreground and background colors using the Color palette, the Swatches palette, the Color Picker, or the Eyedropper Tool. One method of changing foreground and background colors is sampling, in which an existing color is used.


Video Answer


3 Answers

Finding dominant hue.

The provided snippet show an example of how to find a dominant colour. It works by breaking the image into its Hue, saturation and luminance components.

The image reduction

To speed up the process the image is reduced to a smaller image (in this case 128 by 128 pixels). Part of the reduction process also trims some of the outside pixels from the image.

const IMAGE_WORK_SIZE = 128;
const ICOUNT = IMAGE_WORK_SIZE * IMAGE_WORK_SIZE;
if(event.type === "load"){
    rImage = imageTools.createImage(IMAGE_WORK_SIZE, IMAGE_WORK_SIZE);  // reducing image
    c = rImage.ctx;
    // This is where you can crop the image. In this example I only look at the center of the image
    c.drawImage(this,-16,-16,IMAGE_WORK_SIZE + 32, IMAGE_WORK_SIZE + 32); // reduce image size

Find mean luminance

Once reduced I scan the pixels converting them to hsl values and get the mean luminance.

Note that luminance is a logarithmic scale so the mean is the square root of the sum of the squares divided by the count.

pixels = imageTools.getImageData(rImage).data;
l = 0;
for(i = 0; i < pixels.length; i += 4){ 
    hsl = imageTools.rgb2hsl(pixels[i],pixels[i + 1],pixels[i + 2]);
    l += hsl.l * hsl.l;
}
l = Math.sqrt(l/ICOUNT);

Hue histograms for luminance and saturation ranges.

The code can find the dominant colour in a range of saturation and luminance extents. In the example I only use one extent, but you can use as many as you wish. Only pixels that are inside the lum (luminance) and sat (saturation) ranges are used. I record a histogram of the hue for pixels that pass.

Example of hue ranges (one of)

hues = [{  // lum and sat have extent 0-100. high test is no inclusive hence high = 101 if you want the full range
        lum : {
            low :20,    // low limit lum >= this.lum.low
            high : 60,  // high limit lum < this.lum.high
            tot : 0,    // sum of lum values 
        },
        sat : { // all saturations from 0 to 100
            low : 0,
            high : 101,
            tot : 0, // sum of sat
        },
        count : 0, // count of pixels that passed
        histo : new Uint16Array(360), // hue histogram
    }]

In the example I use the mean Luminance to automatically set the lum range.

hues[0].lum.low = l - 30;
hues[0].lum.high = l + 30;

Once the range is set I get the hue histogram for each range (one in this case)

for(i = 0; i < pixels.length; i += 4){ 
    hsl = imageTools.rgb2hsl(pixels[i],pixels[i + 1],pixels[i + 2]);
    for(j = 0; j < hues.length; j ++){
        hr = hues[j]; // hue range
        if(hsl.l >= hr.lum.low && hsl.l < hr.lum.high){
            if(hsl.s >= hr.sat.low && hsl.s < hr.sat.high){
                hr.histo[hsl.h] += 1;
                hr.count += 1;
                hr.lum.tot += hsl.l * hsl.l;
                hr.sat.tot += hsl.s;
            }
        }
    }
}

Weighted mean hue from hue histogram.

Then using the histogram I find the weighted mean hue for the range

// get weighted hue for image
// just to simplify code hue 0 and 1 (reds) can combine
for(j = 0; j < hues.length; j += 1){
    hr = hues[j];
    wHue = 0;
    hueCount = 0;
    hr.histo[1] += hr.histo[0];
    for(i = 1; i < 360; i ++){
        wHue += (i) * hr.histo[i];
        hueCount += hr.histo[i];
    }
    h = Math.floor(wHue / hueCount);
    s = Math.floor(hr.sat.tot / hr.count);
    l = Math.floor(Math.sqrt(hr.lum.tot / hr.count));
    hr.rgb = imageTools.hsl2rgb(h,s,l);
    hr.rgba = imageTools.hex2RGBA(imageTools.rgba2Hex4(hr.rgb));
}

And that is about it. The rest is just display and stuff. The above code requires the imageTools interface (provided) that has tools for manipulating images.

The ugly complement

What you do with the colour/s found is up to you. If you want the complementary colour just convert the rgb to hsl imageTools.rgb2hsl and rotate the hue 180 deg, then convert back to rgb.

var hsl = imageTools.rgb2hsl(rgb.r, rgb.g, rgb.b);
hsl.h += 180;
var complementRgb = imageTools.rgb2hsl(hsl.h, hsl.s, hsl.l);

Personally only some colours work well with their complement. Adding to a pallet is risky, doing it via code is just crazy. Stick with colours in the image. Reduce the lum and sat range if you wish to find accented colours. Each range will have a count of the number of pixels found, use that to find the extent of pixels using the colors in the associated histogram.

Demo "Border the birds"

The demo finds the dominant hue around the mean luminance and uses that hue and mean saturation and luminance to create a border.

The demo using images from wikipedia's image of the day collection as they allow cross site access.

var images = [
   // "https://upload.wikimedia.org/wikipedia/commons/f/fe/Goldcrest_1.jpg",
   "https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Cistothorus_palustris_CT.jpg/450px-Cistothorus_palustris_CT.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Black-necked_Stilt_%28Himantopus_mexicanus%29%2C_Corte_Madera.jpg/362px-Black-necked_Stilt_%28Himantopus_mexicanus%29%2C_Corte_Madera.jpg",     
    "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Daurian_redstart_at_Daisen_Park_in_Osaka%2C_January_2016.jpg/573px-Daurian_redstart_at_Daisen_Park_in_Osaka%2C_January_2016.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Myioborus_torquatus_Santa_Elena.JPG/675px-Myioborus_torquatus_Santa_Elena.JPG",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Great_tit_side-on.jpg/645px-Great_tit_side-on.jpg",
    "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Sarcoramphus_papa_%28K%C3%B6nigsgeier_-_King_Vulture%29_-_Weltvogelpark_Walsrode_2013-01.jpg/675px-Sarcoramphus_papa_%28K%C3%B6nigsgeier_-_King_Vulture%29_-_Weltvogelpark_Walsrode_2013-01.jpg",,
    
];

function loadImageAddBorder(){
    if(images.length === 0){
        return ; // all done   
    }
    var imageSrc = images.shift();
    imageTools.loadImage(
        imageSrc,true,
        function(event){
            var pixels, topRGB, c, rImage, wImage, botRGB, grad, i, hsl, h, s, l, hues, hslMap, wHue, hueCount, j, hr, gradCols, border;
            const IMAGE_WORK_SIZE = 128;
            const ICOUNT = IMAGE_WORK_SIZE * IMAGE_WORK_SIZE;
            if(event.type === "load"){
                rImage = imageTools.createImage(IMAGE_WORK_SIZE, IMAGE_WORK_SIZE);  // reducing image
                c = rImage.ctx;
                // This is where you can crop the image. In this example I only look at the center of the image
                c.drawImage(this,-16,-16,IMAGE_WORK_SIZE + 32, IMAGE_WORK_SIZE + 32); // reduce image size
    
                pixels = imageTools.getImageData(rImage).data;
                h = 0;
                s = 0;
                l = 0;
                // these are the colour ranges you wish to look at
                hues = [{
                        lum : {
                            low :20,
                            high : 60,
                            tot : 0,
                        },
                        sat : { // all saturations
                            low : 0,
                            high : 101,
                            tot : 0,
                        },
                        count : 0,
                        histo : new Uint16Array(360),
                    }]
                for(i = 0; i < pixels.length; i += 4){ 
                    hsl = imageTools.rgb2hsl(pixels[i],pixels[i + 1],pixels[i + 2]);
                    l += hsl.l * hsl.l;
                }
                l = Math.sqrt(l/ICOUNT);
                hues[0].lum.low = l - 30;
                hues[0].lum.high = l + 30;
                for(i = 0; i < pixels.length; i += 4){ 
                    hsl = imageTools.rgb2hsl(pixels[i], pixels[i + 1], pixels[i + 2]);
                    for(j = 0; j < hues.length; j ++){
                        hr = hues[j]; // hue range
                        if(hsl.l >= hr.lum.low && hsl.l < hr.lum.high){
                            if(hsl.s >= hr.sat.low && hsl.s < hr.sat.high){
                                hr.histo[hsl.h] += 1;
                                hr.count += 1;
                                hr.lum.tot += hsl.l * hsl.l;
                                hr.sat.tot += hsl.s;
                            }
                        }
                    }
                }
                // get weighted hue for image
                // just to simplify code hue 0 and 1 (reds) can combine
                for(j = 0; j < hues.length; j += 1){
                    hr = hues[j];
                    wHue = 0;
                    hueCount = 0;
                    hr.histo[1] += hr.histo[0];
                    for(i = 1; i < 360; i ++){
                        wHue += (i) * hr.histo[i];
                        hueCount += hr.histo[i];
                    }
                    h = Math.floor(wHue / hueCount);
                    s = Math.floor(hr.sat.tot / hr.count);
                    l = Math.floor(Math.sqrt(hr.lum.tot / hr.count));
                    hr.rgb = imageTools.hsl2rgb(h,s,l);
                    hr.rgba = imageTools.hex2RGBA(imageTools.rgba2Hex4(hr.rgb));
                }
                gradCols = hues.map(h=>h.rgba);
                if(gradCols.length === 1){
                    gradCols.push(gradCols[0]); // this is a quick fix if only one colour the gradient needs more than one
                }
                border = Math.floor(Math.min(this.width / 10,this.height / 10, 64));
    
                wImage = imageTools.padImage(this,border,border);
                wImage.ctx.fillStyle = imageTools.createGradient(
                    c, "linear", 0, 0, 0, wImage.height,gradCols
                );
                wImage.ctx.fillRect(0, 0, wImage.width, wImage.height);
                wImage.ctx.fillStyle = "black";
                wImage.ctx.fillRect(border - 2, border - 2, wImage.width - border * 2 + 4, wImage.height - border * 2 + 4);           
                wImage.ctx.drawImage(this,border,border);
                wImage.style.width = (innerWidth -64) + "px";
                document.body.appendChild(wImage);
                setTimeout(loadImageAddBorder,1000);
            }
        }
        
    )
}

setTimeout(loadImageAddBorder,0);



/** ImageTools.js begin **/
var imageTools = (function () {
    // This interface is as is. 
    // No warenties no garenties, and 
    /*****************************/
    /* NOT to be used comercialy */
    /*****************************/
    var workImg,workImg1,keep; // for internal use
    keep = false; 
    const toHex = v => (v < 0x10 ? "0" : "") + Math.floor(v).toString(16);
    var tools = {
        canvas(width, height) {  // create a blank image (canvas)
            var c = document.createElement("canvas");
            c.width = width;
            c.height = height;
            return c;
        },
        createImage (width, height) {
            var i = this.canvas(width, height);
            i.ctx = i.getContext("2d");
            return i;
        },
        loadImage (url, crossSite, cb) { // cb is calback. Check first argument for status
            var i = new Image();
            if(crossSite){
                i.setAttribute('crossOrigin', 'anonymous');
            }
            i.src = url;
            i.addEventListener('load', cb);
            i.addEventListener('error', cb);
            return i;
        },
        image2Canvas(img) {
            var i = this.canvas(img.width, img.height);
            i.ctx = i.getContext("2d");
            i.ctx.drawImage(img, 0, 0);
            return i;
        },
        rgb2hsl(r,g,b){ // integers in the range 0-255
            var min, max, dif, h, l, s;
            h = l = s = 0;
            r /= 255;  // normalize channels
            g /= 255;
            b /= 255;
            min = Math.min(r, g, b);
            max = Math.max(r, g, b);
            if(min === max){  // no colour so early exit
                return {
                    h, s,
                    l : Math.floor(min * 100),  // Note there is loss in this conversion
                }
            }
            dif = max - min;
            l = (max + min) / 2;
            if (l > 0.5) { s = dif / (2 - max - min) }
            else { s = dif / (max + min) }
            if (max === r) {
                if (g < b) { h = (g - b) / dif + 6.0 }
                else { h = (g - b) / dif }                   
            } else if(max === g) { h = (b - r) / dif + 2.0 }
            else {h = (r - g) / dif + 4.0 }   
            h = Math.floor(h * 60);
            s = Math.floor(s * 100);
            l = Math.floor(l * 100);
            return {h, s, l};
        },
        hsl2rgb (h, s, l) { // h in range integer 0-360 (cyclic) and s,l 0-100 both integers
            var p, q;
            const hue2Channel = (h) => {
                h = h < 0.0 ? h + 1 : h > 1 ? h - 1 : h;
                if (h < 1 / 6) { return p + (q - p) * 6 * h }
                if (h < 1 / 2) { return q }
                if (h < 2 / 3) { return p + (q - p) * (2 / 3 - h) * 6 }
                return p;        
            }
            s = Math.floor(s)/100;
            l = Math.floor(l)/100;
            if (s <= 0){  // no colour
                return {
                    r : Math.floor(l * 255),
                    g : Math.floor(l * 255),
                    b : Math.floor(l * 255),
                }
            }
            h = (((Math.floor(h) % 360) + 360) % 360) / 360; // normalize
            if (l < 1 / 2) { q = l * (1 + s) } 
            else { q = l + s - l * s }
            p = 2 * l - q;        
            return {
                r : Math.floor(hue2Channel(h + 1 / 3) * 255),
                g : Math.floor(hue2Channel(h)         * 255),
                b : Math.floor(hue2Channel(h - 1 / 3) * 255),
            }    
            
        },        
        rgba2Hex4(r,g,b,a=255){
            if(typeof r === "object"){
                g = r.g;
                b = r.b;
                a = r.a !== undefined ? r.a : a;
                r = r.r;
            }
            return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(a)}`; 
        },
        hex2RGBA(hex){ // Not CSS colour as can have extra 2 or 1 chars for alpha
                                  // #FFFF & #FFFFFFFF last F and FF are the alpha range 0-F & 00-FF
            if(typeof hex === "string"){
                var str = "rgba(";
                if(hex.length === 4 || hex.length === 5){
                    str += (parseInt(hex.substr(1,1),16) * 16) + ",";
                    str += (parseInt(hex.substr(2,1),16) * 16) + ",";
                    str += (parseInt(hex.substr(3,1),16) * 16) + ",";
                    if(hex.length === 5){
                        str += (parseInt(hex.substr(4,1),16) / 16);
                    }else{
                        str += "1";
                    }
                    return str + ")";
                }
                if(hex.length === 7 || hex.length === 9){
                    str += parseInt(hex.substr(1,2),16) + ",";
                    str += parseInt(hex.substr(3,2),16) + ",";
                    str += parseInt(hex.substr(5,2),16) + ",";
                    if(hex.length === 9){
                        str += (parseInt(hex.substr(7,2),16) / 255).toFixed(3);
                    }else{
                        str += "1";
                    }
                    return str + ")";                
                }
                return "rgba(0,0,0,0)";
            }
            
                
        },            
        createGradient(ctx, type, x, y, xx, yy, colours){ // Colours MUST be array of hex colours NOT CSS colours
                                                          // See this.hex2RGBA for details of format
            var i,g,c;
            var len = colours.length;
            if(type.toLowerCase() === "linear"){
                g = ctx.createLinearGradient(x,y,xx,yy);
            }else{
                g = ctx.createRadialGradient(x,y,xx,x,y,yy);
            }
            for(i = 0; i < len; i++){
                c = colours[i];
                if(typeof c === "string"){
                    if(c[0] === "#"){
                        c = this.hex2RGBA(c);
                    }
                    g.addColorStop(Math.min(1,i / (len -1)),c); // need to clamp top to 1 due to floating point errors causes addColorStop to throw rangeError when number over 1
                }
            }
            return g;
        },
        padImage(img,amount){
            var image = this.canvas(img.width + amount * 2, img.height + amount * 2);
            image.ctx = image.getContext("2d");
            image.ctx.drawImage(img, amount, amount);
            return image;
        },
        getImageData(image, w = image.width, h = image.height) {  // cut down version to prevent intergration 
            if(image.ctx && image.ctx.imageData){
                return image.ctx.imageData;
            }
            return (image.ctx || (this.image2Canvas(image).ctx)).getImageData(0, 0, w, h);
        },
    };
    return tools;
})();

/** ImageTools.js end **/
like image 85
Blindman67 Avatar answered Oct 02 '22 22:10

Blindman67


Sounds like an interesting problem to have!

Each algorithm you're using to generate colors likely has a bias toward certain colors in their respective random color algorithms.

What you're likely seeing is the end result of that bias for each. Both are selecting darker and lighter colors independently.

It may make more sense to keep a hash of common colors and use that hash as opposed to using randomly generated colors.

Either way your 'fitness' check, the algorithm that checks to see which color has the best average contrast is picking lighter and darker colors for both color sets. This makes sense, lighter images should have darker backgrounds and darker images should have lighter backgrounds.

Although you don't explicitly say, I'd bet my bottom dollar you're getting dark background for lighter average images and brighter backgrounds on darker images.

Alternatively rather than using a hash of colors, you could generate multiple random color palettes and combine the result sets to average them out.

Or rather than taking the 6 most commonly occurring colors, why not take the overall color gradient and try against that?

I've put together an example where I get the most commonly occurring color and invert it to get the complementary color. This in theory at least should provide a good contrast ratio for the image as a whole.

Using the most commonly occurring color in the image seems to work quite well. as outlined in my example below. This is a similar technique that Blindman67 uses without the massive bloating of including libraries and performing un-necessary steps, I borrowed the same images that Blindman67 uses for a fair comparison of the result set.

See Get average color of image via Javascript for getting average color (getAverageRGB() function written by James).

var images = [
  "https://upload.wikimedia.org/wikipedia/commons/thumb/2/22/Cistothorus_palustris_CT.jpg/450px-Cistothorus_palustris_CT.jpg",
  "https://upload.wikimedia.org/wikipedia/commons/thumb/3/37/Black-necked_Stilt_%28Himantopus_mexicanus%29%2C_Corte_Madera.jpg/362px-Black-necked_Stilt_%28Himantopus_mexicanus%29%2C_Corte_Madera.jpg",
  "https://upload.wikimedia.org/wikipedia/commons/thumb/c/cc/Daurian_redstart_at_Daisen_Park_in_Osaka%2C_January_2016.jpg/573px-Daurian_redstart_at_Daisen_Park_in_Osaka%2C_January_2016.jpg",
  "https://upload.wikimedia.org/wikipedia/commons/thumb/d/da/Myioborus_torquatus_Santa_Elena.JPG/675px-Myioborus_torquatus_Santa_Elena.JPG",
  "https://upload.wikimedia.org/wikipedia/commons/thumb/e/ef/Great_tit_side-on.jpg/645px-Great_tit_side-on.jpg",
  "https://upload.wikimedia.org/wikipedia/commons/thumb/5/55/Sarcoramphus_papa_%28K%C3%B6nigsgeier_-_King_Vulture%29_-_Weltvogelpark_Walsrode_2013-01.jpg/675px-Sarcoramphus_papa_%28K%C3%B6nigsgeier_-_King_Vulture%29_-_Weltvogelpark_Walsrode_2013-01.jpg",
];

// append images
for (var i = 0; i < images.length; i++) {
  var img = document.createElement('img'),
div = document.createElement('div');
  img.crossOrigin = "Anonymous";

  img.style.border = '1px solid black';
  img.style.margin = '5px';

  div.appendChild(img);

  document.body.appendChild(div);
  (function(img, div) {
img.addEventListener('load', function() {
  var avg = getAverageRGB(img);
  div.style = 'background: rgb(' + avg.r + ',' + avg.g + ',' + avg.b + ')';
  img.style.height = '128px';
  img.style.width = '128px';
});
img.src = images[i];
  }(img, div));
}

function getAverageRGB(imgEl) { // not my work, see http://jsfiddle.net/xLF38/818/
  var blockSize = 5, // only visit every 5 pixels
defaultRGB = {
  r: 0,
  g: 0,
  b: 0
}, // for non-supporting envs
canvas = document.createElement('canvas'),
context = canvas.getContext && canvas.getContext('2d'),
data, width, height,
i = -4,
length,
rgb = {
  r: 0,
  g: 0,
  b: 0
},
count = 0;

  if (!context) {
return defaultRGB;
  }

  height = canvas.height = imgEl.offsetHeight || imgEl.height;
  width = canvas.width = imgEl.offsetWidth || imgEl.width;

  context.drawImage(imgEl, 0, 0);
  try {
data = context.getImageData(0, 0, width, height);
  } catch (e) {
return defaultRGB;
  }

  length = data.data.length;

  while ((i += blockSize * 4) < length) {
++count;
rgb.r += data.data[i];
rgb.g += data.data[i + 1];
rgb.b += data.data[i + 2];
  }

  // ~~ used to floor values
  rgb.r = ~~(rgb.r / count);
  rgb.g = ~~(rgb.g / count);
  rgb.b = ~~(rgb.b / count);

  return rgb;
}
like image 36
Daniel Lane Avatar answered Oct 02 '22 21:10

Daniel Lane


It depends on where the text is that is overlayed on the background image. If the background has some large feature on part of it, the text will likely be placed away from that, so must contrast with that part of the image, but you may also want to pick up a certain color or complement the other colors in the image. I think practically speaking you will need to create a widget for people to easily slide/adjust the foreground color interactively. Or you will need to create a deep learning system in order to do this really effectively.

like image 22
Jason Livesay Avatar answered Oct 02 '22 21:10

Jason Livesay