pick-distinct-colors
Version:
A collection of algorithms and utilities for analyzing and selecting maximally distinct colors
733 lines (660 loc) • 25.6 kB
JavaScript
function rgb2lab(rgb) {
let r = rgb[0] / 255,
g = rgb[1] / 255,
b = rgb[2] / 255;
r = r > 0.04045 ? Math.pow((r + 0.055) / 1.055, 2.4) : r / 12.92;
g = g > 0.04045 ? Math.pow((g + 0.055) / 1.055, 2.4) : g / 12.92;
b = b > 0.04045 ? Math.pow((b + 0.055) / 1.055, 2.4) : b / 12.92;
let x = (r * 0.4124 + g * 0.3576 + b * 0.1805) * 100;
let y = (r * 0.2126 + g * 0.7152 + b * 0.0722) * 100;
let z = (r * 0.0193 + g * 0.1192 + b * 0.9505) * 100;
x /= 95.047;
y /= 100;
z /= 108.883;
x = x > 0.008856 ? Math.pow(x, 1 / 3) : 7.787 * x + 16 / 116;
y = y > 0.008856 ? Math.pow(y, 1 / 3) : 7.787 * y + 16 / 116;
z = z > 0.008856 ? Math.pow(z, 1 / 3) : 7.787 * z + 16 / 116;
return [116 * y - 16, 500 * (x - y), 200 * (y - z)];
}
function deltaE(labA, labB) {
let deltaL = labA[0] - labB[0];
let deltaA = labA[1] - labB[1];
let deltaB = labA[2] - labB[2];
return Math.sqrt(deltaL * deltaL + deltaA * deltaA + deltaB * deltaB);
}
function sortColors(colors) {
const labColors = colors.map(rgb2lab);
const indices = Array.from({
length: colors.length
}, (_, i) => i);
// Sort by L, then a, then b
indices.sort((i, j) => {
const [L1, a1, b1] = labColors[i];
const [L2, a2, b2] = labColors[j];
if (L1 !== L2) return L2 - L1;
if (a1 !== a2) return a2 - a1;
return b2 - b1;
});
return indices.map(i => colors[i]);
}
function calculateMetrics(colors) {
const labColors = colors.map(rgb2lab);
let minDist = Infinity;
let maxDist = -Infinity;
let sumDist = 0;
let count = 0;
for (let i = 0; i < colors.length - 1; i++) {
for (let j = i + 1; j < colors.length; j++) {
const dist = deltaE(labColors[i], labColors[j]);
minDist = Math.min(minDist, dist);
maxDist = Math.max(maxDist, dist);
sumDist += dist;
count++;
}
}
return {
min: minDist,
max: maxDist,
avg: sumDist / count,
sum: sumDist
};
}
function analyzeColorDistribution(colors) {
if (!colors || colors.length === 0) return 'No colors to analyze';
try {
const labColors = colors.map(rgb2lab);
// Initialize stats with first color
const stats = {
L: {
min: labColors[0][0],
max: labColors[0][0],
range: [0, 100]
},
a: {
min: labColors[0][1],
max: labColors[0][1],
range: [-128, 127]
},
b: {
min: labColors[0][2],
max: labColors[0][2],
range: [-128, 127]
}
};
// Process colors in chunks
const chunkSize = 500;
for (let i = 1; i < labColors.length; i += chunkSize) {
const chunk = labColors.slice(i, i + chunkSize);
for (const lab of chunk) {
stats.L.min = Math.min(stats.L.min, lab[0]);
stats.L.max = Math.max(stats.L.max, lab[0]);
stats.a.min = Math.min(stats.a.min, lab[1]);
stats.a.max = Math.max(stats.a.max, lab[1]);
stats.b.min = Math.min(stats.b.min, lab[2]);
stats.b.max = Math.max(stats.b.max, lab[2]);
}
}
// Calculate coverage percentages
const coverage = {
L: ((stats.L.max - stats.L.min) / (stats.L.range[1] - stats.L.range[0]) * 100).toFixed(1),
a: ((stats.a.max - stats.a.min) / (stats.a.range[1] - stats.a.range[0]) * 100).toFixed(1),
b: ((stats.b.max - stats.b.min) / (stats.b.range[1] - stats.b.range[0]) * 100).toFixed(1)
};
return `
<strong>Color Space Coverage:</strong><br>
Lightness (L*): ${coverage.L}%<br>
Green-Red (a*): ${coverage.a}%<br>
Blue-Yellow (b*): ${coverage.b}%
`;
} catch (error) {
console.error('Error in analyzeColorDistribution:', error);
return 'Error analyzing color distribution';
}
}
function findClosestPair(colors) {
const labColors = colors.map(rgb2lab);
let minDist = Infinity;
let closestPair = [0, 1];
for (let i = 0; i < colors.length; i++) {
for (let j = i + 1; j < colors.length; j++) {
const dist = deltaE(labColors[i], labColors[j]);
if (dist < minDist) {
minDist = dist;
closestPair = [i, j];
}
}
}
return {
colors: [colors[closestPair[0]], colors[closestPair[1]]],
distance: minDist
};
}
function maxSumDistancesGlobal(colors, selectCount) {
return new Promise((resolve, reject) => {
// Create worker code with utility functions in scope
const workerCode = `
// Import utility functions from parent
const rgb2lab = ${rgb2lab.toString()};
const deltaE = ${deltaE.toString()};
const sortColors = ${sortColors.toString()};
// Main worker function
function maxSumDistancesGlobal(colors, selectCount) {
const start = performance.now();
const labColors = colors.map(rgb2lab);
// Calculate total distances from each color to all other colors
const totalDistances = colors.map((_, i) => {
let sum = 0;
for (let j = 0; j < colors.length; j++) {
if (i !== j) {
sum += deltaE(labColors[i], labColors[j]);
}
}
return { index: i, sum };
});
// Sort colors by their total distance to all other colors
totalDistances.sort((a, b) => b.sum - a.sum);
// Take the top selectCount colors that have the highest total distances
const selectedIndices = totalDistances.slice(0, selectCount).map(item => item.index);
const selectedColors = selectedIndices.map(i => colors[i]);
return {
colors: sortColors(selectedColors),
time: performance.now() - start
};
}
// Worker message handler
self.onmessage = function(e) {
const { colors, selectCount } = e.data;
try {
const result = maxSumDistancesGlobal(colors, selectCount);
self.postMessage({ type: 'complete', result });
} catch (error) {
self.postMessage({ type: 'error', error: error.message });
}
};
`;
// Create blob and worker
const blob = new Blob([workerCode], {
type: 'application/javascript'
});
const worker = new Worker(URL.createObjectURL(blob));
// Set up worker message handlers
worker.onmessage = function (e) {
if (e.data.type === 'complete') {
resolve(e.data.result);
} else if (e.data.type === 'error') {
reject(new Error(e.data.error));
}
worker.terminate();
};
worker.onerror = function (error) {
reject(error);
worker.terminate();
};
// Start the worker
worker.postMessage({
colors,
selectCount
});
});
}
function maxSumDistancesSequential(colors, selectCount) {
console.log('Starting Maximum Sum (Sequential) calculation...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
const selected = [];
const available = Array.from({
length: colors.length
}, (_, i) => i);
// Helper function to calculate total distance from a point to selected points
function calculateTotalDistance(index) {
return selected.reduce((sum, selectedIndex) => sum + deltaE(labColors[index], labColors[selectedIndex]), 0);
}
// Select first point randomly
const firstIndex = Math.floor(Math.random() * available.length);
selected.push(available[firstIndex]);
available.splice(firstIndex, 1);
// Select remaining points
while (selected.length < selectCount) {
let bestIndex = 0;
let bestDistance = -Infinity;
// Find point with maximum sum of distances to selected points
for (let i = 0; i < available.length; i++) {
const totalDistance = calculateTotalDistance(available[i]);
if (totalDistance > bestDistance) {
bestDistance = totalDistance;
bestIndex = i;
}
}
selected.push(available[bestIndex]);
available.splice(bestIndex, 1);
}
return {
colors: sortColors(selected.map(i => colors[i])),
time: performance.now() - start
};
}
function greedySelection(colors, selectCount) {
console.log('Starting Greedy calculation...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
const selected = [];
const available = Array.from({
length: colors.length
}, (_, i) => i);
// Helper function to calculate minimum distance from a point to selected points
function calculateMinDistance(index) {
if (selected.length === 0) return Infinity;
return Math.min(...selected.map(selectedIndex => deltaE(labColors[index], labColors[selectedIndex])));
}
// Select first point randomly
const firstIndex = Math.floor(Math.random() * available.length);
selected.push(available[firstIndex]);
available.splice(firstIndex, 1);
// Select remaining points
while (selected.length < selectCount) {
let bestIndex = 0;
let bestMinDistance = -Infinity;
// Find point with maximum minimum distance to selected points
for (let i = 0; i < available.length; i++) {
const minDistance = calculateMinDistance(available[i]);
if (minDistance > bestMinDistance) {
bestMinDistance = minDistance;
bestIndex = i;
}
}
selected.push(available[bestIndex]);
available.splice(bestIndex, 1);
}
return {
colors: sortColors(selected.map(i => colors[i])),
time: performance.now() - start
};
}
function kmeansppSelection(colors, selectCount) {
console.log('Starting K-means++ calculation...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
// Helper function to find minimum distance to existing centers
function minDistanceToCenters(point, centers) {
if (centers.length === 0) return Infinity;
return Math.min(...centers.map(center => deltaE(labColors[point], labColors[center])));
}
// Select initial center randomly
const selected = [Math.floor(Math.random() * colors.length)];
// Select remaining centers using k-means++ initialization
while (selected.length < selectCount) {
const distances = Array.from({
length: colors.length
}, (_, i) => {
if (selected.includes(i)) return 0;
const dist = minDistanceToCenters(i, selected);
return dist * dist; // Square distances for k-means++
});
const sum = distances.reduce((a, b) => a + b, 0);
let random = Math.random() * sum;
let selectedIndex = 0;
while (random > 0 && selectedIndex < distances.length) {
if (!selected.includes(selectedIndex)) {
random -= distances[selectedIndex];
}
if (random > 0) selectedIndex++;
}
selected.push(selectedIndex);
}
return {
colors: sortColors(selected.map(i => colors[i])),
time: performance.now() - start
};
}
function simulatedAnnealing(colors, selectCount, settings = {}) {
console.log('Starting Simulated Annealing calculation...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
const maxIterations = 10000;
const initialTemp = settings.initialTemp ?? 1000;
const coolingRate = settings.coolingRate ?? 0.995;
const minTemp = settings.minTemp ?? 0.1;
// Helper function to calculate minimum distance between selected colors
function calculateFitness(selection) {
let minDist = Infinity;
for (let i = 0; i < selection.length - 1; i++) {
for (let j = i + 1; j < selection.length; j++) {
const dist = deltaE(labColors[selection[i]], labColors[selection[j]]);
minDist = Math.min(minDist, dist);
}
}
return minDist;
}
// Generate initial solution
let currentSolution = Array.from({
length: colors.length
}, (_, i) => i).sort(() => Math.random() - 0.5).slice(0, selectCount);
let currentFitness = calculateFitness(currentSolution);
let bestSolution = [...currentSolution];
let bestFitness = currentFitness;
let temperature = initialTemp;
// Main loop
for (let i = 0; i < maxIterations && temperature > minTemp; i++) {
// Generate neighbor by swapping one selected color with an unselected one
const neighborSolution = [...currentSolution];
const swapIndex = Math.floor(Math.random() * selectCount);
const availableIndices = Array.from({
length: colors.length
}, (_, i) => i).filter(i => !currentSolution.includes(i));
const newIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
neighborSolution[swapIndex] = newIndex;
const neighborFitness = calculateFitness(neighborSolution);
// Decide if we should accept the neighbor
const delta = neighborFitness - currentFitness;
if (delta > 0 || Math.random() < Math.exp(delta / temperature)) {
currentSolution = neighborSolution;
currentFitness = neighborFitness;
if (currentFitness > bestFitness) {
bestSolution = [...currentSolution];
bestFitness = currentFitness;
}
}
temperature *= coolingRate;
}
return {
colors: sortColors(bestSolution.map(i => colors[i])),
time: performance.now() - start
};
}
function geneticAlgorithm(colors, selectCount, settings = {}) {
console.log('Starting Genetic Algorithm calculation...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
const populationSize = settings.populationSize ?? 100;
const generations = settings.generations ?? 100;
const mutationRate = settings.mutationRate ?? 0.1;
// Helper function to calculate minimum distance between selected colors
function calculateFitness(selection) {
let minDist = Infinity;
for (let i = 0; i < selection.length - 1; i++) {
for (let j = i + 1; j < selection.length; j++) {
const dist = deltaE(labColors[selection[i]], labColors[selection[j]]);
minDist = Math.min(minDist, dist);
}
}
return minDist;
}
// Generate initial population
let population = Array(populationSize).fill().map(() => Array.from({
length: colors.length
}, (_, i) => i).sort(() => Math.random() - 0.5).slice(0, selectCount));
let bestSolution = population[0];
let bestFitness = calculateFitness(bestSolution);
// Main loop
for (let generation = 0; generation < generations; generation++) {
// Calculate fitness for each solution
const fitnesses = population.map(calculateFitness);
// Update best solution
const maxFitnessIndex = fitnesses.indexOf(Math.max(...fitnesses));
if (fitnesses[maxFitnessIndex] > bestFitness) {
bestSolution = [...population[maxFitnessIndex]];
bestFitness = fitnesses[maxFitnessIndex];
}
// Create new population through selection and crossover
const newPopulation = [];
while (newPopulation.length < populationSize) {
// Tournament selection
const tournament1 = Array(3).fill().map(() => Math.floor(Math.random() * populationSize));
const tournament2 = Array(3).fill().map(() => Math.floor(Math.random() * populationSize));
const parent1 = population[tournament1.reduce((a, b) => fitnesses[a] > fitnesses[b] ? a : b)];
const parent2 = population[tournament2.reduce((a, b) => fitnesses[a] > fitnesses[b] ? a : b)];
// Crossover
const crossoverPoint = Math.floor(Math.random() * selectCount);
const child = [...new Set([...parent1.slice(0, crossoverPoint), ...parent2.slice(crossoverPoint)])];
// Fill up with random colors if needed
while (child.length < selectCount) {
const available = Array.from({
length: colors.length
}, (_, i) => i).filter(i => !child.includes(i));
child.push(available[Math.floor(Math.random() * available.length)]);
}
// Mutation
if (Math.random() < mutationRate) {
const mutationIndex = Math.floor(Math.random() * selectCount);
const available = Array.from({
length: colors.length
}, (_, i) => i).filter(i => !child.includes(i));
child[mutationIndex] = available[Math.floor(Math.random() * available.length)];
}
newPopulation.push(child);
}
population = newPopulation;
}
return {
colors: sortColors(bestSolution.map(i => colors[i])),
time: performance.now() - start
};
}
function particleSwarmOptimization(colors, selectCount, settings = {}) {
console.log('Starting Particle Swarm Optimization...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
const numParticles = settings.numParticles ?? 30;
const maxIterations = settings.psoIterations ?? 100;
const w = settings.inertiaWeight ?? 0.7; // inertia weight
const c1 = settings.cognitiveWeight ?? 1.5; // cognitive weight
const c2 = settings.socialWeight ?? 1.5; // social weight
// Helper function to calculate minimum distance between selected colors
function calculateFitness(selection) {
let minDist = Infinity;
for (let i = 0; i < selection.length - 1; i++) {
for (let j = i + 1; j < selection.length; j++) {
const dist = deltaE(labColors[selection[i]], labColors[selection[j]]);
minDist = Math.min(minDist, dist);
}
}
return minDist;
}
// Initialize particles
const particles = Array(numParticles).fill().map(() => ({
position: Array.from({
length: colors.length
}, (_, i) => i).sort(() => Math.random() - 0.5).slice(0, selectCount),
velocity: Array(selectCount).fill(0),
bestPosition: null,
bestFitness: -Infinity
}));
let globalBestPosition = null;
let globalBestFitness = -Infinity;
// Main loop
for (let iteration = 0; iteration < maxIterations; iteration++) {
for (const particle of particles) {
// Calculate fitness
const fitness = calculateFitness(particle.position);
// Update particle's best
if (fitness > particle.bestFitness) {
particle.bestPosition = [...particle.position];
particle.bestFitness = fitness;
// Update global best
if (fitness > globalBestFitness) {
globalBestPosition = [...particle.position];
globalBestFitness = fitness;
}
}
// Update velocity and position
for (let i = 0; i < selectCount; i++) {
const r1 = Math.random();
const r2 = Math.random();
particle.velocity[i] = Math.floor(w * particle.velocity[i] + c1 * r1 * (particle.bestPosition[i] - particle.position[i]) + c2 * r2 * (globalBestPosition[i] - particle.position[i]));
// Apply velocity (swap with another color)
if (particle.velocity[i] !== 0) {
const available = Array.from({
length: colors.length
}, (_, i) => i).filter(j => !particle.position.includes(j));
if (available.length > 0) {
const swapIndex = Math.floor(Math.random() * available.length);
particle.position[i] = available[swapIndex];
}
}
}
}
}
return {
colors: sortColors(globalBestPosition.map(i => colors[i])),
time: performance.now() - start
};
}
function antColonyOptimization(colors, selectCount, settings = {}) {
console.log('Starting Ant Colony Optimization...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
const numAnts = settings.numAnts ?? 20;
const maxIterations = settings.acoIterations ?? 100;
const evaporationRate = settings.evaporationRate ?? 0.1;
const alpha = settings.pheromoneImportance ?? 1; // pheromone importance
const beta = settings.heuristicImportance ?? 2; // heuristic importance
// Initialize pheromone trails
const pheromones = Array(colors.length).fill(1);
// Calculate heuristic information (distances between colors)
const distances = Array(colors.length).fill().map(() => Array(colors.length));
for (let i = 0; i < colors.length; i++) {
for (let j = i + 1; j < colors.length; j++) {
const distance = deltaE(labColors[i], labColors[j]);
distances[i][j] = distance;
distances[j][i] = distance;
}
}
let bestSolution = null;
let bestFitness = -Infinity;
// Main ACO loop
for (let iteration = 0; iteration < maxIterations; iteration++) {
// Solutions found by ants in this iteration
const solutions = [];
// Each ant constructs a solution
for (let ant = 0; ant < numAnts; ant++) {
const available = Array.from({
length: colors.length
}, (_, i) => i);
const solution = [];
// Randomly select first color
const firstIndex = Math.floor(Math.random() * available.length);
solution.push(available[firstIndex]);
available.splice(firstIndex, 1);
// Select remaining colors
while (solution.length < selectCount) {
// Calculate probabilities for each available color
const probabilities = available.map(i => {
const pheromone = Math.pow(pheromones[i], alpha);
const minDist = Math.min(...solution.map(j => distances[i][j]));
const heuristic = Math.pow(minDist, beta);
return pheromone * heuristic;
});
// Select next color using roulette wheel selection
const total = probabilities.reduce((a, b) => a + b, 0);
let random = Math.random() * total;
let selectedIndex = 0;
while (random > 0 && selectedIndex < probabilities.length) {
random -= probabilities[selectedIndex];
if (random > 0) selectedIndex++;
}
solution.push(available[selectedIndex]);
available.splice(selectedIndex, 1);
}
solutions.push(solution);
}
// Evaluate solutions and update best
for (const solution of solutions) {
const fitness = Math.min(...solution.map((i, idx) => solution.slice(idx + 1).map(j => deltaE(labColors[i], labColors[j]))).flat());
if (fitness > bestFitness) {
bestFitness = fitness;
bestSolution = solution;
}
}
// Update pheromones
for (let i = 0; i < pheromones.length; i++) {
pheromones[i] *= 1 - evaporationRate;
}
// Add new pheromones from solutions
for (const solution of solutions) {
const deposit = 1 / solution.length;
for (const i of solution) {
pheromones[i] += deposit;
}
}
}
return {
colors: sortColors(bestSolution.map(i => colors[i])),
time: performance.now() - start
};
}
function tabuSearch(colors, selectCount, settings = {}) {
console.log('Starting Tabu Search...');
const start = performance.now();
const labColors = colors.map(rgb2lab);
const maxIterations = settings.tabuIterations ?? 1000;
const tabuTenure = settings.tabuTenure ?? 5;
// Helper function to calculate minimum distance between selected colors
function calculateFitness(selection) {
let minDist = Infinity;
for (let i = 0; i < selection.length - 1; i++) {
for (let j = i + 1; j < selection.length; j++) {
const dist = deltaE(labColors[selection[i]], labColors[selection[j]]);
minDist = Math.min(minDist, dist);
}
}
return minDist;
}
// Initialize solution
let current = Array.from({
length: selectCount
}, (_, i) => i);
let best = [...current];
let bestFitness = calculateFitness(best);
// Tabu list implementation using a Map to store move expiration
const tabuList = new Map();
// Generate move key for tabu list
function getMoveKey(oldColor, newColor) {
return `${oldColor}-${newColor}`;
}
for (let iteration = 0; iteration < maxIterations; iteration++) {
let bestNeighborSolution = null;
let bestNeighborFitness = -Infinity;
// Examine all possible moves
for (let i = 0; i < selectCount; i++) {
for (let j = 0; j < colors.length; j++) {
if (!current.includes(j)) {
const moveKey = getMoveKey(current[i], j);
const neighbor = [...current];
neighbor[i] = j;
const fitness = calculateFitness(neighbor);
// Accept if better than current best neighbor and not tabu
// or if satisfies aspiration criterion (better than global best)
if (fitness > bestNeighborFitness && (!tabuList.has(moveKey) || tabuList.get(moveKey) <= iteration) || fitness > bestFitness) {
bestNeighborSolution = neighbor;
bestNeighborFitness = fitness;
}
}
}
}
if (!bestNeighborSolution) break;
// Update current solution
current = bestNeighborSolution;
// Update best solution if improved
if (bestNeighborFitness > bestFitness) {
best = [...current];
bestFitness = bestNeighborFitness;
}
// Update tabu list
for (let i = 0; i < selectCount; i++) {
const moveKey = getMoveKey(current[i], best[i]);
tabuList.set(moveKey, iteration + tabuTenure);
}
// Clean expired tabu moves
for (const [move, expiration] of tabuList.entries()) {
if (expiration <= iteration) {
tabuList.delete(move);
}
}
}
return {
colors: sortColors(best.map(i => colors[i])),
time: performance.now() - start
};
}
export { analyzeColorDistribution, antColonyOptimization, calculateMetrics, deltaE, findClosestPair, geneticAlgorithm, greedySelection, kmeansppSelection, maxSumDistancesGlobal, maxSumDistancesSequential, particleSwarmOptimization, rgb2lab, simulatedAnnealing, tabuSearch };
//# sourceMappingURL=index.js.map