Files
Unlucky-Launcher/crates/design/src/palette/score.rs
T
AskaEth 8a8e5ca0c0
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
CI / runtime smoke (ubuntu x64, xvfb) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好一个文件) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.2.0
首个 iced + Material 3 Expressive 版本的源码快照。
完整历史与日常提交在 dev 分支;main 只放阶段性快照。
2026-11-05 14:22:11 +08:00

208 lines
8.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 主色簇 → 排序后的候选种子:官方 `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
}
/// 彩度最高的那个簇——被 `Score` 全部过滤时用它报「这张图最鲜艳的颜色」。
///
/// ⚠️ 它**不是**「最接近及格的那一族」:`Score` 的过滤有两道(彩度 ≥5、色相桶人气 ≥1%),
/// 面积不足 1% 的高彩度颜料会被**人气**刷掉——例如 1592 个米白 + 8 个纯红的图,
/// 候选为空而这里的「最鲜艳」彩度是 **113.36**。所以调用方的文案要按
/// 「全图最鲜艳的那一族」,不是「差一点就及格的那一族」。
///
/// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对:
/// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值,
/// 于是同一个 `LowChroma` 里的 `color` 与 `chroma` 可能不是一回事(复审 r1 p2-4.2)。
///
/// 同彩度时取像素多的那一个(结果确定,不受簇顺序影响)。
pub(crate) fn most_chromatic(clusters: &[Cluster]) -> Option<&Cluster> {
clusters.iter().max_by(|a, b| {
let key = |cluster: &Cluster| (Hct::from_color(cluster.color).chroma, cluster.population);
let (ca, pa) = key(a);
let (cb, pb) = key(b);
ca.partial_cmp(&cb)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| pa.cmp(&pb))
})
}
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());
let most = most_chromatic(&clusters).expect("灰图也有簇");
assert!(Hct::from_color(most.color).chroma < 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);
}
}