Introduction
Today I am bring you something really interesting that I think is worth sharing. Let me begin by showcasing the end result.
If you can’t wait and want to test it yourself, here are the links to the app demo and the repository.
- Demo app.
- Repository (the entire codebase is commented).
Explanation
We can load any image and extract a color palette and every color is accompanied by its opposed color (complementary).
Example of a similar technique can be found in Spotify, when you navigate to a song/playlist or album you get a custom color gradient on top that represents the dominant color of the picture, this gradient adds a unique feel to each page and it's actually the reason why I am doing this post.
There are several websites that provide this service such as coolors.co or canva.com, if you ever wondered how does it work you are in the correct place, let's find out.
📝 Steps
Now that we know what we are dealing here, let’s start by explaining the process:
- Load an image into a canvas.
- Extract image information.
- Build an array of RGB colors.
- Apply Color quantization.
BONUS TRACK
- Order colors by luminance.
- Create a complementary version of each color.
- Build the HTML to display the color palette.
🖼️ Load an image into a canvas
First we create the basic HTML of our page, we need a form input of type file to upload the image and a canvas element because that’s how we gain access to the image’s data.
index.html
<form action="#">
<input type="file" id="imgfile" />
<input type="button" id="btnLoad" value="Load" onclick="main();" />
</form>
<canvas id="canvas"></canvas>
<div id="palette"></div>
<div id="complementary"></div>
🚜 Extract image information
We load the image into the canvas using the event handler .onload
, this allow us to access the getImageData() method from the canvas API.
index.js
const main = () => {
const imgFile = document.getElementById("imgfile");
const image = new Image();
const file = imgFile.files[0];
const fileReader = new FileReader();
fileReader.onload = () => {
image.onload = () => {
const canvas = document.getElementById("canvas");
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
}
}
}
The information returned from getImageData()
represents all the pixels that compose the image, meaning that we have an humongous array of values in the following format:
{
data: [133,38,51,255,133,38,255,120...],
colorSpace: "srgb",
height: 420,
width: 320
}
Each value inside data represents a channel of a pixel R (red), G (Green), B (Blue) and A (Alpha), Every four elements of the data array form the RGBA color model.
🏗️ Build an array of RGB colors
Immediately after obtaining the image data we have to parse it to something more readable, this will make our live easier in the future.
We loop through the image data every four elements and return an array of color objects in RGB mode instead of RGBA.
index.js
const buildRgb = (imageData) => {
const rgbValues = [];
for (let i = 0; i < imageData.length; i += 4) {
const rgb = {
r: imageData[i],
g: imageData[i + 1],
b: imageData[i + 2],
};
rgbValues.push(rgb);
}
return rgbValues;
};
🎨 Color quantization
After building the rgb colors array we need to somehow know which colors are the most representative of the image, to obtain this we use color quantization.
Wikipedia describes color quantization as
A process that reduces the number of distinct colors used in an image, usually with the intention that the new image should be as visually similar as possible to the original image.
Median cut algorthm
To achieve color quantization we are gonna use an algorithm called median-cut, the process is the following:
- Find the color channel ( red, green or blue) in the image with the biggest range.
- Sort pixels by that channel.
- Divide the list in half.
- Repeat the process for each half until you have the desired number of colors.
It sounds easy but it is a little bit complex, so I am gonna try my best to explain the code below.
Let's begin by creating a function that finds the color channel with the biggest range.
Initialize the min rgb values to the maximum number and the max rgb values to the minimum, this way we can determine what is the lowest and highest accurately.
Then, loop through every pixel and compare it with our current values using Math.min and Math.max.
Subsequently, we check the difference between every channels min and max results and return the letter of the channel with the biggest range.
index.js
const findBiggestColorRange = (rgbValues) => {
let rMin = Number.MAX_VALUE;
let gMin = Number.MAX_VALUE;
let bMin = Number.MAX_VALUE;
let rMax = Number.MIN_VALUE;
let gMax = Number.MIN_VALUE;
let bMax = Number.MIN_VALUE;
rgbValues.forEach((pixel) => {
rMin = Math.min(rMin, pixel.r);
gMin = Math.min(gMin, pixel.g);
bMin = Math.min(bMin, pixel.b);
rMax = Math.max(rMax, pixel.r);
gMax = Math.max(gMax, pixel.g);
bMax = Math.max(bMax, pixel.b);
});
const rRange = rMax - rMin;
const gRange = gMax - gMin;
const bRange = bMax - bMin;
const biggestRange = Math.max(rRange, gRange, bRange);
if (biggestRange === rRange) {
return "r";
} else if (biggestRange === gRange) {
return "g";
} else {
return "b";
}
};
Recursion time
Now that we have the component with the biggest range of colors in it (R, G or B), sort it and then split it by half, using the two halves we repeat the same process and call the function again, each time adding a value to depth.
index.js
const quantization = (rgbValues, depth) => {
// base code goes here
const componentToSortBy = findBiggestColorRange(rgbValues);
rgbValues.sort((p1, p2) => {
return p1[componentToSortBy] - p2[componentToSortBy];
});
const mid = rgbValues.length / 2;
return [
...quantization(rgbValues.slice(0, mid), depth + 1),
...quantization(rgbValues.slice(mid + 1), depth + 1),
];
}
As for the base case, we enter it when our depth is equal to the MAX_DEPTH, in our case 4, then add up all the values and divide by half to get the average.
Note: Depth in this case means how many colors we want by power of 2.
index.js
const quantization = (rgbValues, depth) => {
const MAX_DEPTH = 4;
if (depth === MAX_DEPTH || rgbValues.length === 0) {
const color = rgbValues.reduce(
(prev, curr) => {
prev.r += curr.r;
prev.g += curr.g;
prev.b += curr.b;
return prev;
},
{
r: 0,
g: 0,
b: 0,
}
);
color.r = Math.round(color.r / rgbValues.length);
color.g = Math.round(color.g / rgbValues.length);
color.b = Math.round(color.b / rgbValues.length);
return [color];
}
// recursion code goes below
}
This is it, we are done with median-cut and the palette extraction.
📑 Extra steps
There are a lot of things that we could do here but i don't want to abuse of your precious time, if you are interested in expanding a little bit the scope of the project, check the repository, it contains all the extra code.
- Order colors by luminance. There are different ways of doing this, depending of your needs, here we use the relative luminance.
- Create complementary version of each color.
- Build the HTML to display the color palette.
🗃️ Resources
If you want to go further into the topic I suggest trying different algorithms to create the color palette, find the dominant dolor, understand how color spaces work or add different color schemes, here are some examples to help you out:
- Use K-means algorithm to create the color palette.
- Use Octree algorithm to implement the color palette.
- Watch this talk about color "RGB to XYZ: The Science and History of Color" by John Austin.
- Add different color combinations like Monochromatic or Triadic, check this page for more examples.
👋 Final remarks
Thank you for your time, I hope you enjoyed this article and have learned something along the way, have a nice day :)
Top comments (13)
I'm trying to work out how to return the number of occurrences of each returned colour as well as the sorted value. I can't quite see how to do it when using recursive algorithms. Can anyone suggest a possibility?
Many thanks
very helpful! If only the function was faster :)
Amazing tutorial
How well explained, thank you.
Would you suggest to process the image at the client side or the server side
Its something you should want to have separated from your main services since its really heavy in the processing, it can take a while, for small tests like the one I did here it doesn't matter but for bigger apps take it into consideration.
if you try to use it with higher resolution images it will take several seconds, during that time your client will be blocked.
Cheers
Cover art from Music Albums tends to come in 300x300 max size. So I think this was intended for smaller image sizes.
Thinking outloud...
Larger images, perhaps user interaction should initiate the action to process the image to extract a palette for a specific purpose. If not in a flow of user interaction, I would question why the image needs to be so large. You could show the user a large image and process the palette from the same image, scaled down significantly, in the background. If you need something to automagically happen based on the user seeing a rather large image, with performance. IDK, just a thought, unless there is a faster way to retrieve the palette from an image. I am always about performance, even if just for 300x300 images in my scenario :)
Great job! Thank you very much!
Amazing article.
Thank you for this.
very helpful <3
Thank you for sharing this. That's will help me finish my project.
There is a small issue.
You still return RGB(0, 0, 0) as average, even if there was no color. I think it is better to return an empty array in that case.