Skip to content

Long Screenshot Stitching

51hhh edited this page Sep 16, 2026 · 1 revision

长截图拼接

实现位于 src-tauri/src/capture/longshot/。当前支持竖向向下滚动的固定选区长截图。

capture/longshot/
├── controller.rs      控制器与状态机
├── lifecycle.rs       桌面资源生命周期
├── manager.rs         会话与代次管理
├── session.rs         会话状态
├── frame_adapter.rs   帧适配与固定选区
├── overlap.rs         垂直重叠估计(本页重点)
├── recapture.rs       重新采帧
└── window_host.rs     控制窗与接管

位移估计算法

overlap.rs 的常量都是本项目自有的工程参数。它刻意不复制任何外部实现——同名功能不等于同一套评分器。

预处理

两张 RGBA 帧各自转成 GrayFrame,同时算出两个平面:

  • luma:灰度
  • gradient:比较用梯度

匹配宽度上限 512 列(MAX_MATCH_WIDTH),估计高度上限 16384 行。最小尺寸 8×24。

梯度的一个边界很关键:匹配窗口的末行没有可比较的下一行,纵向梯度必须归零,不能借到窗口外的新内容。comparison_gradient(x, y, has_next_row) 就是为此显式传入 has_next_row。

纹理门槛

texture_threshold: 4.0 / 255.0

先算两帧的纹理度,取较小值。低于阈值直接返回 LongshotEstimateLowTexture。

理由:纯色或近纯色区域上任何位移都「匹配得很好」,评分器无法区分,此时给出的结果是伪精确的。宁可明确失败,不要给一个看似成功的错位。

两级候选筛选

粗筛遍历所有可能位移 0..=max_displacement,其中 max_displacement = height - min_evidence_rows, min_evidence_rows = max(ceil(height/8), 8)。

采样网格 12 列 × 24 行(COARSE_COLUMNS / COARSE_ROWS),得分:

score = 0.7 × (luma MAE / 255) + 0.3 × (gradient MAE / 510)

按得分升序排序,同分时位移小的在前(稳定序),截断保留最多 64 个候选(MAX_FINE_CANDIDATES)。

精筛对这 64 个候选用更密的网格重算:96 列 × 48 行(FINE_COLUMNS / FINE_ROWS)。

精筛多做一步逐行裁剪:先按行算分,把行按得分排序,只保留最好的 80%(retained_row_ratio: 0.8)参与最终 MAE 汇总。

这一步是为了容忍局部动态内容——页面里一小块动画或光标不应否决整帧的正确位移。

接受判据(四道,全部必须通过)

luma_threshold:     18.0 / 255.0
gradient_threshold: 24.0 / 255.0
ambiguity_margin:   0.08
判据 条件 失败错误
绝对相似度 luma_mae ≤ 18/255 且 gradient_mae ≤ 24/255 LongshotEstimateLowSimilarity
歧义边际 与位移不同的次优候选相对差距 ≥ 0.08 LongshotEstimateAmbiguous
有扩展 最佳位移 ≠ 0 LongshotEstimateNoExtension
位移上限 位移 ≤ height × 2/3 LongshotEstimateDisplacementTooLarge

歧义判据用的是相对边际:

margin = (second.score - best.score) / second.score.max(1e-12)

而且取次优时必须 find(|c| c.displacement != best.displacement)——同一位移的不同采样结果不构成竞争候选。

歧义检查是重复图案(列表、表格、代码缩进)下唯一的防线。少了它,重复纹理会给出一个高分但完全错误的位移。

输出

confidence = clamp01(0.5 × absolute_quality
                   + 0.3 × margin_quality
                   + 0.2 × texture_quality)
score      = clamp01(0.7 × luma_mae + 0.3 × gradient_mae)

其中 absolute_quality = 1 - max(luma_mae/luma_threshold, gradient_mae/gradient_threshold)。

估计结果同时带回 overlap_rows、displacement_rows、score、confidence、sampled_width、sampled_rows——采样规模要跟着结果一起交出去,否则调用方无法判断这个分数有多少证据支撑。

均匀采样

fn sample_position(index, count, length) -> usize {
    if count <= 1 { return 0; }
    (index * (length - 1) + (count - 1) / 2) / (count - 1)
}

两端保持不变的整数网格,四舍五入用整数运算完成。这样 index=0 必得 0、index=count-1 必得 length-1,采样不会系统性偏向一侧。

分配失败是显式错误

候选容器一律用 try_reserve_exact,失败返回 LongshotAllocationFailed。累加用 checked_add,溢出返回 LongshotResourceLimit。长截图的工作集随页面长度增长,静默 abort 是不可接受的。

五条工程约定

这些是做这类功能时最容易踩的坑,与具体阈值无关:

一、分清五种坐标与像素。 capture provider 的帧、匹配用的灰度图、原分辨率存储、逻辑坐标、文件导出,是五件不同的事。每帧必须携带实际 ROI / DPR / format / stride。匹配时的宽度压缩绝不能无意缩放滚动轴,保存必须用原分辨率像素。

二、不要用滚轮 delta 代替实际像素位移。 也不要假设「一次滚轮事件 = 一张有效帧」。实测中大量后续帧是「无扩展」或「失配」,只有少数几次产生真正的追加,而最终成品仍然正确。

三、接受 / 无扩展 / 失配 / 限长 / 结束取消,必须分开处理。 把它们合并成一个布尔返回值,就无法向用户解释「为什么滚了很久却没变长」。

四、区分「整帧重叠」与「接入区覆盖」两个数量。 它们通常不相等,测试必须分别断言。把两者混为一谈会写出恰好在简单场景通过、在真实场景错位的实现。

五、先验证存储成功再提交状态。 定义明确的覆盖合同与失败提交顺序,保留最近一次已成功的结果。如果先更新匹配基准、再执行可能失败且无返回值的接入操作,失败时就没有原子性可言。

端到端验收方法

单元测试锁不住这类功能。必须做真实链路的像素级验收:

真实桌面 ROI → 位移估计 → 逻辑拼接 → 编码 → 解码 → 逐像素比对

关键技巧:用每行唯一编码的参考图。给每一行画上可解析的行号(例如 12 位二进制条码),成品解码后就能断言:

  • 行号严格连续,无漏行、无重复行
  • 相邻行号差全部为 1
  • 总高度等于首帧高度加累计位移

这个方法能抓到用尺寸断言完全抓不到的错误——尺寸正确但中间少了一段的成品,只有行号能发现。

叠加场景应覆盖:固定页头、悬浮窗遮挡、重复图案、快速滚动、动态区域。

注意:不能用控件自己渲染出的图充当桌面采集证据。控件渲染存在亚像素差异,与合成器实际输出的帧不是同一批像素。

尚未实现

  • 横向长截图、向上前接
  • 自动滚动(软件主动产生滚动输入)
  • 反向滚动时自动裁剪已拼接区间
  • 固定页头/页尾/侧栏的自动识别与去除
  • 超长图的分块编码路径

其中「自动滚动」与「反向裁剪」是两件不同的事:前者产生输入,后者决定保留哪些像素。设计接口时应保持独立:方向状态 → 保留区间 → 裁剪成功 → 更新匹配基准。

Clone this wiki locally