スクリーンショット2枚を繋ぐのはどれくらい難しいのか?何度も作り直したiOS長尺スクリーンショット結合エンジン

ScrollShot結合エンジンの6段階パイプライン:画面収録から長い画像まで

スクリーンショット2枚を繋ぐのはどれくらい難しいのか?何度も作り直したiOS長尺スクリーンショット結合エンジン

私は数ヶ月間、ひとつのことだけをしていました。スマホでの長尺スクリーンショット結合を「ぴったり」揃えること。

簡単そうに聞こえますよね? 隣り合うスクリーンショット2枚から重なり部分を見つけて、切って、貼り付ける。でも実際にコードを書き始めると、「ぴったり」という4文字がどれだけ底なしのエンジニアリングの深淵であるかに気づきます。

この記事ではScrollShotの長尺スクリーンショット結合エンジンを分解してお見せします。製品の宣伝も大げさな約束もなし。アルゴリズムと、何度も設計をひっくり返すことになったエンジニアリングのディテールだけです。ScrollShotの全体的な位置づけや利用シーンに興味がある方は、まず製品概要:なぜScrollShotをiOS長尺スクリーンショット解决方案として選ぶべきかをご覧ください。


OpenCVのパノラマ結合ではダメなのか?

直感的には、画像結合は昔からある問題です。OpenCVのStitcherクラスなら数行のコードでパノラマ写真が作れます。

しかし長尺スクリーンショットとパノラマ写真は全く異なるものです。パノラマは水平回転撮影で透視変換があり、重なり領域は通常大きくありません。長尺スクリーンショットは垂直一方向スクロールで、連続する2フレームが5%–80%の同一内容を共有します。そして——これが重要——2ピクセルのずれでも目に見えるのです。結合対象がテキストかもしれないからです。

OpenCVを試したことがある人なら同じ経験をしているでしょう。フィーチャーマッチングがチャット画面で完全に迷子になります。メッセージアプリに背景画像を設定しているユーザーがいて、画面の大部分が同じに見えてしまいます。前のフレームの末尾と次のフレームの先頭が重なるのではなく、SIFT特徴点が「似ているけれど違う」マッチングペアだらけになります。

そこで私は別の道を選びました。ゼロからテンプレートマッチングエンジンの最初の1行を書き始め、「垂直スクロールスクリーンショット」シナリオに特化して最適化しました。

全体のパイプラインはこうなっています:

ScrollShot結合エンジンパイプライン

エンジンはApple Vision Frameworkを使ったマッチングにも対応しています(速いけれど粗い)。ただし、この記事では自社開発のテンプレートマッチングに焦点を当てます。実際に重い仕事を担っている部分です。このアルゴリズムが最終的にどのように一度録画するだけで長い画像が得られる体験に結びつくのか知りたい方は、製品機能紹介をご覧ください。


最初の関門:1,800フレームから有効な30フレームを選び出す

30秒の60fps画面収録 = 1,800フレーム。全部使って結合する? メモリが先に吹っ飛びます。時間もです。

最も直感的なアプローチは等間隔フレーム抽出です。毎秒3フレームを抽出する。しかし致命的な欠陥があります。ユーザーのスクロール速度は一定ではない。高速スワイプ時には2フレーム間に半画面分の差があり、結合時に必ず内容が欠落します。ゆっくり見ている時は隣接フレームがほぼ同一で、すべて無駄なフレームになります。

ScrollShotは等距離フレーム抽出を使います。時間ではなく「画面がどれだけスクロールしたか」を見ます。累積変位が閾値を超えたときだけ、フレームを抽出する価値があります。

変位閾値はどれくらいが適切か?

この数字には本当に長く悩まされました。小さすぎると、ゆっくりスクロール時に大量の重複フレームが抽出されます。大きすぎると、高速スクロール時に内容が欠落します。

最終的な計算式:

displacementThreshold = dsHeight × 0.60 × presetScale

ここでdsHeightはダウンサンプリング後の映像フレーム高さ(1080pを3倍ダウンサンプリング ≈ 360p、つまりdsHeight ≈ 640)、0.60は基本比率、presetScaleはユーザーが選択可能なプリセット(積極的/バランス/保守的)です。

つまりバランスモードでは、画面が約384ピクセル(640 × 0.60)スクロールするごとに1フレームを抽出します。画面高さの半分弱くらいです。

もう一つ見落としやすいノイズフィルターがあります。1回の変位が必ず3ピクセル以上でなければ累積変位に加算しない。これがないと、テンプレートマッチングのサブピクセルノイズが徐々に蓄積し、静止画面でも「偽フレーム抽出」が発生します。このバグを見つけるのに丸一日かかりました。

2パススキャンの実装

第1パス(Pass 1)—— 低解像度モーション分析。 映像フレームを3倍ダウンサンプリング(1080p → 360p、1桁の速度向上)、軽量テンプレートマッチングでフレームごとの垂直変位を追跡します。ここにいくつかのエンジニアリング詳細が隠されています:

  • 適応型ステップサイズ:変位が大きい時は大きくスキップ(nativeFPS / 6、約10フレームに1回)、変位が小さい時はフレーム単位の走査に戻す(nativeFPS / 30)。残り距離が閾値の25%以下になったら、強制的に最小ステップに切り替え——そうしないとちょうど1フレームをまたいで見逃します
  • シーン切り替わり検出:隣接フレームのSAD per pixelが40.0を超えたら? ユーザーがアプリを切り替えた可能性大。即座にフレームを抽出
  • バウンスバック切断:Visionのオプティカルフローで動き方向を検出し、累積変位が-50px未満(ユーザーが上にスクロール中)なら、一番上に到達したと判断して以降のフレームをすべて破棄。ただしこの閾値はきつく設定しすぎるとダメ——最初は-10に設定したら、指の微細な震えで切断がトリガーされ、録画が半分になりました

第1.5パス(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のバイアスが含まれているためです(vImageの要件)。二乗後に平均すると高周波エネルギーになります。値が大きいほど鮮明なフレームです。

Laplacian分散ではなくLaplacianエネルギーを使う理由は? スクリーンショットの場面では両手法で選ばれるフレームはほぼ同じですが、前者の計算がよりシンプルだからです。vImageのvDSP_vsq + vDSP_meanv、2行で完了します。

このパスの解像度上限は720×1280で十分です。現実的な理由があります。最も鮮明なフレームはユーザーが指を離した瞬間で、スワイプ中のフレームはモーションブラーがかかりがちです。Laplacianエネルギーはモーションブラーに非常に敏感で、差異は通常2–3倍あります。


コアアルゴリズム:テンプレートマッチングエンジン

エンジン全体で最もコード量が多い部分(単一ファイルで3,000行以上)であり、最も長くバグに苦しめられた部分です。

逆方向マッチング:なぜ新フレームから旧フレームを探すのか?

最初のバージョンは「順方向マッチング」でした——旧フレームの下部からテンプレートを抽出し、新フレーム内で検索する。LINEのチャット画面で見事に失敗しました。旧フレームの下部に「わかった」メッセージが1つあったのに、新フレームには「わかった」メッセージが3つ現れ、マッチングが最も上のものを捉えてoffsetが200ピクセルもずれました。

その後、発想を転換しました。逆方向マッチング(Reverse Matching)。新フレーム(img2)の上部からテンプレートを抽出し、旧フレーム(img1)の下部を検索します。

なぜ逆方向が良いのか? 新フレーム上部は「スクロールされて入ってきたばかりの新内容」で、この内容は旧フレームに必ず存在し、1回だけ存在する(旧フレームの中下部に)。逆に旧フレーム下部の「消えようとしている旧内容」は新フレームでは少しだけ見えるか、すでに新内容に押し出されて画面外かもしれません。

逆方向マッチング図

検索時はステータスバー領域(上部約250px)と下部タブバー領域(約350px)を自動的にスキップします。しかしここにも落とし穴があります——アプリによっては下部ナビゲーションバーの高さが違い、キーボードがあるページもあれば、フローティングボタンがあるページもあります。

そこで動的フッター検出を追加しました。具体的な方法:両フレームの下部から1行ずつ上にスキャンし、各行の中央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つのテンプレート投票で「似ているもの」に対抗

単一のテンプレートブロックは誤マッチングされやすい。ECサイトの商品リストページを想像してください——各商品カードのレイアウトがほぼ同じで、単一テンプレートが前後の商品にマッチしてしまうことがあります。

エンジンは6つのテンプレートブロック(各100px高さ、新フレーム上部から間隔を置いて配置)を同時に抽出し、それぞれ独立して旧フレームで最適マッチング位置を検索し、6つのoffset値を得ます。そして6つのoffsetをクラスタリング(許容誤差5px)して、最大クラスターの中央値を取ります。

6つのうち5つがoffset ≈ 320を指し、1つが800を指していたら、800はほぼ確実に誤マッチングなので捨てればOK。

早期終了の最適化もあります。最初の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はピクセルの絶対値を比較するのではなく、「その領域の明暗パターンが同じか」を比較します。

しかし旧フレーム全体をピクセル単位でスライドして検索するのは遅すぎます。エンジンはピラミッド3段階検索を使用:

段階ステップ検索ウィンドウ候補数目的
Level 1 (Pre)12px全範囲~125大まかな位置特定
Level 2 (Coarse)4px±32px~16行レベルの精度
Level 3 (Fine)1px±4px9ピクセルレベルの微調整

共有すべきデータがあります。Level 1のステップ12は候補位置の1/12しか検索しないことを意味し、ピクセル単位検索と比較して単一テンプレートのマッチング速度が約12倍向上します。3段階を合わせた総計算量は全範囲ピクセル単位検索の約1/8です。

候補が64を超える場合(通常Level 1の全範囲検索で発生)、GCD並列計算(DispatchQueue.concurrentPerform)を自動的に有効化し、検索タスクを複数CPUコアに分散します。ここで落とし穴にハマりました。最初はNSLockで共有bestScore変数を保護していたのですが、6コアでのロック競合がかえって速度を低下させていました。スレッドごとのローカル最良値を記録して最後にマージする方式に変更し、ロック競合をO(n)からO(1)に削減しました。

検索ウィンドウ:歴史に案内させよう

毎回旧フレームの全利用可能領域を検索すると、遅いだけでなく誤マッチングも起きやすい(ページ上部と下部に似たレイアウトがあるかもしれない)。

エンジンは2種類の「事前情報」を使って検索ウィンドウを狭めます:

  • 時間的事前情報:前フレームペアのoffsetが300pxなら、今回も似たようなもの——ユーザーのスクロール速度は急に変化しません。検索ウィンドウを300 ± 120pxに縮小
  • Vision事前情報:最初のフレームペアで履歴データがない場合、Apple Visionの画像整列APIで大まかな推定値を得て、ウィンドウを推定値 ± 180pxに縮小

狭いウィンドウで少なくとも3つの一致するマッチングが見つからない場合(事前情報が外れたという意味)、自動的に全範囲検索にフォールバック。

この「少なくとも3つ一致」という条件も試行錯誤の末に得たものです。最初は「少なくとも1つ」でしたが、Vision事前情報が大きく外れた時(まれに500pxずれる)、狭いウィンドウ内の誤マッチングがたまたま有効な結果として通ってしまいました。3つに変更後、誤マッチングが通るには3つの独立したテンプレートを同時に騙す必要があり、確率的にほぼ不可能になりました。

通常のマッチングがうまくいかない時:3段階フォールバック

実際の環境は実験室よりはるかに過酷です。フローティングボタン、半透明オーバーレイ、再生中の動画、点滅するカーソル——これらがNCCスコアを引き下げます。

エンジンは3段階フォールバック戦略を実装しています。実際のデータで各段階の発動頻度を示します:

第1段階:強マッチング(~80%のフレームペア)。 大半のテンプレートのNCC ≥ 0.6で、同じoffsetを指す。最も一般的なケース。

第2段階:ソフトマッチング(~12%のフレームペア)。 最適テンプレートのNCCが0.45~0.6——フローティングUIが一部領域を遮った可能性。この時、全重複領域のSAD(全ピクセルの輝度差の合計)で二次検証。SAD per pixel ≤ 26.0なら受理、そうでなければ却下。

第3段階:1D Profile NCC(~5%のフレームペア)。 2次元画像を「平らにして」1行の平均値曲線に——各行の全ピクセルを平均して1次元配列を得る。2つの1次元曲線に相互相関を実行。約100倍速いが、偽陽性リスクが高い。

全失敗(~3%のフレームペア)? このフレームはシーン切り替わりまたは重複ゼロの可能性が高い。結合せずに末尾に追加。

第2段階のSAD検証は繊細です。基本閾値はピクセルあたり25.0ですが、重複比率が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をさらに微調整する必要があるのか? これを理解するのに3日かかりました。NCCは最良値付近で非常に「平坦」です——offsetが2px違ってもNCCは0.003しか変わらないことがある。しかしSADは±1pxの変位に極めて敏感で、1pxの差でSADが3–5単位変化します。両者が補完し合います。NCCが大まかな位置決めを担当し、SADがピクセルレベルの校正を担当します。テキストを結合する場合、2pxの差は「1行のテキストが真っ二つに切れる」のと「完璧な整列」の違いです。

継ぎ目線:文字を真っ二つに切るな

offsetを見つけました。2フレームに重複領域があります。どこで「切る」か?

最も直感的なのは真ん中で切ること。しかし真ん中に大きなテキスト行があったら、上半分と下半分が揃わず、一目で違和感があります。

エンジンの方法:重複領域内の各行のピクセル差異を計算し、スライディングウィンドウで差異の合計が最小の位置を見つけます。ウィンドウ高さは適応的で——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のみ微調整。3段階を合わせた有効計算量は全範囲ブルートフォース検索の約1/8です。


無視できないエンジニアリングのディテール

20,000ピクセルの長い画像は一度に読み込めない

30フレームから結合した長い画像は1,170 × 24,000ピクセルになることがあります。フル解像度RGBAのメモリ占有は112MB(1,170 × 24,000 × 4バイト)——iPhoneには多すぎます。

StreamingStitchingPlannerはストリーミング設計です。マッチング中にメモリにあるのは「現在のフレーム + 前のフレーム」のみ。結合結果はPieceの集合(元画像インデックス + クロップ領域 + 対象位置)として記録され、最終レンダリング時にタイル単位でデコード・描画します。

スクロールバー除去アルゴリズム

結合された長い画像の右側にはほぼ必ずスクロールバーが残ります。エンジンは勾配投影法で自動除去します:

右端の幅3%の帯を抽出 → 水平勾配の累積計算 → スクロールバーの左右の端が2つの勾配ピークを形成(間隔3–20px)→ 行ごとにスクロールバーの実際の位置を確認 → 左側の隣接ピクセルで上書き。下部の水平スクロールバーも同様に処理。

アルゴリズムが失敗した時のためのフォールバックUI

どんなに良いアルゴリズムでも失敗することがあります。ScrollShotはすべての継ぎ目線にインタラクティブな微調整を提供——ユーザーは上下にドラッグして結合位置を調整し、リアルタイムプレビューを確認できます。内部のFineTuneGeometryEngineが幾何学的制約を常に有効に保ちます(あるピースを別のピースの上にドラッグすることはできません)。プレビューは低解像度で高速レンダリング、書き出し時は自動的に元解像度に切り替わります。この制御性は手動結合モード(Manual Stitch)のコアインタラクションでもあります。


最後に一言

このエンジンを作った最大の感想:結合アルゴリズム自体は難しくない。難しいのは、あらゆる「理不尽な」実際のページでも動作させることです。

LINEのチャット背景はどれも似ている、楽天の商品リストが無限に続く、設定アプリには広い純白領域がある、YouTubeの動画サムネイルが動いている……どれも教科書にも論文にも載っていないエッジケースです。

特に印象深いバグがあります。iOS設定ページを収録した時、NCCのoffsetが毎回約40pxずれていました。長くデバッグした結果、設定ページ上部に大きな純白領域があり、テンプレートが純白領域にかかるとNCCがどの白い位置でも1.0に近いスコアを出し、区別がつかないことが原因でした。解決策はテンプレートの分散が低すぎる場合に自動的にそのテンプレートをスキップすること——純白テンプレートに情報量がないので、使わない方が良い。

もう一つ、さらにひどい話。YouTubeのトップページを収録した時、動画サムネイルに動いている猫がいました。サムネイル領域が2フレーム間で全く異なり、NCCが0.3まで落ちました。こういうケースはアルゴリズムだけでは救えません。最終的に6テンプレート投票メカニズムが救いました——他の5つのテンプレートは静的領域から抽出され、投票結果は依然として正しかったです。

これらの問題を解決するのは、よりエレガントな数学公式ではなく、一つ一つの「とりあえずこうしておく」というエンジニアリングの工夫です。似たようなことをしている方の参考になれば幸いです。


あわせて読みたい

iOS 長いスクリーンショットスクリーンショット結合アルゴリズムテンプレートマッチングNCCScrollShotエンジン画面収録結合
ブログへ戻る