'#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), ]; } }