feat: K-means color palette extraction with average brand color
Algorithm improvements: - Implemented K-means clustering (3-6 clusters based on image size) - Extract complete color palette from logo (5-7 representative colors) - Calculate average RGB of palette colors as final brand color - Sort palette by saturation (most vibrant colors first) Filters applied: - Skip whites (RGB > 235), blacks (RGB < 20), grays (saturation < 0.2) - Filter palette by lightness (0.25-0.80 range) - 10 iterations K-means with convergence check UI improvements: - Show extracted color palette as color chips - Display final brand color (average of palette) - Labeled as 'Colore Brand (media palette)' - Hover effect on palette colors with tooltips - Console logging for debugging Benefits: - More accurate color extraction from complex logos - Avoids picking background colors (white/black) - Represents true brand identity (not just most common) - Works better with gradients and multi-color logos - Average creates balanced, professional brand color
This commit is contained in:
228
shop-mode.html
228
shop-mode.html
@@ -170,18 +170,23 @@
|
||||
</div>
|
||||
<div
|
||||
x-show="logoColor"
|
||||
class="mt-3 flex items-center gap-2"
|
||||
>
|
||||
<span class="text-xs text-gray-600"
|
||||
>Colore estratto:</span
|
||||
>
|
||||
<div
|
||||
:style="'background-color: ' + logoColor"
|
||||
class="w-8 h-8 rounded border-2 border-gray-300"
|
||||
></div>
|
||||
<span
|
||||
class="text-xs font-mono"
|
||||
x-text="logoColor"
|
||||
<div x-show="logoColor" class="mt-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-semibold text-gray-700">Colore Brand (media palette):</span>
|
||||
<div :style="'background-color: ' + logoColor" class="w-10 h-10 rounded-lg border-2 border-gray-300 shadow-sm"></div>
|
||||
<span class="text-xs font-mono font-bold" x-text="logoColor"></span>
|
||||
</div>
|
||||
<div x-show="colorPalette.length > 0" class="mt-2">
|
||||
<span class="text-xs text-gray-600">Palette estratta:</span>
|
||||
<div class="flex gap-1 mt-1 flex-wrap">
|
||||
<template x-for="color in colorPalette" :key="color">
|
||||
<div :style="'background-color: ' + color"
|
||||
:title="color"
|
||||
class="w-8 h-8 rounded border border-gray-300 shadow-sm cursor-pointer hover:scale-110 transition-transform"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -675,6 +680,7 @@
|
||||
},
|
||||
|
||||
logoColor: null,
|
||||
colorPalette: [],
|
||||
|
||||
cliente: {
|
||||
nome: "",
|
||||
@@ -725,6 +731,7 @@
|
||||
reader.readAsDataURL(file);
|
||||
},
|
||||
|
||||
|
||||
extractDominantColor(imageData) {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
@@ -735,122 +742,143 @@
|
||||
ctx.drawImage(img, 0, 0);
|
||||
|
||||
try {
|
||||
const imgData = ctx.getImageData(
|
||||
0,
|
||||
0,
|
||||
canvas.width,
|
||||
canvas.height,
|
||||
);
|
||||
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imgData.data;
|
||||
|
||||
// Collect color palette with counts
|
||||
const colorMap = {};
|
||||
// Step 1: Collect all valid colors
|
||||
const colors = [];
|
||||
for (let i = 0; i < data.length; i += 16) {
|
||||
// Sample every 4 pixels for better coverage
|
||||
const r = data[i];
|
||||
const g = data[i + 1];
|
||||
const b = data[i + 2];
|
||||
const a = data[i + 3];
|
||||
|
||||
// Skip transparent pixels
|
||||
// Skip transparent
|
||||
if (a < 125) continue;
|
||||
|
||||
// Skip whites (> 240 in all channels)
|
||||
if (r > 240 && g > 240 && b > 240) continue;
|
||||
// Skip whites (> 235)
|
||||
if (r > 235 && g > 235 && b > 235) continue;
|
||||
|
||||
// Skip blacks (< 15 in all channels)
|
||||
if (r < 15 && g < 15 && b < 15) continue;
|
||||
// Skip blacks (< 20)
|
||||
if (r < 20 && g < 20 && b < 20) continue;
|
||||
|
||||
// Skip grays (low saturation: when R≈G≈B)
|
||||
const maxChan = Math.max(r, g, b);
|
||||
const minChan = Math.min(r, g, b);
|
||||
const saturation =
|
||||
maxChan === 0
|
||||
? 0
|
||||
: (maxChan - minChan) / maxChan;
|
||||
if (saturation < 0.15) continue; // Skip low saturation (grays)
|
||||
|
||||
// Quantize colors to reduce variations (group similar colors)
|
||||
const qr = Math.round(r / 10) * 10;
|
||||
const qg = Math.round(g / 10) * 10;
|
||||
const qb = Math.round(b / 10) * 10;
|
||||
const rgb = `${qr},${qg},${qb}`;
|
||||
colorMap[rgb] = (colorMap[rgb] || 0) + 1;
|
||||
}
|
||||
|
||||
// Build palette with color scores
|
||||
const palette = [];
|
||||
for (const [rgb, count] of Object.entries(
|
||||
colorMap,
|
||||
)) {
|
||||
const [r, g, b] = rgb
|
||||
.split(",")
|
||||
.map(Number);
|
||||
|
||||
// Calculate HSL for better color selection
|
||||
// Calculate saturation to skip grays
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2 / 255; // Lightness 0-1
|
||||
const s =
|
||||
max === min
|
||||
? 0
|
||||
: (max - min) / (max + min); // Saturation 0-1
|
||||
const saturation = max === 0 ? 0 : (max - min) / max;
|
||||
if (saturation < 0.2) continue; // Skip grays
|
||||
|
||||
// Skip too light (> 0.85) or too dark (< 0.20)
|
||||
if (l > 0.85 || l < 0.2) continue;
|
||||
colors.push({ r, g, b });
|
||||
}
|
||||
|
||||
// Calculate color "score" based on:
|
||||
// - Saturation (higher is better)
|
||||
// - Frequency (more common is better)
|
||||
// - Ideal lightness (0.4-0.6 is best)
|
||||
const lightnessScore =
|
||||
1 - Math.abs(l - 0.5) * 2; // Peaks at 0.5
|
||||
const score =
|
||||
s * 2 + count / 100 + lightnessScore;
|
||||
if (colors.length === 0) {
|
||||
this.logoColor = "#10b981";
|
||||
this.colorPalette = [];
|
||||
console.log("No valid colors found, using fallback");
|
||||
return;
|
||||
}
|
||||
|
||||
palette.push({
|
||||
r,
|
||||
g,
|
||||
b,
|
||||
count,
|
||||
saturation: s,
|
||||
lightness: l,
|
||||
score,
|
||||
// Step 2: K-means clustering to extract palette (5-7 colors)
|
||||
const numClusters = Math.min(6, Math.max(3, Math.floor(colors.length / 50)));
|
||||
const palette = this.kMeansClustering(colors, numClusters);
|
||||
|
||||
// Step 3: Filter palette colors by lightness
|
||||
const filteredPalette = palette.filter(color => {
|
||||
const max = Math.max(color.r, color.g, color.b);
|
||||
const min = Math.min(color.r, color.g, color.b);
|
||||
const l = (max + min) / 2 / 255;
|
||||
return l > 0.25 && l < 0.80; // Keep mid-range lightness
|
||||
});
|
||||
|
||||
if (filteredPalette.length === 0) {
|
||||
this.logoColor = "#10b981";
|
||||
this.colorPalette = [];
|
||||
console.log("No suitable colors in palette, using fallback");
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort by score (best colors first)
|
||||
palette.sort((a, b) => b.score - a.score);
|
||||
// Step 4: Calculate average color from palette
|
||||
const avgR = Math.round(filteredPalette.reduce((sum, c) => sum + c.r, 0) / filteredPalette.length);
|
||||
const avgG = Math.round(filteredPalette.reduce((sum, c) => sum + c.g, 0) / filteredPalette.length);
|
||||
const avgB = Math.round(filteredPalette.reduce((sum, c) => sum + c.b, 0) / filteredPalette.length);
|
||||
|
||||
// Get the best color from palette
|
||||
if (palette.length > 0) {
|
||||
const bestColor = palette[0];
|
||||
this.logoColor = this.rgbToHex(
|
||||
bestColor.r,
|
||||
bestColor.g,
|
||||
bestColor.b,
|
||||
);
|
||||
console.log(
|
||||
"Extracted color:",
|
||||
this.logoColor,
|
||||
"from palette of",
|
||||
palette.length,
|
||||
"colors",
|
||||
);
|
||||
} else {
|
||||
this.logoColor = "#10b981"; // Fallback emerald
|
||||
console.log(
|
||||
"No suitable colors found, using fallback",
|
||||
);
|
||||
}
|
||||
// Store results
|
||||
this.logoColor = this.rgbToHex(avgR, avgG, avgB);
|
||||
this.colorPalette = filteredPalette.map(c => this.rgbToHex(c.r, c.g, c.b));
|
||||
|
||||
console.log("Extracted palette:", this.colorPalette);
|
||||
console.log("Average brand color:", this.logoColor);
|
||||
} catch (err) {
|
||||
console.error("Error extracting color:", err);
|
||||
this.logoColor = "#10b981"; // Fallback emerald
|
||||
this.logoColor = "#10b981";
|
||||
this.colorPalette = [];
|
||||
}
|
||||
};
|
||||
img.src = imageData;
|
||||
},
|
||||
|
||||
kMeansClustering(colors, k) {
|
||||
// Initialize centroids randomly
|
||||
let centroids = [];
|
||||
const shuffled = [...colors].sort(() => Math.random() - 0.5);
|
||||
for (let i = 0; i < k; i++) {
|
||||
centroids.push({ ...shuffled[i % shuffled.length] });
|
||||
}
|
||||
|
||||
// K-means iterations
|
||||
for (let iter = 0; iter < 10; iter++) {
|
||||
// Assign colors to nearest centroid
|
||||
const clusters = Array(k).fill(null).map(() => []);
|
||||
|
||||
colors.forEach(color => {
|
||||
let minDist = Infinity;
|
||||
let closestIdx = 0;
|
||||
|
||||
centroids.forEach((centroid, idx) => {
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(color.r - centroid.r, 2) +
|
||||
Math.pow(color.g - centroid.g, 2) +
|
||||
Math.pow(color.b - centroid.b, 2)
|
||||
);
|
||||
if (dist < minDist) {
|
||||
minDist = dist;
|
||||
closestIdx = idx;
|
||||
}
|
||||
});
|
||||
|
||||
clusters[closestIdx].push(color);
|
||||
});
|
||||
|
||||
// Update centroids
|
||||
const newCentroids = clusters.map(cluster => {
|
||||
if (cluster.length === 0) return centroids[0]; // Fallback
|
||||
|
||||
const avgR = Math.round(cluster.reduce((sum, c) => sum + c.r, 0) / cluster.length);
|
||||
const avgG = Math.round(cluster.reduce((sum, c) => sum + c.g, 0) / cluster.length);
|
||||
const avgB = Math.round(cluster.reduce((sum, c) => sum + c.b, 0) / cluster.length);
|
||||
|
||||
return { r: avgR, g: avgG, b: avgB };
|
||||
});
|
||||
|
||||
// Check convergence
|
||||
const converged = centroids.every((c, i) =>
|
||||
c.r === newCentroids[i].r &&
|
||||
c.g === newCentroids[i].g &&
|
||||
c.b === newCentroids[i].b
|
||||
);
|
||||
|
||||
centroids = newCentroids;
|
||||
if (converged) break;
|
||||
}
|
||||
|
||||
// Sort by saturation (most saturated first)
|
||||
return centroids.sort((a, b) => {
|
||||
const satA = (Math.max(a.r, a.g, a.b) - Math.min(a.r, a.g, a.b)) / Math.max(a.r, a.g, a.b);
|
||||
const satB = (Math.max(b.r, b.g, b.b) - Math.min(b.r, b.g, b.b)) / Math.max(b.r, b.g, b.b);
|
||||
return satB - satA;
|
||||
});
|
||||
},
|
||||
|
||||
rgbToHex(r, g, b) {
|
||||
return (
|
||||
"#" +
|
||||
|
||||
Reference in New Issue
Block a user