兩張截圖拼起來有多難?一個讓我反覆推翻重來的 iOS 長截圖拼接引擎

ScrollShot 拼接引擎六步處理流程圖:從錄影到長圖

兩張截圖拼起來有多難?一個讓我反覆推翻重來的 iOS 長截圖拼接引擎

我花了好幾個月做一件事:讓手機上的長截圖拼接「剛好對齊」。

聽起來很簡單對吧?兩張相鄰截圖,找到重疊部分,裁一刀黏起來。但如果你真的動手寫,很快就會發現——「剛好對齊」這四個字,是一個深不見底的工程深坑。

這篇文章把 ScrollShot 長截圖的拼接引擎拆開給你看。不賣產品,不畫大餅,只聊演算法和那些讓我反覆改方案的工程細節。如果你對 ScrollShot 的整體定位和適用場景感興趣,可以先看看這篇產品概覽:為什麼選擇 ScrollShot 作為 iOS 長截圖方案


為什麼不用 OpenCV 的全景拼接?

直覺上,影像拼接是個老問題了。OpenCV 的 Stitcher 類別幾行程式碼就能拼全景照片。

但長截圖和全景照片是兩個完全不同的場景。全景是水平旋轉拍攝,有透視變換,重疊區域通常不大;長截圖是垂直單向捲動,連續兩幀有 5%–80% 的內容完全一樣,而且——這很關鍵——拼錯 2 個像素你就看得出來,因為你拼的有可能是文字。

試過 OpenCV 的人大概都有這個經歷:feature matching 在聊天頁面上直接懵了。有的使用者在通訊軟體裡設了背景圖,導致畫面大部分都一樣,而不是上一幀尾部和下一幀頭部重疊,用 SIFT 特徵點密密麻麻全是「相似但不同」的匹配對。

所以我選了另一條路:從零開始寫第一行模板比對引擎程式碼,專門為「垂直捲動截圖」這個場景做最佳化。

整體流水線長這樣:

ScrollShot 拼接引擎流水線

引擎也支援 Apple Vision Framework 做比對(快但粗糙),不過下文重點聊自研的模板比對——這才是真正扛活的部分。想了解這套演算法最終如何轉化為錄影一次即得長圖的完整體驗,可以參看產品功能介紹。


第一關:如何從 1,800 幀裡挑出有效的 30 幀

一段 30 秒的 60fps 錄影 = 1,800 幀。全拿去拼?記憶體先炸了,時間也炸了。

最直覺的方案是等時抽幀:每秒取 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 倍。


核心演算法:模板比對引擎

這是整個引擎程式碼量最大的部分(單個檔案 3,000+ 行),也是我被 bug 折磨最久的部分。

反向比對:為什麼從新幀找舊幀?

第一版我寫的是「正向比對」——從舊幀底部取模板,在新幀裡搜尋。結果在 LINE 聊天頁面上翻車了:舊幀底部是一則「好的」,新幀裡出現了兩則「好的」,比對到了最上面那則,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 秒 LINE 聊天紀錄錄影(iPhone 15 Pro, 1080×2400, 60fps),引擎的關鍵指標:

指標數值
總幀數1,800
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 對
最終長圖尺寸1,170 × 18,600
端到端總耗時~8.5s

金字塔搜尋的加速比很明顯:Level 1 步長 12 跳過 92% 的候選位置,Level 2 在 ±32px 的窄視窗裡只需算 ~16 個點,Level 3 只精調 ±4px。三級加起來的有效計算量大約是暴力全範圍搜尋的 1/8


工程上那些不能忽略的事

20,000 像素的長圖不能全載入

30 幀拼出的長圖可能有 1,170 × 24,000 像素。全解析度 RGBA 在記憶體中佔 112MB(1,170 × 24,000 × 4 bytes),iPhone 受不了。

StreamingStitchingPlanner 的設計是流式的:比對時記憶體中只有「當前幀 + 上一幀」,拼接結果記錄為一組 Piece(來源圖索引 + 裁剪區間 + 目標位置),最終渲染時再分塊解碼、分塊繪製。

捲動條消除演算法

拼接完的長圖右側幾乎一定會殘留一條捲動條。引擎用梯度投影法自動處理掉它:

取右邊緣 3% 寬度的條帶,算水平梯度累加 → 捲動條的左右兩條邊形成兩個梯度峰值(間距 3–20px)→ 逐行確認捲動條實際出現的位置 → 用左側相鄰像素覆蓋掉它。底部水平捲動條同理。

演算法拼接失敗的兜底互動

再好的演算法也有翻車的時候。ScrollShot 在每個縫合線上提供了互動式微調——使用者可以上下拖曳調整拼接位置,即時預覽。底層的 FineTuneGeometryEngine 保證幾何約束始終合法(你不可能把一塊拖到另一塊的上面去)。預覽用低解析度快速渲染,匯出時自動切回原始解析度。這種可控性也是手動拼接模式(Manual Stitch)的核心互動之一。


最後說兩句

做這個引擎最大的感受是:拼接演算法本身不難,難的是讓它在各種「不講道理」的真實頁面上也能工作。

LINE 聊天背景長得像、蝦皮商品列表無限重複、系統設定頁有大片純白背景、YouTube 影片縮圖在動……每一個都是課本上不會教、論文裡不會寫的邊界情況。

有一個我印象特別深的 bug:在 iOS 系統設定頁面錄影,NCC 給的 offset 每次都偏大約 40px。查了半天發現設定頁面頂部有一大塊純白背景,模板取到純白區域後 NCC 在任何白色位置都給出接近 1.0 的分數,根本分不出誰是誰。解決辦法是偵測到模板的變異數過低時自動跳過這個模板——純白模板沒有資訊量,不如不用。

還有一次更離譜。在 YouTube 首頁錄影,有個影片縮圖是一隻在動的貓。縮圖區域在兩幀之間內容完全不同,直接把 NCC 拉低到 0.3。這種 case 靠演算法本身是救不了的,最終是 6 模板投票機制把它救了——其他 5 個模板取的是靜止區域,投票結果依然正確。

解決這些問題靠的不是更優雅的數學公式,而是一個又一個「先這麼兜著」的工程 trick。如果你也在做類似的事情,希望這篇文章能幫你少踩幾個坑。


延伸閱讀

iOS 長截圖截圖拼接演算法模板比對NCCScrollShot 引擎錄影拼接
返回部落格