两张截图拼起来有多难?一个让我反复推翻重来的 iOS 长截图拼接引擎

ScrollShot 拼接引擎六步处理流程图:从录屏到长图

两张截图拼起来有多难?一个让我反复推翻重来的 iOS 长截图拼接引擎

我花了好几个月做一件事:让手机上的长截图拼接”刚好对齐”。

听起来很简单对吧?两张相邻截图,找到重叠部分,裁一刀粘起来。但如果你真的动手写,很快就会发现——“刚好对齐”这四个字,是一个深不见底的工程深坑。

这篇文章把 ScrollShot 长截图的拼接引擎拆开给你看。不卖产品,不画饼,只聊算法和那些让我反复改方案的工程细节。如果你对 ScrollShot 的整体定位和适用场景感兴趣,可以先看看这篇产品概览:为什么选择 ScrollShot 作为 iOS 长截图方案


为什么不用 OpenCV 的全景拼接?

直觉上,图像拼接是个老问题了。OpenCV 的 Stitcher 类几行代码就能拼全景照片。

但长截图和全景照片是两个完全不同的场景。全景是水平旋转拍摄,有透视变换,重叠区域通常不大;长截图是垂直单向滚动,连续两帧有 5%–80% 的内容完全一样,而且——这很关键——拼错 2 个像素你就能看出来,因为你拼的有可能是文字。

试过 OpenCV 的人大概都有这个经历:feature matching 在聊天页面上直接懵了,有的用户在聊天软件里设置了背景图,导致图片大部分都一样,而不是上一帧图片尾部和下一帧图片头部重叠,用SIFT 特征点密密麻麻全是”相似但不同”的匹配对。

所以我选了另一条路:从0开始写第一行模板匹配引擎代码,专门为”垂直滚动截图”这个场景做优化。

整体流水线长这样:

ScrollShot 拼接引擎流水线

引擎也支持 Apple Vision Framework 做匹配(快但粗糙),不过下文重点聊自研的模板匹配——这才是真正扛活的部分。想了解这套算法最终如何转化为录屏一次即得长图的完整体验,可以参看产品功能介绍。


第一关:如何从 1800 帧里挑出 有效的 30 帧

一段 30 秒的 60fps 录屏 = 1800 帧。全拿去拼?内存先炸了,时间也炸了。

最直觉的方案是等时抽帧:每秒取 3 帧。但这方案有个致命缺陷——用户滚动速度不均匀。快速滑动时两帧之间差了半个屏幕,拼接必然漏内容;慢慢看的时候相邻两帧几乎一模一样,全是废帧。

ScrollShot 用的是等距抽帧:不看时间,看”画面滚了多远”。只有累计位移超过阈值,才值得取一帧。

位移阈值取多少合适?

这个数字折腾了我好久。设太小,慢慢滑动时疯狂抽帧,一堆重复的;设太大,快速滑动时漏内容。

最终的公式:

displacementThreshold = dsHeight × 0.60 × presetScale

其中 dsHeight 是降采样后的视频帧高度(1080p 降 3 倍 ≈ 360p,即 dsHeight ≈ 640),0.60 是基础比率,presetScale 是用户可选的档位(激进/均衡/保守)。

也就是说在均衡模式下,画面每滚动约 384 像素(640 × 0.60)抽一帧。差不多是小半个屏幕的高度。

另外有一个容易忽略的噪声过滤器:单次位移必须 ≥ 3 像素才计入累计位移。不加这个的话,模板匹配的亚像素噪声会慢慢积累,让你在静止画面上也”假抽帧”。我调了一下午才发现这个问题。

两遍扫描的具体实现

第一遍(Pass 1)—— 低分辨率运动分析。把视频帧降采样 3 倍(1080p → 360p,速度提升一个量级),用轻量模板匹配逐帧追踪垂直位移。这里藏了几个工程细节:

  • 自适应步长:位移大的时候大步跳帧(nativeFPS / 6,约每 10 帧看一次),位移小的时候切回逐帧扫描(nativeFPS / 30)。当剩余距离不到阈值的 25% 时,强制切回最小步长——不然真的会刚好跨过去、漏掉一帧
  • 场景突变:相邻帧的 SAD per pixel 超过 40.0?大概率用户切换了 App,立即切一帧
  • 回弹截断:用 Vision 的光流检测运动方向,如果累计位移低于 -50px(用户在往回滚),说明已经到顶了,后面的帧全部丢弃。但这个阈值不能设太小——我最初设的是 -10,结果手指轻微抖动就触发截断,录屏直接少了一半

第二遍(Pass 1.5)—— 清晰度精修。对每个候选帧,在前后 ±2 帧的小窗口里找最清晰的那帧。

清晰度怎么量化?我用的是 Laplacian 能量——对灰度图做 3×3 Laplacian 卷积,然后算响应的平方均值:

kernel = [-1, -1, -1,
          -1,  8, -1,
          -1, -1, -1]

sharpness = mean((convolve(gray, kernel) - 128)²)

减去 128 是因为卷积输出带 128 的 bias(vImage 的要求),平方后取均值就是高频能量。值越大,画面越锐利。

为什么用 Laplacian 而不是 Laplacian 方差?因为我发现对于截图场景,两者选出来的帧几乎一样,但前者计算更简单——vImage 的 vDSP_vsq + vDSP_meanv 两行搞定。

这一遍的分辨率上限是 720×1280,够用了。原因很现实:用户滚动时手指抬起的瞬间帧最清晰,滑动中的帧往往带运动模糊。Laplacian 能量对运动模糊非常敏感,差异通常有 2–3 倍。


核心算法:模板匹配引擎

这是整个引擎代码量最大的部分(单个文件 3000+ 行),也是我被 bug 折磨最久的部分。

反向匹配:为什么从新帧找旧帧?

第一版我写的是”正向匹配”——从旧帧底部取模板,在新帧里搜索。结果在微信聊天页面上翻车了:旧帧底部是一条”好的”,新帧里出现了三条”好的”,匹配到了最上面那条,offset 差了 200 像素。

后来换了思路:反向匹配(Reverse Matching)。从新帧(img2)的顶部取模板,去旧帧(img1)的底部搜索。

为什么反向更好?因为新帧顶部是”刚滚出来的新内容”,这些内容在旧帧里一定存在,而且只存在一份(在旧帧的中下部)。反过来,旧帧底部的”即将消失的旧内容”在新帧里可能只露出一点点,或者已经被新内容挤出了画面。

反向匹配示意图

搜索时会自动跳过状态栏区域(顶部约 250px)和底部 Tab Bar 区域(约 350px)。但这里又有个坑——有些 App 的底部导航栏高度不一样,有些页面有键盘,有些有浮动按钮。

所以我加了一个动态 Footer 检测。具体做法:从两帧底部逐行往上扫,取每行中央 80% 的像素(裁掉左右各 10%,避开边缘的浮动按钮),每隔 16 像素采样一次,算两帧同一行的 MAD(平均绝对差)。如果 MAD ≤ 3.2,说明这一行在两帧中几乎一样——大概率是静止的 UI 元素。连续出现超过 4 行非静止的就停(maxGapTolerance = 4),累计静止行少于 24 行则认为没有固定底栏,直接返回 0。

3.2 这个阈值是经验值。纯白背景的静止区域 MAD 通常在 0–1.5,JPEG 压缩噪声带来的波动在 2–4 之间,而真正有内容变化的行至少在 8 以上。3.2 刚好卡在”容忍压缩噪声但拒绝内容变化”的缝隙里。

6 个模板投票,对抗”长得像”

单个模板块很容易误匹配。想象一个电商商品列表页——每个商品卡片的布局几乎一模一样,单个模板可能匹配到上一个或下一个商品。

引擎的做法是同时提取 6 个模板块(每个 100px 高,从新帧顶部间隔排列),各自独立在旧帧中搜索最佳匹配位置,得到 6 个 offset 值。然后把这 6 个 offset 做聚类(容差 5px),取最大簇的中位数。

如果 6 个模板有 5 个指向 offset ≈ 320,1 个指向 800,那 800 那个大概率是误匹配,丢掉就行。

还有一个早停优化:如果前 3 个模板的 offset 已经一致(容差 4px),而且不是接近 0(排除”用户根本没动”的情况),后面 3 个就不用算了。

这里有个我反复纠结的细节:容差设多少?最初我设的 2px,结果因为 NCC 本身的量化误差(后面会聊),同一对帧不同模板的 offset 经常差个 2–3px,导致早停几乎不触发。放宽到 4px 之后,实际测试中大约 60% 的帧对能触发早停,而且没有观察到因此导致的精度下降。

金字塔 NCC:从粗到细搜索

每个模板的匹配使用 NCC(归一化互相关) 衡量相似度。NCC 的公式:

NCC(T,I)=(TiTˉ)(IiIˉ)(TiTˉ)2(IiIˉ)2\text{NCC}(T, I) = \frac{\sum(T_i - \bar{T})(I_i - \bar{I})}{\sqrt{\sum(T_i - \bar{T})^2 \cdot \sum(I_i - \bar{I})^2}}

NCC 的好处是对整体亮度变化不敏感(白天录屏和夜间模式的同一页面也能匹配),值域 [-1, 1],1 = 完全一致。直觉上理解:NCC 比较的不是像素的绝对值,而是”这片区域的明暗起伏模式是否相同”。

但逐像素在整张旧帧上滑动搜索太慢了。引擎用了金字塔三级搜索

层级步长搜索窗口候选点数作用
Level 1 (Pre)12px全范围~125粗略定位
Level 2 (Coarse)4px±32px~16精确到行
Level 3 (Fine)1px±4px9逐像素精调

有一个数据值得分享:Level 1 的步长 12 意味着只搜索 1/12 的候选位置,相比逐像素搜索,单个模板的匹配速度提升了大约 12 倍。三级加起来的总计算量大概是全范围逐像素搜索的 1/8。

当候选点超过 64 个时(通常发生在 Level 1 的全范围搜索),自动启用 GCD 并行计算(DispatchQueue.concurrentPerform),把搜索任务分散到多个 CPU 核心上。这里我踩了一个坑:最初用 NSLock 保护共享的 bestScore 变量,结果发现锁竞争在 6 核上反而拖慢了速度。后来改成每个线程记录本地最优,最后合并,锁竞争从 O(n) 降到 O(1)。

搜索窗口:让历史帮你少走弯路

如果每次都搜旧帧的全部可用区域,又慢又容易误匹配(页面顶部和底部可能有相似布局)。

引擎利用了两种”先验”来缩小搜索窗口:

  • 时序先验:上一对帧的 offset 是 300px,那这一对大概率也差不多——用户的滚动速度不会突变。搜索窗口缩小到 300 ± 120px
  • Vision 先验:第一对帧没有历史数据时,用 Apple Vision 的图像配准 API 给一个粗略估计,窗口缩小到估计值 ± 180px

如果窄窗口内找不到至少 3 个一致的匹配(说明先验估计偏了),自动退化到全范围搜索。

这个”至少 3 个一致”的判定条件也是踩坑踩出来的。最初我用的是”至少 1 个”,结果在 Vision 先验偏差较大时(偶尔能给错 500px),窄窗口里的误匹配刚好被当成了合法结果。改成 3 个之后,误匹配必须同时骗过 3 个独立模板才能通过,概率上基本不可能了。

当正常匹配不管用时,用三层降级方案

真实场景比实验室残酷得多。浮动按钮、半透明弹窗、正在播放的视频、闪烁的光标——这些都会把 NCC 分数拉低。

引擎设计了三层降级策略,我用实际数据说明每层的触发频率:

第一层:强匹配(~80% 帧对)。 多数模板的 NCC ≥ 0.6 且指向同一 offset。最常见的情况。

第二层:软匹配(~12% 帧对)。 最佳模板的 NCC 只有 0.45~0.6——可能是浮动 UI 遮挡了部分区域。此时用全重叠区的 SAD(所有像素的亮度差之和)做二次验证。SAD per pixel ≤ 26.0 就接受,否则拒绝。

第三层:1D Profile NCC(~5% 帧对)。 把二维图像”压扁”成一行均值曲线——每行所有像素取平均,得到一个一维数组。对两条一维曲线做互相关,速度快约 100 倍,但假阳性风险高。

全失败(~3% 帧对)? 这一帧大概率是场景切换或完全无重叠,直接追加到末尾不做拼接。

第三层的 SAD 验证比较讲究。基础阈值是 25.0 per pixel,但当重叠比例超过 80% 时,阈值会自适应收紧:

if overlapRatio > 0.8:
    scale = max(minScale, 1.0 - (overlapRatio - 0.8) × 4.0)
    threshold = 25.0 × scale

为什么要收紧?因为重叠区域越大,SAD 的统计样本越多,随机噪声被平均掉了,误匹配的 SAD 也容易偏低。不收紧的话,大重叠场景下假阳性会增多。

不管走哪条路径,最终都有一次偏移精调:在候选 offset 的 ±6px 范围内逐像素计算 SAD,取最小值。

为什么 NCC 给出的 offset 还需要精调?这是我调了三天才想明白的事。NCC 在最优值附近非常”平坦”——offset 差 2px,NCC 可能只变化 0.003。但 SAD 对 ±1px 的位移极其敏感,差 1px 能让 SAD 变化 3–5 个单位。两者配合,NCC 负责粗定位,SAD 负责像素级校准。拼的是文字,差 2px 就是”一行字被切成两半”和”完美对齐”的区别。

缝合线:别把字切成两半

找到了 offset,两帧有一段重叠区域。在哪里”切一刀”?

最直觉的做法是从中间切。但如果中间恰好有一行大字,切出来上半截和下半截对不齐,人眼一看就知道有问题。

引擎的做法是:计算重叠区内每一行的像素差异,然后用一个滑动窗口找差异总和最小的位置。窗口高度自适应——取 min(overlapH, max(100, adaptiveCutHeight)),其中 adaptiveCutHeight 根据 overlap 和 offset 的比值动态调整。这个位置就是两帧内容最接近的地方,拼接缝落在这里,视觉上最自然。


性能数据

跑了一段典型的 30 秒微信聊天记录录屏(iPhone 15 Pro, 1080×2400, 60fps),引擎的关键指标:

指标数值
总帧数1800
Pass 1 分析耗时3.2s
抽出关键帧26 帧
Pass 1.5 清晰度精修0.8s
模板匹配总耗时4.1s(25 对帧)
平均每对帧匹配164ms
早停触发率58%(15/25 对)
强匹配命中率84%(21/25 对)
软匹配触发3 对
1D Profile 回退1 对
全失败(追加)0 对
最终长图尺寸1170 × 18600
端到端总耗时~8.5s

金字塔搜索的加速比很明显:Level 1 步长 12 跳过 92% 的候选位置,Level 2 在 ±32px 的窄窗口里只需算 ~16 个点,Level 3 只精调 ±4px。三级加起来的有效计算量大约是暴力全范围搜索的 1/8


工程上那些不能忽略的事

20000 像素的长图不能全加载

30 帧拼出的长图可能有 1170 × 24000 像素。全分辨率 RGBA 在内存中占 112MB(1170 × 24000 × 4 bytes),iPhone 受不了。

StreamingStitchingPlanner 的设计是流式的:匹配时内存中只有”当前帧 + 上一帧”,拼接结果记录为一组 Piece(源图索引 + 裁剪区间 + 目标位置),最终渲染时再分块解码、分块绘制。

滚动条消除算法

拼接完的长图右侧几乎一定会残留一条滚动条。引擎用梯度投影法自动干掉它:

取右边缘 3% 宽度的条带,算水平梯度累加 → 滚动条的左右两条边形成两个梯度峰值(间距 3–20px)→ 逐行确认滚动条实际出现的位置 → 用左侧相邻像素覆盖掉它。底部水平滚动条同理。

算法拼接失败的兜底交互

再好的算法也有翻车的时候。ScrollShot 在每个缝合线上提供了交互式微调——用户可以上下拖动调整拼接位置,实时预览。底层的 FineTuneGeometryEngine 保证几何约束始终合法(你不可能把一块拖到另一块的上面去)。预览用低分辨率快速渲染,导出时自动切回原始分辨率。这种可控性也是手动拼接模式(Manual Stitch)的核心交互之一。


最后说两句

做这个引擎最大的感受是:拼接算法本身不难,难的是让它在各种”不讲道理”的真实页面上也能工作。

微信聊天背景长得都一样、淘宝商品列表无限重复、系统设置页有大片纯白背景、B站视频封面在动……每一个都是课本上不会教、论文里不会写的边界情况。

有一个我印象特别深的 bug:在 iOS 系统设置页面录屏,NCC 给的 offset 每次都偏大约 40px。查了半天发现设置页面顶部有一大块纯白背景,模板取到纯白区域后 NCC 在任何白色位置都给出接近 1.0 的分数,根本分不出谁是谁。解决办法是检测到模板的方差过低时自动跳过这个模板——纯白模板没有信息量,不如不用。

还有一次更离谱。在 B 站首页录屏,有个视频封面是一只在动的猫。封面区域在两帧之间内容完全不同,直接把 NCC 拉低到 0.3。这种 case 靠算法本身是救不了的,最终是 6 模板投票机制把它救了——其他 5 个模板取的是静止区域,投票结果依然正确。

解决这些问题靠的不是更优雅的数学公式,而是一个又一个”先这么兜着”的工程 trick。如果你也在做类似的事情,希望这篇文章能帮你少踩几个坑。


延伸阅读

iOS 长截图截图拼接算法模板匹配NCCScrollShot 引擎录屏拼接
返回博客