├── header-photo.jpg
├── README.md
├── index.html
├── LICENSE.md
├── styles.css
└── index.js
/header-photo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/zygisS22/color-palette-extraction/HEAD/header-photo.jpg
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Create a color palette from an image
2 |
3 | 
4 |
5 | Using median cut algorithm & color quantization to obtain a color palette with complementary colors in plain Javascript.
6 |
7 | ### Demo the app [here](https://zygiss22.github.io/color-palette-extraction/).
8 |
9 | This repository was created as an example for this [article](https://dev.to/producthackers/creating-a-color-palette-with-javascript-44ip)
10 |
11 |
12 | ## How to run
13 |
14 | Simply clone and open the HTML file in the browser, that's it.
15 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 | Create color palette
14 |
15 |
16 |
17 |
18 |
19 | Color palette creator
20 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2022 Zygimantas Sniurevicius
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/styles.css:
--------------------------------------------------------------------------------
1 | body {
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | margin: 40px;
6 | flex-direction: column;
7 | font-family: "verdana";
8 | }
9 | h1 {
10 | font-weight: bold;
11 | font-size: 3rem;
12 | letter-spacing: -4.5px;
13 | }
14 | form {
15 | margin: 20px;
16 | }
17 | canvas {
18 | border-top-left-radius: 10px;
19 | border-top-right-radius: 10px;
20 | }
21 | #palette,
22 | #complementary {
23 | display: flex;
24 | justify-content: center;
25 | align-items: center;
26 | flex-wrap: wrap;
27 | width: 80%;
28 | }
29 |
30 | #palette div,
31 | #complementary div {
32 | height: 120px;
33 | width: 120px;
34 | display: flex;
35 | justify-content: center;
36 | align-items: center;
37 |
38 | /* create a little bit of contrast for the text */
39 | mix-blend-mode: difference;
40 | color: #fff;
41 | -webkit-font-smoothing: antialiased;
42 | -moz-osx-font-smoothing: grayscale;
43 | text-shadow: 1px 1px #333;
44 | }
45 |
46 | #palette div,
47 | #complementary div {
48 | height: 120px;
49 | width: 120px;
50 | display: flex;
51 | justify-content: center;
52 | align-items: center;
53 | }
54 |
--------------------------------------------------------------------------------
/index.js:
--------------------------------------------------------------------------------
1 | const buildPalette = (colorsList) => {
2 | const paletteContainer = document.getElementById("palette");
3 | const complementaryContainer = document.getElementById("complementary");
4 | // reset the HTML in case you load various images
5 | paletteContainer.innerHTML = "";
6 | complementaryContainer.innerHTML = "";
7 |
8 | const orderedByColor = orderByLuminance(colorsList);
9 | const hslColors = convertRGBtoHSL(orderedByColor);
10 |
11 | for (let i = 0; i < orderedByColor.length; i++) {
12 | const hexColor = rgbToHex(orderedByColor[i]);
13 |
14 | const hexColorComplementary = hslToHex(hslColors[i]);
15 |
16 | if (i > 0) {
17 | const difference = calculateColorDifference(
18 | orderedByColor[i],
19 | orderedByColor[i - 1]
20 | );
21 |
22 | // if the distance is less than 120 we ommit that color
23 | if (difference < 120) {
24 | continue;
25 | }
26 | }
27 |
28 | // create the div and text elements for both colors & append it to the document
29 | const colorElement = document.createElement("div");
30 | colorElement.style.backgroundColor = hexColor;
31 | colorElement.appendChild(document.createTextNode(hexColor));
32 | paletteContainer.appendChild(colorElement);
33 | // true when hsl color is not black/white/grey
34 | if (hslColors[i].h) {
35 | const complementaryElement = document.createElement("div");
36 | complementaryElement.style.backgroundColor = `hsl(${hslColors[i].h},${hslColors[i].s}%,${hslColors[i].l}%)`;
37 |
38 | complementaryElement.appendChild(
39 | document.createTextNode(hexColorComplementary)
40 | );
41 | complementaryContainer.appendChild(complementaryElement);
42 | }
43 | }
44 | };
45 |
46 | // Convert each pixel value ( number ) to hexadecimal ( string ) with base 16
47 | const rgbToHex = (pixel) => {
48 | const componentToHex = (c) => {
49 | const hex = c.toString(16);
50 | return hex.length == 1 ? "0" + hex : hex;
51 | };
52 |
53 | return (
54 | "#" +
55 | componentToHex(pixel.r) +
56 | componentToHex(pixel.g) +
57 | componentToHex(pixel.b)
58 | ).toUpperCase();
59 | };
60 |
61 | /**
62 | * Convert HSL to Hex
63 | * this entire formula can be found in stackoverflow, credits to @icl7126 !!!
64 | * https://stackoverflow.com/a/44134328/17150245
65 | */
66 | const hslToHex = (hslColor) => {
67 | const hslColorCopy = { ...hslColor };
68 | hslColorCopy.l /= 100;
69 | const a =
70 | (hslColorCopy.s * Math.min(hslColorCopy.l, 1 - hslColorCopy.l)) / 100;
71 | const f = (n) => {
72 | const k = (n + hslColorCopy.h / 30) % 12;
73 | const color = hslColorCopy.l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
74 | return Math.round(255 * color)
75 | .toString(16)
76 | .padStart(2, "0");
77 | };
78 | return `#${f(0)}${f(8)}${f(4)}`.toUpperCase();
79 | };
80 |
81 | /**
82 | * Convert RGB values to HSL
83 | * This formula can be
84 | * found here https://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/
85 | */
86 | const convertRGBtoHSL = (rgbValues) => {
87 | return rgbValues.map((pixel) => {
88 | let hue,
89 | saturation,
90 | luminance = 0;
91 |
92 | // first change range from 0-255 to 0 - 1
93 | let redOpposite = pixel.r / 255;
94 | let greenOpposite = pixel.g / 255;
95 | let blueOpposite = pixel.b / 255;
96 |
97 | const Cmax = Math.max(redOpposite, greenOpposite, blueOpposite);
98 | const Cmin = Math.min(redOpposite, greenOpposite, blueOpposite);
99 |
100 | const difference = Cmax - Cmin;
101 |
102 | luminance = (Cmax + Cmin) / 2.0;
103 |
104 | if (luminance <= 0.5) {
105 | saturation = difference / (Cmax + Cmin);
106 | } else if (luminance >= 0.5) {
107 | saturation = difference / (2.0 - Cmax - Cmin);
108 | }
109 |
110 | /**
111 | * If Red is max, then Hue = (G-B)/(max-min)
112 | * If Green is max, then Hue = 2.0 + (B-R)/(max-min)
113 | * If Blue is max, then Hue = 4.0 + (R-G)/(max-min)
114 | */
115 | const maxColorValue = Math.max(pixel.r, pixel.g, pixel.b);
116 |
117 | if (maxColorValue === pixel.r) {
118 | hue = (greenOpposite - blueOpposite) / difference;
119 | } else if (maxColorValue === pixel.g) {
120 | hue = 2.0 + (blueOpposite - redOpposite) / difference;
121 | } else {
122 | hue = 4.0 + (greenOpposite - blueOpposite) / difference;
123 | }
124 |
125 | hue = hue * 60; // find the sector of 60 degrees to which the color belongs
126 |
127 | // it should be always a positive angle
128 | if (hue < 0) {
129 | hue = hue + 360;
130 | }
131 |
132 | // When all three of R, G and B are equal, we get a neutral color: white, grey or black.
133 | if (difference === 0) {
134 | return false;
135 | }
136 |
137 | return {
138 | h: Math.round(hue) + 180, // plus 180 degrees because that is the complementary color
139 | s: parseFloat(saturation * 100).toFixed(2),
140 | l: parseFloat(luminance * 100).toFixed(2),
141 | };
142 | });
143 | };
144 |
145 | /**
146 | * Using relative luminance we order the brightness of the colors
147 | * the fixed values and further explanation about this topic
148 | * can be found here -> https://en.wikipedia.org/wiki/Luma_(video)
149 | */
150 | const orderByLuminance = (rgbValues) => {
151 | const calculateLuminance = (p) => {
152 | return 0.2126 * p.r + 0.7152 * p.g + 0.0722 * p.b;
153 | };
154 |
155 | return rgbValues.sort((p1, p2) => {
156 | return calculateLuminance(p2) - calculateLuminance(p1);
157 | });
158 | };
159 |
160 | const buildRgb = (imageData) => {
161 | const rgbValues = [];
162 | // note that we are loopin every 4!
163 | // for every Red, Green, Blue and Alpha
164 | for (let i = 0; i < imageData.length; i += 4) {
165 | const rgb = {
166 | r: imageData[i],
167 | g: imageData[i + 1],
168 | b: imageData[i + 2],
169 | };
170 |
171 | rgbValues.push(rgb);
172 | }
173 |
174 | return rgbValues;
175 | };
176 |
177 | /**
178 | * Calculate the color distance or difference between 2 colors
179 | *
180 | * further explanation of this topic
181 | * can be found here -> https://en.wikipedia.org/wiki/Euclidean_distance
182 | * note: this method is not accuarate for better results use Delta-E distance metric.
183 | */
184 | const calculateColorDifference = (color1, color2) => {
185 | const rDifference = Math.pow(color2.r - color1.r, 2);
186 | const gDifference = Math.pow(color2.g - color1.g, 2);
187 | const bDifference = Math.pow(color2.b - color1.b, 2);
188 |
189 | return rDifference + gDifference + bDifference;
190 | };
191 |
192 | // returns what color channel has the biggest difference
193 | const findBiggestColorRange = (rgbValues) => {
194 | /**
195 | * Min is initialized to the maximum value posible
196 | * from there we procced to find the minimum value for that color channel
197 | *
198 | * Max is initialized to the minimum value posible
199 | * from there we procced to fin the maximum value for that color channel
200 | */
201 | let rMin = Number.MAX_VALUE;
202 | let gMin = Number.MAX_VALUE;
203 | let bMin = Number.MAX_VALUE;
204 |
205 | let rMax = Number.MIN_VALUE;
206 | let gMax = Number.MIN_VALUE;
207 | let bMax = Number.MIN_VALUE;
208 |
209 | rgbValues.forEach((pixel) => {
210 | rMin = Math.min(rMin, pixel.r);
211 | gMin = Math.min(gMin, pixel.g);
212 | bMin = Math.min(bMin, pixel.b);
213 |
214 | rMax = Math.max(rMax, pixel.r);
215 | gMax = Math.max(gMax, pixel.g);
216 | bMax = Math.max(bMax, pixel.b);
217 | });
218 |
219 | const rRange = rMax - rMin;
220 | const gRange = gMax - gMin;
221 | const bRange = bMax - bMin;
222 |
223 | // determine which color has the biggest difference
224 | const biggestRange = Math.max(rRange, gRange, bRange);
225 | if (biggestRange === rRange) {
226 | return "r";
227 | } else if (biggestRange === gRange) {
228 | return "g";
229 | } else {
230 | return "b";
231 | }
232 | };
233 |
234 | /**
235 | * Median cut implementation
236 | * can be found here -> https://en.wikipedia.org/wiki/Median_cut
237 | */
238 | const quantization = (rgbValues, depth) => {
239 | const MAX_DEPTH = 4;
240 |
241 | // Base case
242 | if (depth === MAX_DEPTH || rgbValues.length === 0) {
243 | const color = rgbValues.reduce(
244 | (prev, curr) => {
245 | prev.r += curr.r;
246 | prev.g += curr.g;
247 | prev.b += curr.b;
248 |
249 | return prev;
250 | },
251 | {
252 | r: 0,
253 | g: 0,
254 | b: 0,
255 | }
256 | );
257 |
258 | color.r = Math.round(color.r / rgbValues.length);
259 | color.g = Math.round(color.g / rgbValues.length);
260 | color.b = Math.round(color.b / rgbValues.length);
261 |
262 | return [color];
263 | }
264 |
265 | /**
266 | * Recursively do the following:
267 | * 1. Find the pixel channel (red,green or blue) with biggest difference/range
268 | * 2. Order by this channel
269 | * 3. Divide in half the rgb colors list
270 | * 4. Repeat process again, until desired depth or base case
271 | */
272 | const componentToSortBy = findBiggestColorRange(rgbValues);
273 | rgbValues.sort((p1, p2) => {
274 | return p1[componentToSortBy] - p2[componentToSortBy];
275 | });
276 |
277 | const mid = rgbValues.length / 2;
278 | return [
279 | ...quantization(rgbValues.slice(0, mid), depth + 1),
280 | ...quantization(rgbValues.slice(mid + 1), depth + 1),
281 | ];
282 | };
283 |
284 | const main = () => {
285 | const imgFile = document.getElementById("imgfile");
286 | const image = new Image();
287 | const file = imgFile.files[0];
288 | const fileReader = new FileReader();
289 |
290 | // Whenever file & image is loaded procced to extract the information from the image
291 | fileReader.onload = () => {
292 | image.onload = () => {
293 | // Set the canvas size to be the same as of the uploaded image
294 | const canvas = document.getElementById("canvas");
295 | canvas.width = image.width;
296 | canvas.height = image.height;
297 | const ctx = canvas.getContext("2d");
298 | ctx.drawImage(image, 0, 0);
299 |
300 | /**
301 | * getImageData returns an array full of RGBA values
302 | * each pixel consists of four values: the red value of the colour, the green, the blue and the alpha
303 | * (transparency). For array value consistency reasons,
304 | * the alpha is not from 0 to 1 like it is in the RGBA of CSS, but from 0 to 255.
305 | */
306 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
307 |
308 | // Convert the image data to RGB values so its much simpler
309 | const rgbArray = buildRgb(imageData.data);
310 |
311 | /**
312 | * Color quantization
313 | * A process that reduces the number of colors used in an image
314 | * while trying to visually maintin the original image as much as possible
315 | */
316 | const quantColors = quantization(rgbArray, 0);
317 |
318 | // Create the HTML structure to show the color palette
319 | buildPalette(quantColors);
320 | };
321 | image.src = fileReader.result;
322 | };
323 | fileReader.readAsDataURL(file);
324 | };
325 |
326 | main();
327 |
--------------------------------------------------------------------------------