Build An eCommerce Color Search Tool With NodeJS + React | Part 1
Most of us have probably been shopping online and used a color search tool to find a tee-shirt or phone or Tesla. These color search features range in complexity from basic to overkill. I think an example of a basic version could have 4 to 8 basic colors that filter 100 products. In an implementation like this, the color values are most likely a data property assigned just by looking at the product. This is perfectly feasible and useful in many cases.
What Is Advanced Color Search
Consider a more advanced use case. A large eCommerce platform that sell fabrics, or used cars, or any scenario with a wide range of product - imagine 100,000 unique SKUs from 25 different suppliers. Customers expect more nuanced color options, and color naming conventions differ from supplier to supplier. All of a sudden we need software that can analyze an image and assign color levels.
Project Outline
This project will be broken into two separate posts.
Post 1 - Server Side Logic
In Post 1 we will use Node to build server side logic that can analyze a set of images and assign each image a score between 0 and 100 for each color in a predetermined palette. I will attempt to keep this implementation as simple as possible, leaving framework and database choices up to each individual developer.
Post 2 - Client Side Logic
In Post 2 we will use React to build the user interface for a client side color tool. Again, I will attempt to keep things simple, creating an interface of clickable squares for a variety of colors that when clicked filter the original image set.
Getting Started
You will need to have NodeJS installed on your local machine. At the time of publication, I am running version 14.16.1
.
Dependencies
We will be using the canvas and color-diff libraries.
Create a new directory, initialize the project and install the dependencies using NPM.
mkdir color-tool
cd color-tool
npm init -y
npm install canvas color-diff
"dependencies": {
"canvas": "^2.8.0",
"color-diff": "^1.2.0"
}
Example Image Files
For this example application I have curated one hundred textile images that we can use to test our tool. Of course, the tool can be used on any image, including image URLs.
Download the example images here
Color Tool Code
The beauty of a project like this one is that it leaves almost endless possibilities for a developer to customize. While I will attempt to keep things simple, I encourage you to take this basic idea and make it your own.
Choosing A Color Palette
A natural starting point for our code is to choose a color palette. I would hypothesize that the more colors included, the more precise the color tool can become, assuming an even distribution of color. I have selected a palette of 26 colors for this demo. In the wild, there are many examples of color tools using a much larger number of colors.
The color palette is represented by a JavaScript array of objects. The name
key is a human readable string describing the color. The color
key is a nested object with keys R
, G
and B
, which stand for red, green and blue, each with an integer value between 0 and 255. This formatting allows the palette to be both understandable and to work easily with the color diff library.
const PALETTE = [
{ name: 'black', color: { R: 0, G: 0, B: 0 } },
{ name: 'gray', color: { R: 128, G: 128, B: 128 } },
{ name: 'silver', color: { R: 192, G: 192, B: 192 } },
{ name: 'white', color: { R: 255, G: 255, B: 255 } },
{ name: 'beige', color: { R: 245, G: 245, B: 220 } },
{ name: 'tan', color: { R: 218, G: 200, B: 160 } },
{ name: 'taupe', color: { R: 176, G: 156, B: 130 } },
{ name: 'navy', color: { R: 19, G: 43, B: 83 } },
{ name: 'purple', color: { R: 103, G: 53, B: 126 } },
{ name: 'blue', color: { R: 31, G: 94, B: 158 } },
{ name: 'peacock', color: { R: 0, G: 128, B: 128 } },
{ name: 'aqua', color: { R: 85, G: 194, B: 195 } },
{ name: 'light_blue', color: { R: 144, G: 193, B: 228 } },
{ name: 'pink', color: { R: 235, G: 111, B: 164 } },
{ name: 'brown', color: { R: 79, G: 41, B: 7 } },
{ name: 'burgundy', color: { R: 117, G: 15, B: 23 } },
{ name: 'terracotta', color: { R: 178, G: 77, B: 56 } },
{ name: 'coral', color: { R: 247, G: 117, B: 100 } },
{ name: 'peach', color: { R: 251, G: 189, B: 147 } },
{ name: 'orange', color: { R: 250, G: 118, B: 10 } },
{ name: 'red', color: { R: 192, G: 7, B: 24 } },
{ name: 'dark_green', color: { R: 32, G: 75, B: 33 } },
{ name: 'green', color: { R: 36, G: 138, B: 15 } },
{ name: 'olive', color: { R: 128, G: 128, B: 0 } },
{ name: 'gold', color: { R: 222, G: 170, B: 13 } },
{ name: 'yellow', color: { R: 255, G: 210, B: 70 } },
]
Importing Our Images
Unzip the example images and place them into a directory named images
with your project directory.
Next, layout the basic code structure and use a couple of Node's built-in modules to read the images
directory.
const { createCanvas, loadImage } = require('canvas')
const diff = require('color-diff')
const { readdirSync, writeFileSync } = require('fs')
const path = require('path')
const IMAGES_DIR = path.join(__dirname, 'images')
async function main() {
try {
const imagePaths = readdirSync(IMAGES_DIR)
for (let imagePath of imagePaths) {
console.log(imagePath)
}
} catch (error) {
console.log(error)
}
}
main()
The code above brings in the two libraries, plus Node's fs
and path
modules.
Add a start
script to package.json
.
"scripts": {
"start": "node ."
},
Run npm start
and you should see a long list of relative paths, pointing to your images, printed in the terminal.
Using Node Canvas
The next step in the image analysis is to get the color information from each and every pixel from each image. How else would we be able to measure color levels?
The canvas
library for Node has everything we need and works very much like the Canvas Web API. I have added comments in the code to help outline each step.
Why The Try...Catch Blocks
I have added a second try/catch block inside the for
loop. This is a pattern I generally follow as it will allow me to log the specific iteration if an error occurs, and catching an error allow the loop to continue, so a small issue won't derail the entire process.
async function main() {
try {
const imagePaths = await readdirSync(IMAGES_DIR)
for (let imagePath of imagePaths) {
try {
// load the current image
const image = await loadImage(path.join(IMAGES_DIR, imagePath))
// get image dimensions
const { width, height } = image
// create a new canvas using same dimensions
const canvas = createCanvas(width, height)
// isolate the context
const ctx = canvas.getContext('2d')
// draw image onto canvas starting at coordinate 0,0
ctx.drawImage(image, 0, 0)
// grab image data from canvas
const imageData = ctx.getImageData(0, 0, width, height)
// isolate pixel by pixel data
const rgba = imageData.data
// total number of pixels
const totalPixels = width * height
// total number of values [r,g,b,a,r,g,b,a....]
const totalValues = totalPixels * 4
// logs
console.log(totalValues)
console.log(rgba)
} catch (error) {
console.log(`Error processing ${imagePath}`)
console.log(error)
}
}
} catch (error) {
console.log(`Error reading ${IMAGES_DIR}`)
console.log(error)
}
}
Canvas, Context & Image Data
When I first discovered the Canvas API I was blown away by everything that it can do, and to be honest, I have only scratched the surface. In any event, Canvas allows us to draw the image and the Canvas Rendering Context allows us to grab data about every pixel on the Canvas through getImageData. This method returns a huge chunk of information appropriately called ImageData which has a property data
where we finally find what we are looking for.
This raw image data is stored in a specific type of array called an Uint8ClampedArray. Values in this type of array must be between 0 and 255. Sound familiar?
Every 4 integers in the image data represent 1 pixel. The first integer of the array is the R
or red
value of the first pixel. The second integer is G
or green
. The third is B
or blue
. The fourth is the A
or alpha
value. The fifth integer is the R
value of the next pixel, and on and on and on. In the code above you can see that the length of the image data array is the name value as width * height * 4
.
The output from the logs above should look something like the following if everything is working.
Using Color Diff
The color-diff
library is the straw that stirs the drink for this entire project. Under the hood this package implements the CIEDE2000 color difference algorithm. This gives our program the ability to look at a single pixel from an image and to, in effect, round that value to the closest color value in our predetermined palette.
To manipulate our color values two additional data structures are helpful. These can be defined on the global
level, or outside our main
function.
// array of PALETTE color objects
const paletteColors = []
// object with color name keys and 0 value
const allColorsZero = {}
// create both at the same time
for (let colorObject of PALETTE) {
paletteColors.push(colorObject.color)
allColorsZero[colorObject.name] = 0
}
paletteColors
strips the color objects fromPALETTE
to be used as the second argument to thecolor-diff
allColorsZero
is used to assign a value of0
to any color that does not appear in a given image
The Full Code
Below is the full, working example. Please note that the example merely writes the color analysis data to a JSON file. In most real world eCommerce scenarios, the product data would have to be accessed initially, to fetch the images. Once the analysis is complete the values would have to be saved to a database. These framework and database choices are beyond the scope of the article and are the perogative of each developer.
This logic could be interfaced with an application in a number of ways.
- As part of the product creation process
- On demand as a service using a REST or GraphQL API to read/write data
- Via a cron job
const PALETTE = [
{ name: 'black', color: { R: 0, G: 0, B: 0 } },
{ name: 'gray', color: { R: 128, G: 128, B: 128 } },
{ name: 'silver', color: { R: 192, G: 192, B: 192 } },
{ name: 'white', color: { R: 255, G: 255, B: 255 } },
{ name: 'floral_white', color: { R: 255, G: 250, B: 240 } },
{ name: 'beige', color: { R: 245, G: 245, B: 220 } },
{ name: 'tan', color: { R: 218, G: 200, B: 160 } },
{ name: 'purple', color: { R: 103, G: 53, B: 126 } },
{ name: 'navy', color: { R: 19, G: 43, B: 83 } },
{ name: 'blue', color: { R: 31, G: 94, B: 158 } },
{ name: 'peacock', color: { R: 0, G: 128, B: 128 } },
{ name: 'aqua', color: { R: 85, G: 194, B: 195 } },
{ name: 'light_blue', color: { R: 144, G: 193, B: 228 } },
{ name: 'light_cyan', color: { R: 224, G: 255, B: 255 } },
{ name: 'dark_green', color: { R: 32, G: 75, B: 33 } },
{ name: 'green', color: { R: 36, G: 138, B: 15 } },
{ name: 'olive', color: { R: 128, G: 128, B: 0 } },
{ name: 'pale_green', color: { R: 163, G: 176, B: 133 } },
{ name: 'gold', color: { R: 222, G: 170, B: 13 } },
{ name: 'yellow', color: { R: 255, G: 210, B: 70 } },
{ name: 'lavender', color: { R: 230, G: 230, B: 250 } },
{ name: 'brown', color: { R: 79, G: 41, B: 7 } },
{ name: 'burgundy', color: { R: 117, G: 15, B: 23 } },
{ name: 'terracotta', color: { R: 178, G: 77, B: 56 } },
{ name: 'coral', color: { R: 247, G: 117, B: 100 } },
{ name: 'peach', color: { R: 251, G: 189, B: 147 } },
{ name: 'orange', color: { R: 250, G: 118, B: 10 } },
{ name: 'taupe', color: { R: 176, G: 156, B: 130 } },
{ name: 'red', color: { R: 192, G: 7, B: 24 } },
{ name: 'pink', color: { R: 235, G: 111, B: 164 } },
]
const { createCanvas, loadImage } = require('canvas')
const diff = require('color-diff')
const { readdirSync, writeFileSync } = require('fs')
const path = require('path')
const IMAGES_DIR = path.join(__dirname, 'images')
// array of PALETTE color objects
const paletteColors = []
// object with color name keys and 0 value
const allColorsZero = {}
// create both at the same time
for (let colorObject of PALETTE) {
paletteColors.push(colorObject.color)
allColorsZero[colorObject.name] = 0
}
async function main() {
try {
const imagePaths = readdirSync(IMAGES_DIR)
const analyzedImages = []
for (let imagePath of imagePaths) {
try {
// load the current image
const image = await loadImage(path.join(IMAGES_DIR, imagePath))
// get image dimensions
const { width, height } = image
// create a new canvas using same dimensions
const canvas = createCanvas(width, height)
// isolate the context
const ctx = canvas.getContext('2d')
// draw image onto canvas starting at coordinate 0,0
ctx.drawImage(image, 0, 0)
// grab image data from canvas
const imageData = ctx.getImageData(0, 0, width, height)
// isolate pixel by pixel data
const rgba = imageData.data
// total number of pixels
const totalPixels = width * height
// total number of values [r,g,b,a,r,g,b,a....]
const totalValues = totalPixels * 4
// keys for each color in palette present in image
// value is count of pixels with that color value
const colorValues = {}
// loop over RGBA pixel quartets
for (let i = 0; i < totalValues; i += 4) {
try {
// construct rgb color object
const R = rgba[i]
const G = rgba[i + 1]
const B = rgba[i + 2]
const color = { R, G, B }
// color diff will round to the closest color in the palette
const closestPaletteColor = diff.closest(color, paletteColors)
// get human readable name of closest color
const colorName = PALETTE.find((el) => el.color === closestPaletteColor)['name']
// already exists increment by one
// doesn't exist initalize with value of 1
if (colorValues[colorName]) {
colorValues[colorName] += 1
} else {
colorValues[colorName] = 1
}
} catch (error) {
console.log(`Error closest color ${imagePath}`)
console.log(error)
}
}
// percentage values for each existing color
const colorPercentages = {}
// for in object loop over keys to convert each into percentage
for (let colorName in colorValues) {
colorPercentages[colorName] = Math.round((colorValues[colorName] / totalPixels) * 100)
}
// final object contains 0 for colors that do not appear in image
const allColorPercentages = Object.assign({}, allColorsZero, colorPercentages)
// associate image with color analysis
const analyzedImage = {
relativePath: imagePath,
colorAnalysis: allColorPercentages,
}
// build array of image data
analyzedImages.push(analyzedImage)
} catch (error) {
console.log(`Error processing ${imagePath}`)
console.log(error)
}
}
// write data to JSON file
try {
writeFileSync(path.join(__dirname, 'image-analysis.json'), JSON.stringify(analyzedImages))
} catch (error) {
console.log(`Error writing output`)
console.log(error)
}
} catch (error) {
console.log(`Error reading ${IMAGES_DIR}`)
console.log(error)
}
}
main()
After running npm start
, you should see a new file in your root directory named image-analysis.json
. If everything has worked correctly the output should be an array of objects describing each image file that was input.
The following image registered values for black, gray, silver, white, beige, tan, taupe, navy, purple and blue.
{
"relativePath": "kandira-indigo-fabric.jpg",
"colorAnalysis": {
"black": 5,
"gray": 6,
"silver": 6,
"white": 30,
"beige": 2,
"tan": 8,
"taupe": 7,
"navy": 31,
"purple": 2,
"blue": 3,
"peacock": 0,
"aqua": 0,
"light_blue": 0,
"pink": 0,
"brown": 0,
"burgundy": 0,
"terracotta": 0,
"coral": 0,
"peach": 0,
"orange": 0,
"red": 0,
"dark_green": 0,
"green": 0,
"olive": 0,
"gold": 0,
"yellow": 0
}
}
Another image registered 100% red.
{
"relativePath": "lange-claret-fabric.jpg",
"colorAnalysis": {
"black": 0,
"gray": 0,
"silver": 0,
"white": 0,
"beige": 0,
"tan": 0,
"taupe": 0,
"navy": 0,
"purple": 0,
"blue": 0,
"peacock": 0,
"aqua": 0,
"light_blue": 0,
"pink": 0,
"brown": 0,
"burgundy": 0,
"terracotta": 0,
"coral": 0,
"peach": 0,
"orange": 0,
"red": 100,
"dark_green": 0,
"green": 0,
"olive": 0,
"gold": 0,
"yellow": 0
}
},
Final Thoughts
While far from perfect, this logic has been a useful arrow in my eCommerce quiver. It is exciting to see what approximately 100 lines of code can do, and I am grateful for open source tools that the NodeJS ecosystem provides. It seems that when I think of something I want to accomplish through code, the building blocks I need are always at my fingertips. In that spirit, I hope that some reader finds this code helpful.
Be sure to check out Post 2 of this series, where we create a client side user interface that leverages the data generated from logic in this post.