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:
d.viti
2025-10-14 00:22:47 +02:00
parent 546d7201b0
commit 95f26e1bd4

View File

@@ -170,18 +170,23 @@
</div> </div>
<div <div
x-show="logoColor" x-show="logoColor"
class="mt-3 flex items-center gap-2" <div x-show="logoColor" class="mt-3">
> <div class="flex items-center gap-2 mb-2">
<span class="text-xs text-gray-600" <span class="text-xs font-semibold text-gray-700">Colore Brand (media palette):</span>
>Colore estratto:</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>
:style="'background-color: ' + logoColor" <div x-show="colorPalette.length > 0" class="mt-2">
class="w-8 h-8 rounded border-2 border-gray-300" <span class="text-xs text-gray-600">Palette estratta:</span>
></div> <div class="flex gap-1 mt-1 flex-wrap">
<span <template x-for="color in colorPalette" :key="color">
class="text-xs font-mono" <div :style="'background-color: ' + color"
x-text="logoColor" :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> ></span>
</div> </div>
</div> </div>
@@ -675,6 +680,7 @@
}, },
logoColor: null, logoColor: null,
colorPalette: [],
cliente: { cliente: {
nome: "", nome: "",
@@ -725,131 +731,153 @@
reader.readAsDataURL(file); reader.readAsDataURL(file);
}, },
extractDominantColor(imageData) {
const img = new Image();
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
try { extractDominantColor(imageData) {
const imgData = ctx.getImageData( const img = new Image();
0, img.onload = () => {
0, const canvas = document.createElement("canvas");
canvas.width, const ctx = canvas.getContext("2d");
canvas.height, canvas.width = img.width;
); canvas.height = img.height;
const data = imgData.data; ctx.drawImage(img, 0, 0);
// Collect color palette with counts try {
const colorMap = {}; const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 0; i < data.length; i += 16) { const data = imgData.data;
// 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 // Step 1: Collect all valid colors
if (a < 125) continue; const colors = [];
for (let i = 0; i < data.length; i += 16) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
// Skip whites (> 240 in all channels) // Skip transparent
if (r > 240 && g > 240 && b > 240) continue; if (a < 125) continue;
// Skip blacks (< 15 in all channels) // Skip whites (> 235)
if (r < 15 && g < 15 && b < 15) continue; if (r > 235 && g > 235 && b > 235) continue;
// Skip grays (low saturation: when R≈G≈B) // Skip blacks (< 20)
const maxChan = Math.max(r, g, b); if (r < 20 && g < 20 && b < 20) continue;
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) // Calculate saturation to skip grays
const qr = Math.round(r / 10) * 10; const max = Math.max(r, g, b);
const qg = Math.round(g / 10) * 10; const min = Math.min(r, g, b);
const qb = Math.round(b / 10) * 10; const saturation = max === 0 ? 0 : (max - min) / max;
const rgb = `${qr},${qg},${qb}`; if (saturation < 0.2) continue; // Skip grays
colorMap[rgb] = (colorMap[rgb] || 0) + 1;
}
// Build palette with color scores colors.push({ r, g, b });
const palette = []; }
for (const [rgb, count] of Object.entries(
colorMap,
)) {
const [r, g, b] = rgb
.split(",")
.map(Number);
// Calculate HSL for better color selection if (colors.length === 0) {
const max = Math.max(r, g, b); this.logoColor = "#10b981";
const min = Math.min(r, g, b); this.colorPalette = [];
const l = (max + min) / 2 / 255; // Lightness 0-1 console.log("No valid colors found, using fallback");
const s = return;
max === min }
? 0
: (max - min) / (max + min); // Saturation 0-1
// Skip too light (> 0.85) or too dark (< 0.20) // Step 2: K-means clustering to extract palette (5-7 colors)
if (l > 0.85 || l < 0.2) continue; const numClusters = Math.min(6, Math.max(3, Math.floor(colors.length / 50)));
const palette = this.kMeansClustering(colors, numClusters);
// Calculate color "score" based on: // Step 3: Filter palette colors by lightness
// - Saturation (higher is better) const filteredPalette = palette.filter(color => {
// - Frequency (more common is better) const max = Math.max(color.r, color.g, color.b);
// - Ideal lightness (0.4-0.6 is best) const min = Math.min(color.r, color.g, color.b);
const lightnessScore = const l = (max + min) / 2 / 255;
1 - Math.abs(l - 0.5) * 2; // Peaks at 0.5 return l > 0.25 && l < 0.80; // Keep mid-range lightness
const score = });
s * 2 + count / 100 + lightnessScore;
palette.push({ if (filteredPalette.length === 0) {
r, this.logoColor = "#10b981";
g, this.colorPalette = [];
b, console.log("No suitable colors in palette, using fallback");
count, return;
saturation: s, }
lightness: l,
score,
});
}
// Sort by score (best colors first) // Step 4: Calculate average color from palette
palette.sort((a, b) => b.score - a.score); 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 // Store results
if (palette.length > 0) { this.logoColor = this.rgbToHex(avgR, avgG, avgB);
const bestColor = palette[0]; this.colorPalette = filteredPalette.map(c => this.rgbToHex(c.r, c.g, c.b));
this.logoColor = this.rgbToHex(
bestColor.r, console.log("Extracted palette:", this.colorPalette);
bestColor.g, console.log("Average brand color:", this.logoColor);
bestColor.b, } catch (err) {
); console.error("Error extracting color:", err);
console.log( this.logoColor = "#10b981";
"Extracted color:", this.colorPalette = [];
this.logoColor, }
"from palette of", };
palette.length, img.src = imageData;
"colors", },
);
} else { kMeansClustering(colors, k) {
this.logoColor = "#10b981"; // Fallback emerald // Initialize centroids randomly
console.log( let centroids = [];
"No suitable colors found, using fallback", const shuffled = [...colors].sort(() => Math.random() - 0.5);
); for (let i = 0; i < k; i++) {
} centroids.push({ ...shuffled[i % shuffled.length] });
} catch (err) { }
console.error("Error extracting color:", err);
this.logoColor = "#10b981"; // Fallback emerald // K-means iterations
} for (let iter = 0; iter < 10; iter++) {
}; // Assign colors to nearest centroid
img.src = imageData; 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) { rgbToHex(r, g, b) {
return ( return (