Home » Nodejs » Programmatically determine best foreground color to be placed onto an image

Programmatically determine best foreground color to be placed onto an image

Posted by: admin November 29, 2017 Leave a comment

Questions:

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

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 **/

Questions:
Answers:

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; }

Questions:
Answers:

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.