├── 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 | ![Header image](/header-photo.jpg) 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 |
21 | 22 | 23 |
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 | --------------------------------------------------------------------------------