Files
Unlucky-Launcher/crates/app-iced/src/shot/diff.rs
T
AskaEth a1adf024ba fix(app-iced): M68 r1 —— M69 迁移 + 守门补洞 + 药丸 56×32 + caption 图标 24 + 东侧热区回 1180
代码复审 p2-2items / 视觉复审 p2-2items 的修复轮(配色重拍等 M70 定案)。

M69 迁移(2 行)
- nav 轨宽:nav_rail_width(metrics) 函数体改用 metrics.nav_rail_width
- theme::STROKE_PX 改为 pub use unlucky_design::stroke::HAIRLINE as STROKE_PX

P2-1 零硬编码守门补洞(reviewer 造样 5 个漏网样本全部补上,并进自检正向断言)
- COLOR 调用表补 from_rgb8 / rgb8 / rgba(`from_rgb(` 子串匹配不到 `from_rgb8(` 是根因)
- 新增 `Color { r: .., g: .. }` 结构体字面量检查
- 尺寸规则遇到 Length::Fixed( / Padding::from( / Size::new( 往里剥一层再判数字
- 新增渲染层回归守门 pill_never_overrides_its_size_with_fill(禁 nav.rs 出现 center_*( ):
  视觉复审 P2-1 的病根就是 center_*(Length::Fill) 把容器尺寸覆盖成 Fill

视觉 P2-1 药丸 88×32 → 56×32(实测 x=16..71)
- 去掉 center_x/center_y(Fill);居中改由父 column 的 align_x + 显式 width(轨宽) 完成
  (只去掉 center_* 还不够:column 会 shrink 到 56,药丸贴左缘 x=0..55,是同一个坑的第二层)

视觉 P2-2 / 用户点名 caption 图标 46 → 24(按钮容器保持 46×48)
- button 内包 container(...).width(24).height(24).center(Length::Fill):
  只设 width/height 会让容器缩到 24 并贴 button 内容左上角;center(Fill) 才既撑满又居中

视觉 P3-1 东侧/南侧缩放热区改成叠层
- 原来是 row 的兄弟列,吃掉 4px 布局宽(右留白 28、关闭键离窗角 4px);
  现在用 stack! + 对齐容器叠在工作区上,布局宽回到 1180(卡片右缘 x=1155 = 右留白 24)

P3 清单
1. --data-dir 接受并忽略(翻牌前 CI 兼容;现役 smoke 命令行可原样跑,exit 0)+ 单测
2. --diff 内建 ±N px 平移扫描(--diff-scan),CI 不再依赖 numpy
3. 截图模式等 SystemTheme 到达才出图(否则暗色系统上会拍到浅色)
4. (Auto, 未编入 wgpu) 返回 tiny-skia,不再把 wgpu 写进候选列表
5. --renderer=gpu 也跳过适配器枚举(决策与探测无关,省 ~0.6~1.2s)
6. icons::handle 加 debug_assert_eq!(IconKind::ALL[kind as usize], kind)
7. 轨顶留白改 container padding-top,实测 24(原来 spacer + column spacing = 28)

顺带:shot.rs 拆成 shot/mod.rs + shot/diff.rs(≤400 行门禁)

门禁:fmt OK / clippy -D warnings 干净 / 46+5 测试全过 / cargo check --workspace --all-targets OK
体积:双后端 6,769,664 B(6.46 MiB)、仅 CPU 3,955,712 B(3.77 MiB)
截图:r1 代码验证图(r1-verify / hover / pressed / compare-nav-rail)已出;4 张交付主图待 M70 定案后重拍
2026-11-01 17:46:55 +08:00

353 lines
11 KiB
Rust

//! 两图逐像素对比(GPU/CPU 几何一致性)与 ±N px 平移扫描。
//!
//! 拆成子模块只为「src 文件 ≤400 行」的门禁;对外通过 `shot::{compare, diff_files, Diff, Image}`
//! 使用,调用方不用感知这层。
use std::path::Path;
/// 一张已解码的 RGBA 图。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Image {
/// RGBA8 字节(逐像素 4 字节)。
pub rgba: Vec<u8>,
/// 宽(px)。
pub width: u32,
/// 高(px)。
pub height: u32,
}
impl Image {
/// 读 PNG(只支持 PNG:本 crate 的 `image` 只开了 png 编码/解码)。
///
/// # Errors
/// 读文件失败或解码失败时返回人话。
pub fn load(path: &Path) -> Result<Self, String> {
let reader = image::ImageReader::open(path)
.map_err(|error| format!("读图 {} 失败:{error}", path.display()))?
.with_guessed_format()
.map_err(|error| format!("判定 {} 的格式失败:{error}", path.display()))?;
let decoded = reader
.decode()
.map_err(|error| format!("解码 {} 失败:{error}", path.display()))?
.to_rgba8();
Ok(Self {
width: decoded.width(),
height: decoded.height(),
rgba: decoded.into_raw(),
})
}
}
/// 两图对比结果(GPU/CPU 一致性靠它给数字,不靠肉眼)。
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Diff {
/// 宽(px)。
pub width: u32,
/// 高(px)。
pub height: u32,
/// 有差异的像素数。
pub differing_pixels: usize,
/// 单通道最大差(0~255)。
pub max_channel_delta: u8,
/// 单通道平均差(0~255)。
pub mean_channel_delta: f64,
/// 平移扫描的最佳对齐(dx, dy);不扫描时为 (0, 0)。
pub best_shift: (i32, i32),
/// 最佳对齐下的差异像素数(不扫描时等于 `differing_pixels`)。
pub best_differing_pixels: usize,
}
/// 逐像素对比两张尺寸相同的图。
///
/// # Errors
/// 尺寸不同直接报错——**几何不一致不许静默通过**。
pub fn compare(left: &Image, right: &Image) -> Result<Diff, String> {
if (left.width, left.height) != (right.width, right.height) {
return Err(format!(
"尺寸不同:{}x{} vs {}x{}",
left.width, left.height, right.width, right.height
));
}
let pixel_count = left.rgba.len() / 4;
let mut differing = 0_usize;
let mut max_delta = 0_u8;
let mut sum_delta = 0_u64;
for index in 0..pixel_count {
let offset = index * 4;
let mut pixel_differs = false;
for channel in 0..4 {
let delta = left.rgba[offset + channel].abs_diff(right.rgba[offset + channel]);
if delta > 0 {
pixel_differs = true;
max_delta = max_delta.max(delta);
sum_delta += u64::from(delta);
}
}
if pixel_differs {
differing += 1;
}
}
Ok(Diff {
width: left.width,
height: left.height,
differing_pixels: differing,
max_channel_delta: max_delta,
mean_channel_delta: if pixel_count == 0 {
0.0
} else {
sum_delta as f64 / (pixel_count * 4) as f64
},
best_shift: (0, 0),
best_differing_pixels: differing,
})
}
/// ±`max_shift` px 的平移扫描:把 `right` 平移后再比,返回差异像素数最小的那一档。
///
/// 用途:D30 的「几何逐像素一致」除了看差异占比,还要证明**没有整体错位**——
/// 如果 (0,0) 就是最优,说明差异只来自抗锯齿而不是位移/缩放。CI 不必再依赖 numpy。
///
/// 平移后越界的像素按「无法比较」处理,不计入差异(否则边缘会造成假阳性)。
pub fn shift_scan(left: &Image, right: &Image, max_shift: i32) -> Result<Diff, String> {
let mut best = compare(left, right)?;
let max_shift = max_shift.unsigned_abs();
if (left.width, left.height) != (right.width, right.height) {
return Err("尺寸不同,无法做平移扫描".to_owned());
}
for dy in -(max_shift as i32)..=(max_shift as i32) {
for dx in -(max_shift as i32)..=(max_shift as i32) {
if (dx, dy) == (0, 0) {
continue;
}
let count = shifted_differing_pixels(left, right, dx, dy);
if count < best.best_differing_pixels {
best.best_differing_pixels = count;
best.best_shift = (dx, dy);
}
}
}
Ok(best)
}
/// 把 `right` 平移 `(dx, dy)` 后与 `left` 比较,返回差异像素数(越界不算)。
fn shifted_differing_pixels(left: &Image, right: &Image, dx: i32, dy: i32) -> usize {
let width = left.width as i32;
let height = left.height as i32;
let mut differing = 0;
for y in 0..height {
let source_y = y - dy;
if !(0..height).contains(&source_y) {
continue;
}
for x in 0..width {
let source_x = x - dx;
if !(0..width).contains(&source_x) {
continue;
}
let left_offset = ((y * width + x) * 4) as usize;
let right_offset = ((source_y * width + source_x) * 4) as usize;
if left.rgba[left_offset..left_offset + 4] != right.rgba[right_offset..right_offset + 4]
{
differing += 1;
}
}
}
differing
}
impl Diff {
/// 完全一致(几何 + 颜色都一致)。
pub fn is_identical(&self) -> bool {
self.differing_pixels == 0
}
/// 一行日志。
pub fn describe(&self) -> String {
let total = (self.width as usize) * (self.height as usize);
let percent = if total == 0 {
0.0
} else {
self.differing_pixels as f64 * 100.0 / total as f64
};
format!(
"[diff] {}x{} differing={}/{} ({percent:.2}%) max_delta={} mean_delta={:.4} best_shift=({},{}) best_differing={}",
self.width,
self.height,
self.differing_pixels,
total,
self.max_channel_delta,
self.mean_channel_delta,
self.best_shift.0,
self.best_shift.1,
self.best_differing_pixels,
)
}
}
/// `--diff <left> <right>` 的实现:读两张 PNG、对比、打日志。
///
/// # Errors
/// 任一图读不出来,或尺寸不同。
pub fn diff_files(left: &Path, right: &Path) -> Result<Diff, String> {
diff_files_with_scan(left, right, 0)
}
/// 同 [`diff_files`],但额外做 ±`max_shift` px 的平移扫描(`--diff-scan`)。
///
/// # Errors
/// 任一图读不出来,或尺寸不同。
pub fn diff_files_with_scan(left: &Path, right: &Path, max_shift: i32) -> Result<Diff, String> {
let left = Image::load(left)?;
let right = Image::load(right)?;
if max_shift == 0 {
compare(&left, &right)
} else {
shift_scan(&left, &right, max_shift)
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use iced::window;
use super::*;
use crate::shot::{save, write_rgba};
fn temp_dir() -> PathBuf {
let dir = std::env::temp_dir().join("ull-app-iced-shot-tests");
std::fs::create_dir_all(&dir).expect("建临时目录");
dir
}
#[test]
fn identical_images_have_zero_diff() {
let image = Image {
rgba: vec![7_u8; 4 * 4],
width: 2,
height: 2,
};
let diff = compare(&image, &image).unwrap();
assert!(diff.is_identical());
assert_eq!(diff.max_channel_delta, 0);
}
#[test]
fn geometry_mismatch_is_an_error_not_a_silent_pass() {
let square = Image {
rgba: vec![0; 16],
width: 2,
height: 2,
};
let strip = Image {
rgba: vec![0; 16],
width: 4,
height: 1,
};
assert!(compare(&square, &strip).is_err());
}
#[test]
fn color_only_differences_are_counted_per_pixel() {
let left = Image {
rgba: vec![0, 0, 0, 255, 0, 0, 0, 255],
width: 2,
height: 1,
};
let right = Image {
rgba: vec![10, 0, 0, 255, 0, 0, 0, 255],
width: 2,
height: 1,
};
let diff = compare(&left, &right).unwrap();
assert_eq!(diff.differing_pixels, 1);
assert_eq!(diff.max_channel_delta, 10);
assert!(diff.describe().contains("differing=1/2"));
}
#[test]
fn png_roundtrip_through_disk() {
let path = temp_dir().join("roundtrip.png");
let bytes = vec![200_u8; 4 * 3 * 2];
write_rgba(&bytes, 3, 2, &path).unwrap();
let loaded = Image::load(&path).unwrap();
assert_eq!((loaded.width, loaded.height), (3, 2));
assert_eq!(loaded.rgba, bytes);
let diff = diff_files(&path, &path).unwrap();
assert!(diff.is_identical());
}
#[test]
fn shift_scan_finds_the_best_alignment() {
// 5x5 图,R 通道存唯一值;构造 right[y][x] = left[y+1][x+1](即把左图向左上挪 1px),
// 于是「left 与 right 在 dy=1,dx=1 时逐像素相等」——扫描应该把 (1,1) 找出来。
let side = 5_i32;
let mut rgba = Vec::new();
for y in 0..side {
for x in 0..side {
rgba.extend([(y * side + x) as u8, 0, 0, 255]);
}
}
let left = Image {
rgba,
width: side as u32,
height: side as u32,
};
let mut moved = vec![255_u8; (side * side * 4) as usize];
for y in 0..side - 1 {
for x in 0..side - 1 {
let source = ((y + 1) * side + (x + 1)) as usize * 4;
let target = (y * side + x) as usize * 4;
moved[target..target + 4].copy_from_slice(&left.rgba[source..source + 4]);
}
}
let right = Image {
rgba: moved,
width: side as u32,
height: side as u32,
};
let diff = shift_scan(&left, &right, 2).unwrap();
assert_eq!(diff.best_shift, (1, 1), "应扫出 1px 的位移");
assert_eq!(diff.best_differing_pixels, 0, "对齐后应逐像素相等");
assert!(
diff.differing_pixels > 0,
"不对齐时应该有差异(否则这条测试没有信息量)"
);
// 同一张图:最佳对齐就是 (0,0)
let same = shift_scan(&left, &left, 2).unwrap();
assert_eq!(same.best_shift, (0, 0));
assert_eq!(same.best_differing_pixels, 0);
}
#[test]
fn wrong_byte_count_is_reported() {
let screenshot = window::Screenshot::new(vec![0_u8; 3], iced::Size::new(1, 1), 1.0);
let error = save(&screenshot, &temp_dir().join("unused.png")).unwrap_err();
assert!(error.contains("字节数对不上"), "{error}");
}
}