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 fa
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.
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
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);
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;
}
}
}
}
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.
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.
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 **/