|
/** |
|
* 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)))); |
|
} |
|
}; |