diff --git a/crates/design/src/palette/seed/mod.rs b/crates/design/src/palette/seed/mod.rs index f6a3f3d..3184184 100644 --- a/crates/design/src/palette/seed/mod.rs +++ b/crates/design/src/palette/seed/mod.rs @@ -26,6 +26,7 @@ //! | 纯色图(有彩度) | 正常取色 | 单色图只要够鲜艳就是合格的种子(不做特殊化) | //! | 灰度/灰白图 | [`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 视为无信息量`), @@ -138,16 +139,13 @@ pub fn analyze_rgba(pixels: &[u8], width: u32, height: u32) -> Result = samples - .into_iter() - .filter(|color| color.a >= ALPHA_THRESHOLD) - .collect(); - if opaque.is_empty() { + if samples.is_empty() { return Err(Failure::NoOpaquePixels); } - let clusters = quantize::quantize(&opaque); + let clusters = quantize::quantize(&samples); let ranked = score::rank(&clusters); match ranked.first() { Some(best) if best.chroma >= MIN_SEED_CHROMA => Ok(Seed { @@ -183,6 +181,15 @@ fn most_populated(clusters: &[Cluster]) -> Option<&Cluster> { } /// 块平均降采样:块边长按「不超过 [`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 { let mut block = 1usize; while width.div_ceil(block) * height.div_ceil(block) > MAX_SAMPLES { @@ -196,19 +203,24 @@ fn sample_blocks(pixels: &[u8], width: usize, height: usize) -> Vec { let mut left = 0usize; while left < width { let right = (left + block).min(width); - let mut sum = [0u64; 4]; + 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; - for (channel, slot) in sum.iter_mut().enumerate() { - *slot += u64::from(pixels[offset + channel]); + if pixels[offset + 3] < ALPHA_THRESHOLD { + continue; + } + for channel in 0..3 { + sum[channel] += 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))); + 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; diff --git a/crates/design/src/palette/seed/tests.rs b/crates/design/src/palette/seed/tests.rs index cc98f88..fc1c598 100644 --- a/crates/design/src/palette/seed/tests.rs +++ b/crates/design/src/palette/seed/tests.rs @@ -137,6 +137,42 @@ fn fully_transparent_image_has_no_seed() { 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);