From 18327fb0385b15a4e4a4c615f8ba7b666b2e5dc7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:12:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(design):=20=E9=85=8D=E8=89=B2=E6=9D=A5?= =?UTF-8?q?=E6=BA=90=E7=BA=AF=E6=95=B0=E5=AD=A6=E5=B1=82=EF=BC=88Monet=20?= =?UTF-8?q?=E5=BC=8F=E9=87=8F=E5=8C=96=E5=8F=96=E8=89=B2=20+=20=E8=89=B2?= =?UTF-8?q?=E5=8D=A1=20+=20=E4=B8=89=E7=BA=A7=E5=9B=9E=E8=90=BD=20+=20?= =?UTF-8?q?=E5=AF=B9=E6=AF=94=E5=BA=A6=E6=94=B6=E6=95=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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(原因码/提示键) --- crates/design/src/color/mod.rs | 3 +- crates/design/src/color/scheme.rs | 12 + crates/design/src/color/space.rs | 14 + crates/design/src/lib.rs | 8 +- crates/design/src/palette/card.rs | 289 ++++++++++++++ crates/design/src/palette/mod.rs | 60 +++ crates/design/src/palette/quantize.rs | 338 ++++++++++++++++ crates/design/src/palette/resolve.rs | 552 ++++++++++++++++++++++++++ crates/design/src/palette/score.rs | 191 +++++++++ crates/design/src/palette/seed.rs | 352 ++++++++++++++++ crates/design/src/palette/wcag.rs | 281 +++++++++++++ 11 files changed, 2098 insertions(+), 2 deletions(-) create mode 100644 crates/design/src/palette/card.rs create mode 100644 crates/design/src/palette/mod.rs create mode 100644 crates/design/src/palette/quantize.rs create mode 100644 crates/design/src/palette/resolve.rs create mode 100644 crates/design/src/palette/score.rs create mode 100644 crates/design/src/palette/seed.rs create mode 100644 crates/design/src/palette/wcag.rs diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index c0309d4..b09f830 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -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; diff --git a/crates/design/src/color/scheme.rs b/crates/design/src/color/scheme.rs index 1d8a008..68892f8 100644 --- a/crates/design/src/color/scheme.rs +++ b/crates/design/src/color/scheme.rs @@ -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]; diff --git a/crates/design/src/color/space.rs b/crates/design/src/color/space.rs index 04f92cd..e721235 100644 --- a/crates/design/src/color/space.rs +++ b/crates/design/src/color/space.rs @@ -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)); diff --git a/crates/design/src/lib.rs b/crates/design/src/lib.rs index 0eeb1de..f479ac1 100644 --- a/crates/design/src/lib.rs +++ b/crates/design/src/lib.rs @@ -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; diff --git a/crates/design/src/palette/card.rs b/crates/design/src/palette/card.rs new file mode 100644 index 0000000..6d0a580 --- /dev/null +++ b/crates/design/src/palette/card.rs @@ -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::ALL.into_iter().find(|card| card.key() == key) + } +} + +impl From 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 = 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) + ); + } + } +} diff --git a/crates/design/src/palette/mod.rs b/crates/design/src/palette/mod.rs new file mode 100644 index 0000000..c5e2f39 --- /dev/null +++ b/crates/design/src/palette/mod.rs @@ -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; diff --git a/crates/design/src/palette/quantize.rs b/crates/design/src/palette/quantize.rs new file mode 100644 index 0000000..365cd93 --- /dev/null +++ b/crates/design/src/palette/quantize.rs @@ -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 { + 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 { + 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 { + let mut by_population: Vec = (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 = 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> = 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 = 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:?}" + ); + } +} diff --git a/crates/design/src/palette/resolve.rs b/crates/design/src/palette/resolve.rs new file mode 100644 index 0000000..8bc4002 --- /dev/null +++ b/crates/design/src/palette/resolve.rs @@ -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 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, + /// 深浅模式。 + pub flavor: SchemeFlavor, + /// 对比度收敛改了哪些角色(空 = 没动)。 + pub adjustments: Vec, + 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>, + wallpaper: Option>, + preset: Option, + 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, + seed: Seed, + adjustments: Vec, + }, +} + +fn try_level(input: Option>, 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 { + 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") + ); + } +} diff --git a/crates/design/src/palette/score.rs b/crates/design/src/palette/score.rs new file mode 100644 index 0000000..d0222f3 --- /dev/null +++ b/crates/design/src/palette/score.rs @@ -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 { + let total: u64 = clusters.iter().map(|c| u64::from(c.population)).sum(); + if total == 0 { + return Vec::new(); + } + + let hcts: Vec = 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 = 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::>(), + second.iter().map(|r| r.cluster.color).collect::>() + ); + } + + #[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); + } +} diff --git a/crates/design/src/palette/seed.rs b/crates/design/src/palette/seed.rs new file mode 100644 index 0000000..a0403c3 --- /dev/null +++ b/crates/design/src/palette/seed.rs @@ -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 { + 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 = 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`(`None` = 这张图没有可用的颜色信息)。 +/// +/// 需要知道「为什么没有」就用 [`analyze_rgba`]。 +pub fn seed_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option { + 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 { + 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 { + 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}"); + } + } +} diff --git a/crates/design/src/palette/wcag.rs b/crates/design/src/palette/wcag.rs new file mode 100644 index 0000000..2830239 --- /dev/null +++ b/crates/design/src/palette/wcag.rs @@ -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, + /// 全表里最低的对比度(用来观察「刚好卡线」的方案)。 + 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) { + 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 { + 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}°"); + } +}