Skip to content

Instantly share code, notes, and snippets.

@arenagroove
Last active April 22, 2025 02:41
Show Gist options
  • Select an option

  • Save arenagroove/296b6c4dae814bd8b95d970adc6fdd3d to your computer and use it in GitHub Desktop.

Select an option

Save arenagroove/296b6c4dae814bd8b95d970adc6fdd3d to your computer and use it in GitHub Desktop.
Optimized color analysis utilities in JavaScript for extracting average, perceptual dominant, luminance-weighted, and k-means clustered colors from image pixel data.

Fast Optimized JavaScript Image Color Extract Utilities

A set of high-performance, dependency-free functions for extracting color information from image pixel data using the HTML Canvas API.

Features

  • Fast average color calculation using loop unrolling and Uint32Array
  • Perceptual dominant color detection using HSL quantization and weighted analysis
  • Luminance-weighted color averaging for natural-looking color estimation
  • K-Means clustering for extracting top representative colors
  • All functions are optimized for performance-critical environments (e.g., image previews, UI theming)
  • Bitwise operations used for faster processing and memory efficiency

File

  • color-analysis-optimized.js — Main utility file containing all color extraction methods

Usage Example

// Assuming the colorUtils object has been included or imported

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');

const img = new Image();
img.crossOrigin = 'anonymous'; // Allow CORS image loading
img.src = 'https://example.com/path/to/image.jpg';

img.onload = () => {
    canvas.width = img.naturalWidth;
    canvas.height = img.naturalHeight;
    ctx.drawImage(img, 0, 0);

    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
    const pixelData = new Uint32Array(imageData.data.buffer);
    const len = pixelData.length;

    const avgColor = colorUtils.getFastAverageColor(pixelData, len);
    const dominantColor = colorUtils.getPerceptualDominantColor(pixelData, len);
    const topColors = colorUtils.getKMeansColors(imageData.data, imageData.data.length, 5);
    const lumColor = colorUtils.getLuminanceWeightedAverageColor(imageData.data, imageData.data.length);

    console.log('Average Color:', avgColor);
    console.log('Perceptual Dominant Color:', dominantColor);
    console.log('Top K-Means Colors:', topColors);
    console.log('Luminance Weighted Color:', lumColor);
};

CodePen Demo

CodePen Example

/**
* Color Utilities – High Performance Color Extraction
* Author: Luis Martinez
* Author URI: https://www.lessrain.com
* Version: 1.0
*
* - Fast average color using loop unrolling
* - Perceptual dominant color via HSL binning + weighted frequency
* - Luminance-weighted color average (visually accurate)
* - Optimized K-Means clustering using unique color masks
* - [View Live Demo on CodePen](https://codepen.io/luis-lessrain/pen/gbbpEYj)
*
*/
const colorUtils = {
getFastAverageColor(pixelData, len, sampleStep = 16) {
const step = sampleStep;
let r = 0,
g = 0,
b = 0,
count = 0;
let i = 0;
const UNROLL = 8;
const maxUnrolled = len - UNROLL;
let p, j;
for (; i < maxUnrolled; i += UNROLL * step) {
p = [
pixelData[i],
pixelData[i + step],
pixelData[i + 2 * step],
pixelData[i + 3 * step],
pixelData[i + 4 * step],
pixelData[i + 5 * step],
pixelData[i + 6 * step],
pixelData[i + 7 * step]
];
j = 0;
for (; j < UNROLL; j++) {
r += p[j] & 0xff;
g += (p[j] >> 8) & 0xff;
b += (p[j] >> 16) & 0xff;
}
count += UNROLL;
}
for (; i < len; i += step) {
p = pixelData[i];
r += p & 0xff;
g += (p >> 8) & 0xff;
b += (p >> 16) & 0xff;
count++;
}
return [
(r / count + 0.5) | 0,
(g / count + 0.5) | 0,
(b / count + 0.5) | 0
];
},
getPerceptualDominantColor(pixelData, len, sampleStep = 8) {
const colorCounts = Object.create(null);
let maxCount = 0,
dominantColor = [0, 0, 0];
let i, pixel, alpha, r, g, b, h, s, l, qH, qS, qL, colorKey, count;
for (i = 0; i < len; i += sampleStep) {
pixel = pixelData[i];
alpha = (pixel >> 24) & 0xff;
if (alpha < 128) continue;
r = pixel & 0xff;
g = (pixel >> 8) & 0xff;
b = (pixel >> 16) & 0xff;
[h, s, l] = colorUtils.rgbToHslFast(r, g, b);
if (s < 0.15 || l < 0.1 || l > 0.95) continue;
qH = Math.round(h * 360 / 15) % 24;
qS = Math.min(3, Math.floor(s * 4));
qL = Math.min(3, Math.floor(l * 4));
colorKey = `${qH},${qS},${qL}`;
let weight = Math.pow(s, 1.5) * (1 - Math.abs(l - 0.5)) * 2;
count = (colorCounts[colorKey] || 0) + weight;
colorCounts[colorKey] = count;
if (count > maxCount) {
maxCount = count;
dominantColor = colorUtils.hslToRgbFast(qH * 15 / 360, qS / 4, qL / 4);
}
}
return dominantColor;
},
rgbToHslFast(r, g, b) {
const inv255 = 1 / 255;
r *= inv255, g *= inv255, b *= inv255;
let max = r > g ? (r > b ? r : b) : (g > b ? g : b);
let min = r < g ? (r < b ? r : b) : (g < b ? g : b);
let h = 0,
s, l = (max + min) * 0.5;
let d = max - min;
if (d !== 0) {
s = d / (l > 0.5 ? (2 - max - min) : (max + min));
if (max === r) {
h = ((g - b) / d + (g < b ? 6 : 0)) * (1 / 6);
} else if (max === g) {
h = ((b - r) / d + 2) * (1 / 6);
} else {
h = ((r - g) / d + 4) * (1 / 6);
}
} else {
s = 0;
}
return [h, s, l];
},
hslToRgbFast(h, s, l) {
const a = s * Math.min(l, 1 - l);
const f = (n, k = (n + h * 12) % 12) =>
l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1);
return [
f(0) * 255 | 0,
f(8) * 255 | 0,
f(4) * 255 | 0
];
},
getKMeansColors(imgData, len, k = 5, sampleStep = 16, sortByLightness = true) {
const step = sampleStep * 4;
const pixels = new Uint8Array((len / step) * 3);
const uniqueColors = new Uint8Array(1 << 18);
let idx = 0,
i = 0,
r, g, b, colorKey;
for (; i < len; i += step) {
r = imgData[i] & 0xF8;
g = imgData[i + 1] & 0xF8;
b = imgData[i + 2] & 0xF8;
colorKey = (r << 12) | (g << 6) | b;
if (!uniqueColors[colorKey]) {
uniqueColors[colorKey] = 1;
pixels[idx++] = r;
pixels[idx++] = g;
pixels[idx++] = b;
}
}
const result = colorUtils.kMeans(pixels.subarray(0, idx), k, 10);
if (sortByLightness) {
result.sort((a, b) => {
const lumA = a[0] * 0.299 + a[1] * 0.587 + a[2] * 0.114;
const lumB = b[0] * 0.299 + b[1] * 0.587 + b[2] * 0.114;
return lumA - lumB;
});
}
return result;
},
kMeans(pixels, k, maxIterations) {
const numPixels = pixels.length / 3;
const centroids = new Float32Array(k * 3);
const assignments = new Uint8Array(numPixels);
const clusterSizes = new Uint32Array(k);
const newCentroids = new Float32Array(k * 3);
for (let i = 0, j = 0; i < k; i++, j += 3) {
centroids[j] = pixels[j];
centroids[j + 1] = pixels[j + 1];
centroids[j + 2] = pixels[j + 2];
}
let index, r, g, b, bestCluster, minDist, cIndex, cr, cg, cb, dist;
for (let iter = 0; iter < maxIterations; iter++) {
for (let c = 0; c < k * 3; c++) newCentroids[c] = 0;
for (let c = 0; c < k; c++) clusterSizes[c] = 0;
for (let i = 0; i < numPixels; i++) {
index = i * 3;
r = pixels[index];
g = pixels[index + 1];
b = pixels[index + 2];
bestCluster = 0;
minDist = Infinity;
for (let c = 0; c < k; c++) {
cIndex = c * 3;
cr = centroids[cIndex];
cg = centroids[cIndex + 1];
cb = centroids[cIndex + 2];
dist = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
if (dist < minDist) {
minDist = dist;
bestCluster = c;
}
}
assignments[i] = bestCluster;
clusterSizes[bestCluster]++;
cIndex = bestCluster * 3;
newCentroids[cIndex] += r;
newCentroids[cIndex + 1] += g;
newCentroids[cIndex + 2] += b;
}
let stable = true;
let divisor, newR, newG, newB;
for (let c = 0; c < k; c++) {
if (clusterSizes[c] === 0) continue;
cIndex = c * 3;
divisor = clusterSizes[c] || 1;
newR = (newCentroids[cIndex] / divisor) >> 0;
newG = (newCentroids[cIndex + 1] / divisor) >> 0;
newB = (newCentroids[cIndex + 2] / divisor) >> 0;
if (newR !== centroids[cIndex] || newG !== centroids[cIndex + 1] || newB !== centroids[cIndex + 2]) {
centroids[cIndex] = newR;
centroids[cIndex + 1] = newG;
centroids[cIndex + 2] = newB;
stable = false;
}
}
if (stable) break;
}
const formattedResult = new Array(k);
for (let i = 0; i < k; i++) {
formattedResult[i] = [
centroids[i * 3],
centroids[i * 3 + 1],
centroids[i * 3 + 2]
];
}
return formattedResult;
},
getLuminanceWeightedAverageColor(imgData, len, sampleStep = 16) {
const step = sampleStep * 4;
const inv255Sq = 1 / (255 * 255);
let r = 0,
g = 0,
b = 0,
totalWeight = 0;
let lr, lg, lb, luminance, weight;
let i = 0;
for (; i < len; i += step) {
lr = imgData[i];
lg = imgData[i + 1];
lb = imgData[i + 2];
luminance = (lr * 76 + lg * 150 + lb * 29) >> 8;
weight = (luminance * luminance) * inv255Sq;
r += lr * weight;
g += lg * weight;
b += lb * weight;
totalWeight += weight;
}
return totalWeight ? [
(r / totalWeight + 0.5) | 0,
(g / totalWeight + 0.5) | 0,
(b / totalWeight + 0.5) | 0
] : [0, 0, 0];
},
sanitizeColor(color) {
if (!Array.isArray(color) || color.length !== 3 || color.some((val) => val === undefined || isNaN(val))) {
return [0, 0, 0];
}
return color.map((val) => Math.max(0, Math.min(255, Math.round(val))));
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment