Repository navigation
Long Screenshot Stitching
实现位于 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
- 总高度等于首帧高度加累计位移
这个方法能抓到用尺寸断言完全抓不到的错误——尺寸正确但中间少了一段的成品,只有行号能发现。
叠加场景应覆盖:固定页头、悬浮窗遮挡、重复图案、快速滚动、动态区域。
注意:不能用控件自己渲染出的图充当桌面采集证据。控件渲染存在亚像素差异,与合成器实际输出的帧不是同一批像素。
- 横向长截图、向上前接
- 自动滚动(软件主动产生滚动输入)
- 反向滚动时自动裁剪已拼接区间
- 固定页头/页尾/侧栏的自动识别与去除
- 超长图的分块编码路径
其中「自动滚动」与「反向裁剪」是两件不同的事:前者产生输入,后者决定保留哪些像素。设计接口时应保持独立:方向状态 → 保留区间 → 裁剪成功 → 更新匹配基准。