Updated on Jun 5th, 202110 min readnodegraphics

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.

A Standard Color Tool
A Standard Color Tool

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
package.json
  "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.

A Secondary Color Palette
A Secondary Color Palette

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.

Image Paths Output In Terminal
Image Paths Output In 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?

🧠 ➕ 💡 Just like RGB color codes!

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.

Image Data Output In Terminal
Image Data Output In Terminal

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 from PALETTE to be used as the second argument to the color-diff
  • allColorsZero is used to assign a value of 0 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
index.js
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.

Example Image Analysis
Example Image Analysis
  {
    "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.

Example Image Analysis
Example Image Analysis
  {
    "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.

Benjamin Brooke Avatar
Benjamin Brooke

Hi, I'm Ben. I work as a full stack developer for an eCommerce company. My goal is to share knowledge through my blog and courses. In my free time I enjoy cycling and rock climbing.