From 18327fb0385b15a4e4a4c615f8ba7b666b2e5dc7 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:12:37 +0800 Subject: [PATCH 01/11] =?UTF-8?q?feat(design):=20=E9=85=8D=E8=89=B2?= =?UTF-8?q?=E6=9D=A5=E6=BA=90=E7=BA=AF=E6=95=B0=E5=AD=A6=E5=B1=82=EF=BC=88?= =?UTF-8?q?Monet=20=E5=BC=8F=E9=87=8F=E5=8C=96=E5=8F=96=E8=89=B2=20+=20?= =?UTF-8?q?=E8=89=B2=E5=8D=A1=20+=20=E4=B8=89=E7=BA=A7=E5=9B=9E=E8=90=BD?= =?UTF-8?q?=20+=20=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}°"); + } +} From 1aac62f6f0363ec1fe9471cca23995466f56f5fc Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:17:43 +0800 Subject: [PATCH 02/11] =?UTF-8?q?feat(palette-extract):=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E8=A7=A3=E7=A0=81=E9=80=82=E9=85=8D=E5=B1=82=EF=BC=88?= =?UTF-8?q?D40=20=E7=AC=AC=202=20=E5=B1=82=EF=BC=8C=E5=94=AF=E4=B8=80?= =?UTF-8?q?=E5=BC=95=E5=85=A5=20image=20=E7=9A=84=E5=9C=B0=E6=96=B9?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 crate unlucky-palette-extract:路径/字节 → RGBA → design 的种子 - image 只开 png + jpeg(关 default-features,不拖 rayon/avif/webp) - 解码前按 Limits 拒超大尺寸(解压炸弹),解码后最长边缩到 256 - input(&Result) 把失败翻成 ImageInput::Undecodable(回落链靠它区分「没设」与「坏了」) - 11 个单测覆盖纯色/JPEG/抄错扩展名/垃圾字节/截断/缺文件/1px/灰图/大图/超限/零拷贝 --- Cargo.lock | 8 ++ Cargo.toml | 7 + crates/palette-extract/Cargo.toml | 26 ++++ crates/palette-extract/src/lib.rs | 215 ++++++++++++++++++++++++++++ crates/palette-extract/src/tests.rs | 154 ++++++++++++++++++++ 5 files changed, 410 insertions(+) create mode 100644 crates/palette-extract/Cargo.toml create mode 100644 crates/palette-extract/src/lib.rs create mode 100644 crates/palette-extract/src/tests.rs diff --git a/Cargo.lock b/Cargo.lock index 1e3da14..90dd0b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6942,6 +6942,14 @@ dependencies = [ name = "unlucky-design" version = "0.1.0" +[[package]] +name = "unlucky-palette-extract" +version = "0.1.0" +dependencies = [ + "image", + "unlucky-design", +] + [[package]] name = "unlucky-presenter" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1caed83..ab81a1b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,9 @@ members = [ # P4(D26 iced 重写)第一步:与 Slint 版 `crates/app` 并存的新 UI crate。 # bin 名 `unlucky-launcher-iced`,翻牌时才改名(见 DevDocs/SubAgentsTalk/app-iced-骨架与翻牌.md)。 "crates/app-iced", + # M87(D40 第 2 层):配色取色的解码适配层。**唯一**允许引入 `image` 类依赖的地方, + # 使 `crates/design` 能继续宣称「零第三方依赖」。 + "crates/palette-extract", ] [workspace.package] @@ -61,6 +64,10 @@ zip = { version = "9", default-features = false, features = ["deflate-flate2-zli flate2 = "1" tar = "0.4" +# M87(D40):配色取色的解码适配层。版本与 `crates/app-iced` 的截图通道对齐(同一份 +# `image` 只编译一次);编解码器由各 crate 自己按需开 feature,这里只钉版本。 +image = { version = "0.25", default-features = false } + # UI 层唯一入口(D2:Slint,RF 2.0 轨道)。 # 默认特性已含 backend-default + renderer-femtovg(GL)+ renderer-software(CPU 软渲染兜底, # Win7 老机无可用 GPU 时的降级路径,见 DevDocs/PlanDoc/01c-native-gui-options.md §6)。 diff --git a/crates/palette-extract/Cargo.toml b/crates/palette-extract/Cargo.toml new file mode 100644 index 0000000..d70a98f --- /dev/null +++ b/crates/palette-extract/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "unlucky-palette-extract" +description = "配色取色的解码适配层(D40 第 2 层):图片路径/字节 → RGBA 像素 → unlucky-design 的种子" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true +publish.workspace = true + +# 分层纪律(D40):`crates/design` 保持零第三方依赖,**图片解码只能在这里**。 +# 本 crate 的职责边界很窄:解码 + 限尺寸 + 把像素交给 design,不碰 UI、不碰设置、不碰壁纸探测。 +[dependencies] +unlucky-design = { path = "../design", version = "0.1" } + +# 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): +# - png:启动器背景图最常见的无损格式,也是截图通道用的格式; +# - jpeg:Windows 壁纸与用户照片的绝对主力(不引它等于「取色对一半用户不可用」)。 +# 不开 `default-features`(默认会拖进 rayon + avif/ravif + webp + tiff + gif + exr + qoi +# 等一整套编码器,体积与审计面都翻倍);不开 webp/tiff/avif 是因为 M73 的壁纸探测拿到的 +# 就是 png/jpg/bmp 三类,等真有人需要再单开一个 feature。 +image = { workspace = true, default-features = false, features = ["png", "jpeg"] } + +[lints] +workspace = true diff --git a/crates/palette-extract/src/lib.rs b/crates/palette-extract/src/lib.rs new file mode 100644 index 0000000..232e2cc --- /dev/null +++ b/crates/palette-extract/src/lib.rs @@ -0,0 +1,215 @@ +//! # 配色取色的解码适配层(D40 三层里的第 2 层) +//! +//! 本 crate 只做一件事:**图片路径 / 字节 → RGBA 像素 → `unlucky_design` 的种子色**。 +//! +//! ```no_run +//! use unlucky_palette_extract::{input, load}; +//! use unlucky_design::palette::{resolve_source, CardKey}; +//! use unlucky_design::color::SchemeFlavor; +//! +//! // ① 读用户设的启动器背景图(失败不 panic,失败状态会原样进 Provenance) +//! let background = load("D:/Pictures/bg.png"); +//! let (scheme, provenance) = resolve_source( +//! Some(input(&background)), +//! None, // ② 桌面壁纸:本次不启用 +//! Some(CardKey::Sky), // ③ 用户选中的色卡 = 回落目标 +//! SchemeFlavor::Light, +//! ); +//! println!("{:?} / {:?}", provenance.level, provenance.hint_key()); +//! ``` +//! +//! ## 为什么解码要单独成 crate(D40) +//! +//! `crates/design` 的卖点是「零第三方依赖」(`cargo tree -p unlucky-design` 单行),而图片解码 +//! 必然拉依赖。把 `image` 塞进 design 会让那句卖点变成假话,所以: +//! +//! | 层 | crate | 依赖 | 职责 | +//! |---|---|---|---| +//! | 1 纯数学 | `unlucky-design` | 无 | 像素/种子 → 49 角色方案、色卡、回落决策、对比度收敛 | +//! | 2 解码 | **本 crate** | `image`(png + jpeg) | 路径/字节 → RGBA 像素;限尺寸;错误分类 | +//! | 3 调用侧 | 后续 appearance/settings mission | UI 框架 | 拿路径、把 `Provenance` 变文案 | +//! +//! ## 为什么选 `image`(而不是 `png` + `zune-jpeg` 各接一个) +//! +//! 1. **仓库里已经有它**:`crates/app-iced` 的截图通道用 `image`(png)。同一份依赖只编译一次, +//! 版本由 workspace 的 `image = "0.25"` 统一钉住,`cargo deny` 的许可面不新增条目。 +//! 2. **格式嗅探现成**:`ImageReader::with_guessed_format()` 按魔数判格式,用户把 `.jpg` +//! 改成 `.png` 也能读(壁纸文件被改名的概率不低)。 +//! 3. **有 `Limits`**:可以在解码前拒绝「头里写着 40000×40000」的 PNG(压缩炸弹), +//! 单接 `png` crate 也要自己写这层判断。 +//! +//! 许可:`image` 是 `MIT OR Apache-2.0`,在 `deny.toml` 白名单内(`[licenses].allow`)。 +//! +//! ## 体积影响(`image` 只开 png + jpeg,关掉 default-features) +//! +//! 拉进的传递依赖就这几个:`png`(+ `fdeflate`/`miniz_oxide`/`crc32fast`/`flate2` 已在树里)、 +//! `zune-jpeg` + `zune-core`、`byteorder-lite`、`num-traits`、`moxcms`/`pxfm`(色彩转换)。 +//! 关掉的默认特性里有 `rayon`(省一个线程池)、`avif`/`ravif`(最大的一个,编解码器整套)、 +//! `webp`/`tiff`/`gif`/`exr`/`qoi`/`hdr`/`dds`/`ff`。实测增量见 M87 报告(`cargo tree` 前后对比)。 +//! +//! ## 两条硬纪律 +//! +//! 1. **解码后才交出去**:送进 `unlucky_design` 的像素最多 [`MAX_EDGE`] 边长(默认 256)。 +//! 取色只需要色彩分布,不需要分辨率;一张 4K 壁纸缩到 256×144 后量化结果与全分辨率 +//! 在肉眼层面一致,内存却从 33MB 掉到 147KB。`crates/design` 里还有第二道块平均降采样 +//! (防御式:直接调 design 的调用方也能拿到有界开销)。 +//! 2. **失败不 panic、不 return 默认色**:解码失败只变成 [`ExtractError`], +//! [`input`] 把它翻成 `ImageInput::Undecodable` 交给 `resolve_source`, +//! 由后者回落色卡并在 `Provenance` 里留下原因。**不许**在这里「读不到就给个蓝色」。 + +use std::io::Cursor; +use std::path::Path; + +use unlucky_design::palette::{self, ImageInput, ImageSource, Seed}; + +/// 交给 `unlucky_design` 前允许的最大边长(像素)。 +/// +/// 256 的依据:量化器自己只采 65536 个点(≈256²),再大的图在 `crates/design` 里也会被 +/// 块平均降采样;提前缩小只是省内存与解码时间。可预测、可测试(见 `tests.rs`)。 +pub const MAX_EDGE: u32 = 256; + +/// 解码**之前**允许的图片头尺寸上限。 +/// +/// 4K 壁纸是 3840×2160,8K 是 7680×4320;16384 留出 2 倍余量,同时挡住「头里写着 +/// 40000×40000」的解压炸弹(否则解码器会先按头分配几十 GB)。 +const MAX_SOURCE_EDGE: u32 = 16_384; + +/// 解码后的 RGBA8 图像(行主序,`width × height × 4` 字节)。 +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rgba8 { + width: u32, + height: u32, + pixels: Vec, +} + +impl Rgba8 { + /// 宽(像素)。 + pub fn width(&self) -> u32 { + self.width + } + + /// 高(像素)。 + pub fn height(&self) -> u32 { + self.height + } + + /// 原始 RGBA8 字节(`width × height × 4`)。 + pub fn pixels(&self) -> &[u8] { + &self.pixels + } + + /// 包成 `unlucky_design` 的借用视图(零拷贝,供 `resolve_source` 用)。 + pub fn view(&self) -> ImageSource<'_> { + ImageSource { + pixels: &self.pixels, + width: self.width, + height: self.height, + } + } +} + +/// 解码/取色失败的原因。 +#[derive(Debug)] +pub enum ExtractError { + /// 读文件失败(不存在、无权限、被占用)。 + Io(std::io::Error), + /// 解码失败(格式不认识、文件截断、超出 [`MAX_SOURCE_EDGE`] 限制)。 + Decode(image::ImageError), + /// 解码成功但取色失败(图太小、全透明、彩度不足……原因在 `unlucky_design`)。 + Palette(palette::Failure), +} + +impl std::fmt::Display for ExtractError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ExtractError::Io(error) => write!(f, "读取图片失败:{error}"), + ExtractError::Decode(error) => write!(f, "解码图片失败:{error}"), + ExtractError::Palette(failure) => { + write!(f, "取色失败({}):{failure:?}", failure.code()) + } + } + } +} + +impl std::error::Error for ExtractError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + ExtractError::Io(error) => Some(error), + ExtractError::Decode(error) => Some(error), + ExtractError::Palette(_) => None, + } + } +} + +/// 从内存字节解码(格式按魔数嗅探,不看扩展名)。 +pub fn decode_bytes(bytes: &[u8]) -> Result { + let reader = image::ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(ExtractError::Io)?; + finish(reader) +} + +/// 从文件路径解码(格式按魔数嗅探,不看扩展名)。 +pub fn load(path: impl AsRef) -> Result { + let reader = image::ImageReader::open(path).map_err(ExtractError::Io)?; + let reader = reader.with_guessed_format().map_err(ExtractError::Io)?; + finish(reader) +} + +/// 路径 → 种子色(解码 + 量化一步到位;只需要 bool「能不能取」时用 [`input`])。 +pub fn extract_seed(path: impl AsRef) -> Result { + seed_from_rgba8(&load(path)?) +} + +/// 字节 → 种子色(与 [`extract_seed`] 同一条路径,测试与「内存里的图」用)。 +pub fn seed_from_bytes(bytes: &[u8]) -> Result { + seed_from_rgba8(&decode_bytes(bytes)?) +} + +fn seed_from_rgba8(image: &Rgba8) -> Result { + palette::analyze_rgba(image.pixels(), image.width(), image.height()) + .map_err(ExtractError::Palette) +} + +/// 把「读到了 / 没读到」翻成 `resolve_source` 的输入。 +/// +/// 这是本 crate 与编排层之间**唯一**的接线点:`Err` → `ImageInput::Undecodable` +/// (回落链会据此写 `Skip::Undecodable` 而不是 `Skip::Absent`——两者对用户的含义不同: +/// 「你没设背景图」 vs 「你的背景图坏了」)。 +pub fn input<'a>(loaded: &'a Result) -> ImageInput<'a> { + match loaded { + Ok(image) => ImageInput::Pixels(image.view()), + Err(_) => ImageInput::Undecodable, + } +} + +fn finish( + mut reader: image::ImageReader, +) -> Result { + let mut limits = image::Limits::default(); + limits.max_image_width = Some(MAX_SOURCE_EDGE); + limits.max_image_height = Some(MAX_SOURCE_EDGE); + reader.limits(limits); + + let decoded = reader.decode().map_err(ExtractError::Decode)?; + let rgba = decoded.to_rgba8(); + let (width, height) = (rgba.width(), rgba.height()); + let scaled = if width.max(height) > MAX_EDGE { + // 等比缩到长边 = MAX_EDGE。`thumbnail` 是整数快速采样,对取色(只关心色彩分布) + // 足够,且比 Lanczos 快一个量级。 + let factor = f64::from(MAX_EDGE) / f64::from(width.max(height)); + let target_width = ((f64::from(width) * factor).round() as u32).max(1); + let target_height = ((f64::from(height) * factor).round() as u32).max(1); + image::imageops::thumbnail(&rgba, target_width, target_height) + } else { + rgba + }; + Ok(Rgba8 { + width: scaled.width(), + height: scaled.height(), + pixels: scaled.into_raw(), + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs new file mode 100644 index 0000000..7ba9755 --- /dev/null +++ b/crates/palette-extract/src/tests.rs @@ -0,0 +1,154 @@ +//! 解码适配层的单测:真 PNG / 真 JPEG 字节进,种子或**具体错误**出。 +//! +//! 这里刻意不 mock 解码器:本 crate 存在的意义就是「真解码」,mock 掉它等于不测。 +//! 测试用内存里的字节(`encode_*` 现造),不依赖任何外部素材文件。 + +use std::io::Cursor; + +use super::*; +use image::{DynamicImage, ImageFormat, Rgba, RgbaImage}; + +fn solid_image(width: u32, height: u32, rgb: [u8; 3]) -> RgbaImage { + RgbaImage::from_pixel(width, height, Rgba([rgb[0], rgb[1], rgb[2], 0xFF])) +} + +fn encode_png(image: &RgbaImage) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(image.clone()) + .write_to(&mut bytes, ImageFormat::Png) + .expect("PNG 编码应当成功"); + bytes.into_inner() +} + +fn encode_jpeg(image: &RgbaImage, quality: u8) -> Vec { + let mut bytes = Cursor::new(Vec::new()); + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, quality); + encoder + .encode_image(&DynamicImage::ImageRgba8(image.clone())) + .expect("JPEG 编码应当成功"); + bytes.into_inner() +} + +#[test] +fn solid_png_keeps_the_exact_seed_colour() { + let png = encode_png(&solid_image(32, 32, [0xC0, 0x39, 0x2B])); + let seed = seed_from_bytes(&png).expect("砖红纯色图应当有种子"); + // 32×32 不触发缩放;design 侧对纯色图做桶内原值平均 → 逐通道相等。 + assert_eq!(seed.color.r, 0xC0); + assert_eq!(seed.color.g, 0x39); + assert_eq!(seed.color.b, 0x2B); + assert!(seed.chroma > 8.0); +} + +#[test] +fn jpeg_decoding_is_wired_up() { + // 只验证「jpeg feature 真的接上了」:JPEG 会动色,只要求色相别跑偏太多。 + let jpeg = encode_jpeg(&solid_image(48, 48, [0xC0, 0x39, 0x2B]), 92); + let seed = seed_from_bytes(&jpeg).expect("JPEG 应当能解码并取色"); + assert!(seed.chroma > 8.0, "彩度 {}", seed.chroma); + assert!( + (seed.hue - 30.0).abs() < 25.0, + "砖红的 CAM16 色相应当在 30° 附近,实际 {}", + seed.hue + ); +} + +#[test] +fn extension_is_ignored_and_format_is_sniffed() { + // 用户把 .jpg 改成 .png 的情况很常见:按魔数判断,不按扩展名。 + let jpeg = encode_jpeg(&solid_image(16, 16, [0x2E, 0x7D, 0x4F]), 95); + let decoded = decode_bytes(&jpeg).expect("魔数嗅探应当认得 JPEG"); + assert_eq!((decoded.width(), decoded.height()), (16, 16)); +} + +#[test] +fn garbage_bytes_fail_and_map_to_undecodable() { + let loaded = decode_bytes(b"this is definitely not an image"); + assert!( + matches!( + loaded, + Err(ExtractError::Decode(_)) | Err(ExtractError::Io(_)) + ), + "非图片字节应当解码失败" + ); + assert_eq!(input(&loaded), ImageInput::Undecodable); +} + +#[test] +fn truncated_png_fails_instead_of_returning_half_an_image() { + let png = encode_png(&solid_image(64, 64, [0x3C, 0x8D, 0xBC])); + let truncated = &png[..png.len() / 2]; + let loaded = decode_bytes(truncated); + assert!(loaded.is_err(), "截断的 PNG 不许当成有效图"); + assert_eq!(input(&loaded), ImageInput::Undecodable); +} + +#[test] +fn missing_file_is_an_io_error() { + let loaded = load("Z:/definitely/not/here/background.png"); + match loaded { + Err(ExtractError::Io(_)) => {} + other => panic!("应当报 IO 错误,实际 {other:?}"), + } +} + +#[test] +fn one_pixel_image_is_reported_as_too_small() { + let png = encode_png(&solid_image(1, 1, [0xC0, 0x39, 0x2B])); + match seed_from_bytes(&png) { + Err(ExtractError::Palette(palette::Failure::TooSmall { width, height })) => { + assert_eq!((width, height), (1, 1)); + } + other => panic!("1×1 图应当取色失败,实际 {other:?}"), + } +} + +#[test] +fn gray_png_falls_back_instead_of_inventing_a_colour() { + // D40 的反证:灰白图**不许**变成青色。这里必须报低彩度,而不是返回一个种子。 + let png = encode_png(&solid_image(24, 24, [0xEE, 0xEE, 0xEC])); + match seed_from_bytes(&png) { + Err(ExtractError::Palette(palette::Failure::LowChroma { chroma, .. })) => { + assert!(chroma < 8.0, "彩度 {chroma}"); + } + other => panic!("灰白图应当报低彩度,实际 {other:?}"), + } +} + +#[test] +fn large_image_is_scaled_to_the_max_edge() { + let png = encode_png(&solid_image(1024, 512, [0xB2, 0x6A, 0x00])); + let decoded = decode_bytes(&png).expect("大图应当解码成功"); + assert_eq!(decoded.width(), MAX_EDGE); + assert_eq!(decoded.height(), MAX_EDGE / 2); + assert_eq!( + decoded.pixels().len(), + (MAX_EDGE * MAX_EDGE / 2 * 4) as usize + ); + // 缩放后仍是那个橙色:取色的结论不该被「缩了多少」改变。 + let seed = seed_from_rgba8(&decoded).expect("琥珀橙有种子"); + assert!(seed.chroma > 8.0); +} + +#[test] +fn absurd_dimensions_are_rejected_before_allocating() { + // 头里写着 20000 宽的 PNG:超过 MAX_SOURCE_EDGE,必须在解码前拒掉。 + let png = encode_png(&solid_image(20_000, 1, [0x3C, 0x8D, 0xBC])); + match decode_bytes(&png) { + Err(ExtractError::Decode(_)) => {} + other => panic!("超限尺寸应当被拒,实际 {other:?}"), + } +} + +#[test] +fn input_borrows_without_copying() { + let loaded = decode_bytes(&encode_png(&solid_image(4, 4, [0x67, 0x50, 0xA4]))); + match input(&loaded) { + ImageInput::Pixels(source) => { + assert_eq!((source.width, source.height), (4, 4)); + // 零拷贝:视图指向的就是 `Rgba8` 自己的缓冲。 + assert_eq!(source.pixels, loaded.as_ref().expect("解码成功").pixels()); + } + ImageInput::Undecodable => panic!("刚解码成功却报了 undecodable"), + } +} From 31ee69100a74e54e17ad08760041bc92696b48e9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:34:55 +0800 Subject: [PATCH 03/11] =?UTF-8?q?refactor(design):=20resolve=20=E6=8B=86?= =?UTF-8?q?=E6=88=90=E7=9B=AE=E5=BD=95=E6=A8=A1=E5=9D=97=EF=BC=88=E5=8D=95?= =?UTF-8?q?=E6=96=87=E4=BB=B6=20=E2=89=A4400=20=E8=A1=8C=EF=BC=89=EF=BC=8C?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=96=87=E6=A1=A3=E5=8F=A3=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - palette/resolve/{mod.rs,provenance.rs}:编排函数与 Provenance 类型分开,路径不变 - color/mod.rs + palettes.rs:原来的「取色/色卡库不在本 crate、还没实现」改成指向 crate::palette - 取色 gap 登记改为「单种子已实现,多 source 色仍未实现」 --- crates/design/src/color/mod.rs | 7 +- crates/design/src/color/palettes.rs | 11 +- .../palette/{resolve.rs => resolve/mod.rs} | 236 +---------------- .../design/src/palette/resolve/provenance.rs | 237 ++++++++++++++++++ 4 files changed, 261 insertions(+), 230 deletions(-) rename crates/design/src/palette/{resolve.rs => resolve/mod.rs} (60%) create mode 100644 crates/design/src/palette/resolve/provenance.rs diff --git a/crates/design/src/color/mod.rs b/crates/design/src/color/mod.rs index b09f830..73af9ad 100644 --- a/crates/design/src/color/mod.rs +++ b/crates/design/src/color/mod.rs @@ -41,7 +41,7 @@ //! |---|---|---| //! | 对比度三档(default/medium/high) | `ContrastCurve` + `contrastLevel` | **未实现**:只做 `contrastLevel = 0`。在 0 档下官方那层「tone 差对 + 对比度修正」机制的输入全是常数,等价于本模块的静态 tone 表(已由对齐测试反证) | //! | 其他配色变体 | `Vibrant` / `Expressive` / `Fidelity` / `Content` / `Monochrome` / `Neutral` / `Rainbow` / `FruitSalad` / `CMF` | **未实现**:只有 `TonalSpot`(M3 默认、也是 Material Theme Builder 默认) | -//! | 壁纸多色取色 | `QuantizerCelebi` / `Score` / 多 source 色 | **未实现**:桌面启动器用不到(安卓端特性),也避免引入图像依赖 | +//! | 壁纸多色取色 | `QuantizerCelebi` / `Score` / 多 source 色 | **单种子部分已实现**:量化(5 bit 直方图 + CIELAB k-means)与 `Score` 挑主色在 [`crate::palette`](M87)。**多 source 色**(一张图出 4 个种子、每个 `*_container` 换色相)仍未实现——产品口径是「一套主题一个强调色」(D38) | //! | 换观察条件 | `Hct.inViewingConditions` | **未实现**:只有 sRGB 屏的默认条件(D65 / 200 lux / 背景 `L* 50`) | //! //! ## 「自定义强调色」与官方 `from_seed` 的关系 @@ -65,8 +65,9 @@ //! //! **定位(D38)**:这套默认只是「**莫奈关闭时的出厂预选色卡**」(首次启动、用户还没选过色卡时 //! 用的那一套),**不是唯一的配色来源**。用户的配色来源有三级优先:① 启动器背景图取色(Monet) -//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色与「内置色卡库」都不在本 crate, -//! 也还没实现;本模块只负责「给定一个种子 + 中性彩度,派生出 49 个角色」。 +//! → ② 桌面壁纸取色 → ③ 莫奈关闭(默认)时从内置多套色卡里选。取色(量化 + 挑主色)、内置色卡库 +//! 与三级回落都在 [`crate::palette`](M87 落地,D40 分层);**本模块只负责**「给定种子 + 中性彩度, +//! 派生出 49 个角色」,不碰像素、不碰路径、不碰回落决策。 //! //! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**: //! > diff --git a/crates/design/src/color/palettes.rs b/crates/design/src/color/palettes.rs index 6b78e49..08da0aa 100644 --- a/crates/design/src/color/palettes.rs +++ b/crates/design/src/color/palettes.rs @@ -40,8 +40,10 @@ pub const BASELINE_SEED: Color = Color::rgb(0x67, 0x50, 0xA4); /// /// 用户定了三级优先的配色来源:① 启动器背景图取色(Monet)→ ② 桌面壁纸取色 → ③ 莫奈关闭 /// (默认)时用户从内置多套色卡里选。本常量 + [`DEFAULT_NEUTRAL_CHROMA`] 只定义**第三级里 -/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套)。取色(Monet / 壁纸 / -/// `QuantizerCelebi`)与「内置多色卡库」都不在本 crate,也还没实现。 +/// 出厂预选的那一套**(首次启动、用户没选过色卡时的那一套;`palette::DEFAULT_CARD` 的种子即本值)。 +/// +/// 取色(量化 + 挑主色)、内置色卡库与三级回落都在 [`crate::palette`](M87,分层见 D40); +/// 本模块只负责「给定种子 + 中性彩度,派生出 49 个角色」。 pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC); /// 产品出厂默认的中性调色板彩度(官方 Tonal Spot 是 6,这里 12 = **有意偏离**)。 @@ -54,8 +56,9 @@ pub const DEFAULT_SEED: Color = Color::rgb(0x3C, 0x8D, 0xBC); /// 13 时 nv = 15 仍低于 secondary 的 16;**14 才会撞上**(nv = 16 = secondary),届时要一并改 /// `NEUTRAL_VARIANT_OFFSET`(私有常量)的规则。nv 的那一半偏离见 `color` 模块文档的偏离登记。 /// -/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。将来若做「内置多套色卡」, -/// 每套色卡各带自己的中性彩度更合适——本常量到时候会退化成「默认那套的取值」。 +/// 同样只服务于「出厂预选色卡」(D38 第三级),不约束将来的取色结果。 +/// 内置多套色卡落地时每张卡各带自己的中性彩度(`palette::Card::neutral_chroma`),八张卡 +/// 当前都取本值——换卡只换色相,不让窗口底色的冷度跟着跳。 pub const DEFAULT_NEUTRAL_CHROMA: f64 = 12.0; /// 调色板槽位(M3 的六条 tonal palette)。 diff --git a/crates/design/src/palette/resolve.rs b/crates/design/src/palette/resolve/mod.rs similarity index 60% rename from crates/design/src/palette/resolve.rs rename to crates/design/src/palette/resolve/mod.rs index 8bc4002..a09621e 100644 --- a/crates/design/src/palette/resolve.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -32,11 +32,21 @@ //! 2. **不许静默变黑白**。彩度不足的图**不**会被「压成灰方案」,而是直接回落到用户选的那张色卡。 //! 3. **不许静默换成默认卡**。回落的落点是「用户当前选中的卡」(参数 `preset`), //! 只有用户从没选过卡(`None`)才用 [`super::DEFAULT_CARD`]。 +//! +//! ## 文件划分 +//! +//! [`provenance`] 放「结果解释」的数据类型(`Level` / `Outcome` / `Skip` / `Provenance`), +//! 本文件放编排函数与测试。拆开只是为了单文件 ≤400 行的纪律,对外路径仍是 +//! `palette::resolve::*`。 use super::card::{Card, CardKey, DEFAULT_CARD}; -use super::seed::{analyze_rgba, Failure, Seed}; +use super::seed::{analyze_rgba, Seed}; use super::wcag::{check, converge, Adjustment}; -use crate::color::{Color, Scheme, SchemeFlavor}; +use crate::color::{Scheme, SchemeFlavor}; + +mod provenance; + +pub use provenance::{Level, Outcome, Provenance, Skip}; /// 已解码的图片:借用像素,不拷贝(解码在 `crates/palette-extract`)。 #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -70,227 +80,6 @@ impl<'a> ImageInput<'a> { }) } } - -/// 方案的来源层级(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() - } -} - /// 三级来源编排(纯函数)。 /// /// 参数: @@ -409,6 +198,7 @@ fn card_key(card: Card) -> CardKey { #[cfg(test)] mod tests { use super::*; + use crate::color::Color; fn solid(color: Color, width: u32, height: u32) -> Vec { let mut out = Vec::with_capacity((width * height * 4) as usize); diff --git a/crates/design/src/palette/resolve/provenance.rs b/crates/design/src/palette/resolve/provenance.rs new file mode 100644 index 0000000..ee8c63b --- /dev/null +++ b/crates/design/src/palette/resolve/provenance.rs @@ -0,0 +1,237 @@ +//! 一次配色的「结果解释」:最终用了哪一级、前几级为什么没用、对比度被改了什么。 +//! +//! 这些类型是**给 UI 看的契约**([`Provenance::hint_key`] 的键 → 文案的映射见 +//! `DevDocs/SubAgentsTalk/palette-source-contract.md`),所以刻意做得比内部需要更细: +//! 每一种失败都带足以写出一句具体提示的数据(彩度是多少、图有多小、哪一级坏了)。 +//! +//! 与 [`super`] 拆开只为单文件 ≤400 行;对外路径仍是 `palette::resolve::*`。 + +use super::super::card::CardKey; +use super::super::seed::{Failure, Seed}; +use super::super::wcag::Adjustment; +use crate::color::{Color, SchemeFlavor}; + +/// 方案的来源层级(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 = ①)。 + /// + /// `pub(super)`:`resolve` 的编排函数要按下标填 `Provenance` 的三个格子, + /// 但它不该出现在对外 API 里(外部只该问「哪一级」,不该拿索引当协议)。 + pub(super) 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, + /// 三级各自的处置结果(`pub(super)`:只能由 `resolve::resolve_source` 填, + /// 外部一律通过 [`Provenance::outcome`] / [`Provenance::background`] 等访问器读)。 + pub(super) 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() + } +} From 80314f4830ae51bf863a96d8ec4a4e62797e17b8 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:36:49 +0800 Subject: [PATCH 04/11] =?UTF-8?q?docs(design):=20hint=5Fkey=20=E7=9A=84?= =?UTF-8?q?=E6=96=87=E6=A1=88=E6=98=A0=E5=B0=84=E8=A1=A8=E6=90=AC=E8=BF=9B?= =?UTF-8?q?=20provenance.rs=20=E7=9A=84=E6=96=87=E6=A1=A3=E6=B3=A8?= =?UTF-8?q?=E9=87=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DevDocs/ 是 gitignore 的本地文档,契约里的「键 → 文案」表review 时看不到, 所以在 hint_key 的 rustdoc 里放同样一张表(差异只有措辞建议,结构一致)。 --- .../design/src/palette/resolve/provenance.rs | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/design/src/palette/resolve/provenance.rs b/crates/design/src/palette/resolve/provenance.rs index ee8c63b..c7734f9 100644 --- a/crates/design/src/palette/resolve/provenance.rs +++ b/crates/design/src/palette/resolve/provenance.rs @@ -175,9 +175,23 @@ impl Provenance { /// UI 提示键(`None` = 无需提示)。 /// - /// 形如 `palette.hint.background.low-chroma`,文案映射表见 - /// `DevDocs/SubAgentsTalk/palette-source-contract.md`。`key` 稳定、可持久化进日志; - /// 本 crate 不产出人话文案(文案属于 UI 层,D40 的分层纪律)。 + /// 形如 `palette.hint.background.low-chroma`,`key` 稳定、可持久化进日志; + /// 本 crate 不产出人话文案(文案属于 UI 层,D40 的分层纪律),UI 只做查表: + /// + /// | 键 | 建议文案(`{card}` = `provenance.card.name()`) | + /// |---|---| + /// | `palette.hint.background.undecodable` | 背景图读取失败,已改用{card}配色 | + /// | `palette.hint.background.too-small` | 背景图太小,无法取色,已改用{card}配色 | + /// | `palette.hint.background.bad-dimensions` | 背景图数据异常,已改用{card}配色 | + /// | `palette.hint.background.no-opaque-pixels` | 背景图几乎全透明,无法取色,已改用{card}配色 | + /// | `palette.hint.background.low-chroma` | 背景图颜色太淡,取不出主题色,已改用{card}配色 | + /// | `palette.hint.background.contrast` | 背景图配色达不到可读性要求,已改用{card}配色 | + /// | `palette.hint.wallpaper.*` | 同上,把「背景图」换成「桌面壁纸」 | + /// | `palette.hint.preset.contrast` | 当前配色可读性不足,已自动微调 | + /// | `palette.hint.adjusted` | 为可读性微调了少量颜色(`adjustments` 非空但无失败) | + /// + /// 完整契约(含可选补充提示、持久化纪律、性能账)见 + /// `DevDocs/SubAgentsTalk/palette-source-contract.md`(该目录是本地文档,不入库)。 /// /// 优先级:① 先解释「为什么回落」(第一个失败的层级)→ ② 再说「微调过对比度」。 /// 前者对用户更重要:用户看到的是「我的壁纸怎么没生效」,而不是「颜色被调了 2%」。 From 36c1cce00dd25a5b4d36c2ecddb3c493e711eb61 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:52:05 +0800 Subject: [PATCH 05/11] =?UTF-8?q?test(design):=20=E8=A1=A5=E3=80=8C?= =?UTF-8?q?=E5=85=88=E9=87=8F=E5=8C=96=E5=86=8D=E6=89=93=E5=88=86=E3=80=8D?= =?UTF-8?q?=E7=9A=84=E7=AB=AF=E5=88=B0=E7=AB=AF=E5=8F=8D=E8=AF=81=EF=BC=88?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=20r1=20p1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reviewer 实测变异:把 analyze_rgba 改成不透明像素算术平均,150 个测试全绿—— 因为反证只测了 quantize/score 中间层,接线没人覆盖。新增: - seed::tests::end_to_end_picks_the_five_percent_accent(95% 米白 + 5% 珊瑚 → #FF6B5A) - seed::tests::end_to_end_picks_the_three_percent_accent(97/3 → #FF8800) - resolve::tests::a_five_percent_accent_drives_level_one(一级成功、种子是珊瑚、≠ 回落色卡) 顺带把 seed 的单测拆到 seed/tests.rs(410 行 → 220 + 190,恢复 ≤400 纪律) --- crates/design/src/palette/resolve/mod.rs | 29 +++ .../src/palette/{seed.rs => seed/mod.rs} | 134 +----------- crates/design/src/palette/seed/tests.rs | 190 ++++++++++++++++++ 3 files changed, 220 insertions(+), 133 deletions(-) rename crates/design/src/palette/{seed.rs => seed/mod.rs} (64%) create mode 100644 crates/design/src/palette/seed/tests.rs diff --git a/crates/design/src/palette/resolve/mod.rs b/crates/design/src/palette/resolve/mod.rs index a09621e..f88ef44 100644 --- a/crates/design/src/palette/resolve/mod.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -339,4 +339,33 @@ mod tests { Some("palette.hint.background.no-opaque-pixels") ); } + + #[test] + fn a_five_percent_accent_drives_level_one() { + // 端到端反证(与 `palette::seed::tests::end_to_end_picks_the_five_percent_accent` 配对): + // 95% 米白 + 5% 珊瑚的背景图**必须**在一级取色成功,且种子里就是那 5% 的珊瑚。 + // 若 `analyze_rgba` 退化成「不透明像素求算术平均」,这条会变成「回落色卡」而红。 + let beige = Color::rgb(0xF5, 0xF1, 0xEA); + let coral = Color::rgb(0xFF, 0x6B, 0x5A); + let mut pixels = Vec::with_capacity(40 * 40 * 4); + for index in 0..40 * 40 { + let color = if index < 40 * 40 - 80 { beige } else { coral }; + pixels.extend_from_slice(&[color.r, color.g, color.b, color.a]); + } + + let (scheme, provenance) = resolve_source( + Some(ImageInput::pixels(&pixels, 40, 40)), + None, + Some(CardKey::Rose), + SchemeFlavor::Light, + ); + assert_eq!(provenance.level, Level::BackgroundImage, "{provenance:?}"); + assert_eq!(provenance.background(), Outcome::Used); + let seed = provenance.seed.expect("一级成功必须带种子"); + assert_eq!(seed.color, coral, "种子应当是 5% 的珊瑚"); + assert!(provenance.hint_key().is_none()); + // 方案来自珊瑚,而不是「回落到用户选的 Rose 卡」——两者 primary 不同。 + assert_ne!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light)); + assert!(crate::palette::wcag::check(&scheme).is_clean()); + } } diff --git a/crates/design/src/palette/seed.rs b/crates/design/src/palette/seed/mod.rs similarity index 64% rename from crates/design/src/palette/seed.rs rename to crates/design/src/palette/seed/mod.rs index a0403c3..f6a3f3d 100644 --- a/crates/design/src/palette/seed.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -217,136 +217,4 @@ fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec { } #[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}"); - } - } -} +mod tests; diff --git a/crates/design/src/palette/seed/tests.rs b/crates/design/src/palette/seed/tests.rs new file mode 100644 index 0000000..cc98f88 --- /dev/null +++ b/crates/design/src/palette/seed/tests.rs @@ -0,0 +1,190 @@ +//! `seed` 模块的单测:五类输入各自的处置 + **端到端反证**(不许退化成平均色)。 +//! +//! 为什么端到端测试必须在这里(而不是只测 `quantize`/`score`):那两条单测手工构造 +//! `Cluster` 直接喂中间层,**绕过了 `analyze_rgba` 的接线**。把 `analyze_rgba` 换成 +//! 「不透明像素求算术平均」时,中间层单测照样绿,只有这里会红。 + +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 +} + +/// 生成「其余是 `base`,`accent_pixels` 个像素是 `accent`」的图(强调色排在末尾)。 +/// +/// 这是**反证「不许取平均色」**的输入构造器:强调色面积小到平均色会被底色吃掉, +/// 但它是人眼会记住、能当强调色用的那一小撮。 +fn two_tone(width: u32, height: u32, base: Color, accent: Color, accent_pixels: usize) -> Vec { + let total = (width * height) as usize; + assert!(accent_pixels <= total); + let mut out = Vec::with_capacity(total * 4); + for index in 0..total { + let color = if index < total - accent_pixels { + base + } else { + accent + }; + out.extend_from_slice(&[color.r, color.g, color.b, color.a]); + } + out +} + +#[test] +fn end_to_end_picks_the_five_percent_accent() { + // ⚠️ 端到端反证:这条必须走 `analyze_rgba` 的**完整管线**(量化 → 打分 → 彩度门槛), + // 不许像 `quantize`/`score` 的单测那样手工构造 `Cluster` 直接喂中间层。 + // 如果 `analyze_rgba` 被改成「不透明像素求算术平均」,平均值会是米白(chroma ≈4)→ + // 返回 `LowChroma` 而不是种子,这条立刻红。 + let beige = Color::from_hex("#F5F1EA").expect("合法 hex"); + let coral = Color::from_hex("#FF6B5A").expect("合法 hex"); + let pixels = two_tone(40, 40, beige, coral, 80); // 95% 米白 + 5% 珊瑚 + + let seed = analyze_rgba(&pixels, 40, 40).expect("5% 的珊瑚应当被选中当种子"); + assert_eq!( + seed.color, + coral, + "种子必须是珊瑚(实际 {});米白占比 95% 但它不是「主色」", + seed.color.to_hex() + ); + assert!(seed.chroma > 40.0, "珊瑚彩度应当很高,实测 {}", seed.chroma); + // 与「平均色」泾渭分明:原图的算术平均值是米白,色相/彩度都差一整个量级。 + assert!(!seed.is_muted()); +} + +#[test] +fn end_to_end_picks_the_three_percent_accent() { + // 面积再降到 3%(48/1600):`Score` 的 1% 人气门槛仍然放行,平均色更不可能靠巧合命中。 + let beige = Color::from_hex("#F5F1EA").expect("合法 hex"); + let orange = Color::from_hex("#FF8800").expect("合法 hex"); + let pixels = two_tone(40, 40, beige, orange, 48); + + let seed = analyze_rgba(&pixels, 40, 40).expect("3% 的橙应当被选中当种子"); + assert_eq!(seed.color, orange, "实际 {}", seed.color.to_hex()); + assert!(seed.chroma > 40.0, "彩度 {}", seed.chroma); +} + +#[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}"); + } +} From 2ce394fbfc9fb1a0c96abf9453ae0155702a4777 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:53:20 +0800 Subject: [PATCH 06/11] =?UTF-8?q?fix(design):=20=E6=B7=B7=E5=90=88?= =?UTF-8?q?=E9=80=8F=E6=98=8E=E5=BA=A6=E4=B8=8D=E5=86=8D=E8=A2=AB=E8=AF=AF?= =?UTF-8?q?=E5=88=A4=E4=B8=BA=E3=80=8C=E5=85=A8=E9=80=8F=E6=98=8E=E3=80=8D?= =?UTF-8?q?=EF=BC=88=E5=A4=8D=E5=AE=A1=20r1=20p2-1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先按 alpha 过滤像素、再做块平均(整块全透明则跳过)。原实现先平均再比门槛, 块内 255 与 0 的整数除法得 127 < 128 → 512×512 里 131072 个不透明像素被报 NoOpaquePixels,UI 会告诉用户「背景图几乎全透明」,与事实相反。 - 新增 half_transparent_image_keeps_its_opaque_colour(红) - 新增 mostly_transparent_image_still_samples_the_visible_part - 模块文档加一行「混合透明度」的处置与踩坑说明 --- crates/design/src/palette/seed/mod.rs | 34 +++++++++++++++-------- crates/design/src/palette/seed/tests.rs | 36 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/crates/design/src/palette/seed/mod.rs b/crates/design/src/palette/seed/mod.rs index f6a3f3d..3184184 100644 --- a/crates/design/src/palette/seed/mod.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -26,6 +26,7 @@ //! | 纯色图(有彩度) | 正常取色 | 单色图只要够鲜艳就是合格的种子(不做特殊化) | //! | 灰度/灰白图 | [`Failure::LowChroma`](**不是**返回青灰色) | D40 实测:零彩度种子的 CAM16 色相恒为 209.49°,硬取色会把灰白壁纸「凭空上色」成青色 `#006874`;极低彩度段色相还是噪声放大器(`#808080` → 209.5°、`#7A7C7E` → 231.2°,摆 22°) | //! | 超大图(>65536 像素) | 先按 4^n 块做**块平均**降采样,再量化 | 取色只需要「色彩分布」,不需要分辨率;块平均能保住细纹理的平均色,避免等距抽样在条纹/网格图上走样 | +//! | 混合透明度(一半不透明一半全透明) | **先按 alpha 过滤、再块平均**(整块全透明则跳过) | 「先平均再比门槛」会把 `255/2 = 127 < 128` 判成全透明,于是 13 万个不透明像素被报成 `NoOpaquePixels`——复审实测踩到的坑 | //! | 解码失败 | 不在本 crate:见 `crates/palette-extract` | D40 分层 | //! //! 阈值 [`MIN_SEED_CHROMA`] = 8 是 **D40 的定案值**(`seed chroma < ~8 视为无信息量`), @@ -138,16 +139,13 @@ pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result = samples - .into_iter() - .filter(|color| color.a >= ALPHA_THRESHOLD) - .collect(); - if opaque.is_empty() { + if samples.is_empty() { return Err(Failure::NoOpaquePixels); } - let clusters = quantize::quantize(&opaque); + let clusters = quantize::quantize(&samples); let ranked = score::rank(&clusters); match ranked.first() { Some(best) if best.chroma >= MIN_SEED_CHROMA => Ok(Seed { @@ -183,6 +181,15 @@ fn most_populated(clusters: &[Cluster]) -> Option<&Cluster> { } /// 块平均降采样:块边长按「不超过 [`MAX_SAMPLES`] 个采样点」反推,小图不降采样(块边长 1)。 +/// +/// **先按 alpha 过滤、再求平均**:块内只累加 `a >= ALPHA_THRESHOLD` 的像素,整块没有有效像素 +/// 就直接跳过(不产出采样点)。 +/// +/// 为什么不能「先平均、再拿平均 alpha 跟门槛比」:块内 alpha 用整数除法截断, +/// 一半不透明一半透明的块平均下来是 `255/2 = 127 < 128`,于是**整张图被判成「没有不透明像素」** +/// (复审实测:512×512 里 131072 个完全不透明像素 → `Err(NoOpaquePixels)`)。 +/// 副作用是「半透明像素的颜色」也被排除在外——这是有意的:半透明像素的 RGB 常常是 +/// 未预乘的原始值(有些解码器给的是 0,0,0),混进来会把种子拽向黑色。 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 { @@ -196,19 +203,24 @@ fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec { let mut left = 0usize; while left < width { let right = (left + block).min(width); - let mut sum = [0u64; 4]; + let mut sum = [0u64; 3]; 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]); + if pixels[offset + 3] < ALPHA_THRESHOLD { + continue; + } + for channel in 0..3 { + sum[channel] += 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))); + if count > 0 { + let average = |channel: usize| (sum[channel] / count) as u8; + samples.push(Color::rgb(average(0), average(1), average(2))); + } left = right; } top = bottom; diff --git a/crates/design/src/palette/seed/tests.rs b/crates/design/src/palette/seed/tests.rs index cc98f88..fc1c598 100644 --- a/crates/design/src/palette/seed/tests.rs +++ b/crates/design/src/palette/seed/tests.rs @@ -137,6 +137,42 @@ fn fully_transparent_image_has_no_seed() { assert_eq!(analyze_rgba(&pixels, 4, 4), Err(Failure::NoOpaquePixels)); } +#[test] +fn half_transparent_image_keeps_its_opaque_colour() { + // 复审 p2-1 的反证:512×512、一半完全不透明砖红、一半全透明(RGB=0)交错。 + // 修复前「先块平均、再拿平均 alpha 比 128」:块内 255 与 0 平均被整数除法截成 127 < 128, + // 于是全部 131072 个不透明像素被判成「没有不透明像素」→ Err(NoOpaquePixels)。 + // 修复后(先过滤再平均)必须取到砖红本身。 + let (width, height) = (512usize, 512usize); + let mut pixels = Vec::with_capacity(width * height * 4); + for index in 0..width * height { + if index % 2 == 0 { + pixels.extend_from_slice(&[0xC0, 0x39, 0x2B, 0xFF]); + } else { + pixels.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + } + } + let seed = analyze_rgba(&pixels, 512, 512).expect("一半不透明像素必须能取色"); + assert_eq!(seed.color, Color::rgb(0xC0, 0x39, 0x2B)); + assert!(seed.chroma > 40.0, "彩度 {}", seed.chroma); +} + +#[test] +fn mostly_transparent_image_still_samples_the_visible_part() { + // 90% 全透明 + 10% 不透明绿:可见部分够鲜艳就该取到它,而不是报「没有不透明像素」。 + let (width, height) = (64usize, 64usize); + let mut pixels = Vec::with_capacity(width * height * 4); + for index in 0..width * height { + if index % 10 == 0 { + pixels.extend_from_slice(&[0x2E, 0x7D, 0x4F, 0xFF]); + } else { + pixels.extend_from_slice(&[0x00, 0x00, 0x00, 0x00]); + } + } + let seed = analyze_rgba(&pixels, 64, 64).expect("10% 可见像素必须能取色"); + assert_eq!(seed.color, Color::rgb(0x2E, 0x7D, 0x4F)); +} + #[test] fn large_image_is_downsampled_and_still_deterministic() { let (width, height) = (512_u32, 512_u32); From 579f3ae271f9506f150cd504cad88193f0c91efb Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:56:30 +0800 Subject: [PATCH 07/11] =?UTF-8?q?feat(palette-extract):=20=E8=A1=A5=20bmp?= =?UTF-8?q?=20=E8=A7=A3=E7=A0=81=E5=99=A8=EF=BC=88=E5=A4=8D=E5=AE=A1=20r1?= =?UTF-8?q?=20p2-2=EF=BC=8C=E5=AE=9A=E6=A1=88=E5=8A=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M73 的 Windows 壁纸探测只读注册表/.theme 里的路径,不筛扩展名,.bmp 壁纸真会出现; image 的 bmp 是独立 feature 且无额外传递依赖(bmp = []),代价≈0。 - features 加 "bmp",Cargo.toml 与 lib.rs 注释的口径对齐(原来注释说支持 bmp 但没开) - 新增 bmp_is_supported(真编一张 BMP 再解码取色) --- crates/palette-extract/Cargo.toml | 14 ++++++++++---- crates/palette-extract/src/lib.rs | 4 +++- crates/palette-extract/src/tests.rs | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/crates/palette-extract/Cargo.toml b/crates/palette-extract/Cargo.toml index d70a98f..5944084 100644 --- a/crates/palette-extract/Cargo.toml +++ b/crates/palette-extract/Cargo.toml @@ -16,11 +16,17 @@ unlucky-design = { path = "../design", version = "0.1" } # 只开两个编解码器(MIT OR Apache-2.0,deny.toml 白名单内): # - png:启动器背景图最常见的无损格式,也是截图通道用的格式; -# - jpeg:Windows 壁纸与用户照片的绝对主力(不引它等于「取色对一半用户不可用」)。 +# - jpeg:Windows 壁纸与用户照片的绝对主力(不引它等于「取色对一半用户不可用」); +# - bmp:Windows 壁纸的真会出现(M73 的探测只读注册表/`.theme` 里的路径,**不筛扩展名**), +# 而 `image` 的 `bmp` 是独立 feature 且**无额外传递依赖**(`bmp = []`),代价≈0。 # 不开 `default-features`(默认会拖进 rayon + avif/ravif + webp + tiff + gif + exr + qoi -# 等一整套编码器,体积与审计面都翻倍);不开 webp/tiff/avif 是因为 M73 的壁纸探测拿到的 -# 就是 png/jpg/bmp 三类,等真有人需要再单开一个 feature。 -image = { workspace = true, default-features = false, features = ["png", "jpeg"] } +# 等一整套编码器,体积与审计面都翻倍);不开 webp/tiff/avif 是因为 M73 拿到的格式只有 +# png/jpg/bmp 三类,等真有人需要再单开一个 feature。 +image = { workspace = true, default-features = false, features = [ + "png", + "jpeg", + "bmp", +] } [lints] workspace = true diff --git a/crates/palette-extract/src/lib.rs b/crates/palette-extract/src/lib.rs index 232e2cc..ab38838 100644 --- a/crates/palette-extract/src/lib.rs +++ b/crates/palette-extract/src/lib.rs @@ -40,10 +40,12 @@ //! //! 许可:`image` 是 `MIT OR Apache-2.0`,在 `deny.toml` 白名单内(`[licenses].allow`)。 //! -//! ## 体积影响(`image` 只开 png + jpeg,关掉 default-features) +//! ## 体积影响(`image` 只开 png + jpeg + bmp,关掉 default-features) //! //! 拉进的传递依赖就这几个:`png`(+ `fdeflate`/`miniz_oxide`/`crc32fast`/`flate2` 已在树里)、 //! `zune-jpeg` + `zune-core`、`byteorder-lite`、`num-traits`、`moxcms`/`pxfm`(色彩转换)。 +//! `bmp` 是 `image` 的独立 feature 且**不带任何传递依赖**(`bmp = []`),加它只是因为 +//! M73 的 Windows 壁纸探测不筛扩展名、`.bmp` 壁纸真会出现。 //! 关掉的默认特性里有 `rayon`(省一个线程池)、`avif`/`ravif`(最大的一个,编解码器整套)、 //! `webp`/`tiff`/`gif`/`exr`/`qoi`/`hdr`/`dds`/`ff`。实测增量见 M87 报告(`cargo tree` 前后对比)。 //! diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs index 7ba9755..1465523 100644 --- a/crates/palette-extract/src/tests.rs +++ b/crates/palette-extract/src/tests.rs @@ -61,6 +61,22 @@ fn extension_is_ignored_and_format_is_sniffed() { assert_eq!((decoded.width(), decoded.height()), (16, 16)); } +#[test] +fn bmp_is_supported() { + // M73 的 Windows 壁纸探测不筛扩展名,`.bmp` 壁纸会真的出现在取色路径上。 + // BMP 编码不是无损于色的(24 bit 存 RGB,这里是 32 bit Rgba),所以逐通道比对即可。 + let mut bytes = Cursor::new(Vec::new()); + DynamicImage::ImageRgba8(solid_image(32, 32, [0xB2, 0x3A, 0x48])) + .write_to(&mut bytes, ImageFormat::Bmp) + .expect("BMP 编码应当成功"); + let seed = seed_from_bytes(&bytes.into_inner()).expect("BMP 应当能解码并取色"); + assert_eq!( + seed.color, + unlucky_design::color::Color::rgb(0xB2, 0x3A, 0x48) + ); + assert!(seed.chroma > 8.0); +} + #[test] fn garbage_bytes_fail_and_map_to_undecodable() { let loaded = decode_bytes(b"this is definitely not an image"); From 948a3685cd18e5775ce3c2c3486f6c5fbaddae40 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 09:58:44 +0800 Subject: [PATCH 08/11] =?UTF-8?q?test(design):=20=E9=92=89=E4=BD=8F=20D40?= =?UTF-8?q?=20=E7=9A=84=E5=BD=A9=E5=BA=A6=E9=97=A8=E6=A7=9B=208=EF=BC=88?= =?UTF-8?q?=E5=A4=8D=E5=AE=A1=20r1=20p2-3=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MIN_SEED_CHROMA 8.0 → 6.0 的变异原本全绿(那条断言比的是常量自己)。 新增两条用实测 chroma≈7.23 的灰蓝(#7E8489): - seed::tests::chroma_just_below_the_gate_is_rejected(门槛之上/之下夹住) - resolve::tests::faint_colour_falls_back_to_the_selected_card(回落 + 落点是选中卡 + 方案逐值等于该卡) 顺带把 resolve 的单测拆到 resolve/tests.rs(400 行上限) --- crates/design/src/palette/resolve/mod.rs | 174 +----------------- crates/design/src/palette/resolve/tests.rs | 204 +++++++++++++++++++++ crates/design/src/palette/seed/tests.rs | 28 +++ 3 files changed, 233 insertions(+), 173 deletions(-) create mode 100644 crates/design/src/palette/resolve/tests.rs diff --git a/crates/design/src/palette/resolve/mod.rs b/crates/design/src/palette/resolve/mod.rs index f88ef44..f8c328e 100644 --- a/crates/design/src/palette/resolve/mod.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -196,176 +196,4 @@ fn card_key(card: Card) -> CardKey { } #[cfg(test)] -mod tests { - use super::*; - use crate::color::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 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") - ); - } - - #[test] - fn a_five_percent_accent_drives_level_one() { - // 端到端反证(与 `palette::seed::tests::end_to_end_picks_the_five_percent_accent` 配对): - // 95% 米白 + 5% 珊瑚的背景图**必须**在一级取色成功,且种子里就是那 5% 的珊瑚。 - // 若 `analyze_rgba` 退化成「不透明像素求算术平均」,这条会变成「回落色卡」而红。 - let beige = Color::rgb(0xF5, 0xF1, 0xEA); - let coral = Color::rgb(0xFF, 0x6B, 0x5A); - let mut pixels = Vec::with_capacity(40 * 40 * 4); - for index in 0..40 * 40 { - let color = if index < 40 * 40 - 80 { beige } else { coral }; - pixels.extend_from_slice(&[color.r, color.g, color.b, color.a]); - } - - let (scheme, provenance) = resolve_source( - Some(ImageInput::pixels(&pixels, 40, 40)), - None, - Some(CardKey::Rose), - SchemeFlavor::Light, - ); - assert_eq!(provenance.level, Level::BackgroundImage, "{provenance:?}"); - assert_eq!(provenance.background(), Outcome::Used); - let seed = provenance.seed.expect("一级成功必须带种子"); - assert_eq!(seed.color, coral, "种子应当是 5% 的珊瑚"); - assert!(provenance.hint_key().is_none()); - // 方案来自珊瑚,而不是「回落到用户选的 Rose 卡」——两者 primary 不同。 - assert_ne!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light)); - assert!(crate::palette::wcag::check(&scheme).is_clean()); - } -} +mod tests; diff --git a/crates/design/src/palette/resolve/tests.rs b/crates/design/src/palette/resolve/tests.rs new file mode 100644 index 0000000..ee34e38 --- /dev/null +++ b/crates/design/src/palette/resolve/tests.rs @@ -0,0 +1,204 @@ +//! `resolve` 的单测:三级优先序、失败回落、以及「回落落点是用户选的卡」这三类断言。 +//! +//! 与 `mod.rs` 拆开只为单文件 ≤400 行;测试仍属于 `palette::resolve::tests`。 + +use super::*; +use crate::color::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 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") + ); +} + +#[test] +fn faint_colour_falls_back_to_the_selected_card() { + // 复审 p2-3:D40 的门槛是 8,落在 6~8 之间的图必须**回落**,且落点是用户选中的那张卡。 + // 变异反证:`MIN_SEED_CHROMA` 8.0 → 6.0 时,这个种子会通过门槛, + // `level` 变成 `BackgroundImage`,本条的第一条断言变红。 + let faint = Color::rgb(0x7E, 0x84, 0x89); // CAM16 chroma ≈ 7.23 + let pixels = solid(faint, 16, 16); + let (scheme, provenance) = resolve_source( + Some(ImageInput::pixels(&pixels, 16, 16)), + None, + Some(CardKey::Amber), + SchemeFlavor::Light, + ); + + assert_eq!(provenance.level, Level::Preset, "低彩度图必须回落"); + assert_eq!(provenance.card, CardKey::Amber, "落点是用户选中的卡"); + assert_eq!( + scheme, + CardKey::Amber.card().scheme(SchemeFlavor::Light), + "方案必须逐值等于选中卡" + ); + match provenance.background() { + Outcome::Failed(Skip::LowChroma { chroma, .. }) => { + assert!((6.0..8.0).contains(&chroma), "实测彩度 {chroma}"); + } + other => panic!("应当是低彩度失败,实际 {other:?}"), + } +} + +#[test] +fn a_five_percent_accent_drives_level_one() { + // 端到端反证(与 `palette::seed::tests::end_to_end_picks_the_five_percent_accent` 配对): + // 95% 米白 + 5% 珊瑚的背景图**必须**在一级取色成功,且种子里就是那 5% 的珊瑚。 + // 若 `analyze_rgba` 退化成「不透明像素求算术平均」,这条会变成「回落色卡」而红。 + let beige = Color::rgb(0xF5, 0xF1, 0xEA); + let coral = Color::rgb(0xFF, 0x6B, 0x5A); + let mut pixels = Vec::with_capacity(40 * 40 * 4); + for index in 0..40 * 40 { + let color = if index < 40 * 40 - 80 { beige } else { coral }; + pixels.extend_from_slice(&[color.r, color.g, color.b, color.a]); + } + + let (scheme, provenance) = resolve_source( + Some(ImageInput::pixels(&pixels, 40, 40)), + None, + Some(CardKey::Rose), + SchemeFlavor::Light, + ); + assert_eq!(provenance.level, Level::BackgroundImage, "{provenance:?}"); + assert_eq!(provenance.background(), Outcome::Used); + let seed = provenance.seed.expect("一级成功必须带种子"); + assert_eq!(seed.color, coral, "种子应当是 5% 的珊瑚"); + assert!(provenance.hint_key().is_none()); + // 方案来自珊瑚,而不是「回落到用户选的 Rose 卡」——两者 primary 不同。 + assert_ne!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light)); + assert!(crate::palette::wcag::check(&scheme).is_clean()); +} diff --git a/crates/design/src/palette/seed/tests.rs b/crates/design/src/palette/seed/tests.rs index fc1c598..1a65375 100644 --- a/crates/design/src/palette/seed/tests.rs +++ b/crates/design/src/palette/seed/tests.rs @@ -224,3 +224,31 @@ fn seed_scheme_meets_wcag_on_the_primary_pair() { assert!(ratio >= 4.5, "{flavor:?} 只有 {ratio}"); } } + +#[test] +fn chroma_just_below_the_gate_is_rejected() { + // 把 D40 的口径(`MIN_SEED_CHROMA` = 8,「< ~8 视为无信息量」)钉成可执行的断言: + // 图里的颜色实测 chroma ≈7.23 —— 落在 `Score` 的候选门槛(5)之上、D40 的种子门槛(8)之下。 + // 这类图必须**回落**,而不是「勉强取一个低彩度种子」。 + // + // 变异反证:把 `MIN_SEED_CHROMA` 从 8.0 改成 6.0,这个种子会通过门槛 → 本条变红。 + let faint = Color::from_hex("#7E8489").expect("合法 hex"); + let chroma = crate::color::Hct::from_color(faint).chroma; + assert!( + (6.0..8.0).contains(&chroma), + "这条测试依赖「彩度落在 6~8 之间」,实测 {chroma}" + ); + + let pixels = solid(faint, 16, 16); + match analyze_rgba(&pixels, 16, 16) { + Err(Failure::LowChroma { + chroma: measured, .. + }) => { + assert!( + (6.0..MIN_SEED_CHROMA).contains(&measured), + "报回来的彩度 {measured} 应当仍在门槛之下" + ); + } + other => panic!("彩度 {chroma} 必须回落,实际 {other:?}"), + } +} From f4c48752c24ff258cc249dbd5cb93920cb9466d9 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:01:26 +0800 Subject: [PATCH 09/11] =?UTF-8?q?fix(design):=20Outcome::NotReached=20/=20?= =?UTF-8?q?=E8=89=B2=E7=9B=B8=E7=8E=AF=E5=9B=9E=E7=BB=95=E6=96=AD=E8=A8=80?= =?UTF-8?q?=20/=20LowChroma=20=E5=8F=A3=E5=BE=84=E7=BB=9F=E4=B8=80=20/=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=E6=8C=87=E5=90=91=EF=BC=88=E5=A4=8D=E5=AE=A1?= =?UTF-8?q?=20r1=20p2-4=20+=20=E8=89=B2=E5=BD=A9=20r1=20p2-2/3/4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Outcome 新增 NotReached:区分「用户没提供」与「更高级成功所以没轮到」, UI 不会再对着用户已选的卡说「色卡:未选择」;新增 not_reached_is_distinguishable_from_not_provided - cards_are_visually_distinct 补色相环回绕间隔(原来只比排序相邻差,350° 的卡会静默通过) - Failure::LowChroma 的 color 与 chroma 现在永远来自同一个簇(原来一条路径拿 A 簇的色值配 B 簇的彩度); 两条路径的口径写进文档注释,并提醒 UI 别把数字写成「彩度 X < 门槛 8」 - card.rs:测试名指向修正(card_keys_are_stable_and_unique,测试就在本文件)、 「对比度 8.4:1」的假示例换成实测区间(light 6.43~6.49 / dark 7.70~7.75)、 中性彩度那段补上「请求值相同 ≠ 实际表面同冷度」的事实与 D43 去向 --- crates/design/src/palette/card.rs | 28 +++++++++++-- crates/design/src/palette/resolve/mod.rs | 4 +- .../design/src/palette/resolve/provenance.rs | 10 +++++ crates/design/src/palette/resolve/tests.rs | 42 ++++++++++++++++++- crates/design/src/palette/score.rs | 25 +++++++---- crates/design/src/palette/seed/mod.rs | 41 +++++++++++------- 6 files changed, 122 insertions(+), 28 deletions(-) diff --git a/crates/design/src/palette/card.rs b/crates/design/src/palette/card.rs index 6d0a580..fb803d6 100644 --- a/crates/design/src/palette/card.rs +++ b/crates/design/src/palette/card.rs @@ -9,13 +9,20 @@ //! - 显示名([`Card::name`])可以改、可以本地化,`key` 不许改; //! - 新卡只能**追加**,旧卡永不删除(删了老用户的选择会静默失效 → 见 //! [`CardKey::from_key`] 的回落口径); -//! - `palette/tests.rs` 里有一条 `card_keys_are_stable` 把 8 个 key 逐字钉住——谁改谁红。 +//! - `card.rs` 的 `card_keys_are_stable_and_unique` 把 8 个 key 逐字钉住——谁改谁红 +//! (测试就在本文件末尾的 `tests` 模块里,没有独立的 `palette/tests.rs`)。 //! //! ## 每张卡自带中性彩度 //! //! 八张卡都取 [`DEFAULT_NEUTRAL_CHROMA`](12,M70 的「浅冷」口径)而不是官方 6: //! 换卡只该换**色相**,不该让窗口底色的「冷度」跟着跳——那种跳变会让用户在设置页里 -//! 逐张试色时觉得「每张卡都像换了个应用」。要偏离这条得单独论证。 +//! 逐张试色时觉得「每张卡都像换了个应用」。 +//! +//! ⚠️ **这只是「请求值」相同**:中性调色板的色相目前仍 = 种子色相(`Palettes::from_seed_*`), +//! 所以八张卡的实际表面色是逐卡染色的(teal 薄荷白 / forest、olive 偏绿 / amber、rose 暖白), +//! 浅色档实测 CAM16 彩度从 1.29(rose)跨到 12.03(teal)——tone 98 处暖相色域装不下 +//! chroma 12。tower 已定案(D43)改成「色卡固定冷调中性色相、只换强调色」,**不在 M87 做**; +//! 在 D43 落地前,别把上面那段当成已经成立的行为。要偏离这条得单独论证。 //! //! ## 卡与取色的关系(D38) //! @@ -47,7 +54,12 @@ impl Card { ) } - /// 这张卡的 `on-primary` 对 `primary` 的实测对比度(设置页显示「对比度 8.4:1」用)。 + /// 这张卡的 `on-primary` 对 `primary` 的实测对比度。 + /// + /// 设置页可以直接显示成「对比度 {:.2}:1」。**2026-11 实测(8 张卡)**: + /// 浅色档 6.43~6.49、深色档 7.70~7.75(逐张数值见 `every_card_scheme_meets_wcag_aa` + /// 的断言上限与 M87 报告表格)——两种模式都远高于 WCAG AA 的 4.5:1。 + /// 别在文案里写死一个数:卡换种子后它会变,用本方法的返回值。 pub fn primary_contrast(self, flavor: SchemeFlavor) -> f32 { let scheme = self.scheme(flavor); scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary) @@ -277,6 +289,16 @@ mod tests { let gap = pair[1] - pair[0]; assert!(gap >= 20.0, "色相挤在一起:{pair:?}"); } + // 色相是环:排序后「最大 − 最小」不是真实间隔,**回绕那一侧**也要检查。 + // 漏了这条的话,将来加一张 350° 的卡(与 rose 15.85° 实际只差 25.7°)会被算成 + // 334.2° 而静默通过。 + let wrap_gap = 360.0 - (hues[hues.len() - 1] - hues[0]); + assert!( + wrap_gap >= 20.0, + "色相环回绕处挤在一起:{:.2}° ↔ {:.2}°(间隔 {wrap_gap:.2}°)", + hues[hues.len() - 1], + hues[0] + ); for card in CARDS { let light = card.scheme(SchemeFlavor::Light); let dark = card.scheme(SchemeFlavor::Dark); diff --git a/crates/design/src/palette/resolve/mod.rs b/crates/design/src/palette/resolve/mod.rs index f8c328e..ceddb60 100644 --- a/crates/design/src/palette/resolve/mod.rs +++ b/crates/design/src/palette/resolve/mod.rs @@ -19,6 +19,7 @@ //! | 情形 | 结果 | `Provenance` 里留下 | //! |---|---|---| //! | 没有背景图 | 跳到 ② | `Outcome::Absent` | +//! | 更高级已经成功 | 不再看这一级 | `Outcome::NotReached`(**不等于**「用户没提供」) | //! | 背景图解码失败 | 跳到 ② | `Outcome::Failed(Skip::Undecodable)` | //! | 背景图太小 / 像素缓冲不对 / 全透明 | 跳到 ② | `… TooSmall / BadDimensions / NoOpaquePixels` | //! | 背景图彩度不足(D40 的 `< 8`) | 跳到 ② | `… LowChroma { chroma }` | @@ -97,7 +98,8 @@ pub fn resolve_source( flavor: SchemeFlavor, ) -> (Scheme, Provenance) { let card = preset.map(Card::from).unwrap_or(DEFAULT_CARD); - let mut outcomes = [Outcome::Absent; 3]; + // 先假定「没轮到」,逐级评估时再改成 Absent / Failed / Used。 + let mut outcomes = [Outcome::NotReached; 3]; for (level, input) in [ (Level::BackgroundImage, background), diff --git a/crates/design/src/palette/resolve/provenance.rs b/crates/design/src/palette/resolve/provenance.rs index c7734f9..d3ea5ad 100644 --- a/crates/design/src/palette/resolve/provenance.rs +++ b/crates/design/src/palette/resolve/provenance.rs @@ -51,7 +51,17 @@ pub enum Outcome { /// 用了这一级。 Used, /// 用户没提供这一级(没设背景图 / 没开莫奈取色)。 + /// + /// 注意与 [`Outcome::NotReached`] 的区别:`Absent` 是「用户没有这一级的输入」, + /// 而「用户提供了、但更高级已经成功所以没轮到」是 `NotReached`。两者对 UI 的含义完全不同—— + /// 把后者说成「未选择」,用户明明选了卡却被告知「色卡:未选择」。 Absent, + /// 有输入、但因为更高级已经成功,**没轮到**这一级。 + /// + /// 只可能出现在低优先级那一侧:背景图成功 → 壁纸与色卡都是 `NotReached`。 + /// 色卡即使 `NotReached` 也可能参与生成(它的中性彩度决定表面色温),所以 UI 要看 + /// [`Provenance::card`] 而不是把 `NotReached` 当成「没用它」。 + NotReached, /// 有输入但不合格,已跳过。 Failed(Skip), } diff --git a/crates/design/src/palette/resolve/tests.rs b/crates/design/src/palette/resolve/tests.rs index ee34e38..36ef697 100644 --- a/crates/design/src/palette/resolve/tests.rs +++ b/crates/design/src/palette/resolve/tests.rs @@ -24,7 +24,7 @@ fn level_one_wins_when_background_is_good() { ); assert_eq!(provenance.level, Level::BackgroundImage); assert_eq!(provenance.background(), Outcome::Used); - assert_eq!(provenance.preset(), Outcome::Absent); + assert_eq!(provenance.preset(), Outcome::NotReached); assert!(provenance.hint_key().is_none()); assert!(check(&scheme).is_clean()); assert!(provenance.seed.is_some()); @@ -122,7 +122,11 @@ fn background_beats_wallpaper_when_both_work() { SchemeFlavor::Dark, ); assert_eq!(provenance.level, Level::BackgroundImage); - assert_eq!(provenance.wallpaper(), Outcome::Absent, "② 根本没轮到"); + assert_eq!( + provenance.wallpaper(), + Outcome::NotReached, + "② 有输入但根本没轮到——不等于「没提供」" + ); assert!(!provenance.did_fall_back()); } @@ -202,3 +206,37 @@ fn a_five_percent_accent_drives_level_one() { assert_ne!(scheme, CardKey::Rose.card().scheme(SchemeFlavor::Light)); assert!(crate::palette::wcag::check(&scheme).is_clean()); } + +#[test] +fn not_reached_is_distinguishable_from_not_provided() { + // 复审(色彩 r1 p2-2):`Absent` 曾同时表示「用户没提供」和「更高级成功所以没轮到」, + // UI 照字面写「色卡:未选择」就会与事实相反(用户明明选了卡,而且它的中性彩度参与了生成)。 + let pixels = solid(Color::rgb(0xC0, 0x39, 0x2B), 4, 4); + + // 一级成功:壁纸与色卡都是「没轮到」,但 card 仍然是用户选的那张。 + let (_, provenance) = resolve_source( + Some(ImageInput::pixels(&pixels, 4, 4)), + Some(ImageInput::Undecodable), // 壁纸**提供了**,只是没轮到 + Some(CardKey::Forest), + SchemeFlavor::Light, + ); + assert_eq!(provenance.level, Level::BackgroundImage); + assert_eq!(provenance.wallpaper(), Outcome::NotReached); + assert_eq!(provenance.preset(), Outcome::NotReached); + assert_eq!(provenance.card, CardKey::Forest, "没轮到 ≠ 没选择"); + + // 真的没提供:背景图 `None` → Absent;壁纸成功 → 色卡 NotReached。 + let (_, provenance) = resolve_source( + None, + Some(ImageInput::pixels(&pixels, 4, 4)), + Some(CardKey::Forest), + SchemeFlavor::Light, + ); + assert_eq!(provenance.background(), Outcome::Absent); + assert_eq!(provenance.preset(), Outcome::NotReached); + assert_eq!( + provenance.reason_codes(), + Vec::<(&'static str, &'static str)>::new(), + "没提供 / 没轮到都不是「失败」,不该进原因码" + ); +} diff --git a/crates/design/src/palette/score.rs b/crates/design/src/palette/score.rs index d0222f3..20ccff7 100644 --- a/crates/design/src/palette/score.rs +++ b/crates/design/src/palette/score.rs @@ -115,12 +115,22 @@ pub(crate) fn rank(clusters: &[Cluster]) -> Vec { 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) +/// 彩度最高的那个簇——被 `Score` 全部过滤时,它是「最接近候选门槛」的那一族。 +/// +/// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对: +/// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值, +/// 于是同一个 `LowChroma` 里的 `color` 与 `chroma` 可能不是一回事(复审 r1 p2-4.2)。 +/// +/// 同彩度时取像素多的那一个(结果确定,不受簇顺序影响)。 +pub(crate) fn most_chromatic(clusters: &[Cluster]) -> Option<&Cluster> { + clusters.iter().max_by(|a, b| { + let key = |cluster: &Cluster| (Hct::from_color(cluster.color).chroma, cluster.population); + let (ca, pa) = key(a); + let (cb, pb) = key(b); + ca.partial_cmp(&cb) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| pa.cmp(&pb)) + }) } fn hue_bucket(hue: f64) -> usize { @@ -157,7 +167,8 @@ mod tests { cluster("#0A0A0A", 300), ]; assert!(rank(&clusters).is_empty()); - assert!(max_chroma(&clusters) < CUTOFF_CHROMA); + let most = most_chromatic(&clusters).expect("灰图也有簇"); + assert!(Hct::from_color(most.color).chroma < CUTOFF_CHROMA); } #[test] diff --git a/crates/design/src/palette/seed/mod.rs b/crates/design/src/palette/seed/mod.rs index 3184184..7721a39 100644 --- a/crates/design/src/palette/seed/mod.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -33,7 +33,7 @@ //! 与 [`super::score::CUTOFF_CHROMA`] = 5 分工不同:5 决定「能不能当候选」,8 决定 //! 「这个候选够不够格当种子」。落在这条缝里(5~8)的图会有一堆候选,最终仍然回落色卡。 -use super::quantize::{self, Cluster}; +use super::quantize; use super::score; use crate::color::{Color, Palettes, Scheme, SchemeFlavor}; @@ -103,10 +103,19 @@ pub enum Failure { /// 全是透明/半透明像素,没有可用的不透明像素。 NoOpaquePixels, /// 彩度不足:`chroma < MIN_SEED_CHROMA`,硬取色会凭空上色。 + /// + /// `color` 与 `chroma` **永远来自同一个簇**(复审 r1 p2-4.2 定的口径): + /// - 有候选(通过 `Score` 的彩度 ≥5 与人气 ≥1% 过滤)但最强候选仍 `< 8` → + /// 报 `Score` **榜首**的色值与彩度(它是「本次能拿到的最好候选」); + /// - 一个候选都没有(灰度图 / 只有零碎高彩度小面积)→ 报**彩度最高**的那一簇 + /// (此时彩度必然 < 候选门槛 5)。 + /// + /// ⚠️ UI 别把这个数字写成「彩度 X < 门槛 8」:第二种情况(高彩度但面积不足 1%)里 + /// X 可能 ≥8,真正的失败原因是「没有一族面积够大的颜色」。文案用「颜色太淡 / 太杂」即可。 LowChroma { - /// 最有希望的那个色(提示文案里当「最接近的颜色」)。 + /// 最接近能用的那个色(提示文案里当「最接近的颜色」)。 color: Color, - /// 实测 CAM16 彩度。 + /// 该色的实测 CAM16 彩度(与 `color` 成对)。 chroma: f64, }, } @@ -154,18 +163,24 @@ pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result 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), - }), + // 一个候选都没有(灰度图):报**彩度最高**的那一族,色值与彩度成对(必然 < 候选门槛 5)。 + None => { + let closest = score::most_chromatic(&clusters); + Err(Failure::LowChroma { + color: closest + .map(|cluster| cluster.color) + .unwrap_or(Color::rgb(0x80, 0x80, 0x80)), + chroma: closest + .map(|cluster| crate::color::Hct::from_color(cluster.color).chroma) + .unwrap_or(0.0), + }) + } } } @@ -176,10 +191,6 @@ 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)。 /// /// **先按 alpha 过滤、再求平均**:块内只累加 `a >= ALPHA_THRESHOLD` 的像素,整块没有有效像素 From 09b716ae79dca5238925e06cec1d132af6a783b6 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:22:52 +0800 Subject: [PATCH 10/11] =?UTF-8?q?docs(design)+test(palette-extract):=20r2?= =?UTF-8?q?=20=E4=B8=89=E6=9D=A1=E5=8F=A3=E5=BE=84=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E4=B8=8E=E5=B8=B8=E6=95=B0=E7=BB=91=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - LowChroma 文档:删掉「彩度必然 < 候选门槛 5」(人气门槛也会淘汰高彩度小面积, 实测 0.5% 纯红报 113.36),改为与契约文档 §6 一致的口径;代码臂注释与 score::most_chromatic 同步 - Seed::color / share 改成有边界的说法:≤MAX_SAMPLES 才是图上真实色、share 的分母是有效采样点 - palette-extract 新增 max_edge_stays_within_the_design_sample_budget: MAX_EDGE² ≤ MAX_SAMPLES(256² 当前余量为零,调大就红) - card.rs:把 6.43~6.50 / 7.70~7.76 写成断言(原来只有 ≥4.5 下限,文档却宣称区间) - LowChroma.color 字段注释改成「最接近可用的候选色」(113 那条并不「能用」) --- crates/design/src/palette/card.rs | 28 +++++++++++++++------ crates/design/src/palette/score.rs | 7 +++++- crates/design/src/palette/seed/mod.rs | 35 +++++++++++++++++++++------ crates/palette-extract/src/tests.rs | 15 ++++++++++++ 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/crates/design/src/palette/card.rs b/crates/design/src/palette/card.rs index fb803d6..8781ca7 100644 --- a/crates/design/src/palette/card.rs +++ b/crates/design/src/palette/card.rs @@ -57,9 +57,11 @@ impl Card { /// 这张卡的 `on-primary` 对 `primary` 的实测对比度。 /// /// 设置页可以直接显示成「对比度 {:.2}:1」。**2026-11 实测(8 张卡)**: - /// 浅色档 6.43~6.49、深色档 7.70~7.75(逐张数值见 `every_card_scheme_meets_wcag_aa` - /// 的断言上限与 M87 报告表格)——两种模式都远高于 WCAG AA 的 4.5:1。 - /// 别在文案里写死一个数:卡换种子后它会变,用本方法的返回值。 + /// 浅色档 **6.43~6.49**、深色档 **7.70~7.75**——两种模式都远高于 WCAG AA 的 4.5:1。 + /// + /// 这两个区间**被测试钉住**(不是口头声明):`every_card_scheme_meets_wcag_aa` 对每张卡 + /// 断言「下限 [`wcag::AA_TEXT`] + 落在上述区间内」,所以将来改种子/调色板常数把比值 + /// 拖出区间会当场红——文档里的数字与测试同源。要改数值请连测试区间一起改。 pub fn primary_contrast(self, flavor: SchemeFlavor) -> f32 { let scheme = self.scheme(flavor); scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary) @@ -266,12 +268,24 @@ mod tests { "{} / {flavor:?} 未过门禁", card.key ); + let ratio = card.primary_contrast(flavor); assert!( - card.primary_contrast(flavor) >= wcag::AA_TEXT, - "{} / {flavor:?} primary 对 {} 只有 {:.2}", + ratio >= wcag::AA_TEXT, + "{} / {flavor:?} primary 对 {} 只有 {ratio:.2}", card.key, - card.name, - card.primary_contrast(flavor) + card.name + ); + // 上半区间:文档(`Card::primary_contrast`)向用户承诺的就是这两个区间, + // 只钉「≥ 4.5」等于没钉——比值掉到 5.0 也全绿,而文档会继续说 6.43~6.49。 + // 边界取实测极值 ±0.01:浅色 6.4324~6.4906、深色 7.7024~7.7504。 + let (low, high) = match flavor { + SchemeFlavor::Light => (6.43, 6.50), + SchemeFlavor::Dark => (7.70, 7.76), + }; + assert!( + (low..=high).contains(&ratio), + "{} / {flavor:?} primary 对比度 {ratio:.4} 超出文档区间 {low}~{high}", + card.key ); } } diff --git a/crates/design/src/palette/score.rs b/crates/design/src/palette/score.rs index 20ccff7..30445ca 100644 --- a/crates/design/src/palette/score.rs +++ b/crates/design/src/palette/score.rs @@ -115,7 +115,12 @@ pub(crate) fn rank(clusters: &[Cluster]) -> Vec { ranked } -/// 彩度最高的那个簇——被 `Score` 全部过滤时,它是「最接近候选门槛」的那一族。 +/// 彩度最高的那个簇——被 `Score` 全部过滤时用它报「这张图最鲜艳的颜色」。 +/// +/// ⚠️ 它**不是**「最接近及格的那一族」:`Score` 的过滤有两道(彩度 ≥5、色相桶人气 ≥1%), +/// 面积不足 1% 的高彩度颜料会被**人气**刷掉——例如 1592 个米白 + 8 个纯红的图, +/// 候选为空而这里的「最鲜艳」彩度是 **113.36**。所以调用方的文案要按 +/// 「全图最鲜艳的那一族」,不是「差一点就及格的那一族」。 /// /// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对: /// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值, diff --git a/crates/design/src/palette/seed/mod.rs b/crates/design/src/palette/seed/mod.rs index 7721a39..ce06dab 100644 --- a/crates/design/src/palette/seed/mod.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -52,13 +52,22 @@ const ALPHA_THRESHOLD: u8 = 128; /// 取色结果:种子色 + 足以让 UI 解释「为什么是这个颜色」的观感数据。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct Seed { - /// 种子色(图上真实存在的颜色,不是各色平均出来的)。 + /// 种子色。 + /// + /// **像素数 ≤ [`MAX_SAMPLES`] 时**它是图上真实存在的颜色(代表色取「簇内像素最多的桶里 + /// 像素原值平均」,纯色图逐通道精确);**更大的图会先做块平均降采样**,此时它是块平均后的 + /// 颜色——`2×2` 块内红蓝交替会平均成 `#703C75`,这个色值在图上任何位置都不存在。 + /// 降采样本来就要平均(顺带降噪),所以这是**有意的行为**,不是「绝不出现平均色」的保证。 pub color: Color, /// 种子的 CAM16 色相角(度)。 pub hue: f64, /// 种子的 CAM16 彩度。 pub chroma: f64, - /// 该色在其色相族里占全图的像素比例(0..1)。 + /// 该色所在色相族占**有效采样点**的比例(0..1)。 + /// + /// 分母是采样点(块平均后的点、且已剔除透明像素),不是全图像素数。所以只有 + /// 「无透明 + 像素数 ≤ [`MAX_SAMPLES`]」时它才等于「占全图比例」;大图(有降采样) + /// 或有透明区域时只能说「占有效采样点的比例」。 pub share: f64, } @@ -107,13 +116,21 @@ pub enum Failure { /// `color` 与 `chroma` **永远来自同一个簇**(复审 r1 p2-4.2 定的口径): /// - 有候选(通过 `Score` 的彩度 ≥5 与人气 ≥1% 过滤)但最强候选仍 `< 8` → /// 报 `Score` **榜首**的色值与彩度(它是「本次能拿到的最好候选」); - /// - 一个候选都没有(灰度图 / 只有零碎高彩度小面积)→ 报**彩度最高**的那一簇 - /// (此时彩度必然 < 候选门槛 5)。 + /// - 一个候选都没有 → 报**彩度最高**的那一簇。 + /// 这条**不是「灰图专属」**:`Score` 除了彩度 ≥5 还要求所在色相桶的人气 ≥1%, + /// 所以「高彩度但面积太小」也会走到这里。实测(复审探针):40×40、1592 个米白 + /// `#F5F1EA` + 8 个纯红 `#FF0000`(0.5% 面积)→ 报回来的彩度是 **113.36** + /// (`#FF0000` 的 CAM16 彩度,见 `color::hct` 的对账表)。 /// - /// ⚠️ UI 别把这个数字写成「彩度 X < 门槛 8」:第二种情况(高彩度但面积不足 1%)里 - /// X 可能 ≥8,真正的失败原因是「没有一族面积够大的颜色」。文案用「颜色太淡 / 太杂」即可。 + /// ⚠️ 因此这个数字**不受候选门槛 5 约束**,可能远高于 8。UI 别把它写成 + /// 「彩度 X < 门槛 8」:真正的失败原因是「没有一族**面积够大**的颜色」。文案用 + /// 「颜色太淡 / 太杂」即可;要显示数字也只在 `chroma < CUTOFF_CHROMA(5)` 时显示 + /// (那时的含义才是「整张图没颜色」,即纯灰图)。 LowChroma { - /// 最接近能用的那个色(提示文案里当「最接近的颜色」)。 + /// 取色失败时最接近可用的候选色(提示文案里可以显示成一个小色块)。 + /// + /// 注意它不一定「能用」:高彩度但面积很小的颜料(如 0.5% 面积的纯红)也会出现在这里, + /// 见 [`Failure::LowChroma`] 的说明。 color: Color, /// 该色的实测 CAM16 彩度(与 `color` 成对)。 chroma: f64, @@ -169,7 +186,9 @@ pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result { let closest = score::most_chromatic(&clusters); Err(Failure::LowChroma { diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs index 1465523..6f57b6d 100644 --- a/crates/palette-extract/src/tests.rs +++ b/crates/palette-extract/src/tests.rs @@ -156,6 +156,21 @@ fn absurd_dimensions_are_rejected_before_allocating() { } } +#[test] +fn max_edge_stays_within_the_design_sample_budget() { + // 结构性防线(复审 r2 要求):`unlucky_design` 只在「像素数 ≤ MAX_SAMPLES」时才不做块平均, + // 也只有那时 `Seed::color` 才保证是图上真实存在的颜色。当前 256² = 65536 = MAX_SAMPLES, + // **余量为零**——谁把 MAX_EDGE 调到 512,UI 路径就会开始返回「块平均出来的、图上不存在的 + // 颜色」(例如红蓝交替被平均成 #703C75),而没有任何东西会红。这条把两个常数绑在一起。 + let budget = unlucky_design::palette::MAX_SAMPLES; + let edge = MAX_EDGE as usize; + assert!( + edge * edge <= budget, + "MAX_EDGE={MAX_EDGE} 的平方({})超过 design 的采样预算 {budget}: 调大它之前先读 `unlucky_design::palette::Seed::color` 的文档——超过预算后种子不再是图上真实存在的颜色", + edge * edge + ); +} + #[test] fn input_borrows_without_copying() { let loaded = decode_bytes(&encode_png(&solid_image(4, 4, [0x67, 0x50, 0xA4]))); From 8be91a9cc9d82f80cd3ec409fb7c18dae060af4c Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 2 Nov 2026 10:28:29 +0800 Subject: [PATCH 11/11] =?UTF-8?q?docs(design)+test(palette-extract):=20?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E3=80=8C=E7=A7=8D=E5=AD=90=E4=B8=80=E5=AE=9A?= =?UTF-8?q?=E6=98=AF=E5=8E=9F=E5=9B=BE=E7=9C=9F=E5=AE=9E=E9=A2=9C=E8=89=B2?= =?UTF-8?q?=E3=80=8D=E7=9A=84=E8=BF=87=E5=BA=A6=E6=89=BF=E8=AF=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自测发现:400×400 红蓝棋盘在 MAX_EDGE=256(适配层 thumbnail 缩放)与 512(design 块平均) 下都取到 #704070,都不在原图上——「≤MAX_SAMPLES ⇒ 真实颜色」只对「传进 analyze_rgba 的 那个缓冲」成立,不是整条链的性质。 - Seed::color 文档写明边界 + 上游缩放同样会破坏该性质(附实测例) - 结构性断言的注释/断言文案改为「design 不再叠第二层块平均」,不再声称能保住真实颜色 --- crates/design/src/palette/seed/mod.rs | 13 +++++++++---- crates/palette-extract/src/tests.rs | 14 +++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/crates/design/src/palette/seed/mod.rs b/crates/design/src/palette/seed/mod.rs index ce06dab..a2c577a 100644 --- a/crates/design/src/palette/seed/mod.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -54,10 +54,15 @@ const ALPHA_THRESHOLD: u8 = 128; pub struct Seed { /// 种子色。 /// - /// **像素数 ≤ [`MAX_SAMPLES`] 时**它是图上真实存在的颜色(代表色取「簇内像素最多的桶里 - /// 像素原值平均」,纯色图逐通道精确);**更大的图会先做块平均降采样**,此时它是块平均后的 - /// 颜色——`2×2` 块内红蓝交替会平均成 `#703C75`,这个色值在图上任何位置都不存在。 - /// 降采样本来就要平均(顺带降噪),所以这是**有意的行为**,不是「绝不出现平均色」的保证。 + /// **只对「传进 [`analyze_rgba`] 的那个缓冲」作保证**:像素数 ≤ [`MAX_SAMPLES`] 时不做块 + /// 平均,种子是缓冲里真实存在的颜色(代表色取「簇内像素最多的桶里像素原值平均」, + /// 纯色图逐通道精确);更大的缓冲会先块平均,此时可能是平均出来的新颜色。 + /// + /// ⚠️ 整条链上**任何一次缩放**都会破坏「真实颜色」,不只是这一层:`crates/palette-extract` + /// 在超过 `MAX_EDGE` 时先用 `thumbnail` 缩放。实测 400×400 红蓝棋盘(每像素交替): + /// `MAX_EDGE = 256`(适配层缩放)与 `MAX_EDGE = 512`(本层块平均)都取到 `#704070`, + /// 这个色值在原图上**一个像素都不是**。降采样本来就要平均(顺带降噪),所以这是 + /// **有意的行为**;不要把它当「绝不出现平均色」的保证,也别拿它判断「这个色在壁纸里存在」。 pub color: Color, /// 种子的 CAM16 色相角(度)。 pub hue: f64, diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs index 6f57b6d..dc17050 100644 --- a/crates/palette-extract/src/tests.rs +++ b/crates/palette-extract/src/tests.rs @@ -158,15 +158,19 @@ fn absurd_dimensions_are_rejected_before_allocating() { #[test] fn max_edge_stays_within_the_design_sample_budget() { - // 结构性防线(复审 r2 要求):`unlucky_design` 只在「像素数 ≤ MAX_SAMPLES」时才不做块平均, - // 也只有那时 `Seed::color` 才保证是图上真实存在的颜色。当前 256² = 65536 = MAX_SAMPLES, - // **余量为零**——谁把 MAX_EDGE 调到 512,UI 路径就会开始返回「块平均出来的、图上不存在的 - // 颜色」(例如红蓝交替被平均成 #703C75),而没有任何东西会红。这条把两个常数绑在一起。 + // 结构性防线(复审 r2 要求):`unlucky_design` 只在「像素数 ≤ MAX_SAMPLES」时才不做块平均。 + // 当前 256² = 65536 = MAX_SAMPLES,**余量为零**:谁把 MAX_EDGE 调到 512,本适配层交出去的 + // 缓冲就会超过 design 的采样预算,design 会**在适配层已经缩过之后再叠一层块平均** + // (色彩被二次抹平),而这条退化没有任何测试会报错——两个常数此前是各写各的。 + // + // 注意别把这条读成「≤ 预算 ⇒ 种子一定是原图真实颜色」:本适配层的 `thumbnail` 本身就会平均 + // (400×400 红蓝棋盘在 MAX_EDGE=256 下取到的 `#704070` 也不在原图上)。这条只保证 + // 「design 侧不再叠第二层平均」,逐像素语义见 `Seed::color` 的文档。 let budget = unlucky_design::palette::MAX_SAMPLES; let edge = MAX_EDGE as usize; assert!( edge * edge <= budget, - "MAX_EDGE={MAX_EDGE} 的平方({})超过 design 的采样预算 {budget}: 调大它之前先读 `unlucky_design::palette::Seed::color` 的文档——超过预算后种子不再是图上真实存在的颜色", + "MAX_EDGE={MAX_EDGE} 的平方({})超过 design 的采样预算 {budget}: 调大它之前先读 `unlucky_design::palette::Seed::color` 的文档——超过预算后 design 会在本适配层缩放之上再叠一层块平均", edge * edge ); }