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

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

No files matched your search

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