146 lines
4.4 KiB
PHP
146 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Support;
|
|
|
|
class PaletteExtractor
|
|
{
|
|
/**
|
|
* Extract a usable brand palette from an image file.
|
|
* Returns ['primary' => '#rrggbb', 'accent' => '#rrggbb', 'tint' => '#rrggbb']
|
|
* or null if extraction fails (e.g. GD missing, unreadable file).
|
|
*/
|
|
public static function extract(string $path): ?array
|
|
{
|
|
if (!extension_loaded('gd') || !is_readable($path)) {
|
|
return null;
|
|
}
|
|
|
|
$info = @getimagesize($path);
|
|
if (!$info) {
|
|
return null;
|
|
}
|
|
|
|
$img = match ($info[2]) {
|
|
IMAGETYPE_JPEG => @imagecreatefromjpeg($path),
|
|
IMAGETYPE_PNG => @imagecreatefrompng($path),
|
|
IMAGETYPE_GIF => @imagecreatefromgif($path),
|
|
IMAGETYPE_WEBP => function_exists('imagecreatefromwebp') ? @imagecreatefromwebp($path) : null,
|
|
default => null,
|
|
};
|
|
if (!$img) {
|
|
return null;
|
|
}
|
|
|
|
$w = imagesx($img);
|
|
$h = imagesy($img);
|
|
$stepX = max(1, (int) floor($w / 60));
|
|
$stepY = max(1, (int) floor($h / 60));
|
|
|
|
$buckets = [];
|
|
for ($y = 0; $y < $h; $y += $stepY) {
|
|
for ($x = 0; $x < $w; $x += $stepX) {
|
|
$rgba = imagecolorat($img, $x, $y);
|
|
$a = ($rgba >> 24) & 0x7F;
|
|
if ($a > 90) {
|
|
continue; // mostly transparent
|
|
}
|
|
$r = ($rgba >> 16) & 0xFF;
|
|
$g = ($rgba >> 8) & 0xFF;
|
|
$b = $rgba & 0xFF;
|
|
|
|
$sum = $r + $g + $b;
|
|
if ($sum < 60 || $sum > 720) {
|
|
continue; // too dark or too light
|
|
}
|
|
|
|
$max = max($r, $g, $b);
|
|
$min = min($r, $g, $b);
|
|
if (($max - $min) < 25) {
|
|
continue; // too gray
|
|
}
|
|
|
|
// quantize to 32-step buckets
|
|
$key = (($r >> 5) << 10) | (($g >> 5) << 5) | ($b >> 5);
|
|
if (!isset($buckets[$key])) {
|
|
$buckets[$key] = ['r' => 0, 'g' => 0, 'b' => 0, 'n' => 0];
|
|
}
|
|
$buckets[$key]['r'] += $r;
|
|
$buckets[$key]['g'] += $g;
|
|
$buckets[$key]['b'] += $b;
|
|
$buckets[$key]['n']++;
|
|
}
|
|
}
|
|
imagedestroy($img);
|
|
|
|
if (empty($buckets)) {
|
|
return null;
|
|
}
|
|
|
|
uasort($buckets, fn ($a, $b) => $b['n'] <=> $a['n']);
|
|
|
|
$candidates = [];
|
|
foreach ($buckets as $bucket) {
|
|
$candidates[] = [
|
|
'r' => (int) round($bucket['r'] / $bucket['n']),
|
|
'g' => (int) round($bucket['g'] / $bucket['n']),
|
|
'b' => (int) round($bucket['b'] / $bucket['n']),
|
|
'n' => $bucket['n'],
|
|
];
|
|
if (count($candidates) >= 8) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
$primary = $candidates[0];
|
|
$accent = null;
|
|
foreach (array_slice($candidates, 1) as $c) {
|
|
$dr = $c['r'] - $primary['r'];
|
|
$dg = $c['g'] - $primary['g'];
|
|
$db = $c['b'] - $primary['b'];
|
|
if (sqrt($dr * $dr + $dg * $dg + $db * $db) > 60) {
|
|
$accent = $c;
|
|
break;
|
|
}
|
|
}
|
|
if (!$accent) {
|
|
$accent = self::shift($primary, 30);
|
|
}
|
|
|
|
$tint = self::mixWithWhite($primary, 0.92);
|
|
|
|
return [
|
|
'primary' => self::toHex($primary),
|
|
'accent' => self::toHex($accent),
|
|
'tint' => self::toHex($tint),
|
|
];
|
|
}
|
|
|
|
private static function toHex(array $c): string
|
|
{
|
|
return sprintf('#%02x%02x%02x',
|
|
max(0, min(255, $c['r'])),
|
|
max(0, min(255, $c['g'])),
|
|
max(0, min(255, $c['b']))
|
|
);
|
|
}
|
|
|
|
private static function shift(array $c, int $delta): array
|
|
{
|
|
return [
|
|
'r' => ($c['r'] + $delta) % 256,
|
|
'g' => ($c['g'] + $delta * 2) % 256,
|
|
'b' => ($c['b'] + $delta * 3) % 256,
|
|
];
|
|
}
|
|
|
|
private static function mixWithWhite(array $c, float $whiteRatio): array
|
|
{
|
|
$w = max(0.0, min(1.0, $whiteRatio));
|
|
return [
|
|
'r' => (int) round($c['r'] * (1 - $w) + 255 * $w),
|
|
'g' => (int) round($c['g'] * (1 - $w) + 255 * $w),
|
|
'b' => (int) round($c['b'] * (1 - $w) + 255 * $w),
|
|
];
|
|
}
|
|
}
|