Files
Unlucky-Launcher/crates/app-iced/src/components/feedback/pixel.rs
T
AskaEth a5d1dec495
CI / fmt (rustfmt --check) (push) Waiting to run
CI / clippy (ubuntu-latest) (push) Waiting to run
CI / clippy (windows-latest) (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run
CI / build release (ubuntu-latest) (push) Waiting to run
CI / build release (windows-latest) (push) Waiting to run
CI / runtime smoke (ubuntu x64, xvfb) (push) Waiting to run
CI / runtime smoke (macos arm64, probe) (push) Skipped
CI / update-e2e (windows x64) (push) Waiting to run
GPU evidence / GPU 判据自测(不需 GPU;跳过 ≠ 已验证) (push) Waiting to run
GPU evidence / GPU 证据 — 探测可用产物与硬件适配器 (push) Waiting to run
Guards / core 零 UI 守门 (push) Waiting to run
Guards / presenter 零 UI 守门 (push) Waiting to run
Guards / design 零第三方依赖守门 (push) Waiting to run
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Waiting to run
Guards / 文件行数 ≤400 守门 (push) Waiting to run
Guards / 依赖许可与来源(cargo-deny) (push) Waiting to run
Release guard / 发布标记语义守门(push tag → 稳定版) (push) Waiting to run
Signing status(播报,不是门禁) / 判据自测(三行/退出码/变异重放/接线) (push) Waiting to run
Signing status(播报,不是门禁) / 签名就绪度(播报) (push) Waiting to run
Release / 选槽位(默认四个:Windows 两档 + Linux 两档) (push) Waiting to run
release: v0.2.4 里程碑快照(孤儿提交,无父;树 = dev 68362e5)
本批内容见 CHANGELOG.md 的 0.2.4 段。按 D41 用孤儿快照推 main,保留「公开史只有里程碑」的模型。
2026-11-11 21:41:38 +08:00

348 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 像素取证的小工具(**仅测试**):读 PNG、按「底色 → 墨色」的覆盖率找连通块与长条。
//!
//! 为什么不复用 `gallery/scan.rs`:那个模块的可见性是
//! `pub(in crate::app::components::gallery)`,从家族目录拿不到(`selection/pixel.rs` 也是
//! 同样的理由自己写了一份)。这里只重写本家族真正用到的几个纯函数。
//!
//! **判据口径 1:覆盖率而不是精确色。** 4px 厚的进度条、28 圆角的卡片,边缘全是抗锯齿;
//! 按「精确色」连通会把它们切碎(实测进度条只剩中间 1~2 行)。
//! **判据口径 2:覆盖率 ≥50%。** 50% 是几何边界(30% 会把半格抗锯齿也算进去,4px 量成 6)。
use std::path::PathBuf;
use iced::Color;
/// 8bit RGB。
pub(super) fn rgb(color: Color) -> [u8; 3] {
[
(color.r * 255.0).round() as u8,
(color.g * 255.0).round() as u8,
(color.b * 255.0).round() as u8,
]
}
/// 容差比较(抗锯齿/取整)。
pub(super) fn matches(pixel: [u8; 3], expected: [u8; 3]) -> bool {
pixel.iter().zip(expected).all(|(a, b)| a.abs_diff(b) <= 10)
}
/// `ULL_GALLERY_SHOT` → 截图路径(相对路径按**工作区根**解释:`cargo test` 的 cwd 是 crate 目录)。
pub(super) fn shot_path() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("ULL_GALLERY_SHOT")?);
if path.is_absolute() || path.exists() {
return Some(path);
}
let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..");
Some(root.join(path))
}
/// 一张图的像素访问器。
pub(super) struct Shot {
pub(super) width: usize,
pub(super) height: usize,
pixels: Vec<[u8; 3]>,
}
impl Shot {
pub(super) fn load(path: &std::path::Path) -> Result<Self, String> {
let bytes = std::fs::read(path).map_err(|error| format!("读不动 {path:?}:{error}"))?;
let image = image::load_from_memory(&bytes)
.map_err(|error| format!("解 PNG 失败:{error}"))?
.to_rgb8();
let (width, height) = (image.width() as usize, image.height() as usize);
Ok(Self {
width,
height,
pixels: image.pixels().map(|p| p.0).collect(),
})
}
pub(super) fn at(&self, x: usize, y: usize) -> [u8; 3] {
self.pixels[y * self.width + x]
}
/// **合成图**(不读盘):按 `(x, y) → RGB` 造一张 `width × height` 的图。
///
/// 只给回归用例用:有些几何错误在真截图上**不会自然出现**(例如「轨道被宿主裁短」——
/// 真实渲染里轨道永远占满宿主,只有把像素涂掉才看得到,那是复审的一次性动作)。
/// 合成图让这类形状变成可复现的夹具,见 `tests/bands.rs`。
pub(super) fn from_rows(
width: usize,
height: usize,
pixel: impl Fn(usize, usize) -> [u8; 3],
) -> Self {
let mut pixels = Vec::with_capacity(width * height);
for y in 0..height {
for x in 0..width {
pixels.push(pixel(x, y));
}
}
Self {
width,
height,
pixels,
}
}
}
/// 一个外接框(闭区间)。
#[derive(Debug, Clone, Copy)]
pub(super) struct BBox {
pub(super) left: usize,
pub(super) right: usize,
pub(super) top: usize,
pub(super) bottom: usize,
}
impl BBox {
pub(super) fn new(x: usize, y: usize) -> Self {
Self {
left: x,
right: x,
top: y,
bottom: y,
}
}
pub(super) fn include(&mut self, x: usize, y: usize) {
self.left = self.left.min(x);
self.right = self.right.max(x);
self.top = self.top.min(y);
self.bottom = self.bottom.max(y);
}
pub(super) fn width(&self) -> usize {
self.right - self.left + 1
}
pub(super) fn height(&self) -> usize {
self.bottom - self.top + 1
}
}
/// 尺寸容差 ±1(rubric §6 的默认容差:控件常落在半像素上,实测会多一行抗锯齿)。
pub(super) fn near(value: usize, expected: usize) -> bool {
value.abs_diff(expected) <= 1
}
pub(super) fn dims(boxes: &[BBox]) -> String {
boxes
.iter()
.map(|bbox| format!("{}×{}", bbox.width(), bbox.height()))
.collect::<Vec<_>>()
.join(", ")
}
/// `pixel` 在「`under` → `fill`」这条混色线上的**覆盖率**(0..1;≥0.5 视为「算这块墨」)。
///
/// 投影到混色向量上取比例:抗锯齿像素正好落在 0..1 之间,另一种墨(文字、别的容器色)
/// 会落在线的两侧偏远处,比例被夹到 0 或 1 之外 → 用「偏离线距离」再筛一道。
pub(super) fn coverage(pixel: [u8; 3], fill: [u8; 3], under: [u8; 3]) -> f32 {
let axis = [0, 1, 2].map(|i| f32::from(fill[i]) - f32::from(under[i]));
let offset = [0, 1, 2].map(|i| f32::from(pixel[i]) - f32::from(under[i]));
let square: f32 = axis.iter().map(|a| a * a).sum();
if square <= f32::EPSILON {
return 0.0;
}
let t: f32 = (0..3).map(|i| axis[i] * offset[i]).sum::<f32>() / square;
let distance = (0..3)
.map(|i| (offset[i] - t * axis[i]).powi(2))
.sum::<f32>()
.sqrt();
// 8.0 而不是 16.0:深色填充(如 InverseSurface)与浅底之间的混色轴很长,深色**文字**
// (OnSurfaceVariant)投影上去残差只有 ≈9 —— 16 的阈值会把文字行算成填充,于是 48 高的
// 提示条被切成好几段(实测 46 + 1 + 1)。8.0 只排除文字,保留抗锯齿边(残差 0~3)。
if distance > 8.0 {
return 0.0;
}
t.clamp(0.0, 1.0)
}
/// 第 `y` 行上所有「算 `fill` 那块墨」的连续段 `(起点, 长度)`。
pub(super) fn runs(shot: &Shot, y: usize, fill: Color, under: Color) -> Vec<(usize, usize)> {
let (ink, base) = (rgb(fill), rgb(under));
let mut found = Vec::new();
let mut start: Option<usize> = None;
for x in 0..shot.width {
let hit = coverage(shot.at(x, y), ink, base) >= 0.5;
match (hit, start) {
(true, None) => start = Some(x),
(false, Some(from)) => {
found.push((from, x - from));
start = None;
}
_ => {}
}
}
if let Some(from) = start {
found.push((from, shot.width - from));
}
found
}
/// 一行里有多少「算 `fill` 那块墨」的像素(按覆盖率 ≥50% 判)。
///
/// 用**计数**而不是「最长段」:一条 344 宽的提示条,里面一行中文字会把它切成几段,
/// 按最长段判会把中间那些行漏掉(实测 48 高被切成 6 + 6 两段)。
pub(super) fn row_ink(shot: &Shot, y: usize, fill: Color, under: Color) -> usize {
let (ink, base) = (rgb(fill), rgb(under));
(0..shot.width)
.filter(|x| coverage(shot.at(*x, y), ink, base) >= 0.5)
.count()
}
/// 连续行里「墨量 ≥ `min_ink`」的那些行 → 聚成**竖带** `(top, bottom)`。
///
/// 用途:一条 4px 的进度条在图上就是「4 个连续行、每行都有 ~320 个墨点」;
/// 一个 48 高的提示条就是「48 个连续行」。比按精确色找 bbox 稳得多。
pub(super) fn bands(shot: &Shot, fill: Color, under: Color, min_ink: usize) -> Vec<(usize, usize)> {
let mut bands: Vec<(usize, usize)> = Vec::new();
let mut current: Option<usize> = None;
for y in 0..shot.height {
let hit = row_ink(shot, y, fill, under) >= min_ink;
match (hit, current) {
(true, None) => current = Some(y),
(false, Some(top)) => {
bands.push((top, y - 1));
current = None;
}
_ => {}
}
}
if let Some(top) = current {
bands.push((top, shot.height - 1));
}
bands
}
/// 竖带里最宽的一行的段长:取「每行最长连通段」的最大值。
///
/// 为什么要取最大而不是某一行:矩形控件上下有留白行(没文字),只有那几行量得到全宽;
/// 文字会把中间行切成几段。两条同色矩形并排时,`runs` 分别返回各自的长段,所以也不会串。
pub(super) fn band_width(shot: &Shot, band: (usize, usize), fill: Color, under: Color) -> usize {
(band.0..=band.1)
.map(|y| {
runs(shot, y, fill, under)
.into_iter()
.map(|(_, len)| len)
.max()
.unwrap_or(0)
})
.max()
.unwrap_or(0)
}
/// 按「**每行最长连续段** ≥ `min_run`」聚成的竖带。
///
/// 与 [`bands`] 的区别只在「一行算不算墨」的判法:`bands` 数**覆盖率 ≥50% 的像素个数**,
/// 本函数要求这些像素连成一长段。M248 换了进度轨道的角色之后,这个区别变成了分水岭:
///
/// - 轨道从强调色族(`SecondaryContainer`)改成中性容器面(`SurfaceContainerHighest`)后,
/// 「画布 → 轨道」这条混色轴**正好穿过中性文字与中性容器面**:文字像素的投影 `t` 会 clamp 到
/// 1.0、残差只剩 <8,于是整行文字都能被数成「轨道墨」(浅色板实测:命中行数从 2 条涨到 5 条,
/// 多出来的三条是图注文字,高度 6 / 2 / 6 行);
/// - 按**连续段**判就不受影响:一行 12px 的字形边缘凑不出 200px 的连续同色段,而 360px 的轨道
/// 一眼就能认出来。
///
/// 阈值的取值口径:轨道带最窄的整段是 200px 以上(线性进度宿主 320/360 宽,指示条压在轨道上
/// 会把轨道切成几段,最长的那段仍 ≥200——不加指示条的确定态那一格是整条 360)。
pub(super) fn bands_by_run(
shot: &Shot,
fill: Color,
under: Color,
min_run: usize,
) -> Vec<(usize, usize)> {
let mut found: Vec<(usize, usize)> = Vec::new();
let mut current: Option<usize> = None;
for y in 0..shot.height {
let longest = runs(shot, y, fill, under)
.into_iter()
.map(|(_, len)| len)
.max()
.unwrap_or(0);
let hit = longest >= min_run;
match (hit, current) {
(true, None) => current = Some(y),
(false, Some(top)) => {
found.push((top, y - 1));
current = None;
}
_ => {}
}
}
if let Some(top) = current {
found.push((top, shot.height - 1));
}
found
}
/// 某种颜色的**精确色**连通块(只用于「找着一大块实心」这种粗定位)。
pub(super) fn blobs(shot: &Shot, color: Color, min_side: usize) -> Vec<BBox> {
let target = rgb(color);
let mut seen = vec![false; shot.width * shot.height];
let mut found = Vec::new();
for y in 0..shot.height {
for x in 0..shot.width {
if seen[y * shot.width + x] || !matches(shot.at(x, y), target) {
continue;
}
let mut stack = vec![(x, y)];
seen[y * shot.width + x] = true;
let mut bbox = BBox::new(x, y);
while let Some((cx, cy)) = stack.pop() {
bbox.include(cx, cy);
for (nx, ny) in [
(cx + 1, cy),
(cx.saturating_sub(1), cy),
(cx, cy + 1),
(cx, cy.saturating_sub(1)),
] {
let inside = nx < shot.width && ny < shot.height;
if inside && !seen[ny * shot.width + nx] && matches(shot.at(nx, ny), target) {
seen[ny * shot.width + nx] = true;
stack.push((nx, ny));
}
}
}
if bbox.width() >= min_side && bbox.height() >= min_side {
found.push(bbox);
}
}
}
found
}
/// 在 `bbox` 内找「最接近 `ink`(而不是 `under`)」的像素外接框(量文字/图标的内缩)。
pub(super) fn ink_inside(
shot: &Shot,
bbox: &BBox,
ink: Color,
under: Color,
margin: usize,
) -> Option<BBox> {
let (target, base) = (rgb(ink), rgb(under));
let mut found: Option<BBox> = None;
for y in (bbox.top + margin)..=(bbox.bottom.saturating_sub(margin)) {
for x in (bbox.left + margin)..=(bbox.right.saturating_sub(margin)) {
let pixel = shot.at(x, y);
let to_ink = pixel
.iter()
.zip(target)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum::<u32>();
let to_base = pixel
.iter()
.zip(base)
.map(|(a, b)| u32::from(a.abs_diff(b)))
.sum::<u32>();
if to_ink < to_base {
match &mut found {
Some(bbox) => bbox.include(x, y),
None => found = Some(BBox::new(x, y)),
}
}
}
}
found
}