feat(design): 配色来源纯数学层(Monet 式量化取色 + 色卡 + 三级回落 + 对比度收敛)

D40 分层:design 保持零第三方依赖,只吃已解码的 RGBA 像素。
- palette/quantize:5bit 直方图 + CIELAB k-means(QuantizerCelebi/Score 的形状)
- palette/score:官方 Score 口径(CUTOFF_CHROMA 5 / TARGET 48 / 权重 0.7+0.3)
- palette/seed:seed_from_rgba + D40 彩度门槛 8,灰白图回落而非凭空上色
- palette/card:8 张内置色卡,key 为持久化键
- palette/wcag:26 对文字对比度门禁 + 沿 tone 轴收敛并记录修正
- palette/resolve:纯函数三级来源编排 + Provenance(原因码/提示键)
This commit is contained in:
AskaEth committed 2026-11-02 11:04:10 +08:00
1 parent c1fc5b8086
commit 18327fb038
11 files changed
+2098 -2

No files matched your search

+191
View File
@@ -0,0 +1,191 @@
//! 主色簇 → 排序后的候选种子:官方 `Score` 的口径。
//!
//! ## 官方怎么挑「最好的那个颜色」
//!
//! `Score` 不是「像素最多的颜色」,而是三件事的加权:
//!
//! 1. **有人气**:颜色所在**色相桶**(1 度一桶)占全图的像素比例要够大
//! (`CUTOFF_EXCITED_PROPORTION = 1%` 以下的色相直接出局)。用色相桶而不是单色占比,
//! 是为了把「同一个色相的一族明暗」合成人气,而不是让它们互相拆票。
//! 2. **有颜色**:`CUTOFF_CHROMA = 5` 以下的颜色出局——灰、白、黑再多也不该当种子。
//! 3. **彩度越高越优先**,但**封顶**:`TARGET_CHROMA = 48` 以上不再加分(否则一张荧光图会被
//! 推到色域边缘,整套配色的 on-color 会难看得像报警灯)。
//!
//! 打分式(官方形状):
//!
//! ```text
//! excited = 该色所在色相桶的像素占比
//! chroma_weight = clamp((chroma − 5) / (48 − 5), 0, 1)
//! score = 0.7 × excited + 0.3 × chroma_weight
//! ```
//!
//! ## 来源等级
//!
//! - **A**:四个常数(`CUTOFF_CHROMA = 5`、`CUTOFF_EXCITED_PROPORTION = 0.01`、
//! `TARGET_CHROMA = 48`、`WEIGHT_PROPORTION/WEIGHT_CHROMA = 0.7/0.3`)与「按色相桶计人气」
//! 的做法都来自 `material-color-utilities` 的 `score/Score.java`。
//! - **B**:把 `excited` 直接当 `proportion` 项用(官方的 `Score` 内部还有一层
//! `hueOffset`/`huePopulation` 的除法归一,口径相同、写法不同)。本实现的选择是
//! 「只看色相桶占比」,因为它是能全单测的最简形式,且与官方在样例图上选出同一个色相。
//!
//! 注意与 D40 的彩度门槛([`super::MIN_SEED_CHROMA`] = 8)的分工:
//! **这里的 5 是「要不要把它当候选」,那里的 8 是「这个结果够不够格当种子」**。
//! 一张偏灰的图会有一堆彩度 5~7 的候选通过 `Score`,然后被 8 的门槛挡下并回落到色卡——
//! 这正是 D40 要的行为(宁可回落,不许凭空上色)。
use super::quantize::Cluster;
use crate::color::Hct;
/// 彩度下限:低于它的颜色不参与打分(官方 `Score.CUTOFF_CHROMA`)。
pub(crate) const CUTOFF_CHROMA: f64 = 5.0;
/// 色相桶人气下限(官方 `Score.CUTOFF_EXCITED_PROPORTION`)。
pub(crate) const CUTOFF_EXCITED_PROPORTION: f64 = 0.01;
/// 彩度加分封顶(官方 `Score.TARGET_CHROMA`)。
pub(crate) const TARGET_CHROMA: f64 = 48.0;
/// 人气项的权重(官方 `Score.WEIGHT_PROPORTION`)。
const WEIGHT_PROPORTION: f64 = 0.7;
/// 彩度项的权重(官方 `Score.WEIGHT_CHROMA`)。
const WEIGHT_CHROMA: f64 = 0.3;
/// 一个候选种子(含打分所需的中间量,方便测试与提示文案)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Ranked {
/// 候选簇。
pub cluster: Cluster,
/// 全图像素占比(0..1)。
pub share: f64,
/// 所在色相桶的像素占比(0..1,官方所谓 excited proportion)。
pub excited: f64,
/// CAM16 彩度。
pub chroma: f64,
/// CAM16 色相角。
pub hue: f64,
/// 最终打分(越大越优先)。
pub score: f64,
}
/// 按官方 `Score` 口径排序候选;被过滤掉的簇不出现在结果里。
pub(crate) fn rank(clusters: &[Cluster]) -> Vec<Ranked> {
let total: u64 = clusters.iter().map(|c| u64::from(c.population)).sum();
if total == 0 {
return Vec::new();
}
let hcts: Vec<Hct> = clusters.iter().map(|c| Hct::from_color(c.color)).collect();
let mut hue_population = [0u64; 360];
for (cluster, hct) in clusters.iter().zip(&hcts) {
hue_population[hue_bucket(hct.hue)] += u64::from(cluster.population);
}
let mut ranked: Vec<Ranked> = clusters
.iter()
.zip(&hcts)
.filter_map(|(cluster, hct)| {
if hct.chroma < CUTOFF_CHROMA {
return None;
}
let share = f64::from(cluster.population) / total as f64;
let excited = hue_population[hue_bucket(hct.hue)] as f64 / total as f64;
if excited < CUTOFF_EXCITED_PROPORTION {
return None;
}
let chroma_weight =
((hct.chroma - CUTOFF_CHROMA) / (TARGET_CHROMA - CUTOFF_CHROMA)).clamp(0.0, 1.0);
Some(Ranked {
cluster: *cluster,
share,
excited,
chroma: hct.chroma,
hue: hct.hue,
score: WEIGHT_PROPORTION * excited + WEIGHT_CHROMA * chroma_weight,
})
})
.collect();
ranked.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
// 同分时像素多的优先,再同则按色值定序 → 结果完全确定。
.then_with(|| b.cluster.population.cmp(&a.cluster.population))
.then_with(|| a.cluster.color.r.cmp(&b.cluster.color.r))
.then_with(|| a.cluster.color.g.cmp(&b.cluster.color.g))
.then_with(|| a.cluster.color.b.cmp(&b.cluster.color.b))
});
ranked
}
/// 候选里最高的 CAM16 彩度——被 `Score` 全部过滤时用它生成「这图太灰了」的提示。
pub(crate) fn max_chroma(clusters: &[Cluster]) -> f64 {
clusters
.iter()
.map(|c| Hct::from_color(c.color).chroma)
.fold(0.0_f64, f64::max)
}
fn hue_bucket(hue: f64) -> usize {
let wrapped = hue.rem_euclid(360.0);
(wrapped.round() as usize) % 360
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
fn cluster(hex: &str, population: u32) -> Cluster {
Cluster {
color: Color::from_hex(hex).expect("合法 hex"),
population,
}
}
#[test]
fn accent_with_small_area_but_high_chroma_transfers_to_seed() {
// 95% 米白 + 5% 珊瑚:米白被彩度门槛挡掉,珊瑚当种子。
let clusters = [cluster("#F5F1EA", 950), cluster("#FF6B5A", 50)];
let ranked = rank(&clusters);
assert_eq!(ranked.len(), 1, "{ranked:?}");
assert_eq!(ranked[0].cluster.color.to_hex(), "#FF6B5A");
}
#[test]
fn grayscale_clusters_are_all_filtered() {
let clusters = [
cluster("#FFFFFF", 1000),
cluster("#808080", 400),
cluster("#0A0A0A", 300),
];
assert!(rank(&clusters).is_empty());
assert!(max_chroma(&clusters) < CUTOFF_CHROMA);
}
#[test]
fn tie_break_is_deterministic() {
let clusters = [cluster("#3366CC", 100), cluster("#2B6DD0", 100)];
let first = rank(&clusters);
let second = rank(&clusters);
assert_eq!(first.len(), 2);
assert_eq!(
first.iter().map(|r| r.cluster.color).collect::<Vec<_>>(),
second.iter().map(|r| r.cluster.color).collect::<Vec<_>>()
);
}
#[test]
fn tiny_population_hue_is_ignored() {
// 0.5% 的青绿被 excited proportion 门槛(1%)挡掉。
let clusters = [cluster("#FF0000", 995), cluster("#00C8B4", 5)];
let ranked = rank(&clusters);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].cluster.color.to_hex(), "#FF0000");
}
#[test]
fn hue_bucket_wraps() {
assert_eq!(hue_bucket(0.0), 0);
assert_eq!(hue_bucket(359.6), 0);
assert_eq!(hue_bucket(-1.0), 359);
assert_eq!(hue_bucket(720.4), 0);
}
}