docs(design)+test(palette-extract): r2 三条口径修正与常数绑定

- LowChroma 文档:删掉「彩度必然 < 候选门槛 5」(人气门槛也会淘汰高彩度小面积,
  实测 0.5% 纯红报 113.36),改为与契约文档 §6 一致的口径;代码臂注释与 score::most_chromatic 同步
- Seed::color / share 改成有边界的说法:≤MAX_SAMPLES 才是图上真实色、share 的分母是有效采样点
- palette-extract 新增 max_edge_stays_within_the_design_sample_budget:
  MAX_EDGE² ≤ MAX_SAMPLES(256² 当前余量为零,调大就红)
- card.rs:把 6.43~6.50 / 7.70~7.76 写成断言(原来只有 ≥4.5 下限,文档却宣称区间)
- LowChroma.color 字段注释改成「最接近可用的候选色」(113 那条并不「能用」)
This commit is contained in:
AskaEth committed 2026-11-02 11:05:03 +08:00
1 parent f4c48752c2
commit 09b716ae79
4 files changed
+69 -16

No files matched your search

+21 -7
View File
@@ -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
);
}
}
+6 -1
View File
@@ -115,7 +115,12 @@ pub(crate) fn rank(clusters: &[Cluster]) -> Vec<Ranked> {
ranked
}
/// 彩度最高的那个簇——被 `Score` 全部过滤时,它是「最接近候选门槛」的那一族。
/// 彩度最高的那个簇——被 `Score` 全部过滤时用它报「这张图最鲜艳的颜色」。
///
/// ⚠️ 它**不是**「最接近及格的那一族」:`Score` 的过滤有两道(彩度 ≥5、色相桶人气 ≥1%),
/// 面积不足 1% 的高彩度颜料会被**人气**刷掉——例如 1592 个米白 + 8 个纯红的图,
/// 候选为空而这里的「最鲜艳」彩度是 **113.36**。所以调用方的文案要按
/// 「全图最鲜艳的那一族」,不是「差一点就及格的那一族」。
///
/// 取色失败(`Failure::LowChroma`)时**色值与彩度都从这一个簇取**,保证二者成对:
/// 之前的两条路径一条报 `Score` 榜首、一条报「彩度最高」,而后者还拿着**另一个**簇的色值,
+27 -8
View File
@@ -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<Seed, Fail
color: best.cluster.color,
chroma: best.chroma,
}),
// 一个候选都没有(灰度图):报**彩度最高**的那一族,色值与彩度成对(必然 < 候选门槛 5)。
// 一个候选都没有:报**彩度最高**的那一族,色值与彩度成对。
// 这不等于「整张图没颜色」——1592 米白 + 8 个纯红的图也走这里(红色面积 0.5% 被人气
// 门槛淘汰),报回来的彩度是 113.36。真实失败原因是「没有一族面积够大的颜色」。
None => {
let closest = score::most_chromatic(&clusters);
Err(Failure::LowChroma {
+15
View File
@@ -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])));