两张截图拼起来有多难?一个让我反复推翻重来的 iOS 长截图拼接引擎
两张截图拼起来有多难?一个让我反复推翻重来的 iOS 长截图拼接引擎
我花了好几个月做一件事:让手机上的长截图拼接”刚好对齐”。
听起来很简单对吧?两张相邻截图,找到重叠部分,裁一刀粘起来。但如果你真的动手写,很快就会发现——“刚好对齐”这四个字,是一个深不见底的工程深坑。
这篇文章把 ScrollShot 长截图的拼接引擎拆开给你看。不卖产品,不画饼,只聊算法和那些让我反复改方案的工程细节。如果你对 ScrollShot 的整体定位和适用场景感兴趣,可以先看看这篇产品概览:为什么选择 ScrollShot 作为 iOS 长截图方案。
为什么不用 OpenCV 的全景拼接?
直觉上,图像拼接是个老问题了。OpenCV 的 Stitcher 类几行代码就能拼全景照片。
但长截图和全景照片是两个完全不同的场景。全景是水平旋转拍摄,有透视变换,重叠区域通常不大;长截图是垂直单向滚动,连续两帧有 5%–80% 的内容完全一样,而且——这很关键——拼错 2 个像素你就能看出来,因为你拼的有可能是文字。
试过 OpenCV 的人大概都有这个经历:feature matching 在聊天页面上直接懵了,有的用户在聊天软件里设置了背景图,导致图片大部分都一样,而不是上一帧图片尾部和下一帧图片头部重叠,用SIFT 特征点密密麻麻全是”相似但不同”的匹配对。
所以我选了另一条路:从0开始写第一行模板匹配引擎代码,专门为”垂直滚动截图”这个场景做优化。
整体流水线长这样:

引擎也支持 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 的好处是对整体亮度变化不敏感(白天录屏和夜间模式的同一页面也能匹配),值域 [-1, 1],1 = 完全一致。直觉上理解:NCC 比较的不是像素的绝对值,而是”这片区域的明暗起伏模式是否相同”。
但逐像素在整张旧帧上滑动搜索太慢了。引擎用了金字塔三级搜索:
| 层级 | 步长 | 搜索窗口 | 候选点数 | 作用 |
|---|---|---|---|---|
| Level 1 (Pre) | 12px | 全范围 | ~125 | 粗略定位 |
| Level 2 (Coarse) | 4px | ±32px | ~16 | 精确到行 |
| Level 3 (Fine) | 1px | ±4px | 9 | 逐像素精调 |
有一个数据值得分享: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。如果你也在做类似的事情,希望这篇文章能帮你少踩几个坑。
延伸阅读
- 为什么选择 ScrollShot?iOS 无缝长截图的终极解决方案——从产品视角看 ScrollShot 如何解决 iOS 长截图的痛点,包括录屏自动拼接、手动拼接、隐私保护和高清导出。