Merge branch 'feat/p4-palette-source-trio-image-wallpaper-p'

This commit is contained in:
AskaEth committed 2026-11-02 11:10:59 +08:00
commit 1d0ff70908
20 files changed
+2935 -9

No files matched your search

Generated
+8
View File
@@ -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"
+7
View File
@@ -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)。
+6 -4
View File
@@ -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 个角色」,不碰像素、不碰路径、不碰回落决策。
//!
//! > ⚠️ **有意偏离登记(两处,同一处偏离的两半)**:
//! >
@@ -114,7 +115,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;
+7 -4
View File
@@ -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)。
+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;
+325
View File
@@ -0,0 +1,325 @@
//! 内置色卡(D38 第三级):用户在「关闭莫奈取色」时手动选的那几张。
//!
//! ## `key` 是持久化键,命名就此定死
//!
//! 每张卡的 [`Card::key`] 会被写进用户设置(settings 里存字符串),**改了就等于把用户的选择
//! 抹成出厂默认**。所以:
//!
//! - 只用小写 ASCII + 单段单词,不带动词、不带颜色数值(`sky`,不是 `blue_3c8dbc`);
//! - 显示名([`Card::name`])可以改、可以本地化,`key` 不许改;
//! - 新卡只能**追加**,旧卡永不删除(删了老用户的选择会静默失效 → 见
//! [`CardKey::from_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)
//!
//! 取色成功时,**当前选中的卡仍然参与生成**:它的中性彩度决定取色方案的表面色温
//! (见 [`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` 的实测对比度。
///
/// 设置页可以直接显示成「对比度 {:.2}:1」。**2026-11 实测(8 张卡)**:
/// 浅色档 **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)
}
}
/// 全部内置色卡(下标 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
);
let ratio = card.primary_contrast(flavor);
assert!(
ratio >= wcag::AA_TEXT,
"{} / {flavor:?} primary 对 {} 只有 {ratio:.2}",
card.key,
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
);
}
}
}
#[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:?}");
}
// 色相是环:排序后「最大 − 最小」不是真实间隔,**回绕那一侧**也要检查。
// 漏了这条的话,将来加一张 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);
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:?}"
);
}
}
+201
View File
@@ -0,0 +1,201 @@
//! 三级来源编排:**纯函数**,一次调用算完「用哪一级的颜色、为什么不是别的级」。
//!
//! 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::NotReached`(**不等于**「用户没提供」) |
//! | 背景图解码失败 | 跳到 ② | `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`]。
//!
//! ## 文件划分
//!
//! [`provenance`] 放「结果解释」的数据类型(`Level` / `Outcome` / `Skip` / `Provenance`),
//! 本文件放编排函数与测试。拆开只是为了单文件 ≤400 行的纪律,对外路径仍是
//! `palette::resolve::*`。
use super::card::{Card, CardKey, DEFAULT_CARD};
use super::seed::{analyze_rgba, Seed};
use super::wcag::{check, converge, Adjustment};
use crate::color::{Scheme, SchemeFlavor};
mod provenance;
pub use provenance::{Level, Outcome, Provenance, Skip};
/// 已解码的图片:借用像素,不拷贝(解码在 `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,
})
}
}
/// 三级来源编排(纯函数)。
///
/// 参数:
/// - `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);
// 先假定「没轮到」,逐级评估时再改成 Absent / Failed / Used。
let mut outcomes = [Outcome::NotReached; 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;
@@ -0,0 +1,261 @@
//! 一次配色的「结果解释」:最终用了哪一级、前几级为什么没用、对比度被改了什么。
//!
//! 这些类型是**给 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,
/// 用户没提供这一级(没设背景图 / 没开莫奈取色)。
///
/// 注意与 [`Outcome::NotReached`] 的区别:`Absent` 是「用户没有这一级的输入」,
/// 而「用户提供了、但更高级已经成功所以没轮到」是 `NotReached`。两者对 UI 的含义完全不同——
/// 把后者说成「未选择」,用户明明选了卡却被告知「色卡:未选择」。
Absent,
/// 有输入、但因为更高级已经成功,**没轮到**这一级。
///
/// 只可能出现在低优先级那一侧:背景图成功 → 壁纸与色卡都是 `NotReached`。
/// 色卡即使 `NotReached` 也可能参与生成(它的中性彩度决定表面色温),所以 UI 要看
/// [`Provenance::card`] 而不是把 `NotReached` 当成「没用它」。
NotReached,
/// 有输入但不合格,已跳过。
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>,
/// 三级各自的处置结果(`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`,`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%」。
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()
}
}
+242
View File
@@ -0,0 +1,242 @@
//! `resolve` 的单测:三级优先序、失败回落、以及「回落落点是用户选的卡」这三类断言。
//!
//! 与 `mod.rs` 拆开只为单文件 ≤400 行;测试仍属于 `palette::resolve::tests`。
use super::*;
use crate::color::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 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::NotReached);
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::NotReached,
"② 有输入但根本没轮到——不等于「没提供」"
);
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());
}
#[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(),
"没提供 / 没轮到都不是「失败」,不该进原因码"
);
}
+207
View File
@@ -0,0 +1,207 @@
//! 主色簇 → 排序后的候选种子:官方 `Score` 的口径。
//!
//! ## 官方怎么挑「最好的那个颜色」
//!
//! `Score` 不是「像素最多的颜色」,而是三件事的加权:
//!
//! 1. **有人气**:颜色所在**色相桶**(1 度一桶)占全图的像素比例要够大
//! (`CUTOFF_EXCITED_PROPORTION = 1%` 以下的色相直接出局)。用色相桶而不是单色占比,
//! 是为了把「同一个色相的一族明暗」合成人气,而不是让它们互相拆票。
//! 2. **有颜色**:`CUTOFF_CHROMA = 5` 以下的颜色出局——灰、白、黑再多也不该当种子。
//! 3. **彩度越高越优先**,但**封顶**:`TARGET_CHROMA = 48` 以上不再加分(否则一张荧光图会被
//! 推到色域边缘,整套配色的 on-color 会难看得像报警灯)。
//!
//! 打分式(官方形状):
//!
//! ```text
//! excited = 该色所在色相桶的像素占比
//! chroma_weight = clamp((chroma − 5) / (48 − 5), 0, 1)
//! score = 0.7 × excited + 0.3 × chroma_weight
//! ```
//!
//! ## 来源等级
//!
//! - **A**:四个常数(`CUTOFF_CHROMA = 5`、`CUTOFF_EXCITED_PROPORTION = 0.01`、
//! `TARGET_CHROMA = 48`、`WEIGHT_PROPORTION/WEIGHT_CHROMA = 0.7/0.3`)与「按色相桶计人气」
//! 的做法都来自 `material-color-utilities` 的 `score/Score.java`。
//! - **B**:把 `excited` 直接当 `proportion` 项用(官方的 `Score` 内部还有一层
//! `hueOffset`/`huePopulation` 的除法归一,口径相同、写法不同)。本实现的选择是
//! 「只看色相桶占比」,因为它是能全单测的最简形式,且与官方在样例图上选出同一个色相。
//!
//! 注意与 D40 的彩度门槛([`super::MIN_SEED_CHROMA`] = 8)的分工:
//! **这里的 5 是「要不要把它当候选」,那里的 8 是「这个结果够不够格当种子」**。
//! 一张偏灰的图会有一堆彩度 5~7 的候选通过 `Score`,然后被 8 的门槛挡下并回落到色卡——
//! 这正是 D40 要的行为(宁可回落,不许凭空上色)。
use super::quantize::Cluster;
use crate::color::Hct;
/// 彩度下限:低于它的颜色不参与打分(官方 `Score.CUTOFF_CHROMA`)。
pub(crate) const CUTOFF_CHROMA: f64 = 5.0;
/// 色相桶人气下限(官方 `Score.CUTOFF_EXCITED_PROPORTION`)。
pub(crate) const CUTOFF_EXCITED_PROPORTION: f64 = 0.01;
/// 彩度加分封顶(官方 `Score.TARGET_CHROMA`)。
pub(crate) const TARGET_CHROMA: f64 = 48.0;
/// 人气项的权重(官方 `Score.WEIGHT_PROPORTION`)。
const WEIGHT_PROPORTION: f64 = 0.7;
/// 彩度项的权重(官方 `Score.WEIGHT_CHROMA`)。
const WEIGHT_CHROMA: f64 = 0.3;
/// 一个候选种子(含打分所需的中间量,方便测试与提示文案)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct Ranked {
/// 候选簇。
pub cluster: Cluster,
/// 全图像素占比(0..1)。
pub share: f64,
/// 所在色相桶的像素占比(0..1,官方所谓 excited proportion)。
pub excited: f64,
/// CAM16 彩度。
pub chroma: f64,
/// CAM16 色相角。
pub hue: f64,
/// 最终打分(越大越优先)。
pub score: f64,
}
/// 按官方 `Score` 口径排序候选;被过滤掉的簇不出现在结果里。
pub(crate) fn rank(clusters: &[Cluster]) -> Vec<Ranked> {
let total: u64 = clusters.iter().map(|c| u64::from(c.population)).sum();
if total == 0 {
return Vec::new();
}
let hcts: Vec<Hct> = clusters.iter().map(|c| Hct::from_color(c.color)).collect();
let mut hue_population = [0u64; 360];
for (cluster, hct) in clusters.iter().zip(&hcts) {
hue_population[hue_bucket(hct.hue)] += u64::from(cluster.population);
}
let mut ranked: Vec<Ranked> = clusters
.iter()
.zip(&hcts)
.filter_map(|(cluster, hct)| {
if hct.chroma < CUTOFF_CHROMA {
return None;
}
let share = f64::from(cluster.population) / total as f64;
let excited = hue_population[hue_bucket(hct.hue)] as f64 / total as f64;
if excited < CUTOFF_EXCITED_PROPORTION {
return None;
}
let chroma_weight =
((hct.chroma - CUTOFF_CHROMA) / (TARGET_CHROMA - CUTOFF_CHROMA)).clamp(0.0, 1.0);
Some(Ranked {
cluster: *cluster,
share,
excited,
chroma: hct.chroma,
hue: hct.hue,
score: WEIGHT_PROPORTION * excited + WEIGHT_CHROMA * chroma_weight,
})
})
.collect();
ranked.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
// 同分时像素多的优先,再同则按色值定序 → 结果完全确定。
.then_with(|| b.cluster.population.cmp(&a.cluster.population))
.then_with(|| a.cluster.color.r.cmp(&b.cluster.color.r))
.then_with(|| a.cluster.color.g.cmp(&b.cluster.color.g))
.then_with(|| a.cluster.color.b.cmp(&b.cluster.color.b))
});
ranked
}
/// 彩度最高的那个簇——被 `Score` 全部过滤时用它报「这张图最鲜艳的颜色」。
///
/// ⚠️ 它**不是**「最接近及格的那一族」:`Score` 的过滤有两道(彩度 ≥5、色相桶人气 ≥1%),
/// 面积不足 1% 的高彩度颜料会被**人气**刷掉——例如 1592 个米白 + 8 个纯红的图,
/// 候选为空而这里的「最鲜艳」彩度是 **113.36**。所以调用方的文案要按
/// 「全图最鲜艳的那一族」,不是「差一点就及格的那一族」。
///
/// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对:
/// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值,
/// 于是同一个 `LowChroma` 里的 `color` 与 `chroma` 可能不是一回事(复审 r1 p2-4.2)。
///
/// 同彩度时取像素多的那一个(结果确定,不受簇顺序影响)。
pub(crate) fn most_chromatic(clusters: &[Cluster]) -> Option<&Cluster> {
clusters.iter().max_by(|a, b| {
let key = |cluster: &Cluster| (Hct::from_color(cluster.color).chroma, cluster.population);
let (ca, pa) = key(a);
let (cb, pb) = key(b);
ca.partial_cmp(&cb)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| pa.cmp(&pb))
})
}
fn hue_bucket(hue: f64) -> usize {
let wrapped = hue.rem_euclid(360.0);
(wrapped.round() as usize) % 360
}
#[cfg(test)]
mod tests {
use super::*;
use crate::color::Color;
fn cluster(hex: &str, population: u32) -> Cluster {
Cluster {
color: Color::from_hex(hex).expect("合法 hex"),
population,
}
}
#[test]
fn accent_with_small_area_but_high_chroma_transfers_to_seed() {
// 95% 米白 + 5% 珊瑚:米白被彩度门槛挡掉,珊瑚当种子。
let clusters = [cluster("#F5F1EA", 950), cluster("#FF6B5A", 50)];
let ranked = rank(&clusters);
assert_eq!(ranked.len(), 1, "{ranked:?}");
assert_eq!(ranked[0].cluster.color.to_hex(), "#FF6B5A");
}
#[test]
fn grayscale_clusters_are_all_filtered() {
let clusters = [
cluster("#FFFFFF", 1000),
cluster("#808080", 400),
cluster("#0A0A0A", 300),
];
assert!(rank(&clusters).is_empty());
let most = most_chromatic(&clusters).expect("灰图也有簇");
assert!(Hct::from_color(most.color).chroma < CUTOFF_CHROMA);
}
#[test]
fn tie_break_is_deterministic() {
let clusters = [cluster("#3366CC", 100), cluster("#2B6DD0", 100)];
let first = rank(&clusters);
let second = rank(&clusters);
assert_eq!(first.len(), 2);
assert_eq!(
first.iter().map(|r| r.cluster.color).collect::<Vec<_>>(),
second.iter().map(|r| r.cluster.color).collect::<Vec<_>>()
);
}
#[test]
fn tiny_population_hue_is_ignored() {
// 0.5% 的青绿被 excited proportion 门槛(1%)挡掉。
let clusters = [cluster("#FF0000", 995), cluster("#00C8B4", 5)];
let ranked = rank(&clusters);
assert_eq!(ranked.len(), 1);
assert_eq!(ranked[0].cluster.color.to_hex(), "#FF0000");
}
#[test]
fn hue_bucket_wraps() {
assert_eq!(hue_bucket(0.0), 0);
assert_eq!(hue_bucket(359.6), 0);
assert_eq!(hue_bucket(-1.0), 359);
assert_eq!(hue_bucket(720.4), 0);
}
}
+267
View File
@@ -0,0 +1,267 @@
//! 已解码的 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 块做**块平均**降采样,再量化 | 取色只需要「色彩分布」,不需要分辨率;块平均能保住细纹理的平均色,避免等距抽样在条纹/网格图上走样 |
//! | 混合透明度(一半不透明一半全透明) | **先按 alpha 过滤、再块平均**(整块全透明则跳过) | 「先平均再比门槛」会把 `255/2 = 127 < 128` 判成全透明,于是 13 万个不透明像素被报成 `NoOpaquePixels`——复审实测踩到的坑 |
//! | 解码失败 | 不在本 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;
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 {
/// 种子色。
///
/// **只对「传进 [`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,
/// 种子的 CAM16 彩度。
pub chroma: f64,
/// 该色所在色相族占**有效采样点**的比例(0..1)。
///
/// 分母是采样点(块平均后的点、且已剔除透明像素),不是全图像素数。所以只有
/// 「无透明 + 像素数 ≤ [`MAX_SAMPLES`]」时它才等于「占全图比例」;大图(有降采样)
/// 或有透明区域时只能说「占有效采样点的比例」。
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`,硬取色会凭空上色。
///
/// `color` 与 `chroma` **永远来自同一个簇**(复审 r1 p2-4.2 定的口径):
/// - 有候选(通过 `Score` 的彩度 ≥5 与人气 ≥1% 过滤)但最强候选仍 `< 8` →
/// 报 `Score` **榜首**的色值与彩度(它是「本次能拿到的最好候选」);
/// - 一个候选都没有 → 报**彩度最高**的那一簇。
/// 这条**不是「灰图专属」**:`Score` 除了彩度 ≥5 还要求所在色相桶的人气 ≥1%,
/// 所以「高彩度但面积太小」也会走到这里。实测(复审探针):40×40、1592 个米白
/// `#F5F1EA` + 8 个纯红 `#FF0000`(0.5% 面积)→ 报回来的彩度是 **113.36**
/// (`#FF0000` 的 CAM16 彩度,见 `color::hct` 的对账表)。
///
/// ⚠️ 因此这个数字**不受候选门槛 5 约束**,可能远高于 8。UI 别把它写成
/// 「彩度 X < 门槛 8」:真正的失败原因是「没有一族**面积够大**的颜色」。文案用
/// 「颜色太淡 / 太杂」即可;要显示数字也只在 `chroma < CUTOFF_CHROMA(5)` 时显示
/// (那时的含义才是「整张图没颜色」,即纯灰图)。
LowChroma {
/// 取色失败时最接近可用的候选色(提示文案里可以显示成一个小色块)。
///
/// 注意它不一定「能用」:高彩度但面积很小的颜料(如 0.5% 面积的纯红)也会出现在这里,
/// 见 [`Failure::LowChroma`] 的说明。
color: Color,
/// 该色的实测 CAM16 彩度(与 `color` 成对)。
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(),
});
}
// 采样点已经是「只由不透明像素算出的块平均」(见 [`sample_blocks`]),这里不再按 alpha 过滤。
let samples = sample_blocks(pixels, width as usize, height as usize);
if samples.is_empty() {
return Err(Failure::NoOpaquePixels);
}
let clusters = quantize::quantize(&samples);
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,
}),
// 有候选但都不够鲜艳:报 `Score` 榜首的色值与彩度(`Score` 已经按人气 × 彩度排过序,
// 它就是「本次能拿到的最好候选」)。
Some(best) => Err(Failure::LowChroma {
color: best.cluster.color,
chroma: best.chroma,
}),
// 一个候选都没有:报**彩度最高**的那一族,色值与彩度成对。
// 这不等于「整张图没颜色」——1592 米白 + 8 个纯红的图也走这里(红色面积 0.5% 被人气
// 门槛淘汰),报回来的彩度是 113.36。真实失败原因是「没有一族面积够大的颜色」。
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),
})
}
}
}
/// 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()
}
/// 块平均降采样:块边长按「不超过 [`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<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; 3];
let mut count = 0u64;
for y in top..bottom {
for x in left..right {
let offset = (y * width + x) * 4;
if pixels[offset + 3] < ALPHA_THRESHOLD {
continue;
}
for channel in 0..3 {
sum[channel] += u64::from(pixels[offset + channel]);
}
count += 1;
}
}
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;
}
samples
}
#[cfg(test)]
mod tests;
+254
View File
@@ -0,0 +1,254 @@
//! `seed` 模块的单测:五类输入各自的处置 + **端到端反证**(不许退化成平均色)。
//!
//! 为什么端到端测试必须在这里(而不是只测 `quantize`/`score`):那两条单测手工构造
//! `Cluster` 直接喂中间层,**绕过了 `analyze_rgba` 的接线**。把 `analyze_rgba` 换成
//! 「不透明像素求算术平均」时,中间层单测照样绿,只有这里会红。
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
}
/// 生成「其余是 `base`,`accent_pixels` 个像素是 `accent`」的图(强调色排在末尾)。
///
/// 这是**反证「不许取平均色」**的输入构造器:强调色面积小到平均色会被底色吃掉,
/// 但它是人眼会记住、能当强调色用的那一小撮。
fn two_tone(width: u32, height: u32, base: Color, accent: Color, accent_pixels: usize) -> Vec<u8> {
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 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);
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}");
}
}
#[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:?}"),
}
}
+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}°");
}
}
+32
View File
@@ -0,0 +1,32 @@
[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 壁纸与用户照片的绝对主力(不引它等于「取色对一半用户不可用」);
# - 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",
"bmp",
] }
[lints]
workspace = true
+217
View File
@@ -0,0 +1,217 @@
//! # 配色取色的解码适配层(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 + 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` 前后对比)。
//!
//! ## 两条硬纪律
//!
//! 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<u8>,
}
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<Rgba8, ExtractError> {
let reader = image::ImageReader::new(Cursor::new(bytes))
.with_guessed_format()
.map_err(ExtractError::Io)?;
finish(reader)
}
/// 从文件路径解码(格式按魔数嗅探,不看扩展名)。
pub fn load(path: impl AsRef<Path>) -> Result<Rgba8, ExtractError> {
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<Path>) -> Result<Seed, ExtractError> {
seed_from_rgba8(&load(path)?)
}
/// 字节 → 种子色(与 [`extract_seed`] 同一条路径,测试与「内存里的图」用)。
pub fn seed_from_bytes(bytes: &[u8]) -> Result<Seed, ExtractError> {
seed_from_rgba8(&decode_bytes(bytes)?)
}
fn seed_from_rgba8(image: &Rgba8) -> Result<Seed, ExtractError> {
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<Rgba8, ExtractError>) -> ImageInput<'a> {
match loaded {
Ok(image) => ImageInput::Pixels(image.view()),
Err(_) => ImageInput::Undecodable,
}
}
fn finish<R: std::io::BufRead + std::io::Seek>(
mut reader: image::ImageReader<R>,
) -> Result<Rgba8, ExtractError> {
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;
+189
View File
@@ -0,0 +1,189 @@
//! 解码适配层的单测:真 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<u8> {
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<u8> {
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 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");
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 max_edge_stays_within_the_design_sample_budget() {
// 结构性防线(复审 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` 的文档——超过预算后 design 会在本适配层缩放之上再叠一层块平均",
edge * edge
);
}
#[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"),
}
}