test(design): 补「先量化再打分」的端到端反证(复审 r1 p1)

reviewer 实测变异:把 analyze_rgba 改成不透明像素算术平均,150 个测试全绿——
因为反证只测了 quantize/score 中间层,接线没人覆盖。新增:
- seed::tests::end_to_end_picks_the_five_percent_accent(95% 米白 + 5% 珊瑚 → #FF6B5A)
- seed::tests::end_to_end_picks_the_three_percent_accent(97/3 → #FF8800)
- resolve::tests::a_five_percent_accent_drives_level_one(一级成功、种子是珊瑚、≠ 回落色卡)
顺带把 seed 的单测拆到 seed/tests.rs(410 行 → 220 + 190,恢复 ≤400 纪律)
This commit is contained in:
AskaEth committed 2026-11-02 11:04:58 +08:00
1 parent 80314f4830
commit 36c1cce00d
3 files changed
+220 -133

No files matched your search

+220
View File
@@ -0,0 +1,220 @@
//! 已解码的 RGBA 像素 → 一个**种子色**(D38 第一/二级的入口,D40 的 `crates/design` 半边)。
//!
//! 这是整个取色链在 `crates/design` 里的**唯一公开入口**:零 IO、零第三方依赖,
//! 只吃「调用方已经解码好的像素」。解码(PNG/JPEG/WebP…)在 `crates/palette-extract`,
//! 分层理由见 D40。
//!
//! ```no_run
//! use unlucky_design::color::{Scheme, SchemeFlavor};
//! use unlucky_design::palette::seed_from_rgba;
//!
//! // 一张 2×2 的天蓝图(调用方负责解码)
//! let pixels: [u8; 16] = [
//! 0x3C, 0x8D, 0xBC, 0xFF, 0x3C, 0x8D, 0xBC, 0xFF,
//! 0x3C, 0x8D, 0xBC, 0xFF, 0x3C, 0x8D, 0xBC, 0xFF,
//! ];
//! let seed = seed_from_rgba(&pixels, 2, 2).expect("纯色图有确定的种子");
//! let scheme = seed.scheme(SchemeFlavor::Light, 12.0);
//! assert!(scheme.contrast(unlucky_design::color::Role::OnPrimary, unlucky_design::color::Role::Primary) >= 4.5);
//! ```
//!
//! ## 五类输入的处置(每类都有单测)
//!
//! | 输入 | 处置 | 为什么 |
//! |---|---|---|
//! | 过小图(< 4 像素) | [`Failure::TooSmall`] | 1~3 个像素没有「主色」概念,任一个噪点都会变成整套配色 |
//! | 纯色图(有彩度) | 正常取色 | 单色图只要够鲜艳就是合格的种子(不做特殊化) |
//! | 灰度/灰白图 | [`Failure::LowChroma`](**不是**返回青灰色) | D40 实测:零彩度种子的 CAM16 色相恒为 209.49°,硬取色会把灰白壁纸「凭空上色」成青色 `#006874`;极低彩度段色相还是噪声放大器(`#808080` → 209.5°、`#7A7C7E` → 231.2°,摆 22°) |
//! | 超大图(>65536 像素) | 先按 4^n 块做**块平均**降采样,再量化 | 取色只需要「色彩分布」,不需要分辨率;块平均能保住细纹理的平均色,避免等距抽样在条纹/网格图上走样 |
//! | 解码失败 | 不在本 crate:见 `crates/palette-extract` | D40 分层 |
//!
//! 阈值 [`MIN_SEED_CHROMA`] = 8 是 **D40 的定案值**(`seed chroma < ~8 视为无信息量`),
//! 与 [`super::score::CUTOFF_CHROMA`] = 5 分工不同:5 决定「能不能当候选」,8 决定
//! 「这个候选够不够格当种子」。落在这条缝里(5~8)的图会有一堆候选,最终仍然回落色卡。
use super::quantize::{self, Cluster};
use super::score;
use crate::color::{Color, Palettes, Scheme, SchemeFlavor};
/// 最小像素数:低于它的图没有「主色」可言。
pub const MIN_PIXELS: u32 = 4;
/// 参与量化的最大采样点数(块的边长按它反推)。
pub const MAX_SAMPLES: usize = 65_536;
/// D40 的种子彩度门槛:低于它视为「这张图没有可用的颜色信息」。
pub const MIN_SEED_CHROMA: f64 = 8.0;
/// 「低彩度但可用」的第二道线:落在这条线下的种子会被标成 [`Seed::is_muted`],
/// UI 可以提示「从这张图取到的颜色偏灰」。
pub const MUTED_SEED_CHROMA: f64 = 16.0;
/// alpha 低于它的像素不参与取色(半透明叠加会让颜色变成「混了背景的假色」)。
const ALPHA_THRESHOLD: u8 = 128;
/// 取色结果:种子色 + 足以让 UI 解释「为什么是这个颜色」的观感数据。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Seed {
/// 种子色(图上真实存在的颜色,不是各色平均出来的)。
pub color: Color,
/// 种子的 CAM16 色相角(度)。
pub hue: f64,
/// 种子的 CAM16 彩度。
pub chroma: f64,
/// 该色在其色相族里占全图的像素比例(0..1)。
pub share: f64,
}
impl Seed {
/// 种子彩度偏低(可用但发灰)——UI 可提示「这张图颜色较淡」。
pub fn is_muted(self) -> bool {
self.chroma < MUTED_SEED_CHROMA
}
/// 用这个种子生成一套完整调色板(中性彩度由调用方给,通常沿用用户当前选中色卡的取值)。
pub fn palettes(self, neutral_chroma: f64) -> Palettes {
Palettes::from_seed_with_neutral_chroma(self.color, neutral_chroma)
}
/// 用这个种子生成一套完整方案(49 个角色)。
pub fn scheme(self, flavor: SchemeFlavor, neutral_chroma: f64) -> Scheme {
Scheme::from_palettes(&self.palettes(neutral_chroma), flavor)
}
}
/// 取色失败的原因。**每一种都要能变成一句人话**(Provenance 用它生成提示)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Failure {
/// 像素缓冲长度与 `width × height × 4` 不符(调用方给错了数据)。
BadDimensions {
/// 宽。
width: u32,
/// 高。
height: u32,
/// 期望的字节数(`width × height × 4`)。
expected: usize,
/// 实际拿到的字节数。
actual: usize,
},
/// 图太小(< [`MIN_PIXELS`] 像素)。
TooSmall {
/// 宽。
width: u32,
/// 高。
height: u32,
},
/// 全是透明/半透明像素,没有可用的不透明像素。
NoOpaquePixels,
/// 彩度不足:`chroma < MIN_SEED_CHROMA`,硬取色会凭空上色。
LowChroma {
/// 最有希望的那个色(提示文案里当「最接近的颜色」)。
color: Color,
/// 实测 CAM16 彩度。
chroma: f64,
},
}
impl Failure {
/// 稳定原因码(写日志 / 存设置 / 做提示映射的键,别用 `Debug` 输出)。
pub const fn code(self) -> &'static str {
match self {
Failure::BadDimensions { .. } => "bad-dimensions",
Failure::TooSmall { .. } => "too-small",
Failure::NoOpaquePixels => "no-opaque-pixels",
Failure::LowChroma { .. } => "low-chroma",
}
}
}
/// 取色并给出**完整原因**(推荐入口:失败时能生成提示)。
pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result<Seed, Failure> {
let total = (width as usize).saturating_mul(height as usize);
if total < MIN_PIXELS as usize {
return Err(Failure::TooSmall { width, height });
}
let expected = total.saturating_mul(4);
if pixels.len() != expected {
return Err(Failure::BadDimensions {
width,
height,
expected,
actual: pixels.len(),
});
}
let samples = sample_blocks(pixels, width as usize, height as usize);
let opaque: Vec<Color> = samples
.into_iter()
.filter(|color| color.a >= ALPHA_THRESHOLD)
.collect();
if opaque.is_empty() {
return Err(Failure::NoOpaquePixels);
}
let clusters = quantize::quantize(&opaque);
let ranked = score::rank(&clusters);
match ranked.first() {
Some(best) if best.chroma >= MIN_SEED_CHROMA => Ok(Seed {
color: best.cluster.color,
hue: best.hue,
chroma: best.chroma,
share: best.excited,
}),
// 有候选但都不够鲜艳:带着「最鲜艳的候选」和实测彩度回去,让 UI 能说清原因。
Some(best) => Err(Failure::LowChroma {
color: best.cluster.color,
chroma: best.chroma,
}),
// 一个候选都没有(灰度图):把最鲜艳的簇的彩度报回去(通常是 0~4)。
None => Err(Failure::LowChroma {
color: most_populated(&clusters)
.map(|cluster| cluster.color)
.unwrap_or(Color::rgb(0x80, 0x80, 0x80)),
chroma: score::max_chroma(&clusters),
}),
}
}
/// D40 的入口形状:`Option<Seed>`(`None` = 这张图没有可用的颜色信息)。
///
/// 需要知道「为什么没有」就用 [`analyze_rgba`]。
pub fn seed_from_rgba(pixels: &[u8], width: u32, height: u32) -> Option<Seed> {
analyze_rgba(pixels, width, height).ok()
}
fn most_populated(clusters: &[Cluster]) -> Option<&Cluster> {
clusters.iter().max_by_key(|cluster| cluster.population)
}
/// 块平均降采样:块边长按「不超过 [`MAX_SAMPLES`] 个采样点」反推,小图不降采样(块边长 1)。
fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec<Color> {
let mut block = 1usize;
while width.div_ceil(block) * height.div_ceil(block) > MAX_SAMPLES {
block += 1;
}
let mut samples = Vec::with_capacity(width.div_ceil(block) * height.div_ceil(block));
let mut top = 0usize;
while top < height {
let bottom = (top + block).min(height);
let mut left = 0usize;
while left < width {
let right = (left + block).min(width);
let mut sum = [0u64; 4];
let mut count = 0u64;
for y in top..bottom {
for x in left..right {
let offset = (y * width + x) * 4;
for (channel, slot) in sum.iter_mut().enumerate() {
*slot += u64::from(pixels[offset + channel]);
}
count += 1;
}
}
let average = |channel: usize| (sum[channel] / count) as u8;
samples.push(Color::rgba(average(0), average(1), average(2), average(3)));
left = right;
}
top = bottom;
}
samples
}
#[cfg(test)]
mod tests;