From Pixels to Palettes: Implementing Color Extraction in JavaScript
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:
- Put all pixels in a “box”
- Find the channel (R/G/B) with largest range
- Split the box at that channel’s median
- 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:
- Randomly select K colors as cluster centers
- Assign each pixel to nearest center
- Recalculate each cluster’s center (average color)
- 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