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:
1 parent
c1fc5b8086
commit
18327fb038
11 files changed
+2098
-2
No files matched your search
@@ -114,7 +114,8 @@ pub mod tonal;
|
||||
mod cam16;
|
||||
mod critical_planes;
|
||||
mod hct_solver;
|
||||
mod space;
|
||||
// `pub(crate)`:取色量化(`crate::palette::quantize`)要在 CIELAB 里算簇距离,复用同一套换算。
|
||||
pub(crate) mod space;
|
||||
mod viewing_conditions;
|
||||
|
||||
pub use hct::Hct;
|
||||
|
||||
@@ -212,6 +212,18 @@ impl Scheme {
|
||||
Self { flavor, colors }
|
||||
}
|
||||
|
||||
/// 从 49 个已解析色值直接构造(**底层入口**,下标必须等于 `Role as usize`)。
|
||||
///
|
||||
/// 只给两类调用方用:
|
||||
/// 1. `palette::wcag` 的对比度收敛——它按角色替换个别色值后要重新组装成一套方案;
|
||||
/// 2. 测试——要能构造「故意不达标」的方案,比较度校验器才有反证。
|
||||
///
|
||||
/// 常规路径一律走 [`Scheme::from_palettes`]:那样色值才是 tone 表的推导结果,
|
||||
/// 而不是随手塞进来的数。
|
||||
pub fn from_colors(flavor: SchemeFlavor, colors: [Color; 49]) -> Self {
|
||||
Self { flavor, colors }
|
||||
}
|
||||
|
||||
/// 该角色在指定模式下的 tone 值(M3 语义;文档表格、调试与测试用)。
|
||||
pub fn tone_of(role: Role, flavor: SchemeFlavor) -> f64 {
|
||||
let entry = &TONE_TABLE[role as usize];
|
||||
|
||||
@@ -109,6 +109,20 @@ pub(crate) fn lstar_from_color(color: Color) -> f64 {
|
||||
lstar_from_y(xyz_from_color(color)[1])
|
||||
}
|
||||
|
||||
/// 色值 → CIELAB(`[L*, a*, b*]`)。
|
||||
///
|
||||
/// `L*` 与 [`lstar_from_color`] 逐值相同(同一个 `lab_f`);`a*`/`b*` 走 D65 白点归一。
|
||||
/// 取色量化(`crate::palette::quantize`)用它在感知空间里算簇间距离——sRGB 欧氏距离会把
|
||||
/// 暗部差异放大、亮部差异压扁,分出来的簇不符合「看起来是一族颜色」。
|
||||
pub(crate) fn lab_from_color(color: Color) -> [f64; 3] {
|
||||
let xyz = xyz_from_color(color);
|
||||
let f = |component: f64, white: f64| lab_f(component / white);
|
||||
let fx = f(xyz[0], WHITE_POINT_D65[0]);
|
||||
let fy = f(xyz[1], WHITE_POINT_D65[1]);
|
||||
let fz = f(xyz[2], WHITE_POINT_D65[2]);
|
||||
[116.0 * fy - 16.0, 500.0 * (fx - fy), 200.0 * (fy - fz)]
|
||||
}
|
||||
|
||||
/// `L*` → 中性灰色值(`L*` 与彩度无关,纯灰)。
|
||||
pub(crate) fn color_from_lstar(lstar: f64) -> Color {
|
||||
let component = delinearized(y_from_lstar(lstar));
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
//!
|
||||
//! - [`color`]:M3 语义色角色(49 个)× light/dark 双套 + tone 调色板(**真 HCT**:CAM16 色相/彩度
|
||||
//! + CIELAB `L*`,官方 `ColorSpec2021` tone 表,与官方 Java 实现逐通道对齐——口径见模块文档)
|
||||
//! - [`palette`]:配色来源(D38/D40)——图片像素 → 种子 → 完整方案(Monet 式量化 + 打分)、
|
||||
//! 内置色卡、三级优先与失败回落、对比度收敛。**只吃已解码的像素**,解码在 `crates/palette-extract`
|
||||
//! - [`typography`]:M3 type scale 15 档 + 三平台字体族回退栈
|
||||
//! - [`shape`]:M3 Expressive 形状刻度 + shape morph 数据 + [`shape::ComponentRadius`](微控件圆角层,D32)
|
||||
//! - [`elevation`]:elevation level 与 surface tint / surface container 规则
|
||||
@@ -77,7 +79,9 @@
|
||||
//! **按等级偏移角色 tone 表**(例如高对比把 `on-*` 与承载色的 tone 差拉大 5~10),
|
||||
//! 而不是新增一批角色——`Role` 的数量保持 49 不变,`Scheme` 多一个 `contrast_level` 维度。
|
||||
//! (M63 的 G4 只说「高对比模式走 CPU 档」,那是渲染档位,不等价于支持对比度等级。)
|
||||
//! - **动态壁纸取色**:Material You 的多色种子派生未实现(本启动器用不到)。
|
||||
//! - **动态壁纸取色**:M3 的多色种子派生(一张图出 4 个 source color、`*_container` 各自换色相)
|
||||
//! 未实现。本模块只取**一个**主色种子(D38 的产品口径:整套主题一个强调色),
|
||||
//! 实现落在 [`palette`]。
|
||||
//! - **M3 的 elevation tint 叠色**:现行 M3 已改用 surface-container 表达层级,旧的
|
||||
//! `deprecated_tint_alpha()` 只为读旧稿保留。
|
||||
|
||||
@@ -88,12 +92,14 @@ pub mod component_map;
|
||||
pub mod elevation;
|
||||
pub mod icons;
|
||||
pub mod motion;
|
||||
pub mod palette;
|
||||
pub mod shape;
|
||||
pub mod spacing;
|
||||
pub mod typography;
|
||||
|
||||
pub use color::{Color, Role, Scheme, SchemeFlavor};
|
||||
pub use motion::MotionScale;
|
||||
pub use palette::{Card, CardKey, Level as SourceLevel, Provenance, Seed};
|
||||
pub use spacing::stroke;
|
||||
pub use typography::TypeRole;
|
||||
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
//! 内置色卡(D38 第三级):用户在「关闭莫奈取色」时手动选的那几张。
|
||||
//!
|
||||
//! ## `key` 是持久化键,命名就此定死
|
||||
//!
|
||||
//! 每张卡的 [`Card::key`] 会被写进用户设置(settings 里存字符串),**改了就等于把用户的选择
|
||||
//! 抹成出厂默认**。所以:
|
||||
//!
|
||||
//! - 只用小写 ASCII + 单段单词,不带动词、不带颜色数值(`sky`,不是 `blue_3c8dbc`);
|
||||
//! - 显示名([`Card::name`])可以改、可以本地化,`key` 不许改;
|
||||
//! - 新卡只能**追加**,旧卡永不删除(删了老用户的选择会静默失效 → 见
|
||||
//! [`CardKey::from_key`] 的回落口径);
|
||||
//! - `palette/tests.rs` 里有一条 `card_keys_are_stable` 把 8 个 key 逐字钉住——谁改谁红。
|
||||
//!
|
||||
//! ## 每张卡自带中性彩度
|
||||
//!
|
||||
//! 八张卡都取 [`DEFAULT_NEUTRAL_CHROMA`](12,M70 的「浅冷」口径)而不是官方 6:
|
||||
//! 换卡只该换**色相**,不该让窗口底色的「冷度」跟着跳——那种跳变会让用户在设置页里
|
||||
//! 逐张试色时觉得「每张卡都像换了个应用」。要偏离这条得单独论证。
|
||||
//!
|
||||
//! ## 卡与取色的关系(D38)
|
||||
//!
|
||||
//! 取色成功时,**当前选中的卡仍然参与生成**:它的中性彩度决定取色方案的表面色温
|
||||
//! (见 [`super::resolve`])。所以卡片不是「只在第三级才用到」的备胎。
|
||||
|
||||
use super::wcag;
|
||||
use crate::color::{Color, Palettes, Scheme, SchemeFlavor, DEFAULT_NEUTRAL_CHROMA};
|
||||
|
||||
/// 一张内置色卡。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Card {
|
||||
/// 持久化键(写进用户设置,**不许改**)。
|
||||
pub key: &'static str,
|
||||
/// 显示名(可改、可本地化)。
|
||||
pub name: &'static str,
|
||||
/// 种子色(只贡献色相;Tonal Spot 会把彩度收到 36)。
|
||||
pub seed: Color,
|
||||
/// 中性调色板彩度(表面色的「冷度」)。
|
||||
pub neutral_chroma: f64,
|
||||
}
|
||||
|
||||
impl Card {
|
||||
/// 用这张卡生成一套方案(与取色路径同一条生成器,只差种子来源)。
|
||||
pub fn scheme(self, flavor: SchemeFlavor) -> Scheme {
|
||||
Scheme::from_palettes(
|
||||
&Palettes::from_seed_with_neutral_chroma(self.seed, self.neutral_chroma),
|
||||
flavor,
|
||||
)
|
||||
}
|
||||
|
||||
/// 这张卡的 `on-primary` 对 `primary` 的实测对比度(设置页显示「对比度 8.4:1」用)。
|
||||
pub fn primary_contrast(self, flavor: SchemeFlavor) -> f32 {
|
||||
let scheme = self.scheme(flavor);
|
||||
scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary)
|
||||
}
|
||||
}
|
||||
|
||||
/// 全部内置色卡(下标 0 = 出厂默认)。
|
||||
///
|
||||
/// 色相在 CAM16 色环上大致均匀铺开(蓝 240 / 紫 299 / 绿 155 / 橙 65 / 红 10 / 青 185 /
|
||||
/// 暮蓝 ~275 / 橄榄绿 ~110),让用户「随便挑一张都不会撞色」。`palette::card` 的单测
|
||||
/// `cards_are_visually_distinct` 钉住「相邻色相间隔 ≥ 20°」。
|
||||
pub const CARDS: [Card; 8] = [
|
||||
Card {
|
||||
key: "sky",
|
||||
name: "天蓝",
|
||||
// M70 定案 ④(`palettes::DEFAULT_SEED` 同值)。改这张卡 = 改产品默认色,见 M70 文档。
|
||||
seed: Color::rgb(0x3C, 0x8D, 0xBC),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
Card {
|
||||
key: "lavender",
|
||||
name: "薰衣草紫",
|
||||
// 官方 M3 baseline 种子,和 `Scheme::official_baseline` 同色,便于对照官方截图。
|
||||
seed: Color::rgb(0x67, 0x50, 0xA4),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
Card {
|
||||
key: "forest",
|
||||
name: "森林绿",
|
||||
seed: Color::rgb(0x2E, 0x7D, 0x4F),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
Card {
|
||||
key: "amber",
|
||||
name: "琥珀橙",
|
||||
seed: Color::rgb(0xB2, 0x6A, 0x00),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
Card {
|
||||
key: "rose",
|
||||
name: "玫瑰红",
|
||||
seed: Color::rgb(0xB2, 0x3A, 0x48),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
Card {
|
||||
key: "teal",
|
||||
name: "青碧",
|
||||
seed: Color::rgb(0x00, 0x79, 0x6B),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
Card {
|
||||
key: "dusk",
|
||||
name: "暮蓝",
|
||||
seed: Color::rgb(0x4A, 0x5A, 0x9C),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
Card {
|
||||
key: "olive",
|
||||
name: "橄榄绿",
|
||||
// 刻意**没有**「纯灰/石板灰」卡:D40 已实测,零彩度种子的 CAM16 色相恒为 209.49°
|
||||
// (不是「无色」,而是固定的青),极低彩度段色相还是噪声放大器。所以「想要无彩色界面」
|
||||
// 不能靠一张灰卡实现——那需要一个饱和度/对比度维度(未来若做,是另一条轴,不是色卡)。
|
||||
seed: Color::rgb(0x5F, 0x6B, 0x2E),
|
||||
neutral_chroma: DEFAULT_NEUTRAL_CHROMA,
|
||||
},
|
||||
];
|
||||
|
||||
/// 出厂默认卡(首次启动、用户没选过卡时的那一张)。
|
||||
pub const DEFAULT_CARD: Card = CARDS[0];
|
||||
|
||||
/// 色卡的类型化键(`Card::key` 的枚举形态)。
|
||||
///
|
||||
/// 加了它是因为「存字符串」这件事必须只有一处口径:设置读回来时走 [`CardKey::from_key`],
|
||||
/// 写出去时走 [`CardKey::key`],两边不会漂。新卡追加在末尾,**不重排**。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub enum CardKey {
|
||||
/// 天蓝(出厂默认,M70 ④)。
|
||||
Sky,
|
||||
/// 薰衣草紫(官方 M3 baseline)。
|
||||
Lavender,
|
||||
/// 森林绿。
|
||||
Forest,
|
||||
/// 琥珀橙。
|
||||
Amber,
|
||||
/// 玫瑰红。
|
||||
Rose,
|
||||
/// 青碧。
|
||||
Teal,
|
||||
/// 暮蓝。
|
||||
Dusk,
|
||||
/// 橄榄绿。
|
||||
Olive,
|
||||
}
|
||||
|
||||
impl CardKey {
|
||||
/// 全部键(顺序与 [`CARDS`] 一致)。
|
||||
pub const ALL: [CardKey; 8] = [
|
||||
CardKey::Sky,
|
||||
CardKey::Lavender,
|
||||
CardKey::Forest,
|
||||
CardKey::Amber,
|
||||
CardKey::Rose,
|
||||
CardKey::Teal,
|
||||
CardKey::Dusk,
|
||||
CardKey::Olive,
|
||||
];
|
||||
|
||||
/// 持久化键(与 [`Card::key`] 逐字相同)。
|
||||
pub const fn key(self) -> &'static str {
|
||||
CARDS[self.index()].key
|
||||
}
|
||||
|
||||
/// 这张卡。
|
||||
pub const fn card(self) -> Card {
|
||||
CARDS[self.index()]
|
||||
}
|
||||
|
||||
/// 显示名。
|
||||
pub const fn name(self) -> &'static str {
|
||||
CARDS[self.index()].name
|
||||
}
|
||||
|
||||
const fn index(self) -> usize {
|
||||
match self {
|
||||
CardKey::Sky => 0,
|
||||
CardKey::Lavender => 1,
|
||||
CardKey::Forest => 2,
|
||||
CardKey::Amber => 3,
|
||||
CardKey::Rose => 4,
|
||||
CardKey::Teal => 5,
|
||||
CardKey::Dusk => 6,
|
||||
CardKey::Olive => 7,
|
||||
}
|
||||
}
|
||||
|
||||
/// 从持久化键解析;**未知键返回 `None`**(调用方回落 [`DEFAULT_CARD`] 并可以提示
|
||||
/// 「设置里的色卡名已失效」——绝不静默当成默认卡用掉,那样用户会以为设置没保存)。
|
||||
pub fn from_key(key: &str) -> Option<Self> {
|
||||
Self::ALL.into_iter().find(|card| card.key() == key)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<CardKey> for Card {
|
||||
fn from(key: CardKey) -> Self {
|
||||
key.card()
|
||||
}
|
||||
}
|
||||
|
||||
/// 卡片方案是否过 WCAG(给测试与设置页复用;正常路径由 [`super::resolve`] 兜底)。
|
||||
pub fn card_scheme_is_readable(card: Card, flavor: SchemeFlavor) -> bool {
|
||||
wcag::check(&card.scheme(flavor)).is_clean()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn at_least_six_cards_and_default_is_sky() {
|
||||
assert!(CARDS.len() >= 6, "只有 {} 张卡", CARDS.len());
|
||||
assert_eq!(DEFAULT_CARD.key, "sky");
|
||||
assert_eq!(CARDS[0].seed, crate::color::palettes::DEFAULT_SEED);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_keys_are_stable_and_unique() {
|
||||
// ⚠️ 这些字符串是持久化键:改一个就等于把老用户的选择抹成出厂默认。
|
||||
const EXPECTED: [&str; 8] = [
|
||||
"sky", "lavender", "forest", "amber", "rose", "teal", "dusk", "olive",
|
||||
];
|
||||
for (card, expected) in CARDS.iter().zip(EXPECTED) {
|
||||
assert_eq!(card.key, expected);
|
||||
}
|
||||
let mut keys: Vec<&str> = CARDS.iter().map(|card| card.key).collect();
|
||||
keys.sort_unstable();
|
||||
keys.dedup();
|
||||
assert_eq!(keys.len(), CARDS.len(), "key 有重复");
|
||||
for card in CARDS {
|
||||
assert!(
|
||||
card.key.chars().all(|c| c.is_ascii_lowercase()),
|
||||
"{} 不是小写 ASCII",
|
||||
card.key
|
||||
);
|
||||
assert!(!card.name.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_key_roundtrips_and_rejects_unknown() {
|
||||
for key in CardKey::ALL {
|
||||
assert_eq!(CardKey::from_key(key.key()), Some(key));
|
||||
assert_eq!(key.card().key, key.key());
|
||||
}
|
||||
assert_eq!(CardKey::from_key("no-such-card"), None);
|
||||
assert_eq!(CardKey::from_key("SKY"), None, "key 大小写敏感");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_card_scheme_meets_wcag_aa() {
|
||||
for card in CARDS {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
assert!(
|
||||
card_scheme_is_readable(card, flavor),
|
||||
"{} / {flavor:?} 未过门禁",
|
||||
card.key
|
||||
);
|
||||
assert!(
|
||||
card.primary_contrast(flavor) >= wcag::AA_TEXT,
|
||||
"{} / {flavor:?} primary 对 {} 只有 {:.2}",
|
||||
card.key,
|
||||
card.name,
|
||||
card.primary_contrast(flavor)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cards_are_visually_distinct() {
|
||||
// 色相间隔 ≥ 20°:八张卡的种子在两套方案里都不该撞成同一套配色。
|
||||
let mut hues: Vec<f64> = CARDS
|
||||
.iter()
|
||||
.map(|card| crate::color::Hct::from_color(card.seed).hue)
|
||||
.collect();
|
||||
hues.sort_by(|a, b| a.partial_cmp(b).expect("有限色相"));
|
||||
for pair in hues.windows(2) {
|
||||
let gap = pair[1] - pair[0];
|
||||
assert!(gap >= 20.0, "色相挤在一起:{pair:?}");
|
||||
}
|
||||
for card in CARDS {
|
||||
let light = card.scheme(SchemeFlavor::Light);
|
||||
let dark = card.scheme(SchemeFlavor::Dark);
|
||||
assert_ne!(
|
||||
light.get(crate::color::Role::Primary),
|
||||
dark.get(crate::color::Role::Primary)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! # 配色来源(D38 / D40):图片取色 · 内置色卡 · 三级回落 · WCAG 收敛
|
||||
//!
|
||||
//! 用户在 2026-11-01 定的配色来源是三级(D38):
|
||||
//!
|
||||
//! ```text
|
||||
//! ① 启动器背景图取色(用户设了背景图时)
|
||||
//! ↓ 没有背景图 / 取色失败
|
||||
//! ② 桌面壁纸取色(用户开了「莫奈取色」但没设背景图时)
|
||||
//! ↓ 没有壁纸 / 取色失败 / 对比度收敛失败
|
||||
//! ③ 内置色卡(用户手动选的那张;没选过就是出厂默认「天蓝」)
|
||||
//! ```
|
||||
//!
|
||||
//! 取色失败**不静默**:本模块的 [`Provenance`] 会把「最终用了哪一级」和「前几级为什么没用」
|
||||
//! 都带回来,设置页据此显示提示;解码失败也在里面(它不在这里发生,但原因是这里记录的)。
|
||||
//! 契约(UI 怎么调、文案怎么映射)见 `DevDocs/SubAgentsTalk/palette-source-contract.md`。
|
||||
//!
|
||||
//! ## 分层(D40):本模块只做纯数学
|
||||
//!
|
||||
//! | 层 | 在哪 | 职责 | 依赖 |
|
||||
//! |---|---|---|---|
|
||||
//! | 纯数学 | **本模块**(`crates/design`) | 像素 → 种子 → 49 角色;色卡;回落决策;对比度收敛 | 无(零第三方依赖) |
|
||||
//! | 解码 | `crates/palette-extract` | 路径/字节 → RGBA 像素 | `image`(唯一引入处) |
|
||||
//! | 调用侧 | 后续 appearance/settings mission | 拿路径、把 [`Provenance`] 变文案 | UI 框架 |
|
||||
//!
|
||||
//! 所以本模块的「图片输入」永远是**已经解码好的像素**,从来没有路径、没有 IO。
|
||||
//!
|
||||
//! ## 四个部分
|
||||
//!
|
||||
//! - [`seed`]:`seed_from_rgba(pixels, w, h)`(D40 的入口形状)。量化在 [`quantize`],
|
||||
//! 挑主色在 [`score`]——**不是取平均色**,理由见 [`quantize`] 的模块文档。
|
||||
//! - [`card`]:内置色卡(≥6 张,含出厂默认「天蓝」)。卡的 `key` 是**持久化键**,写进用户设置。
|
||||
//! - [`wcag`]:对任意一套方案跑对比度断言;不达标就沿 tone 轴修正,并记录「改了什么」。
|
||||
//! - [`resolve`]:纯函数 `resolve_source(...) -> (Scheme, Provenance)`,实现三级优先与回落。
|
||||
//!
|
||||
//! ## D40 的两条实测事实(不是风格偏好,是物理事实)
|
||||
//!
|
||||
//! 1. **零彩度种子的 CAM16 色相是常数 209.49°**,不是 NaN。所以「灰白壁纸」硬取色会得到
|
||||
//! 一个凭空上色的青色(`#006874`),且极低彩度段色相是噪声放大器(`#808080` → 209.5°、
|
||||
//! `#7A7C7E` → 231.2°)。→ [`seed::MIN_SEED_CHROMA`] = 8 的种子彩度门槛,低于它就回落色卡。
|
||||
//! 2. **动态色也必须过 WCAG**。候选色卡的 208 项门禁(`tests/contrast_aa.rs`)不覆盖取色结果,
|
||||
//! 所以 [`wcag`] 对**任何**来源的方案都跑同一套检查,不达标就收敛或回落。
|
||||
|
||||
pub mod card;
|
||||
pub mod resolve;
|
||||
pub mod seed;
|
||||
pub mod wcag;
|
||||
|
||||
mod quantize;
|
||||
mod score;
|
||||
|
||||
pub use card::{Card, CardKey, CARDS, DEFAULT_CARD};
|
||||
pub use resolve::{resolve_source, ImageInput, ImageSource, Level, Outcome, Provenance, Skip};
|
||||
pub use seed::{
|
||||
analyze_rgba, seed_from_rgba, Failure, Seed, MAX_SAMPLES, MIN_PIXELS, MIN_SEED_CHROMA,
|
||||
MUTED_SEED_CHROMA,
|
||||
};
|
||||
pub use wcag::{converge, Adjustment, WcagReport, AA_NON_TEXT, AA_TEXT, TEXT_PAIRS};
|
||||
|
||||
/// 一套方案的来源层级([`Level`] 的别名,读起来更清楚)。
|
||||
pub use resolve::Level as SourceLevel;
|
||||
@@ -0,0 +1,338 @@
|
||||
//! 像素 → 主色簇:Monet(Material You)取色管线里**量化那一半**(打分那一半在 [`super::score`])。
|
||||
//!
|
||||
//! ## 为什么不是「取平均色」
|
||||
//!
|
||||
//! 平均色是两个独立错误的合成:
|
||||
//!
|
||||
//! 1. **它不存在的颜色**。平均会把「天空 60% + 树叶 30% + 纯白 10%」混成一块脏灰绿,
|
||||
//! 这个色值在图上**一个像素都不是**,拿它当种子,用户看到的整套配色跟壁纸不像。
|
||||
//! 2. **它没有「主色」概念**。一张 95% 是米白、5% 是珊瑚的图,平均色几乎等于米白,
|
||||
//! 而人眼记住的、能当强调色用的是那 5%。官方 Monet 的立场正好相反:**按面积 + 彩度
|
||||
//! 挑一个「有人气又有色」的代表色**(`Score`),不是把整体摊平。
|
||||
//!
|
||||
//! ## 采用的策略(三层)
|
||||
//!
|
||||
//! | 步 | 做什么 | 官方对应 | 等级 |
|
||||
//! |---|---|---|---|
|
||||
//! | 1 | 5 bit/通道直方图(32³ = 32768 桶),桶内记像素数与各通道原值和 | `QuantizerWu` 的 `INDEX_BITS = 5` | A |
|
||||
//! | 2 | 桶当带权点,在 **CIELAB** 里跑 k-means(≤10 轮;簇心用线性 RGB 加权均值更新) | `QuantizerWsmeans`(`MAX_ITERATIONS = 10`、`MIN_DELTA_E = 3.0`) | A(结构)/ B(用直方图桶代替 Wu 的分割盒播种) |
|
||||
//! | 3 | 每簇的代表色 = 簇内像素最多的那个桶里**像素的原值平均** | `QuantizerCelebi` = 量化 + `Score` | A(管线)/ B(代表色取桶内均值) |
|
||||
//!
|
||||
//! 也就是说:本模块 = `QuantizerCelebi` 的形状(直方图量化 + k-means 精修),
|
||||
//! 与官方的差别在**播种方式**——官方用 `QuantizerWu` 的动态规划分割盒当初始簇心,
|
||||
//! 这里用「像素数最多的 24 个桶」。差别可测量(见各模块单测:5% 面积的强调色簇不会被
|
||||
//! 95% 的底色吞掉),换来的是一条可全单测、零依赖、确定性的实现。
|
||||
//!
|
||||
//! ## 代表色为什么要「桶内原值平均」而不是桶中心
|
||||
//!
|
||||
//! 只取桶中心(5 bit 量化点)会让纯色 `#3C8DBC` 的图取出 `#3B8BBB`——量化误差 1~3/通道。
|
||||
//! 单看数字无所谓,但这是**用户能看出来**的:「我选的是这个蓝,凭什么给我的强调色差一点?」
|
||||
//! 桶内原值平均对纯色图精确等于输入色(误差 0),对复杂图就是该桶的真实均值,
|
||||
//! 仍然不是「全图平均色」(只有一个桶那么多像素参与)。
|
||||
//!
|
||||
//! ## 来源等级
|
||||
//!
|
||||
//! - **A**:`material-color-utilities`(Apache-2.0)里确实有 `QuantizerWu` / `QuantizerWsmeans` /
|
||||
//! `QuantizerCelebi` / `Score` 四个类,且 `INDEX_BITS = 5`、`MAX_ITERATIONS = 10`、
|
||||
//! `MIN_DELTA_E = 3.0` 是官方常数——这些是「照抄口径」的部分。
|
||||
//! - **B**:用「top-N 桶」代替 Wu 的分割盒当播种;代表色取桶内原值均值。
|
||||
//! - **C**:不做。拿不准的常数一律写成带注释的「我们的选择」,不假装是官方值。
|
||||
|
||||
use crate::color::space;
|
||||
use crate::color::Color;
|
||||
|
||||
/// 每通道保留的位数:官方 `QuantizerWu` 用 5 bit/通道。
|
||||
pub(crate) const INDEX_BITS: u32 = 5;
|
||||
/// 直方图桶数(32³)。
|
||||
const BIN_COUNT: usize = 1 << (INDEX_BITS as usize * 3);
|
||||
/// 每通道的右移量(8 − 5 = 3)。
|
||||
const SHIFT: u32 = 8 - INDEX_BITS;
|
||||
/// k-means 最多迭代轮数(官方 `QuantizerWsmeans.MAX_ITERATIONS`)。
|
||||
const MAX_ITERATIONS: usize = 10;
|
||||
/// 收敛阈值:簇心在 CIELAB 里移动小于这个距离就停(官方 `MIN_DELTA_E`)。
|
||||
const MIN_DELTA_E: f64 = 3.0;
|
||||
/// 最多保留的簇数(喂给打分的候选数量)。官方 `Score` 通常拿到 256 个候选;
|
||||
/// 24 个足够覆盖「底色 + 两三个强调色簇」,也让色相直方图不被噪声塞满。
|
||||
pub(crate) const MAX_CLUSTERS: usize = 24;
|
||||
|
||||
/// 一个主色簇:图里**真实存在**的颜色 + 被归到它名下的像素数。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct Cluster {
|
||||
/// 簇的代表色(簇内像素最多的桶里,像素原值的平均)。
|
||||
pub color: Color,
|
||||
/// 归到这个簇的采样像素数。
|
||||
pub population: u32,
|
||||
}
|
||||
|
||||
/// 一个非空桶。
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Bin {
|
||||
index: usize,
|
||||
color: Color,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// 把采样像素量化成主色簇(按像素数降序,同数按色值排序 → 结果确定)。
|
||||
pub(crate) fn quantize(samples: &[Color]) -> Vec<Cluster> {
|
||||
let bins = histogram(samples);
|
||||
if bins.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut clusters = if bins.len() <= MAX_CLUSTERS {
|
||||
bins.iter()
|
||||
.map(|bin| Cluster {
|
||||
color: bin.color,
|
||||
population: bin.count,
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
kmeans(&bins)
|
||||
};
|
||||
clusters.sort_by(|a, b| {
|
||||
b.population
|
||||
.cmp(&a.population)
|
||||
.then_with(|| packed(a.color).cmp(&packed(b.color)))
|
||||
});
|
||||
clusters
|
||||
}
|
||||
|
||||
/// 直方图(按桶下标升序,空的桶不出现)。
|
||||
fn histogram(samples: &[Color]) -> Vec<Bin> {
|
||||
let mut counts = vec![0u32; BIN_COUNT];
|
||||
// 每桶各通道的原值和(`u32` 够用:单桶最多 65536 个采样点 × 255 < 2^32)。
|
||||
let mut sums = vec![[0u32; 3]; BIN_COUNT];
|
||||
for color in samples {
|
||||
let index = bin_index(*color);
|
||||
counts[index] = counts[index].saturating_add(1);
|
||||
for (channel, value) in [color.r, color.g, color.b].into_iter().enumerate() {
|
||||
sums[index][channel] = sums[index][channel].saturating_add(u32::from(value));
|
||||
}
|
||||
}
|
||||
counts
|
||||
.iter()
|
||||
.zip(&sums)
|
||||
.enumerate()
|
||||
.filter(|(_, (count, _))| **count > 0)
|
||||
.map(|(index, (count, sum))| Bin {
|
||||
index,
|
||||
color: average_color(*count, *sum),
|
||||
count: *count,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn average_color(count: u32, sum: [u32; 3]) -> Color {
|
||||
let channel = |value: u32| ((f64::from(value) / f64::from(count)).round()) as u8;
|
||||
Color::rgb(channel(sum[0]), channel(sum[1]), channel(sum[2]))
|
||||
}
|
||||
|
||||
fn bin_index(color: Color) -> usize {
|
||||
(usize::from(color.r >> SHIFT) << (INDEX_BITS * 2))
|
||||
| (usize::from(color.g >> SHIFT) << INDEX_BITS)
|
||||
| usize::from(color.b >> SHIFT)
|
||||
}
|
||||
|
||||
/// CIELAB 欧氏距离的平方。
|
||||
fn distance_squared(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
let dl = a[0] - b[0];
|
||||
let da = a[1] - b[1];
|
||||
let db = a[2] - b[2];
|
||||
dl * dl + da * da + db * db
|
||||
}
|
||||
|
||||
/// 带权 k-means(在 CIELAB 里分配、在线性 RGB 里更新簇心)。
|
||||
fn kmeans(bins: &[Bin]) -> Vec<Cluster> {
|
||||
let mut by_population: Vec<usize> = (0..bins.len()).collect();
|
||||
by_population.sort_by(|&a, &b| {
|
||||
bins[b]
|
||||
.count
|
||||
.cmp(&bins[a].count)
|
||||
.then_with(|| bins[a].index.cmp(&bins[b].index))
|
||||
});
|
||||
by_population.truncate(MAX_CLUSTERS);
|
||||
|
||||
let mut centers: Vec<Color> = by_population.iter().map(|&i| bins[i].color).collect();
|
||||
let mut assignments = vec![usize::MAX; bins.len()];
|
||||
|
||||
for _ in 0..MAX_ITERATIONS {
|
||||
let center_lab: Vec<[f64; 3]> = centers.iter().map(|c| space::lab_from_color(*c)).collect();
|
||||
let mut changed = false;
|
||||
for (i, bin) in bins.iter().enumerate() {
|
||||
let lab = space::lab_from_color(bin.color);
|
||||
let mut best = 0;
|
||||
let mut best_distance = f64::INFINITY;
|
||||
for (k, center) in center_lab.iter().enumerate() {
|
||||
let distance = distance_squared(lab, *center);
|
||||
if distance < best_distance {
|
||||
best_distance = distance;
|
||||
best = k;
|
||||
}
|
||||
}
|
||||
if assignments[i] != best {
|
||||
changed = true;
|
||||
assignments[i] = best;
|
||||
}
|
||||
}
|
||||
|
||||
let mut sums = vec![[0.0_f64; 3]; centers.len()];
|
||||
let mut weights = vec![0.0_f64; centers.len()];
|
||||
for (i, bin) in bins.iter().enumerate() {
|
||||
let weight = f64::from(bin.count);
|
||||
let linear = [
|
||||
space::linearized(bin.color.r),
|
||||
space::linearized(bin.color.g),
|
||||
space::linearized(bin.color.b),
|
||||
];
|
||||
let k = assignments[i];
|
||||
for channel in 0..3 {
|
||||
sums[k][channel] += linear[channel] * weight;
|
||||
}
|
||||
weights[k] += weight;
|
||||
}
|
||||
|
||||
let mut shift = 0.0_f64;
|
||||
for (k, center) in centers.iter_mut().enumerate() {
|
||||
if weights[k] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mean = [
|
||||
sums[k][0] / weights[k],
|
||||
sums[k][1] / weights[k],
|
||||
sums[k][2] / weights[k],
|
||||
];
|
||||
let next = space::color_from_linrgb(mean);
|
||||
shift = shift.max(distance_squared(space::lab_from_color(next), center_lab[k]).sqrt());
|
||||
*center = next;
|
||||
}
|
||||
if !changed || shift < MIN_DELTA_E {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 代表色取「簇内像素数最多的真实桶」,**不是**簇心均值:均值色往往在图上不存在,
|
||||
// 用它当种子会让色卡与壁纸看起来不像(见模块文档第 1 条理由)。
|
||||
let mut representatives: Vec<Option<(u32, Color)>> = vec![None; centers.len()];
|
||||
for (i, bin) in bins.iter().enumerate() {
|
||||
let k = assignments[i];
|
||||
let better = match representatives[k] {
|
||||
None => true,
|
||||
Some((best, _)) => bin.count > best,
|
||||
};
|
||||
if better {
|
||||
representatives[k] = Some((bin.count, bin.color));
|
||||
}
|
||||
}
|
||||
|
||||
let mut totals = vec![0u32; centers.len()];
|
||||
for (i, bin) in bins.iter().enumerate() {
|
||||
totals[assignments[i]] = totals[assignments[i]].saturating_add(bin.count);
|
||||
}
|
||||
|
||||
let mut clusters: Vec<Cluster> = representatives
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(k, entry)| {
|
||||
entry.map(|(_, color)| Cluster {
|
||||
color,
|
||||
population: totals[k],
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// 不同簇可能选出同一个代表桶(极少见),合并以免重复喂给打分。
|
||||
clusters.sort_by_key(|cluster| packed(cluster.color));
|
||||
clusters.dedup_by(|a, b| {
|
||||
if a.color == b.color {
|
||||
b.population = b.population.saturating_add(a.population);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
clusters
|
||||
}
|
||||
|
||||
fn packed(color: Color) -> u32 {
|
||||
(u32::from(color.r) << 16) | (u32::from(color.g) << 8) | u32::from(color.b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn bin_index_groups_neighbours() {
|
||||
// 同一个 5 bit 桶内的像素共享桶下标(桶宽 8)。
|
||||
assert_eq!(
|
||||
bin_index(Color::rgb(0x50, 0x80, 0x10)),
|
||||
bin_index(Color::rgb(0x57, 0x87, 0x17))
|
||||
);
|
||||
assert_ne!(
|
||||
bin_index(Color::rgb(0x50, 0x80, 0x10)),
|
||||
bin_index(Color::rgb(0x60, 0x80, 0x10))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_color_keeps_its_exact_value() {
|
||||
// 桶内原值平均 → 纯色图取回的种子与输入色**逐通道相等**(量化误差被平均消掉)。
|
||||
for hex in ["#3C8DBC", "#C0392B", "#00F0FF", "#123456"] {
|
||||
let color = Color::from_hex(hex).expect("合法 hex");
|
||||
let clusters = quantize(&[color; 500]);
|
||||
assert_eq!(clusters.len(), 1);
|
||||
assert_eq!(clusters[0].color, color, "{hex}");
|
||||
assert_eq!(clusters[0].population, 500);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_input_has_no_cluster() {
|
||||
assert!(quantize(&[]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cluster_populations_add_up() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..60 {
|
||||
let shade = (i * 4) as u8;
|
||||
samples.push(Color::rgb(shade, 40, 200));
|
||||
samples.push(Color::rgb(200, shade, 40));
|
||||
samples.push(Color::rgb(40, 200, shade));
|
||||
}
|
||||
let clusters = quantize(&samples);
|
||||
let total: u32 = clusters.iter().map(|c| c.population).sum();
|
||||
assert_eq!(total, samples.len() as u32);
|
||||
assert!(clusters.len() <= MAX_CLUSTERS, "{} 簇", clusters.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kmeans_split_is_deterministic() {
|
||||
let mut samples = Vec::new();
|
||||
for i in 0..90 {
|
||||
let shade = (i * 2) as u8;
|
||||
samples.push(Color::rgb(shade, 40, 200));
|
||||
samples.push(Color::rgb(200, shade, 40));
|
||||
samples.push(Color::rgb(40, 200, shade));
|
||||
}
|
||||
assert_eq!(quantize(&samples), quantize(&samples));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kmeans_keeps_a_small_distinct_cluster() {
|
||||
// 3% 面积的红点 + 97% 的蓝底:k-means 不能把红点并进蓝簇。
|
||||
let mut samples = vec![Color::rgb(0x20, 0x40, 0xC0); 970];
|
||||
samples.extend(vec![Color::rgb(0xD0, 0x30, 0x20); 30]);
|
||||
let clusters = quantize(&samples);
|
||||
assert!(
|
||||
clusters
|
||||
.iter()
|
||||
.any(|c| c.color.r > 0xB0 && c.color.b < 0x60),
|
||||
"{clusters:?}"
|
||||
);
|
||||
assert!(
|
||||
clusters
|
||||
.iter()
|
||||
.any(|c| c.color.b > 0xA0 && c.color.r < 0x60),
|
||||
"{clusters:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//! 三级来源编排:**纯函数**,一次调用算完「用哪一级的颜色、为什么不是别的级」。
|
||||
//!
|
||||
//! D38 的优先序:① 启动器背景图 → ② 桌面壁纸 → ③ 内置色卡。
|
||||
//! 本模块不做任何 IO:像素是调用方解码好传进来的(D40 分层),壁纸路径探测是 M73 的事。
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use unlucky_design::palette::{resolve_source, CardKey, ImageInput, ImageSource, Level};
|
||||
//! use unlucky_design::color::SchemeFlavor;
|
||||
//!
|
||||
//! let pixels = [0x3C, 0x8D, 0xBC, 0xFF].repeat(16); // 4×4 天蓝图(示例)
|
||||
//! let bg = ImageInput::Pixels(ImageSource { pixels: &pixels, width: 4, height: 4 });
|
||||
//! let (scheme, provenance) = resolve_source(Some(bg), None, Some(CardKey::Forest), SchemeFlavor::Light);
|
||||
//! assert_eq!(provenance.level, Level::BackgroundImage);
|
||||
//! assert!(provenance.hint_key().is_none(), "一级成功时没有提示");
|
||||
//! ```
|
||||
//!
|
||||
//! ## 失败链:每一级都可能「有输入但不合格」
|
||||
//!
|
||||
//! | 情形 | 结果 | `Provenance` 里留下 |
|
||||
//! |---|---|---|
|
||||
//! | 没有背景图 | 跳到 ② | `Outcome::Absent` |
|
||||
//! | 背景图解码失败 | 跳到 ② | `Outcome::Failed(Skip::Undecodable)` |
|
||||
//! | 背景图太小 / 像素缓冲不对 / 全透明 | 跳到 ② | `… TooSmall / BadDimensions / NoOpaquePixels` |
|
||||
//! | 背景图彩度不足(D40 的 `< 8`) | 跳到 ② | `… LowChroma { chroma }` |
|
||||
//! | 背景图取色成功但对比度收敛失败 | 跳到 ② | `… Contrast { failures }`(理论上不会发生,仍要留) |
|
||||
//! | 三级都不成立 | 用色卡,**一定**有结果 | `Level::Preset` |
|
||||
//!
|
||||
//! 三条纪律:
|
||||
//!
|
||||
//! 1. **不许静默**。任何一次回落都在 `Provenance` 里留下原因码,[`Provenance::hint_key`]
|
||||
//! 给 UI 一个稳定的提示键(文案映射见 `DevDocs/SubAgentsTalk/palette-source-contract.md`)。
|
||||
//! 2. **不许静默变黑白**。彩度不足的图**不**会被「压成灰方案」,而是直接回落到用户选的那张色卡。
|
||||
//! 3. **不许静默换成默认卡**。回落的落点是「用户当前选中的卡」(参数 `preset`),
|
||||
//! 只有用户从没选过卡(`None`)才用 [`super::DEFAULT_CARD`]。
|
||||
|
||||
use super::card::{Card, CardKey, DEFAULT_CARD};
|
||||
use super::seed::{analyze_rgba, Failure, Seed};
|
||||
use super::wcag::{check, converge, Adjustment};
|
||||
use crate::color::{Color, Scheme, SchemeFlavor};
|
||||
|
||||
/// 已解码的图片:借用像素,不拷贝(解码在 `crates/palette-extract`)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ImageSource<'a> {
|
||||
/// RGBA8 像素,`width × height × 4` 字节。
|
||||
pub pixels: &'a [u8],
|
||||
/// 宽(像素)。
|
||||
pub width: u32,
|
||||
/// 高(像素)。
|
||||
pub height: u32,
|
||||
}
|
||||
|
||||
/// 某一级来源的输入。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ImageInput<'a> {
|
||||
/// 解码好的像素。
|
||||
Pixels(ImageSource<'a>),
|
||||
/// 有这张图,但解码失败(文件损坏 / 格式不支持 / 读不到)。
|
||||
///
|
||||
/// 这个状态由适配层传进来——不传就等于「没有这张图」,UI 会说错话。
|
||||
Undecodable,
|
||||
}
|
||||
|
||||
impl<'a> ImageInput<'a> {
|
||||
/// 便捷构造:从像素缓冲包一个输入。
|
||||
pub fn pixels(pixels: &'a [u8], width: u32, height: u32) -> Self {
|
||||
ImageInput::Pixels(ImageSource {
|
||||
pixels,
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 方案的来源层级(D38 的三级)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Level {
|
||||
/// ① 启动器背景图取色。
|
||||
BackgroundImage,
|
||||
/// ② 桌面壁纸取色。
|
||||
Wallpaper,
|
||||
/// ③ 内置色卡。
|
||||
Preset,
|
||||
}
|
||||
|
||||
impl Level {
|
||||
/// 稳定码(日志/设置里用,别用 `Debug` 输出)。
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Level::BackgroundImage => "background-image",
|
||||
Level::Wallpaper => "wallpaper",
|
||||
Level::Preset => "preset",
|
||||
}
|
||||
}
|
||||
|
||||
/// 在本模块内部当索引用(0 = ①)。
|
||||
const fn index(self) -> usize {
|
||||
match self {
|
||||
Level::BackgroundImage => 0,
|
||||
Level::Wallpaper => 1,
|
||||
Level::Preset => 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 某一级的处置结果。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Outcome {
|
||||
/// 用了这一级。
|
||||
Used,
|
||||
/// 用户没提供这一级(没设背景图 / 没开莫奈取色)。
|
||||
Absent,
|
||||
/// 有输入但不合格,已跳过。
|
||||
Failed(Skip),
|
||||
}
|
||||
|
||||
/// 「有输入但不合格」的具体原因——每一条都要能变成一句人话。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Skip {
|
||||
/// 解码失败(原因在适配层,这里只知道失败)。
|
||||
Undecodable,
|
||||
/// 图太小(< 4 像素)。
|
||||
TooSmall {
|
||||
/// 宽。
|
||||
width: u32,
|
||||
/// 高。
|
||||
height: u32,
|
||||
},
|
||||
/// 像素缓冲长度与宽高不符(调用方给错了数据)。
|
||||
BadDimensions {
|
||||
/// 期望字节数。
|
||||
expected: usize,
|
||||
/// 实际字节数。
|
||||
actual: usize,
|
||||
},
|
||||
/// 全是透明像素。
|
||||
NoOpaquePixels,
|
||||
/// 彩度不足(D40:`< MIN_SEED_CHROMA`),硬取色会凭空上色。
|
||||
LowChroma {
|
||||
/// 实测彩度。
|
||||
chroma: f64,
|
||||
/// 最接近的候选色(提示文案里显示成色块)。
|
||||
color: Color,
|
||||
},
|
||||
/// 取色成功但对比度收敛后仍不达标。
|
||||
Contrast {
|
||||
/// 仍不达标的对数。
|
||||
failures: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl Skip {
|
||||
/// 稳定原因码(`Provenance::hint_key` 的后半段)。
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Skip::Undecodable => "undecodable",
|
||||
Skip::TooSmall { .. } => "too-small",
|
||||
Skip::BadDimensions { .. } => "bad-dimensions",
|
||||
Skip::NoOpaquePixels => "no-opaque-pixels",
|
||||
Skip::LowChroma { .. } => "low-chroma",
|
||||
Skip::Contrast { .. } => "contrast",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<Failure> for Skip {
|
||||
fn from(failure: Failure) -> Self {
|
||||
match failure {
|
||||
Failure::BadDimensions {
|
||||
expected, actual, ..
|
||||
} => Skip::BadDimensions { expected, actual },
|
||||
Failure::TooSmall { width, height } => Skip::TooSmall { width, height },
|
||||
Failure::NoOpaquePixels => Skip::NoOpaquePixels,
|
||||
Failure::LowChroma { color, chroma } => Skip::LowChroma { color, chroma },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 这次配色「最后用了什么、前几级为什么没用」。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Provenance {
|
||||
/// 最终生效的层级。
|
||||
pub level: Level,
|
||||
/// 当前生效的色卡(取色成功时它仍然参与:中性彩度来自它)。
|
||||
pub card: CardKey,
|
||||
/// 取色成功时的种子(色卡档为 `None`)。
|
||||
pub seed: Option<Seed>,
|
||||
/// 深浅模式。
|
||||
pub flavor: SchemeFlavor,
|
||||
/// 对比度收敛改了哪些角色(空 = 没动)。
|
||||
pub adjustments: Vec<Adjustment>,
|
||||
outcomes: [Outcome; 3],
|
||||
}
|
||||
|
||||
impl Provenance {
|
||||
/// 某一级发生了什么。
|
||||
pub fn outcome(&self, level: Level) -> Outcome {
|
||||
self.outcomes[level.index()]
|
||||
}
|
||||
|
||||
/// 背景图那一级。
|
||||
pub fn background(&self) -> Outcome {
|
||||
self.outcome(Level::BackgroundImage)
|
||||
}
|
||||
|
||||
/// 壁纸那一级。
|
||||
pub fn wallpaper(&self) -> Outcome {
|
||||
self.outcome(Level::Wallpaper)
|
||||
}
|
||||
|
||||
/// 色卡那一级。
|
||||
pub fn preset(&self) -> Outcome {
|
||||
self.outcome(Level::Preset)
|
||||
}
|
||||
|
||||
/// 这次配色是否发生过回落(用了 `level` 以下的层级)。
|
||||
pub fn did_fall_back(&self) -> bool {
|
||||
(0..self.level.index()).any(|i| !matches!(self.outcomes[i], Outcome::Absent))
|
||||
}
|
||||
|
||||
/// 第一个「有输入但不合格」的层级(提示文案优先说它)。
|
||||
pub fn first_failure(&self) -> Option<(Level, Skip)> {
|
||||
const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset];
|
||||
LEVELS
|
||||
.into_iter()
|
||||
.find_map(|level| match self.outcome(level) {
|
||||
Outcome::Failed(skip) => Some((level, skip)),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// UI 提示键(`None` = 无需提示)。
|
||||
///
|
||||
/// 形如 `palette.hint.background.low-chroma`,文案映射表见
|
||||
/// `DevDocs/SubAgentsTalk/palette-source-contract.md`。`key` 稳定、可持久化进日志;
|
||||
/// 本 crate 不产出人话文案(文案属于 UI 层,D40 的分层纪律)。
|
||||
///
|
||||
/// 优先级:① 先解释「为什么回落」(第一个失败的层级)→ ② 再说「微调过对比度」。
|
||||
/// 前者对用户更重要:用户看到的是「我的壁纸怎么没生效」,而不是「颜色被调了 2%」。
|
||||
pub fn hint_key(&self) -> Option<&'static str> {
|
||||
if let Some((level, skip)) = self.first_failure() {
|
||||
return Some(match (level, skip) {
|
||||
(Level::BackgroundImage, Skip::Undecodable) => {
|
||||
"palette.hint.background.undecodable"
|
||||
}
|
||||
(Level::BackgroundImage, Skip::TooSmall { .. }) => {
|
||||
"palette.hint.background.too-small"
|
||||
}
|
||||
(Level::BackgroundImage, Skip::BadDimensions { .. }) => {
|
||||
"palette.hint.background.bad-dimensions"
|
||||
}
|
||||
(Level::BackgroundImage, Skip::NoOpaquePixels) => {
|
||||
"palette.hint.background.no-opaque-pixels"
|
||||
}
|
||||
(Level::BackgroundImage, Skip::LowChroma { .. }) => {
|
||||
"palette.hint.background.low-chroma"
|
||||
}
|
||||
(Level::BackgroundImage, Skip::Contrast { .. }) => {
|
||||
"palette.hint.background.contrast"
|
||||
}
|
||||
(Level::Wallpaper, Skip::Undecodable) => "palette.hint.wallpaper.undecodable",
|
||||
(Level::Wallpaper, Skip::TooSmall { .. }) => "palette.hint.wallpaper.too-small",
|
||||
(Level::Wallpaper, Skip::BadDimensions { .. }) => {
|
||||
"palette.hint.wallpaper.bad-dimensions"
|
||||
}
|
||||
(Level::Wallpaper, Skip::NoOpaquePixels) => {
|
||||
"palette.hint.wallpaper.no-opaque-pixels"
|
||||
}
|
||||
(Level::Wallpaper, Skip::LowChroma { .. }) => "palette.hint.wallpaper.low-chroma",
|
||||
(Level::Wallpaper, Skip::Contrast { .. }) => "palette.hint.wallpaper.contrast",
|
||||
(Level::Preset, Skip::Contrast { .. }) => "palette.hint.preset.contrast",
|
||||
// 色卡档不存在「解码/彩度」问题;落到这里说明是将来新增的原因,别乱编键。
|
||||
(Level::Preset, _) => "palette.hint.preset.unavailable",
|
||||
});
|
||||
}
|
||||
if !self.adjustments.is_empty() {
|
||||
return Some("palette.hint.adjusted");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 原因码列表(`[级别码, 原因码]`),写日志/上报用;给不了键的细节都在这里。
|
||||
pub fn reason_codes(&self) -> Vec<(&'static str, &'static str)> {
|
||||
const LEVELS: [Level; 3] = [Level::BackgroundImage, Level::Wallpaper, Level::Preset];
|
||||
LEVELS
|
||||
.into_iter()
|
||||
.filter_map(|level| match self.outcome(level) {
|
||||
Outcome::Failed(skip) => Some((level.code(), skip.code())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// 三级来源编排(纯函数)。
|
||||
///
|
||||
/// 参数:
|
||||
/// - `background`:用户设的启动器背景图(`None` = 没设)。解码失败传
|
||||
/// [`ImageInput::Undecodable`],**不要**传 `None`(那样 UI 分不清「没设」和「坏了」)。
|
||||
/// - `wallpaper`:桌面壁纸(`None` = 没开莫奈 / 探测不到壁纸文件)。
|
||||
/// - `preset`:用户当前选中的色卡(`None` = 从没选过 → 用 [`super::DEFAULT_CARD`])。
|
||||
/// - `flavor`:深浅模式。
|
||||
///
|
||||
/// 返回值永远有一套可用方案:三级全失败时落到色卡,并带上原因。
|
||||
pub fn resolve_source(
|
||||
background: Option<ImageInput<'_>>,
|
||||
wallpaper: Option<ImageInput<'_>>,
|
||||
preset: Option<CardKey>,
|
||||
flavor: SchemeFlavor,
|
||||
) -> (Scheme, Provenance) {
|
||||
let card = preset.map(Card::from).unwrap_or(DEFAULT_CARD);
|
||||
let mut outcomes = [Outcome::Absent; 3];
|
||||
|
||||
for (level, input) in [
|
||||
(Level::BackgroundImage, background),
|
||||
(Level::Wallpaper, wallpaper),
|
||||
] {
|
||||
match try_level(input, card, flavor) {
|
||||
Attempt::Absent => outcomes[level.index()] = Outcome::Absent,
|
||||
Attempt::Failed(skip) => outcomes[level.index()] = Outcome::Failed(skip),
|
||||
Attempt::Used {
|
||||
scheme,
|
||||
seed,
|
||||
adjustments,
|
||||
} => {
|
||||
outcomes[level.index()] = Outcome::Used;
|
||||
return (
|
||||
*scheme,
|
||||
Provenance {
|
||||
level,
|
||||
card: card_key(card),
|
||||
seed: Some(seed),
|
||||
flavor,
|
||||
adjustments,
|
||||
outcomes,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第三级:色卡。收敛仍然要跑(色卡也可能因为将来改常数而越线),但落点是它自己。
|
||||
let (scheme, adjustments) = converge(card.scheme(flavor));
|
||||
let report = check(&scheme);
|
||||
outcomes[Level::Preset.index()] = if report.is_clean() {
|
||||
Outcome::Used
|
||||
} else {
|
||||
Outcome::Failed(Skip::Contrast {
|
||||
failures: report.failures.len(),
|
||||
})
|
||||
};
|
||||
(
|
||||
scheme,
|
||||
Provenance {
|
||||
level: Level::Preset,
|
||||
card: card_key(card),
|
||||
seed: None,
|
||||
flavor,
|
||||
adjustments,
|
||||
outcomes,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// 单级的处理结果。
|
||||
///
|
||||
/// `scheme` 装箱只是为了枚举大小(`Scheme` 是 49 个色值 ≈ 200 字节,逐级在栈上传会白白拷两次);
|
||||
/// 这条路径每次配色只走一次,堆分配的开销可忽略。
|
||||
enum Attempt {
|
||||
Absent,
|
||||
Failed(Skip),
|
||||
Used {
|
||||
scheme: Box<Scheme>,
|
||||
seed: Seed,
|
||||
adjustments: Vec<Adjustment>,
|
||||
},
|
||||
}
|
||||
|
||||
fn try_level(input: Option<ImageInput<'_>>, card: Card, flavor: SchemeFlavor) -> Attempt {
|
||||
let source = match input {
|
||||
None => return Attempt::Absent,
|
||||
Some(ImageInput::Undecodable) => return Attempt::Failed(Skip::Undecodable),
|
||||
Some(ImageInput::Pixels(source)) => source,
|
||||
};
|
||||
|
||||
let seed = match analyze_rgba(source.pixels, source.width, source.height) {
|
||||
Ok(seed) => seed,
|
||||
Err(failure) => return Attempt::Failed(Skip::from(failure)),
|
||||
};
|
||||
|
||||
// 取色方案的表面色温沿用当前色卡的中性彩度:换壁纸不该让窗口底色「冷热乱跳」。
|
||||
let (scheme, adjustments) = converge(seed.scheme(flavor, card.neutral_chroma));
|
||||
let report = check(&scheme);
|
||||
if !report.is_clean() {
|
||||
return Attempt::Failed(Skip::Contrast {
|
||||
failures: report.failures.len(),
|
||||
});
|
||||
}
|
||||
Attempt::Used {
|
||||
scheme: Box::new(scheme),
|
||||
seed,
|
||||
adjustments,
|
||||
}
|
||||
}
|
||||
|
||||
fn card_key(card: Card) -> CardKey {
|
||||
CardKey::from_key(card.key).unwrap_or(CardKey::Sky)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn solid(color: Color, width: u32, height: u32) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity((width * height * 4) as usize);
|
||||
for _ in 0..(width * height) {
|
||||
out.extend_from_slice(&[color.r, color.g, color.b, color.a]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn level_one_wins_when_background_is_good() {
|
||||
let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4);
|
||||
let (scheme, provenance) = resolve_source(
|
||||
Some(ImageInput::pixels(&pixels, 4, 4)),
|
||||
None,
|
||||
Some(CardKey::Forest),
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert_eq!(provenance.level, Level::BackgroundImage);
|
||||
assert_eq!(provenance.background(), Outcome::Used);
|
||||
assert_eq!(provenance.preset(), Outcome::Absent);
|
||||
assert!(provenance.hint_key().is_none());
|
||||
assert!(check(&scheme).is_clean());
|
||||
assert!(provenance.seed.is_some());
|
||||
// 取色方案的表面色温来自当前色卡(森林绿),不是默认卡。
|
||||
assert_eq!(provenance.card, CardKey::Forest);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_background_falls_back_to_wallpaper_with_reason() {
|
||||
// 反证 1:灰白背景图 → 不许凭空上色,必须带着 low-chroma 原因回落。
|
||||
let gray = solid(Color::rgb(0xF2, 0xF2, 0xF0), 8, 8);
|
||||
let teal = solid(Color::rgb(0x00, 0x79, 0x6B), 8, 8);
|
||||
let (_, provenance) = resolve_source(
|
||||
Some(ImageInput::pixels(&gray, 8, 8)),
|
||||
Some(ImageInput::pixels(&teal, 8, 8)),
|
||||
Some(CardKey::Sky),
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert_eq!(provenance.level, Level::Wallpaper);
|
||||
match provenance.background() {
|
||||
Outcome::Failed(Skip::LowChroma { chroma, .. }) => assert!(chroma < 8.0),
|
||||
other => panic!("背景图应当是低彩度失败,实际 {other:?}"),
|
||||
}
|
||||
assert_eq!(
|
||||
provenance.hint_key(),
|
||||
Some("palette.hint.background.low-chroma")
|
||||
);
|
||||
assert!(provenance.did_fall_back());
|
||||
assert_eq!(
|
||||
provenance.reason_codes(),
|
||||
vec![("background-image", "low-chroma")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undecodable_and_tiny_inputs_are_distinguished_from_absent() {
|
||||
let tiny = solid(Color::rgb(0xFF, 0x00, 0x00), 1, 2);
|
||||
let (_, provenance) = resolve_source(
|
||||
Some(ImageInput::Undecodable),
|
||||
Some(ImageInput::pixels(&tiny, 1, 2)),
|
||||
None,
|
||||
SchemeFlavor::Dark,
|
||||
);
|
||||
assert_eq!(provenance.level, Level::Preset);
|
||||
assert_eq!(provenance.background(), Outcome::Failed(Skip::Undecodable));
|
||||
assert_eq!(
|
||||
provenance.wallpaper(),
|
||||
Outcome::Failed(Skip::TooSmall {
|
||||
width: 1,
|
||||
height: 2
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
provenance.hint_key(),
|
||||
Some("palette.hint.background.undecodable")
|
||||
);
|
||||
// 从没选过卡 → 出厂默认。
|
||||
assert_eq!(provenance.card, CardKey::Sky);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_provided_uses_default_card_without_hint() {
|
||||
let (scheme, provenance) = resolve_source(None, None, None, SchemeFlavor::Light);
|
||||
assert_eq!(provenance.level, Level::Preset);
|
||||
assert_eq!(provenance.preset(), Outcome::Used);
|
||||
assert!(!provenance.did_fall_back());
|
||||
assert!(provenance.adjustments.is_empty());
|
||||
assert!(provenance.hint_key().is_none());
|
||||
assert_eq!(scheme, DEFAULT_CARD.scheme(SchemeFlavor::Light));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preset_selection_decides_the_fallback_colour() {
|
||||
// 反证 2:回落落点是「用户选的那张卡」,不是默认卡。
|
||||
let gray = solid(Color::rgb(0x80, 0x80, 0x80), 4, 4);
|
||||
let (scheme, provenance) = resolve_source(
|
||||
Some(ImageInput::pixels(&gray, 4, 4)),
|
||||
None,
|
||||
Some(CardKey::Rose),
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert_eq!(provenance.level, Level::Preset);
|
||||
assert_eq!(provenance.card, CardKey::Rose);
|
||||
assert_eq!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_beats_wallpaper_when_both_work() {
|
||||
let bg = solid(Color::rgb(0xB2, 0x6A, 0x00), 8, 8);
|
||||
let wp = solid(Color::rgb(0x00, 0x79, 0x6B), 8, 8);
|
||||
let (_, provenance) = resolve_source(
|
||||
Some(ImageInput::pixels(&bg, 8, 8)),
|
||||
Some(ImageInput::pixels(&wp, 8, 8)),
|
||||
Some(CardKey::Teal),
|
||||
SchemeFlavor::Dark,
|
||||
);
|
||||
assert_eq!(provenance.level, Level::BackgroundImage);
|
||||
assert_eq!(provenance.wallpaper(), Outcome::Absent, "② 根本没轮到");
|
||||
assert!(!provenance.did_fall_back());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transparent_background_reports_its_own_reason() {
|
||||
let transparent = solid(Color::rgba(0x3C, 0x8D, 0xBC, 0), 4, 4);
|
||||
let (_, provenance) = resolve_source(
|
||||
Some(ImageInput::pixels(&transparent, 4, 4)),
|
||||
None,
|
||||
None,
|
||||
SchemeFlavor::Light,
|
||||
);
|
||||
assert_eq!(
|
||||
provenance.background(),
|
||||
Outcome::Failed(Skip::NoOpaquePixels)
|
||||
);
|
||||
assert_eq!(
|
||||
provenance.hint_key(),
|
||||
Some("palette.hint.background.no-opaque-pixels")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
//! 已解码的 RGBA 像素 → 一个**种子色**(D38 第一/二级的入口,D40 的 `crates/design` 半边)。
|
||||
//!
|
||||
//! 这是整个取色链在 `crates/design` 里的**唯一公开入口**:零 IO、零第三方依赖,
|
||||
//! 只吃「调用方已经解码好的像素」。解码(PNG/JPEG/WebP…)在 `crates/palette-extract`,
|
||||
//! 分层理由见 D40。
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use unlucky_design::color::{Scheme, SchemeFlavor};
|
||||
//! use unlucky_design::palette::seed_from_rgba;
|
||||
//!
|
||||
//! // 一张 2×2 的天蓝图(调用方负责解码)
|
||||
//! let pixels: [u8; 16] = [
|
||||
//! 0x3C, 0x8D, 0xBC, 0xFF, 0x3C, 0x8D, 0xBC, 0xFF,
|
||||
//! 0x3C, 0x8D, 0xBC, 0xFF, 0x3C, 0x8D, 0xBC, 0xFF,
|
||||
//! ];
|
||||
//! let seed = seed_from_rgba(&pixels, 2, 2).expect("纯色图有确定的种子");
|
||||
//! let scheme = seed.scheme(SchemeFlavor::Light, 12.0);
|
||||
//! assert!(scheme.contrast(unlucky_design::color::Role::OnPrimary, unlucky_design::color::Role::Primary) >= 4.5);
|
||||
//! ```
|
||||
//!
|
||||
//! ## 五类输入的处置(每类都有单测)
|
||||
//!
|
||||
//! | 输入 | 处置 | 为什么 |
|
||||
//! |---|---|---|
|
||||
//! | 过小图(< 4 像素) | [`Failure::TooSmall`] | 1~3 个像素没有「主色」概念,任一个噪点都会变成整套配色 |
|
||||
//! | 纯色图(有彩度) | 正常取色 | 单色图只要够鲜艳就是合格的种子(不做特殊化) |
|
||||
//! | 灰度/灰白图 | [`Failure::LowChroma`](**不是**返回青灰色) | D40 实测:零彩度种子的 CAM16 色相恒为 209.49°,硬取色会把灰白壁纸「凭空上色」成青色 `#006874`;极低彩度段色相还是噪声放大器(`#808080` → 209.5°、`#7A7C7E` → 231.2°,摆 22°) |
|
||||
//! | 超大图(>65536 像素) | 先按 4^n 块做**块平均**降采样,再量化 | 取色只需要「色彩分布」,不需要分辨率;块平均能保住细纹理的平均色,避免等距抽样在条纹/网格图上走样 |
|
||||
//! | 解码失败 | 不在本 crate:见 `crates/palette-extract` | D40 分层 |
|
||||
//!
|
||||
//! 阈值 [`MIN_SEED_CHROMA`] = 8 是 **D40 的定案值**(`seed chroma < ~8 视为无信息量`),
|
||||
//! 与 [`super::score::CUTOFF_CHROMA`] = 5 分工不同:5 决定「能不能当候选」,8 决定
|
||||
//! 「这个候选够不够格当种子」。落在这条缝里(5~8)的图会有一堆候选,最终仍然回落色卡。
|
||||
|
||||
use super::quantize::{self, Cluster};
|
||||
use super::score;
|
||||
use crate::color::{Color, Palettes, Scheme, SchemeFlavor};
|
||||
|
||||
/// 最小像素数:低于它的图没有「主色」可言。
|
||||
pub const MIN_PIXELS: u32 = 4;
|
||||
/// 参与量化的最大采样点数(块的边长按它反推)。
|
||||
pub const MAX_SAMPLES: usize = 65_536;
|
||||
/// D40 的种子彩度门槛:低于它视为「这张图没有可用的颜色信息」。
|
||||
pub const MIN_SEED_CHROMA: f64 = 8.0;
|
||||
/// 「低彩度但可用」的第二道线:落在这条线下的种子会被标成 [`Seed::is_muted`],
|
||||
/// UI 可以提示「从这张图取到的颜色偏灰」。
|
||||
pub const MUTED_SEED_CHROMA: f64 = 16.0;
|
||||
/// alpha 低于它的像素不参与取色(半透明叠加会让颜色变成「混了背景的假色」)。
|
||||
const ALPHA_THRESHOLD: u8 = 128;
|
||||
|
||||
/// 取色结果:种子色 + 足以让 UI 解释「为什么是这个颜色」的观感数据。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Seed {
|
||||
/// 种子色(图上真实存在的颜色,不是各色平均出来的)。
|
||||
pub color: Color,
|
||||
/// 种子的 CAM16 色相角(度)。
|
||||
pub hue: f64,
|
||||
/// 种子的 CAM16 彩度。
|
||||
pub chroma: f64,
|
||||
/// 该色在其色相族里占全图的像素比例(0..1)。
|
||||
pub share: f64,
|
||||
}
|
||||
|
||||
impl Seed {
|
||||
/// 种子彩度偏低(可用但发灰)——UI 可提示「这张图颜色较淡」。
|
||||
pub fn is_muted(self) -> bool {
|
||||
self.chroma < MUTED_SEED_CHROMA
|
||||
}
|
||||
|
||||
/// 用这个种子生成一套完整调色板(中性彩度由调用方给,通常沿用用户当前选中色卡的取值)。
|
||||
pub fn palettes(self, neutral_chroma: f64) -> Palettes {
|
||||
Palettes::from_seed_with_neutral_chroma(self.color, neutral_chroma)
|
||||
}
|
||||
|
||||
/// 用这个种子生成一套完整方案(49 个角色)。
|
||||
pub fn scheme(self, flavor: SchemeFlavor, neutral_chroma: f64) -> Scheme {
|
||||
Scheme::from_palettes(&self.palettes(neutral_chroma), flavor)
|
||||
}
|
||||
}
|
||||
|
||||
/// 取色失败的原因。**每一种都要能变成一句人话**(Provenance 用它生成提示)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Failure {
|
||||
/// 像素缓冲长度与 `width × height × 4` 不符(调用方给错了数据)。
|
||||
BadDimensions {
|
||||
/// 宽。
|
||||
width: u32,
|
||||
/// 高。
|
||||
height: u32,
|
||||
/// 期望的字节数(`width × height × 4`)。
|
||||
expected: usize,
|
||||
/// 实际拿到的字节数。
|
||||
actual: usize,
|
||||
},
|
||||
/// 图太小(< [`MIN_PIXELS`] 像素)。
|
||||
TooSmall {
|
||||
/// 宽。
|
||||
width: u32,
|
||||
/// 高。
|
||||
height: u32,
|
||||
},
|
||||
/// 全是透明/半透明像素,没有可用的不透明像素。
|
||||
NoOpaquePixels,
|
||||
/// 彩度不足:`chroma < MIN_SEED_CHROMA`,硬取色会凭空上色。
|
||||
LowChroma {
|
||||
/// 最有希望的那个色(提示文案里当「最接近的颜色」)。
|
||||
color: Color,
|
||||
/// 实测 CAM16 彩度。
|
||||
chroma: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Failure {
|
||||
/// 稳定原因码(写日志 / 存设置 / 做提示映射的键,别用 `Debug` 输出)。
|
||||
pub const fn code(self) -> &'static str {
|
||||
match self {
|
||||
Failure::BadDimensions { .. } => "bad-dimensions",
|
||||
Failure::TooSmall { .. } => "too-small",
|
||||
Failure::NoOpaquePixels => "no-opaque-pixels",
|
||||
Failure::LowChroma { .. } => "low-chroma",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 取色并给出**完整原因**(推荐入口:失败时能生成提示)。
|
||||
pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result<Seed, Failure> {
|
||||
let total = (width as usize).saturating_mul(height as usize);
|
||||
if total < MIN_PIXELS as usize {
|
||||
return Err(Failure::TooSmall { width, height });
|
||||
}
|
||||
let expected = total.saturating_mul(4);
|
||||
if pixels.len() != expected {
|
||||
return Err(Failure::BadDimensions {
|
||||
width,
|
||||
height,
|
||||
expected,
|
||||
actual: pixels.len(),
|
||||
});
|
||||
}
|
||||
|
||||
let samples = sample_blocks(pixels, width as usize, height as usize);
|
||||
let opaque: Vec<Color> = samples
|
||||
.into_iter()
|
||||
.filter(|color| color.a >= ALPHA_THRESHOLD)
|
||||
.collect();
|
||||
if opaque.is_empty() {
|
||||
return Err(Failure::NoOpaquePixels);
|
||||
}
|
||||
|
||||
let clusters = quantize::quantize(&opaque);
|
||||
let ranked = score::rank(&clusters);
|
||||
match ranked.first() {
|
||||
Some(best) if best.chroma >= MIN_SEED_CHROMA => Ok(Seed {
|
||||
color: best.cluster.color,
|
||||
hue: best.hue,
|
||||
chroma: best.chroma,
|
||||
share: best.excited,
|
||||
}),
|
||||
// 有候选但都不够鲜艳:带着「最鲜艳的候选」和实测彩度回去,让 UI 能说清原因。
|
||||
Some(best) => Err(Failure::LowChroma {
|
||||
color: best.cluster.color,
|
||||
chroma: best.chroma,
|
||||
}),
|
||||
// 一个候选都没有(灰度图):把最鲜艳的簇的彩度报回去(通常是 0~4)。
|
||||
None => Err(Failure::LowChroma {
|
||||
color: most_populated(&clusters)
|
||||
.map(|cluster| cluster.color)
|
||||
.unwrap_or(Color::rgb(0x80, 0x80, 0x80)),
|
||||
chroma: score::max_chroma(&clusters),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// D40 的入口形状:`Option<Seed>`(`None` = 这张图没有可用的颜色信息)。
|
||||
///
|
||||
/// 需要知道「为什么没有」就用 [`analyze_rgba`]。
|
||||
pub fn seed_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option<Seed> {
|
||||
analyze_rgba(pixels, width, height).ok()
|
||||
}
|
||||
|
||||
fn most_populated(clusters: &[Cluster]) -> Option<&Cluster> {
|
||||
clusters.iter().max_by_key(|cluster| cluster.population)
|
||||
}
|
||||
|
||||
/// 块平均降采样:块边长按「不超过 [`MAX_SAMPLES`] 个采样点」反推,小图不降采样(块边长 1)。
|
||||
fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec<Color> {
|
||||
let mut block = 1usize;
|
||||
while width.div_ceil(block) * height.div_ceil(block) > MAX_SAMPLES {
|
||||
block += 1;
|
||||
}
|
||||
|
||||
let mut samples = Vec::with_capacity(width.div_ceil(block) * height.div_ceil(block));
|
||||
let mut top = 0usize;
|
||||
while top < height {
|
||||
let bottom = (top + block).min(height);
|
||||
let mut left = 0usize;
|
||||
while left < width {
|
||||
let right = (left + block).min(width);
|
||||
let mut sum = [0u64; 4];
|
||||
let mut count = 0u64;
|
||||
for y in top..bottom {
|
||||
for x in left..right {
|
||||
let offset = (y * width + x) * 4;
|
||||
for (channel, slot) in sum.iter_mut().enumerate() {
|
||||
*slot += u64::from(pixels[offset + channel]);
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
let average = |channel: usize| (sum[channel] / count) as u8;
|
||||
samples.push(Color::rgba(average(0), average(1), average(2), average(3)));
|
||||
left = right;
|
||||
}
|
||||
top = bottom;
|
||||
}
|
||||
samples
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 生成 `width × height` 的全 `color` 图。
|
||||
fn solid(color: Color, width: u32, height: u32) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity((width * height * 4) as usize);
|
||||
for _ in 0..(width * height) {
|
||||
out.extend_from_slice(&[color.r, color.g, color.b, color.a]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solid_vivid_image_yields_seed() {
|
||||
let pixels = solid(Color::rgb(0x3C, 0x8D, 0xBC), 4, 4);
|
||||
let seed = analyze_rgba(&pixels, 4, 4).expect("天蓝纯色图有种子");
|
||||
assert_eq!(seed.color, Color::from_hex("#3C8DBC").expect("合法 hex"));
|
||||
assert!(seed.chroma > MIN_SEED_CHROMA);
|
||||
assert!((seed.share - 1.0).abs() < 1.0e-6);
|
||||
assert!(!seed.is_muted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gray_white_image_is_rejected_with_measured_chroma() {
|
||||
// D40 的反证:灰白壁纸硬取色会得到青色 #006874,这里必须拒绝并报出彩度。
|
||||
let pixels = solid(Color::rgb(0xF2, 0xF2, 0xF0), 8, 8);
|
||||
match analyze_rgba(&pixels, 8, 8) {
|
||||
Err(Failure::LowChroma { chroma, .. }) => {
|
||||
assert!(chroma < MIN_SEED_CHROMA, "彩度 {chroma} 不该过门槛");
|
||||
}
|
||||
other => panic!("灰白图必须被拒,实际 {other:?}"),
|
||||
}
|
||||
assert_eq!(seed_from_rgba(&pixels, 8, 8), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn neutral_gray_hash_rejects_instead_of_inventing_cyan() {
|
||||
// 与 M70 复审实测的坏味道对齐:#808080 的 CAM16 色相 209.5°,硬取色会得到青色。
|
||||
let pixels = solid(Color::rgb(0x80, 0x80, 0x80), 4, 4);
|
||||
assert_eq!(seed_from_rgba(&pixels, 4, 4), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_images_are_too_small() {
|
||||
let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 1, 3);
|
||||
assert_eq!(
|
||||
analyze_rgba(&pixels, 1, 3),
|
||||
Err(Failure::TooSmall {
|
||||
width: 1,
|
||||
height: 3
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
Failure::TooSmall {
|
||||
width: 1,
|
||||
height: 3
|
||||
}
|
||||
.code(),
|
||||
"too-small"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_buffer_length_is_reported() {
|
||||
let pixels = solid(Color::rgb(0xFF, 0x00, 0x00), 2, 2);
|
||||
match analyze_rgba(&pixels[..12], 2, 2) {
|
||||
Err(Failure::BadDimensions {
|
||||
expected, actual, ..
|
||||
}) => {
|
||||
assert_eq!((expected, actual), (16, 12));
|
||||
}
|
||||
other => panic!("应当报维度错误,实际 {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fully_transparent_image_has_no_seed() {
|
||||
let pixels = solid(Color::rgba(0x3C, 0x8D, 0xBC, 0), 4, 4);
|
||||
assert_eq!(analyze_rgba(&pixels, 4, 4), Err(Failure::NoOpaquePixels));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_image_is_downsampled_and_still_deterministic() {
|
||||
let (width, height) = (512_u32, 512_u32);
|
||||
let mut pixels = Vec::with_capacity((width * height * 4) as usize);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
// 细网格:等距抽样会走样,块平均能得到稳定的底色。
|
||||
let checker = (x + y) % 2 == 0;
|
||||
pixels.extend_from_slice(if checker {
|
||||
&[0xD2, 0x3B, 0x2A, 0xFF]
|
||||
} else {
|
||||
&[0x2A, 0x3B, 0xD2, 0xFF]
|
||||
});
|
||||
}
|
||||
}
|
||||
let first = analyze_rgba(&pixels, width, height).expect("大图有种子");
|
||||
let second = analyze_rgba(&pixels, width, height).expect("可复现");
|
||||
assert_eq!(first, second);
|
||||
let samples = sample_blocks(&pixels, width as usize, height as usize);
|
||||
assert!(
|
||||
samples.len() <= MAX_SAMPLES,
|
||||
"采样点 {} 超标",
|
||||
samples.len()
|
||||
);
|
||||
// 块平均把红蓝网格合成偏紫的中间色,而不是撞上纯红或纯蓝之一。
|
||||
assert!(first.chroma > MIN_SEED_CHROMA, "{first:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_sampling_averages_instead_of_picking() {
|
||||
// 2×2 红蓝棋盘,块边长 1(图很小)→ 4 个采样点;只验证采样点数量与顺序稳定。
|
||||
let pixels = [
|
||||
0xFF, 0x00, 0x00, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x00,
|
||||
0x00, 0xFF,
|
||||
];
|
||||
let samples = sample_blocks(&pixels, 2, 2);
|
||||
assert_eq!(samples.len(), 4);
|
||||
assert_eq!(samples[0], Color::rgb(0xFF, 0x00, 0x00));
|
||||
assert_eq!(samples[1], Color::rgb(0x00, 0x00, 0xFF));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_scheme_meets_wcag_on_the_primary_pair() {
|
||||
let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4);
|
||||
let seed = analyze_rgba(&pixels, 4, 4).expect("砖红有种子");
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let scheme = seed.scheme(flavor, 12.0);
|
||||
let ratio = scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary);
|
||||
assert!(ratio >= 4.5, "{flavor:?} 只有 {ratio}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
//! 对比度校验与**收敛**:任意一套方案(不管种子来自图片还是色卡)都要过同一道门。
|
||||
//!
|
||||
//! 为什么需要这一层:`tests/contrast_aa.rs` 的 208 项门禁只覆盖**候选色卡**(它拿 6 个手挑的
|
||||
//! 种子逐对断言)。D38/D40 之后「方案生成」还可能吃图片来的种子,而远程证据不能只靠
|
||||
//! 「tone 轴固定所以一定达标」这句话——那条性质对本 crate 的生成器成立,但一旦有人手动改角色、
|
||||
//! 或者将来接别的生成器(比如非 TonalSpot 变体),门禁必须当场发现。所以:
|
||||
//!
|
||||
//! 1. [`check`] 对**任何** [`Scheme`] 跑全表(26 对文字/图标对,AA ≥ 4.5:1);
|
||||
//! 2. [`converge`] 不达标就**沿 tone 轴修正前景色**(保住色相,只动明度),并把每一处修正
|
||||
//! 连同「改前/改后的实测对比度」记进 [`Adjustment`],让 UI 能说「为了可读性微调了强调色」;
|
||||
//! 3. 修正后仍然不达标的方案,由 [`super::resolve`] 回落到色卡并在 `Provenance` 里登记原因。
|
||||
//!
|
||||
//! ## 为什么只改前景、不改背景
|
||||
//!
|
||||
//! 背景(`surface` / `*-container`)决定整个界面的骨架与层级,改它会让「同一套方案的两个页面
|
||||
//! 底色不一样」;前景(`on-*`)本来就是为可读性服务的,动它代价最小。方向也不是猜的:
|
||||
//! 背景 tone ≥ 50(浅底)就压暗前景,否则提亮前景——修正方向永远朝着「离背景更远」的那一侧。
|
||||
//!
|
||||
//! ## 只做 4.5:1 这一档,`outline` 不在门禁里
|
||||
//!
|
||||
//! M3 里 `outline` 是非文字元素(输入框描边),规范目标是 3:1,且它按设计就贴着 surface;
|
||||
//! `outline-variant` 是纯装饰分隔线。这两个的例外登记在 `crates/design/src/color/mod.rs` 的
|
||||
//! 「对比度纪律」小节,本模块不重复断言(重复断言只会让两边口径打架)。
|
||||
|
||||
use crate::color::{Color, Hct, Role, Scheme};
|
||||
|
||||
/// 正文/图标对的 AA 门槛(WCAG 2.x:普通文字 4.5:1)。
|
||||
pub const AA_TEXT: f32 = 4.5;
|
||||
/// 非文字元素的门槛(描边/图标块 3:1)——本模块不自动收敛这一档,只提供常量给调用方参考。
|
||||
pub const AA_NON_TEXT: f32 = 3.0;
|
||||
|
||||
/// 文字/图标对(前景, 承载色)。与 `tests/contrast_aa.rs` 的 `TEXT_PAIRS` 同表。
|
||||
pub const TEXT_PAIRS: [(Role, Role); 26] = [
|
||||
(Role::OnPrimary, Role::Primary),
|
||||
(Role::OnPrimaryContainer, Role::PrimaryContainer),
|
||||
(Role::OnSecondary, Role::Secondary),
|
||||
(Role::OnSecondaryContainer, Role::SecondaryContainer),
|
||||
(Role::OnTertiary, Role::Tertiary),
|
||||
(Role::OnTertiaryContainer, Role::TertiaryContainer),
|
||||
(Role::OnError, Role::Error),
|
||||
(Role::OnErrorContainer, Role::ErrorContainer),
|
||||
(Role::OnSurface, Role::Surface),
|
||||
(Role::OnSurfaceVariant, Role::SurfaceVariant),
|
||||
(Role::OnBackground, Role::Background),
|
||||
(Role::InverseOnSurface, Role::InverseSurface),
|
||||
(Role::InversePrimary, Role::InverseSurface),
|
||||
(Role::OnPrimaryFixed, Role::PrimaryFixed),
|
||||
(Role::OnPrimaryFixedVariant, Role::PrimaryFixedDim),
|
||||
(Role::OnSecondaryFixed, Role::SecondaryFixed),
|
||||
(Role::OnSecondaryFixedVariant, Role::SecondaryFixedDim),
|
||||
(Role::OnTertiaryFixed, Role::TertiaryFixed),
|
||||
(Role::OnTertiaryFixedVariant, Role::TertiaryFixedDim),
|
||||
(Role::OnSurface, Role::SurfaceContainerLowest),
|
||||
(Role::OnSurface, Role::SurfaceContainerLow),
|
||||
(Role::OnSurface, Role::SurfaceContainer),
|
||||
(Role::OnSurface, Role::SurfaceContainerHigh),
|
||||
(Role::OnSurface, Role::SurfaceContainerHighest),
|
||||
(Role::OnSurfaceVariant, Role::SurfaceContainerHighest),
|
||||
(Role::OnSurfaceVariant, Role::SurfaceContainerLow),
|
||||
];
|
||||
|
||||
/// 一处不达标。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Failure {
|
||||
/// 前景角色。
|
||||
pub fg: Role,
|
||||
/// 承载色角色。
|
||||
pub bg: Role,
|
||||
/// 实测对比度。
|
||||
pub ratio: f32,
|
||||
/// 要求的下限(目前恒为 [`AA_TEXT`])。
|
||||
pub required: f32,
|
||||
}
|
||||
|
||||
/// 一套方案的对比度体检结果。
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WcagReport {
|
||||
/// 全部不达标项(空 = 通过)。
|
||||
pub failures: Vec<Failure>,
|
||||
/// 全表里最低的对比度(用来观察「刚好卡线」的方案)。
|
||||
pub worst: f32,
|
||||
}
|
||||
|
||||
impl WcagReport {
|
||||
/// 是否全部达标。
|
||||
pub fn is_clean(&self) -> bool {
|
||||
self.failures.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// 对任意方案跑全表断言。
|
||||
pub fn check(scheme: &Scheme) -> WcagReport {
|
||||
let mut failures = Vec::new();
|
||||
let mut worst = f32::INFINITY;
|
||||
for (fg, bg) in TEXT_PAIRS {
|
||||
let ratio = scheme.contrast(fg, bg);
|
||||
worst = worst.min(ratio);
|
||||
if ratio < AA_TEXT {
|
||||
failures.push(Failure {
|
||||
fg,
|
||||
bg,
|
||||
ratio,
|
||||
required: AA_TEXT,
|
||||
});
|
||||
}
|
||||
}
|
||||
WcagReport { failures, worst }
|
||||
}
|
||||
|
||||
/// 一处自动修正的记录(UI 可以拿去写「为可读性微调了强调色」)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Adjustment {
|
||||
/// 被改的角色。
|
||||
pub role: Role,
|
||||
/// 为了跟谁达标而改的(承载色角色)。
|
||||
pub against: Role,
|
||||
/// 改前的色值。
|
||||
pub before: Color,
|
||||
/// 改后的色值。
|
||||
pub after: Color,
|
||||
/// 改前的对比度。
|
||||
pub before_ratio: f32,
|
||||
/// 改后的对比度。
|
||||
pub after_ratio: f32,
|
||||
}
|
||||
|
||||
/// 不达标就沿 tone 轴修正,返回(修正后的方案, 修正记录)。
|
||||
///
|
||||
/// 最多三遍:一遍修正后可能让「同一个前景对另一个承载色」变得不达标(例如 `on-surface`
|
||||
/// 同时压在 6 个 surface-container 上),多遍保证收敛。修不动的情况(理论上不存在:纯黑/纯白
|
||||
/// 对任意背景都 ≥ 4.58:1)会保留原值,由调用方用 [`check`] 复核后决定是否回落。
|
||||
pub fn converge(scheme: Scheme) -> (Scheme, Vec<Adjustment>) {
|
||||
let mut colors = [Color::BLACK; 49];
|
||||
for (role, color) in scheme.iter() {
|
||||
colors[role as usize] = color;
|
||||
}
|
||||
|
||||
let mut adjustments = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let mut changed = false;
|
||||
for (fg, bg) in TEXT_PAIRS {
|
||||
let before = colors[fg as usize];
|
||||
let background = colors[bg as usize];
|
||||
let before_ratio = before.contrast_ratio(background);
|
||||
if before_ratio >= AA_TEXT {
|
||||
continue;
|
||||
}
|
||||
let Some(after) = shift_away_from(before, background, AA_TEXT) else {
|
||||
continue;
|
||||
};
|
||||
colors[fg as usize] = after;
|
||||
adjustments.push(Adjustment {
|
||||
role: fg,
|
||||
against: bg,
|
||||
before,
|
||||
after,
|
||||
before_ratio,
|
||||
after_ratio: after.contrast_ratio(background),
|
||||
});
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(Scheme::from_colors(scheme.flavor(), colors), adjustments)
|
||||
}
|
||||
|
||||
/// 保住色相、压掉彩度地往「离背景更远」的 tone 走,直到达标或到尽头。
|
||||
fn shift_away_from(foreground: Color, background: Color, min_ratio: f32) -> Option<Color> {
|
||||
let hct = Hct::from_color(foreground);
|
||||
let darken = background.lstar() >= 50.0;
|
||||
let step = if darken { -4.0 } else { 4.0 };
|
||||
let mut tone = hct.tone.round();
|
||||
for _ in 0..=25 {
|
||||
tone += step;
|
||||
if !(0.0..=100.0).contains(&tone) {
|
||||
break;
|
||||
}
|
||||
// 修正是「为了可读性」而不是「换个品牌色」:色相不动,彩度让求解器自己压到色域内。
|
||||
let candidate = Hct::from_components(hct.hue, hct.chroma, tone).to_color();
|
||||
if candidate.contrast_ratio(background) >= min_ratio {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
// 尽头兜底:纯黑/纯白对任何背景都在 4.58:1 以上(中灰处的理论最低点)。
|
||||
let extreme = if darken { Color::BLACK } else { Color::WHITE };
|
||||
(extreme.contrast_ratio(background) >= min_ratio).then_some(extreme)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::color::SchemeFlavor;
|
||||
|
||||
#[test]
|
||||
fn baseline_is_clean() {
|
||||
for flavor in [SchemeFlavor::Light, SchemeFlavor::Dark] {
|
||||
let report = check(&Scheme::baseline(flavor));
|
||||
assert!(report.is_clean(), "{flavor:?}: {:?}", report.failures);
|
||||
assert!(report.worst >= AA_TEXT, "{flavor:?} worst {}", report.worst);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_scheme_is_detected() {
|
||||
// 反证:把 on-surface 换成 surface(对比度 1:1),校验器必须报出来。
|
||||
let base = Scheme::baseline(SchemeFlavor::Light);
|
||||
let mut colors = [Color::BLACK; 49];
|
||||
for (role, color) in base.iter() {
|
||||
colors[role as usize] = color;
|
||||
}
|
||||
colors[Role::OnSurface as usize] = base.get(Role::Surface);
|
||||
let hostile = Scheme::from_colors(SchemeFlavor::Light, colors);
|
||||
let report = check(&hostile);
|
||||
assert!(!report.is_clean());
|
||||
assert!(report
|
||||
.failures
|
||||
.iter()
|
||||
.any(|failure| failure.fg == Role::OnSurface && failure.bg == Role::Surface));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converge_repairs_a_hostile_scheme() {
|
||||
let base = Scheme::baseline(SchemeFlavor::Light);
|
||||
let mut colors = [Color::BLACK; 49];
|
||||
for (role, color) in base.iter() {
|
||||
colors[role as usize] = color;
|
||||
}
|
||||
colors[Role::OnSurface as usize] = base.get(Role::Surface);
|
||||
colors[Role::OnPrimary as usize] = base.get(Role::Primary);
|
||||
let hostile = Scheme::from_colors(SchemeFlavor::Light, colors);
|
||||
|
||||
let (fixed, adjustments) = converge(hostile);
|
||||
assert!(!adjustments.is_empty(), "应当记录了修正");
|
||||
assert!(check(&fixed).is_clean(), "{:?}", check(&fixed).failures);
|
||||
for adjustment in &adjustments {
|
||||
assert!(adjustment.after_ratio >= AA_TEXT);
|
||||
assert!(adjustment.after_ratio > adjustment.before_ratio);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converge_is_a_no_op_on_a_healthy_scheme() {
|
||||
let clean = Scheme::baseline(SchemeFlavor::Dark);
|
||||
let (fixed, adjustments) = converge(clean);
|
||||
assert!(adjustments.is_empty());
|
||||
assert_eq!(fixed, clean);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_darkens_on_light_background_and_lightens_on_dark() {
|
||||
let light_bg = Scheme::baseline(SchemeFlavor::Light);
|
||||
let surface = light_bg.get(Role::Surface);
|
||||
let fixed = shift_away_from(surface, surface, 4.5).expect("浅底上前景可以压暗");
|
||||
assert!(
|
||||
fixed.lstar() < surface.lstar() - 20.0,
|
||||
"压暗后 tone = {}",
|
||||
fixed.lstar()
|
||||
);
|
||||
assert!(fixed.contrast_ratio(surface) >= 4.5);
|
||||
|
||||
let dark_bg = Scheme::baseline(SchemeFlavor::Dark);
|
||||
let surface = dark_bg.get(Role::Surface);
|
||||
let fixed = shift_away_from(surface, surface, 4.5).expect("深底上前景可以提亮");
|
||||
assert!(
|
||||
fixed.lstar() > surface.lstar() + 20.0,
|
||||
"提亮后 tone = {}",
|
||||
fixed.lstar()
|
||||
);
|
||||
assert!(fixed.contrast_ratio(surface) >= 4.5);
|
||||
// 色相不跟着动:修正只动明度(改前改后的 CAM16 色相差 < 2°)。
|
||||
let before = Hct::from_color(surface);
|
||||
let after = Hct::from_color(fixed);
|
||||
let delta = (before.hue - after.hue)
|
||||
.abs()
|
||||
.min(360.0 - (before.hue - after.hue).abs());
|
||||
assert!(delta < 2.0, "色相被改了 {delta}°");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user