293 lines
11 KiB
PHP
293 lines
11 KiB
PHP
<?php
|
||||
|
|
/**
|
|||
|
|
* 头像主色 —— 给「人」一个稳定的识别色。
|
|||
|
|
*
|
|||
|
|
* 由来(用户 2026-10-01 的第 2 条反馈):观影人标签一律是灰底,十几个人排在一起
|
|||
|
|
* 分不清谁是谁。既然每个人都有头像,就从头像里算出一种主色当背景色;
|
|||
|
|
* 没有头像(或头像是纯灰)的人,按姓名算一个**稳定的**颜色兜底 ——
|
|||
|
|
* 同一个人在任何页面、任何时候看到的颜色都一样。
|
|||
|
|
*
|
|||
|
|
* 三条刻意的设计:
|
|||
|
|
* · **算一次、存库(persons.avatar_color),不要每次渲染都算**。
|
|||
|
|
* 头像在 OSS 上,每渲染一个标签就下载一次原图 = 页面上 20 个人就是 20 次外网请求。
|
|||
|
|
* 只在「上传/换头像」的那一刻算,之后就是读一列 CHAR(7) —— 成本趋零。
|
|||
|
|
* · **主色不等于背景色**。头像是深色时主色也深,直接当背景会变成黑底黑字。
|
|||
|
|
* 这里一律输出两个派生色:`bg()` 是"往白里兑"的浅色底(保证深色字读得清),
|
|||
|
|
* `fg()` 是"往黑里压"的深色字(保证在浅底上够对比)。主色本身只作为**身份色**存库。
|
|||
|
|
* · **灰片回退到姓名色**。证件照、黑白老照片算不出色相(饱和度极低),
|
|||
|
|
* 硬取会得到一个飘忽不定的"最深的那个灰",每次换图颜色就跳一次 ——
|
|||
|
|
* 不如干脆交给姓名哈希,稳定且可预期。
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
declare(strict_types=1);
|
|||
|
|
|
|||
|
|
namespace App;
|
|||
|
|
|
|||
|
|
final class AvatarColor
|
|||
|
|
{
|
|||
|
|
/** 判定「这像素算不算有颜色」的色度门槛(max-min)。低于它当成灰。 */
|
|||
|
|
private const CHROMA_MIN = 18;
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 从一张 GD 图像里取主色,返回 '#rrggbb'。
|
|||
|
|
* 取不到(纯灰/纯白/图坏)返回 '' —— 调用方再决定回退。
|
|||
|
|
*
|
|||
|
|
* 做法:缩到 64×64 采样 → 按色相分 36 个桶(每桶 10°)→ 以「色度 × 像素数」加权
|
|||
|
|
* 选最重的桶 → 桶内按色度加权平均 RGB。比"整图取平均"稳得多:
|
|||
|
|
* 整图平均会被大面积的背景(白墙)拖成一片灰,而这里白墙根本进不了桶。
|
|||
|
|
*/
|
|||
|
|
public static function fromGd($im): string
|
|||
|
|
{
|
|||
|
|
if (!$im) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
$w = imagesx($im);
|
|||
|
|
$h = imagesy($im);
|
|||
|
|
if ($w < 1 || $h < 1) {
|
|||
|
|
return '';
|
|||
|
|
}
|
|||
|
|
// 先缩到 64×64:采样点数固定,一张 4000×3000 的原图也不会算很久
|
|||
|
|
$small = $im;
|
|||
|
|
if ($w > 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)));
|
|||
|
|
}
|
|||
|
|
}
|