diff --git a/crates/design/src/palette/card.rs b/crates/design/src/palette/card.rs index fb803d6..8781ca7 100644 --- a/crates/design/src/palette/card.rs +++ b/crates/design/src/palette/card.rs @@ -57,9 +57,11 @@ impl Card { /// 这张卡的 `on-primary` 对 `primary` 的实测对比度。 /// /// 设置页可以直接显示成「对比度 {:.2}:1」。**2026-11 实测(8 张卡)**: - /// 浅色档 6.43~6.49、深色档 7.70~7.75(逐张数值见 `every_card_scheme_meets_wcag_aa` - /// 的断言上限与 M87 报告表格)——两种模式都远高于 WCAG AA 的 4.5:1。 - /// 别在文案里写死一个数:卡换种子后它会变,用本方法的返回值。 + /// 浅色档 **6.43~6.49**、深色档 **7.70~7.75**——两种模式都远高于 WCAG AA 的 4.5:1。 + /// + /// 这两个区间**被测试钉住**(不是口头声明):`every_card_scheme_meets_wcag_aa` 对每张卡 + /// 断言「下限 [`wcag::AA_TEXT`] + 落在上述区间内」,所以将来改种子/调色板常数把比值 + /// 拖出区间会当场红——文档里的数字与测试同源。要改数值请连测试区间一起改。 pub fn primary_contrast(self, flavor: SchemeFlavor) -> f32 { let scheme = self.scheme(flavor); scheme.contrast(crate::color::Role::OnPrimary, crate::color::Role::Primary) @@ -266,12 +268,24 @@ mod tests { "{} / {flavor:?} 未过门禁", card.key ); + let ratio = card.primary_contrast(flavor); assert!( - card.primary_contrast(flavor) >= wcag::AA_TEXT, - "{} / {flavor:?} primary 对 {} 只有 {:.2}", + ratio >= wcag::AA_TEXT, + "{} / {flavor:?} primary 对 {} 只有 {ratio:.2}", card.key, - card.name, - card.primary_contrast(flavor) + card.name + ); + // 上半区间:文档(`Card::primary_contrast`)向用户承诺的就是这两个区间, + // 只钉「≥ 4.5」等于没钉——比值掉到 5.0 也全绿,而文档会继续说 6.43~6.49。 + // 边界取实测极值 ±0.01:浅色 6.4324~6.4906、深色 7.7024~7.7504。 + let (low, high) = match flavor { + SchemeFlavor::Light => (6.43, 6.50), + SchemeFlavor::Dark => (7.70, 7.76), + }; + assert!( + (low..=high).contains(&ratio), + "{} / {flavor:?} primary 对比度 {ratio:.4} 超出文档区间 {low}~{high}", + card.key ); } } diff --git a/crates/design/src/palette/score.rs b/crates/design/src/palette/score.rs index 20ccff7..30445ca 100644 --- a/crates/design/src/palette/score.rs +++ b/crates/design/src/palette/score.rs @@ -115,7 +115,12 @@ pub(crate) fn rank(clusters: &[Cluster]) -> Vec { ranked } -/// 彩度最高的那个簇——被 `Score` 全部过滤时,它是「最接近候选门槛」的那一族。 +/// 彩度最高的那个簇——被 `Score` 全部过滤时用它报「这张图最鲜艳的颜色」。 +/// +/// ⚠️ 它**不是**「最接近及格的那一族」:`Score` 的过滤有两道(彩度 ≥5、色相桶人气 ≥1%), +/// 面积不足 1% 的高彩度颜料会被**人气**刷掉——例如 1592 个米白 + 8 个纯红的图, +/// 候选为空而这里的「最鲜艳」彩度是 **113.36**。所以调用方的文案要按 +/// 「全图最鲜艳的那一族」,不是「差一点就及格的那一族」。 /// /// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对: /// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值, diff --git a/crates/design/src/palette/seed/mod.rs b/crates/design/src/palette/seed/mod.rs index 7721a39..ce06dab 100644 --- a/crates/design/src/palette/seed/mod.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -52,13 +52,22 @@ const ALPHA_THRESHOLD: u8 = 128; /// 取色结果:种子色 + 足以让 UI 解释「为什么是这个颜色」的观感数据。 #[derive(Debug, Clone, Copy, PartialEq)] pub struct Seed { - /// 种子色(图上真实存在的颜色,不是各色平均出来的)。 + /// 种子色。 + /// + /// **像素数 ≤ [`MAX_SAMPLES`] 时**它是图上真实存在的颜色(代表色取「簇内像素最多的桶里 + /// 像素原值平均」,纯色图逐通道精确);**更大的图会先做块平均降采样**,此时它是块平均后的 + /// 颜色——`2×2` 块内红蓝交替会平均成 `#703C75`,这个色值在图上任何位置都不存在。 + /// 降采样本来就要平均(顺带降噪),所以这是**有意的行为**,不是「绝不出现平均色」的保证。 pub color: Color, /// 种子的 CAM16 色相角(度)。 pub hue: f64, /// 种子的 CAM16 彩度。 pub chroma: f64, - /// 该色在其色相族里占全图的像素比例(0..1)。 + /// 该色所在色相族占**有效采样点**的比例(0..1)。 + /// + /// 分母是采样点(块平均后的点、且已剔除透明像素),不是全图像素数。所以只有 + /// 「无透明 + 像素数 ≤ [`MAX_SAMPLES`]」时它才等于「占全图比例」;大图(有降采样) + /// 或有透明区域时只能说「占有效采样点的比例」。 pub share: f64, } @@ -107,13 +116,21 @@ pub enum Failure { /// `color` 与 `chroma` **永远来自同一个簇**(复审 r1 p2-4.2 定的口径): /// - 有候选(通过 `Score` 的彩度 ≥5 与人气 ≥1% 过滤)但最强候选仍 `< 8` → /// 报 `Score` **榜首**的色值与彩度(它是「本次能拿到的最好候选」); - /// - 一个候选都没有(灰度图 / 只有零碎高彩度小面积)→ 报**彩度最高**的那一簇 - /// (此时彩度必然 < 候选门槛 5)。 + /// - 一个候选都没有 → 报**彩度最高**的那一簇。 + /// 这条**不是「灰图专属」**:`Score` 除了彩度 ≥5 还要求所在色相桶的人气 ≥1%, + /// 所以「高彩度但面积太小」也会走到这里。实测(复审探针):40×40、1592 个米白 + /// `#F5F1EA` + 8 个纯红 `#FF0000`(0.5% 面积)→ 报回来的彩度是 **113.36** + /// (`#FF0000` 的 CAM16 彩度,见 `color::hct` 的对账表)。 /// - /// ⚠️ UI 别把这个数字写成「彩度 X < 门槛 8」:第二种情况(高彩度但面积不足 1%)里 - /// X 可能 ≥8,真正的失败原因是「没有一族面积够大的颜色」。文案用「颜色太淡 / 太杂」即可。 + /// ⚠️ 因此这个数字**不受候选门槛 5 约束**,可能远高于 8。UI 别把它写成 + /// 「彩度 X < 门槛 8」:真正的失败原因是「没有一族**面积够大**的颜色」。文案用 + /// 「颜色太淡 / 太杂」即可;要显示数字也只在 `chroma < CUTOFF_CHROMA(5)` 时显示 + /// (那时的含义才是「整张图没颜色」,即纯灰图)。 LowChroma { - /// 最接近能用的那个色(提示文案里当「最接近的颜色」)。 + /// 取色失败时最接近可用的候选色(提示文案里可以显示成一个小色块)。 + /// + /// 注意它不一定「能用」:高彩度但面积很小的颜料(如 0.5% 面积的纯红)也会出现在这里, + /// 见 [`Failure::LowChroma`] 的说明。 color: Color, /// 该色的实测 CAM16 彩度(与 `color` 成对)。 chroma: f64, @@ -169,7 +186,9 @@ pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result { let closest = score::most_chromatic(&clusters); Err(Failure::LowChroma { diff --git a/crates/palette-extract/src/tests.rs b/crates/palette-extract/src/tests.rs index 1465523..6f57b6d 100644 --- a/crates/palette-extract/src/tests.rs +++ b/crates/palette-extract/src/tests.rs @@ -156,6 +156,21 @@ fn absurd_dimensions_are_rejected_before_allocating() { } } +#[test] +fn max_edge_stays_within_the_design_sample_budget() { + // 结构性防线(复审 r2 要求):`unlucky_design` 只在「像素数 ≤ MAX_SAMPLES」时才不做块平均, + // 也只有那时 `Seed::color` 才保证是图上真实存在的颜色。当前 256² = 65536 = MAX_SAMPLES, + // **余量为零**——谁把 MAX_EDGE 调到 512,UI 路径就会开始返回「块平均出来的、图上不存在的 + // 颜色」(例如红蓝交替被平均成 #703C75),而没有任何东西会红。这条把两个常数绑在一起。 + let budget = unlucky_design::palette::MAX_SAMPLES; + let edge = MAX_EDGE as usize; + assert!( + edge * edge <= budget, + "MAX_EDGE={MAX_EDGE} 的平方({})超过 design 的采样预算 {budget}: 调大它之前先读 `unlucky_design::palette::Seed::color` 的文档——超过预算后种子不再是图上真实存在的颜色", + edge * edge + ); +} + #[test] fn input_borrows_without_copying() { let loaded = decode_bytes(&encode_png(&solid_image(4, 4, [0x67, 0x50, 0xA4])));