From Pixels to Palettes: Implementing Color Extraction in JavaScript#

I recently needed to extract dominant colors from brand logos for a design system. After trying several online tools, I found the underlying principle fascinating—it’s essentially clustering analysis on pixel data. Here’s what I learned.

The Naive Approach: Frequency Counting#

The core idea is straightforward: read image pixels → count color frequencies → sort and pick top N.

function extractColors(canvas) {
  const ctx = canvas.getContext('2d')
  const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
  const data = imageData.data  // RGBA array, 4 elements per pixel

  const colorMap = new Map()

  for (let i = 0; i < data.length; i += 4) {
    const r = data[i]
    const g = data[i + 1]
    const b = data[i + 2]
    const a = data[i + 3]

    // Skip semi-transparent pixels
    if (a < 128) continue

    // RGB to HEX
    const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b)
      .toString(16).slice(1)}`

    colorMap.set(hex, (colorMap.get(hex) || 0) + 1)
  }

  return Array.from(colorMap.entries())
    .sort((a, b) => b[1] - a[1])
    .slice(0, 8)
}

This works, but has a fatal flaw: exact pixel colors are too numerous. A 100×100 image has 10,000 pixels, each with slightly different RGB values. You might end up with 8 “almost identical” colors like:

  • #F3A012
  • #F3A113
  • #F3A011

Human eyes can’t distinguish these, but the algorithm treats them as separate.

Color Quantization: Grouping Similar Colors#

The solution is Color Quantization—dividing the RGB color space into regions and treating colors in the same region as identical.

Method 1: Bit Truncation#

The simplest approach: clear the lower bits of each RGB channel.

function quantizeColor(r, g, b, level = 2) {
  const shift = 8 - level  // level=2 keeps top 2 bits
  const mask = 0xFF << shift

  return {
    r: (r & mask),
    g: (g & mask),
    b: (b & mask)
  }
}

// Before: #F3A012 → After: #F0A000

With 2 bits preserved, color space drops from 16,777,216 to 4,096 colors. With 3 bits, down to 512.

Fast but produces color banding, especially in gradients.

Method 2: Median Cut#

A more precise approach that recursively splits the color space:

  1. Put all pixels in a “box”
  2. Find the channel (R/G/B) with largest range
  3. Split the box at that channel’s median
  4. Repeat for each box until you have target number of colors
function medianCut(pixels, depth) {
  if (depth === 0) {
    // Calculate average color at box center
    const avg = pixels.reduce(
      (sum, p) => ({ r: sum.r + p.r, g: sum.g + p.g, b: sum.b + p.b }),
      { r: 0, g: 0, b: 0 }
    )
    return [{
      r: Math.round(avg.r / pixels.length),
      g: Math.round(avg.g / pixels.length),
      b: Math.round(avg.b / pixels.length),
      count: pixels.length
    }]
  }

  // Find channel with largest range
  const ranges = ['r', 'g', 'b'].map(channel => {
    const values = pixels.map(p => p[channel])
    return { channel, range: Math.max(...values) - Math.min(...values) }
  })
  const maxChannel = ranges.sort((a, b) => b.range - a.range)[0].channel

  // Sort and split at median
  pixels.sort((a, b) => a[maxChannel] - b[maxChannel])
  const mid = Math.floor(pixels.length / 2)

  return [
    ...medianCut(pixels.slice(0, mid), depth - 1),
    ...medianCut(pixels.slice(mid), depth - 1)
  ]
}

Much better results because it adapts to actual color distribution.

Method 3: K-Means Clustering#

The professional approach using K-Means algorithm:

  1. Randomly select K colors as cluster centers
  2. Assign each pixel to nearest center
  3. Recalculate each cluster’s center (average color)
  4. Repeat steps 2-3 until convergence
function kMeans(pixels, k, maxIterations = 20) {
  // K-Means++ initialization: spread initial centers
  const centers = [pixels[Math.floor(Math.random() * pixels.length)]]

  while (centers.length < k) {
    const distances = pixels.map(p => {
      const minDist = Math.min(...centers.map(c => colorDistance(p, c)))
      return minDist * minDist
    })
    const totalDist = distances.reduce((a, b) => a + b, 0)
    let random = Math.random() * totalDist

    for (let i = 0; i < distances.length; i++) {
      random -= distances[i]
      if (random <= 0) {
        centers.push(pixels[i])
        break
      }
    }
  }

  // Iterative optimization
  for (let iter = 0; iter < maxIterations; iter++) {
    const clusters = Array.from({ length: k }, () => [])

    // Assign pixels to nearest center
    for (const pixel of pixels) {
      let minDist = Infinity, minIdx = 0
      for (let i = 0; i < centers.length; i++) {
        const dist = colorDistance(pixel, centers[i])
        if (dist < minDist) {
          minDist = dist
          minIdx = i
        }
      }
      clusters[minIdx].push(pixel)
    }

    // Update centers
    let converged = true
    for (let i = 0; i < k; i++) {
      if (clusters[i].length === 0) continue

      const avg = clusters[i].reduce(
        (sum, p) => ({ r: sum.r + p.r, g: sum.g + p.g, b: sum.b + p.b }),
        { r: 0, g: 0, b: 0 }
      )
      const newCenter = {
        r: Math.round(avg.r / clusters[i].length),
        g: Math.round(avg.g / clusters[i].length),
        b: Math.round(avg.b / clusters[i].length)
      }

      if (colorDistance(newCenter, centers[i]) > 1) {
        converged = false
      }
      centers[i] = newCenter
    }

    if (converged) break
  }

  return centers.map((c, i) => ({ ...c, count: clusters[i]?.length || 0 }))
}

function colorDistance(c1, c2) {
  return Math.sqrt(
    Math.pow(c1.r - c2.r, 2) +
    Math.pow(c1.g - c2.g, 2) +
    Math.pow(c1.b - c2.b, 2)
  )
}

K-Means produces the best clustering but is computationally expensive. For large images, downsampling helps:

function downsample(canvas, maxSize = 100) {
  const scale = Math.min(maxSize / canvas.width, maxSize / canvas.height)
  if (scale >= 1) return canvas

  const newCanvas = document.createElement('canvas')
  newCanvas.width = canvas.width * scale
  newCanvas.height = canvas.height * scale

  const ctx = newCanvas.getContext('2d')
  ctx.drawImage(canvas, 0, 0, newCanvas.width, newCanvas.height)

  return newCanvas
}

Practical Optimizations#

1. Transparent Pixel Filtering#

if (a < 128) continue  // Alpha < 50% treated as transparent

2. White/Black Filtering#

// Skip pure white and black (usually not dominant colors)
if ((r > 250 && g > 250 && b > 250) || (r < 5 && g < 5 && b < 5)) continue

3. HSL Space Filtering#

Filter out low-saturation colors in some scenarios:

function rgbToHsl(r, g, b) {
  r /= 255; g /= 255; b /= 255
  const max = Math.max(r, g, b), min = Math.min(r, g, b)
  let h, s, l = (max + min) / 2

  if (max === min) {
    h = s = 0  // Gray
  } else {
    const d = max - min
    s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
    switch (max) {
      case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break
      case g: h = ((b - r) / d + 2) / 6; break
      case b: h = ((r - g) / d + 4) / 6; break
    }
  }
  return { h: h * 360, s: s * 100, l: l * 100 }
}

// Filter low saturation colors
const hsl = rgbToHsl(r, g, b)
if (hsl.s < 10) continue  // Gray-white with saturation < 10%

Comparison of Approaches#

I implemented a basic version in the Color Extractor tool. For simple logos and icons, frequency counting works fine. For precision, use K-Means.

Algorithm Speed Accuracy Use Case
Frequency Counting Fastest Low Quick preview, simple images
Median Cut Medium Medium Balanced approach
K-Means Slow High Design tools, professional applications

Complete Color Extraction Pipeline#

async function extractDominantColors(imageUrl, options = {}) {
  const { colorCount = 8, downsampleSize = 100 } = options

  // 1. Load image
  const img = await loadImage(imageUrl)

  // 2. Draw to Canvas
  const canvas = document.createElement('canvas')
  canvas.width = img.width
  canvas.height = img.height
  const ctx = canvas.getContext('2d')
  ctx.drawImage(img, 0, 0)

  // 3. Downsample (optional)
  const sampled = downsample(canvas, downsampleSize)

  // 4. Get pixel data
  const imageData = ctx.getImageData(0, 0, sampled.width, sampled.height)
  const pixels = []
  for (let i = 0; i < imageData.data.length; i += 4) {
    if (imageData.data[i + 3] < 128) continue  // Skip transparent
    pixels.push({
      r: imageData.data[i],
      g: imageData.data[i + 1],
      b: imageData.data[i + 2]
    })
  }

  // 5. K-Means clustering
  const colors = kMeans(pixels, colorCount)

  // 6. Sort by pixel count
  return colors.sort((a, b) => b.count - a.count)
}

Color extraction seems simple, but achieving accuracy and efficiency requires understanding algorithms. Hope this helps.


Related: Color Palette Generator | Color Shades Generator