fix(app-iced): M79 r2 —— 分隔线守卫换成「锚定细线行 + 整段跨度数段」

代码复审 r2 P1-1:上一条守卫只认 r1 那种「半透明 AA 缝」,四处假通过。
两个结构性原因与对应修法:

1. **窗口被截成 150px**:`longest_h_line` 凑满 150px 就 return,`line_segments` 只在那
   150px 里数段 → 分隔线实长 242px,只看前 62%;缺口在窗口外看不见、在窗口内则被
   「跳到缺口之后重新起跑」滑过。现在 `find_thin_line` 返回的是**该行整段墨迹跨度**,
   `line_segments` 在这个跨度上数段。
2. **行的锚定不稳**:原来「第一条 ≥150px 的连续带」能被五态行的 40px 高底色带、
   `Primary` 指示条顶替(mutE/mutF 会量到完全不同的元素)。现在四条判据一起锚定:
   跨度 ≥200 · 近乎实心(墨迹 ≥ 跨度×4/5)· 薄(跨中点纵向墨迹 ≤3 行)· 低对比
   (线色与底色单通道差 ≤120)。

`TOL=6` 的口径写进注释:**单通道**最大可辨差;40% 覆盖的抗锯齿行实测差 `(9,6,4)`,
6 能看见它又挡住 ±3 渲染噪声;相邻那行是满覆盖(线色差 25/17/10),所以行锚定不会丢。

五次验收(真跑 probe,退出码即判据;变异图在 `target/m79/{mutB,mutC,mutE,mutF}/`
由 r2/r1 图改像素生成,不入库):

| 图 | 结果 | 实测 |
|---|---|---|
| r2 原图 | **rc=0 绿** | 1 段 |
| `before/`(r1 逐 tab + AA 缝) | **rc=101 红** | 3 段,y=471 x24..265 |
| mutB(缺口 x=200..201,旧窗口外) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutC(缺口 x=100..101,旧窗口内) | **rc=101 红** | 2 段,y=471 x24..266 |
| mutE(r1 两个缝位 102/192 挖真缝) | **rc=101 红** | 3 段,**y=471 x24..266**(仍是分隔线那行,不是 y=503 的五态带) |
| mutF(额外:r1 分段线 + 上方 200px Primary 带) | **rc=101 红** | 3 段,y=471(锚定没被 Primary 带顶替) |

门禁:fmt ✓ · `cargo test -p unlucky-app-iced` 197 passed · 全套 probe 对 r2 图 5 passed ·
`clippy --all-targets -D warnings` 干净。未改任何组件实现。
This commit is contained in:
AskaEth committed 2026-11-02 11:56:06 +08:00
1 parent 55a775fca7
commit dffae2c429
2 files changed
+77 -26

No files matched your search

@@ -181,30 +181,80 @@ impl Shot {
span
}
/// 全图里**第一行**横跨 ≥ `min_len` 的「与底色不同」的连续像素:返回 `(y, x0, x1)`。
/// 找「一条**细、低对比、近乎实心**的长横线」,返回 `(y, x0, x1)`:
/// `y` 是线最实的那一行,`x0..x1` 是**该行整段墨迹的跨度**(不是某一次连续 run 的窗口)。
///
/// 用于找 1px 的分隔线:它的颜色是混合值,不能按精确色找。
pub(super) fn longest_h_line(
/// 四条判据,每条都对应一次真实的假通过(视觉 r1 P2-2 复核时被点出来的):
///
/// 1. **跨度 ≥ `min_len`**(200):`Secondary` 分隔线整行 242px;标题/标签行凑不满,能跳过它们。
/// 2. **近乎实心**(墨迹数 ≥ 跨度 × 4/5):逐 tab 分段时中间是真缺口,但只有 1~2px,仍算实心
/// ——**必须先能找到这一行**,再由 [`Shot::line_segments`] 把缺口数成多段。
/// (早先「凑满 150px 就 return,然后在窗口里数段」的写法会滑过缺口,永远数到 1。)
/// 3. **薄**(跨中点纵向墨迹 ≤ 3 行):五态行里的 hover 底色是 40px 高的带,被这条排除
/// (早先的写法会把那条 224px 的带选中,量到完全不同的元素)。
/// 4. **低对比**(线色与底色的单通道最大差 ≤ `max_contrast`):`Primary` 指示条是高饱和色,被排除。
///
/// `TOL` 的口径:**单通道**最大可辨差,取 6。40% 覆盖的抗锯齿行实测单通道差 `(9,6,4)`,
/// 6 能看见它、又挡住 ±3 的渲染噪声;覆盖再低的行会落到判据外,但**相邻那行是满覆盖**
/// (实测线色差 25/17/10),所以行的锚定不会丢。
pub(super) fn find_thin_line(
&self,
background: Rgb,
min_len: usize,
max_contrast: u8,
) -> Option<(usize, usize, usize)> {
const TOL: u8 = 6;
let is_ink = |pixel: Rgb| differs(pixel, background, TOL);
let contrast = |pixel: Rgb| {
pixel
.iter()
.zip(background)
.map(|(p, q)| p.abs_diff(q))
.max()
.unwrap_or(0)
};
for y in 0..self.height {
let mut run = 0;
let mut start = 0;
for x in 0..self.width {
if differs(self.at(x, y), background, 6) {
if run == 0 {
start = x;
}
run += 1;
if run >= min_len {
return Some((y, start, x));
}
} else {
run = 0;
}
let ink: Vec<usize> = (0..self.width).filter(|x| is_ink(self.at(*x, y))).collect();
let (Some(&first), Some(&last)) = (ink.first(), ink.last()) else {
continue;
};
let len = last - first + 1;
if len < min_len || ink.len() * 5 < len * 4 {
continue;
}
// 薄:跨中点纵向墨迹 ≤ 3 行
let mid = (first + last) / 2;
let mut top = y;
while top > 0 && is_ink(self.at(mid, top - 1)) {
top -= 1;
}
let mut bottom = y;
while bottom + 1 < self.height && is_ink(self.at(mid, bottom + 1)) {
bottom += 1;
}
if bottom - top + 1 > 3 {
continue;
}
// 低对比:取这条细带里最实的一行(满覆盖),跨段与线色都按那一行算
let best = (top..=bottom)
.max_by_key(|candidate| {
(first..=last)
.filter(|x| is_ink(self.at(*x, *candidate)))
.count()
})
.unwrap_or(y);
let line = self.at(
(first..=last)
.find(|x| is_ink(self.at(*x, best)))
.unwrap_or(mid),
best,
);
let c = contrast(line);
if c > max_contrast || c <= TOL {
continue;
}
return Some((best, first, last));
}
None
}