64 || $h > 64) { $scale = min(64 / $w, 64 / $h); $sw = max(1, (int)round($w * $scale)); $sh = max(1, (int)round($h * $scale)); $tmp = imagecreatetruecolor($sw, $sh); if (!$tmp) { return ''; } imagecopyresampled($tmp, $im, 0, 0, 0, 0, $sw, $sh, $w, $h); $small = $tmp; $w = $sw; $h = $sh; } $buckets = []; // hue桶 => [sumR, sumG, sumB, weight] for ($y = 0; $y < $h; $y++) { for ($x = 0; $x < $w; $x++) { $rgb = imagecolorat($small, $x, $y); $r = ($rgb >> 16) & 0xFF; $g = ($rgb >> 8) & 0xFF; $b = $rgb & 0xFF; $mx = max($r, $g, $b); $mn = min($r, $g, $b); $chroma = $mx - $mn; if ($chroma < self::CHROMA_MIN) { continue; // 灰/白/黑:不参与 } if ($mx > 246 || $mn < 12) { continue; // 死白 / 死黑:多半是背景或阴影 } $hue = self::hueOf($r, $g, $b, $mx, $mn, $chroma); $bucket = (int)floor($hue / 10); if ($bucket >= 36) { $bucket = 35; } // 权重 = 色度(越鲜艳越能代表这张图)× 一个"别太暗也别太亮"的系数 $weight = $chroma * (1 - abs(($mx + $mn) / 510 - 0.5)); if (!isset($buckets[$bucket])) { $buckets[$bucket] = [0, 0, 0, 0.0]; } $buckets[$bucket][0] += $r * $weight; $buckets[$bucket][1] += $g * $weight; $buckets[$bucket][2] += $b * $weight; $buckets[$bucket][3] += $weight; } } if ($small !== $im) { imagedestroy($small); } if (!$buckets) { return ''; } // 取权重最大的桶 $best = null; $bestW = -1.0; foreach ($buckets as $b) { if ($b[3] > $bestW) { $bestW = $b[3]; $best = $b; } } if ($best === null || $best[3] <= 0) { return ''; } return sprintf('#%02X%02X%02X', (int)round($best[0] / $best[3]), (int)round($best[1] / $best[3]), (int)round($best[2] / $best[3])); } /** 从图片二进制里取主色(OSS/本地都行);失败返回 '' */ public static function fromBinary(string $bin): string { if ($bin === '') { return ''; } $im = @imagecreatefromstring($bin); if ($im === false) { return ''; } $hex = self::fromGd($im); imagedestroy($im); return $hex; } /** * 姓名兜底色:同样的人永远同一个颜色。 * * 色相由姓名的 md5 决定(36 个色相里挑一个),饱和度与亮度**写死**在好看的区间内 * —— 不用哈希去碰 S/L,那样会随机出刺眼的荧光色或看不清的深色。 */ public static function fromName(string $name): string { $name = trim($name); $seed = $name === '' ? '?' : $name; // ⚠️ 色相取 **0.1° 精度**(3600 档)而不是 360 档:派生出来的底色只保留色相 // (S/L 都被 bg()/fg() 拉到固定区间),所以两个人要能区分,只能靠色相。 // 360 档时十几个人里就撞色(实测:7 个名字里有两组落到同一个色)。 // 干脆用 md5 的整个 32 位当连续色相,不再分档。 $hue = hexdec(substr(md5($seed), 0, 8)) / 4294967296.0; return self::hslToHex($hue, 0.55, 0.45); } /** * 一个人的展示色:库里算过就用库里的,没算过按姓名兜底。 * 页面上一律走这里 —— 不许各自 `$p['avatar_color'] ?: '#888'`。 */ public static function for(?array $p): string { if (!$p) { return self::fromName(''); } $c = self::normalize($p['avatar_color'] ?? null); if ($c !== '') { return $c; } return self::fromName((string)($p['name'] ?? '')); } /** 规整成 '#rrggbb';认不出来返回 '' */ public static function normalize($hex): string { $hex = strtolower(trim((string)$hex)); if ($hex === '') { return ''; } if ($hex[0] === '#') { $hex = substr($hex, 1); } if (preg_match('/^[0-9a-f]{3}$/', $hex)) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } if (!preg_match('/^[0-9a-f]{6}$/', $hex)) { return ''; } return '#' . $hex; } /** * 背景色:把主色往白里兑。 * * 兑完统一到 L≈0.90、S≈0.55 —— 不管头像是深蓝还是亮黄, * 出来的都是"淡淡的那种蓝/黄",深色字压在上面都读得清, * 同时彼此之间又能一眼区分(这正是用户要的效果)。 */ public static function bg(?string $hex): string { [$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName('')); $s = $s < 0.18 ? 0.30 : min(0.62, max(0.34, $s)); return self::hslToHex($h, $s, 0.90); } /** 文字色:把主色往黑里压,保证在 bg() 上有足够对比 */ public static function fg(?string $hex): string { [$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName('')); $s = $s < 0.18 ? 0.35 : min(0.75, max(0.40, $s)); return self::hslToHex($h, $s, 0.30); } /** 边线色(比 bg 深一档,让浅色标签在白底上仍有轮廓) */ public static function bd(?string $hex): string { [$h, $s] = self::hsl(self::normalize($hex) ?: self::fromName('')); $s = $s < 0.18 ? 0.35 : min(0.70, max(0.38, $s)); return self::hslToHex($h, $s, 0.72); } // ---------------- 颜色空间小工具 ---------------- /** 0..360 的色相 */ private static function hueOf(int $r, int $g, int $b, int $mx, int $mn, int $chroma): float { if ($chroma === 0) { return 0.0; } if ($mx === $r) { $h = fmod((($g - $b) / $chroma), 6.0); } elseif ($mx === $g) { $h = (($b - $r) / $chroma) + 2.0; } else { $h = (($r - $g) / $chroma) + 4.0; } $h *= 60.0; if ($h < 0) { $h += 360.0; } return $h; } /** @return array{0:float,1:float,2:float} h(0..1) s(0..1) l(0..1) */ private static function hsl(string $hex): array { $hex = ltrim($hex, '#'); $r = hexdec(substr($hex, 0, 2)) / 255; $g = hexdec(substr($hex, 2, 2)) / 255; $b = hexdec(substr($hex, 4, 2)) / 255; $mx = max($r, $g, $b); $mn = min($r, $g, $b); $l = ($mx + $mn) / 2; $d = $mx - $mn; if ($d == 0) { return [0.0, 0.0, $l]; } // 分母在 l=0(死黑)/ l=1(死白)时为 0 —— 那时饱和度本来就没有意义,记 0。 $den = 1 - abs(2 * $l - 1); $s = $den > 1e-9 ? $d / $den : 0.0; $h = self::hueOf( (int)round($r * 255), (int)round($g * 255), (int)round($b * 255), (int)round($mx * 255), (int)round($mn * 255), max(1, (int)round($d * 255)) ); return [$h / 360.0, $s, $l]; } private static function hslToHex(float $h, float $s, float $l): string { $h = $h - floor($h); if ($s <= 0) { $v = (int)round($l * 255); return sprintf('#%02X%02X%02X', $v, $v, $v); } $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s; $p = 2 * $l - $q; $conv = static function (float $t) use ($p, $q): float { $t = $t - floor($t); if ($t < 1 / 6) { return $p + ($q - $p) * 6 * $t; } if ($t < 1 / 2) { return $q; } if ($t < 2 / 3) { return $p + ($q - $p) * (2 / 3 - $t) * 6; } return $p; }; $r = (int)round($conv($h + 1 / 3) * 255); $g = (int)round($conv($h) * 255); $b = (int)round($conv($h - 1 / 3) * 255); return sprintf('#%02X%02X%02X', max(0, min(255, $r)), max(0, min(255, $g)), max(0, min(255, $b))); } }