fix(design): 混合透明度不再被误判为「全透明」(复审 r1 p2-1)

先按 alpha 过滤像素、再做块平均(整块全透明则跳过)。原实现先平均再比门槛,
块内 255 与 0 的整数除法得 127 < 128 → 512×512 里 131072 个不透明像素被报
NoOpaquePixels,UI 会告诉用户「背景图几乎全透明」,与事实相反。
- 新增 half_transparent_image_keeps_its_opaque_colour(红)
- 新增 mostly_transparent_image_still_samples_the_visible_part
- 模块文档加一行「混合透明度」的处置与踩坑说明
This commit is contained in:
AskaEth committed 2026-11-02 11:04:59 +08:00
1 parent 36c1cce00d
commit 2ce394fbfc
2 files changed
+59 -11

No files matched your search

+23 -11
View File
@@ -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<Seed, Fail
});
}
// 采样点已经是「只由不透明像素算出的块平均」(见 [`sample_blocks`]),这里不再按 alpha 过滤。
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() {
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<Color> {
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<Color> {
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;
+36
View File
@@ -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);