0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ブラウザだけで動画の顔差し替えを動かす:WebGPU推論パイプラインの実装で学んだこと

0
Posted at

動画の顔差し替え処理は、クラウド GPU 上で実行するものだと思われがちです。動画をアップロードし、サーバー側でフレームごとに処理して、完成したファイルをダウンロードする構成が一般的でした。

しかし、WebGPU、WebCodecs、Web Worker、ONNX Runtime Web が実用段階に入り、これまでサーバー側に置かれていた画像処理の一部をブラウザ内で完結できるようになっています。

実際に実装してみると、モデル推論は課題の一部にすぎませんでした。フレームのデータ変換、CPU と GPU 間の転送、初期化時のメモリピーク、複数人物の追跡、マスクの後処理、リソース解放、キャンセル処理まで含めて設計しなければ、継続的に使える機能にはなりません。

本記事では UI や機能紹介ではなく、ブラウザローカルの動画顔差し替えパイプラインで直面した実装上の論点を整理します。

関連する実装は、オープンソースの動画編集プロジェクト Timeline Studio に統合しています。

責任ある利用について

本記事はブラウザ上の画像処理と機械学習推論を扱う技術資料であり、法的助言ではありません。顔画像・映像は、本人および権利者から利用目的を含む明確な許諾を得た素材だけを使用してください。違法・権利侵害・虚偽・誤認を招くコンテンツ、なりすまし、嫌がらせ、詐欺などへの利用を禁止します。生成物を実写または事実の記録であるかのように提示せず、必要に応じて AI による加工であることを明示してください。利用者は適用される法令、契約、素材ライセンスおよび掲載先の規約を確認し、自らの責任で利用する必要があります。

1. まず、1 フレームのデータフローを把握する

動画の 1 フレームは、デコードされてから再び動画へ書き込まれるまで、おおむね次の経路を通ります。

VideoFrame / Canvas
    ↓
RGBA Uint8ClampedArray
    ↓
NCHW Float32Array
    ↓
ONNX Tensor
    ↓
生成 RGB と Alpha Mask
    ↓
Canvas 合成
    ↓
WebP 中間フレーム
    ↓
WebM エンコード

このパイプラインでは、矢印の部分にも時間とメモリが必要です。

SCRFD と MobileFaceSwap の入力は NCHW レイアウトですが、Canvas から取得する画素は RGBA が交互に並んでいます。そのため、推論前に 3 つの色チャンネルを連続した配列へ展開します。

const plane = width * height;
const tensor = new Float32Array(plane * 3);

for (let i = 0; i < plane; i += 1) {
  tensor[i] = normalize(rgba[i * 4]);
  tensor[plane + i] = normalize(rgba[i * 4 + 1]);
  tensor[plane * 2 + i] = normalize(rgba[i * 4 + 2]);
}

一度だけなら単純な処理ですが、動画ではフレームごとに繰り返されます。Canvas の読み出し、Float32Array の構築、スレッド間転送が重なると、CPU 使用率とメモリ帯域に無視できない負荷がかかります。

モデルの入力サイズは精度だけで決めず、前処理とデータ転送のコストも含めて選ぶ必要があります。

2. 1080p の画面全体を生成モデルへ渡さない

顔検出と顔生成は目的が異なるため、同じ解像度で処理する必要はありません。

処理段階 入力サイズ 目的
SCRFD による検出 640×640 画面全体から顔とランドマークを検出する
ID 特徴量の抽出 112×112 姿勢の影響を受けにくいソース ID を抽出する
顔の生成 224×224 対象の姿勢に合わせた顔を生成する
オプティカルフロー 長辺 720px 以下 5 点ランドマークを低コストで追跡する
最終合成 元動画の解像度 背景と元画像のディテールを維持する

検出では画面全体を確認する必要があるため、640×640 を使って小さな顔の再現率を確保します。一方、生成で必要なのはアライメント済みの顔領域だけなので、224×224 に固定できます。

1080p フレーム全体を生成ネットワークへ入力すると、変化しない背景にも大量の計算を使います。全体で検出し、顔をアライメントし、最後に ROI だけを生成する構成が、ブラウザで実行可能な計算量に抑える鍵です。

3. モデルは並列ダウンロード、WebGPU Session は直列初期化

初回実行時には、モデルファイルの取得と ONNX Session の初期化が必要です。この 2 つには異なる並行処理戦略が適しています。

モデルファイルは互いに依存しないため並列で取得できます。一方、Session の作成には、一般に次の処理が含まれます。

  • ONNX グラフの解析
  • グラフ最適化とオペレーター融合
  • WebGPU Shader の生成
  • Pipeline のコンパイル
  • Weight のアップロード
  • GPU Buffer の割り当て

複数モデルの Session を同時に作ると、短時間に多数の Shader コンパイルと GPU Buffer の確保が発生します。初期化の揺らぎ、GPU メモリのピーク上昇、端末によっては Device Lost の原因になります。

そこで、ダウンロードだけを並列化し、Session は直列に作成します。

const [
  detectorBuffer,
  identityBuffer,
  conditionerBuffer,
  generatorBuffer,
] = await Promise.all(modelDownloads);

const detector = await createSession(detectorBuffer);
const identity = await createSession(identityBuffer);
const conditioner = await createSession(conditionerBuffer);
const generator = await createSession(generatorBuffer);

ネットワーク帯域は並列に利用しつつ、GPU 初期化時の負荷集中を避けられます。

4. Worker との通信では Buffer をコピーしない

推論や画像処理で UI をブロックしないように、動画フレームや Tensor は Web Worker へ送ります。

ここで ArrayBuffer を transfer list に指定しない場合、ブラウザが構造化クローンを実行することがあります。640×640 の Float32 RGB Tensor は、1 つだけでも約 4.69 MB です。

640 × 640 × 3 × 4 Bytes ≈ 4.69 MB

検出用のアンカーフレームごとにコピーすると、メモリ帯域と GC の負荷が急速に増えます。

所有権を Worker へ移す Transferable を使用します。

worker.postMessage(
  {
    type: "detect",
    pixels: tensor.buffer,
  },
  [tensor.buffer],
);

転送後、元スレッドの Buffer は detached になり、Worker が所有権を引き継ぎます。生成された RGB Tensor と Alpha Mask をメインスレッドへ戻すときも同じ方法を使用できます。

Transferable はモデル推論そのものを高速化する機能ではありません。しかし、スレッド間コピーと寿命の短い大きなオブジェクトを減らせるため、連続的な動画処理では大きな効果があります。

5. オプティカルフローよりも「結果を信用できるか」が重要

すべてのフレームで完全な顔検出を実行すると計算量が増えます。そのため、アンカーフレームで顔を検出し、フレーム間では Lucas–Kanade オプティカルフローを使ってランドマークを伝播します。

ただし、オプティカルフローは局所的な画素移動を推定するだけで、結果が正しいことを保証しません。そこで forward-backward check を行います。

前フレームのランドマークを (p_t) とし、順方向の追跡結果を次のように表します。

$$
p_{t+1}=F(I_t,I_{t+1},p_t)
$$

次に、その点を逆方向へ追跡します。

$$
\hat{p_t}=F(I_{t+1},I_t,p_{t+1})
$$

forward-backward error は次のとおりです。

$$
e_{fb}=\lVert \hat{p_t}-p_t \rVert_2
$$

安定した追跡なら、逆方向の結果は元の位置付近へ戻るはずです。実装では 5 点の平均誤差を計算し、少なくとも 4 点が有効で、平均誤差がしきい値以下の場合のみ採用します。

このチェックにより、次のようなケースを除外できます。

  • 高速な動きによる誤対応
  • 手や物体による顔の遮蔽
  • モーションブラー
  • 人物のフレームアウト
  • 急激な照明変化
  • ランドマークが背景テクスチャへ移動するドリフト

オプティカルフローは短い区間の伝播に限定し、一定間隔で SCRFD を再実行して累積誤差を抑えます。

6. 複数人物の映像では、最高スコアの顔が正解とは限らない

各フレームで検出スコアが最も高い顔を選ぶだけでは、複数人物の映像で対象が切り替わります。途中から入ってきた人物の顔が大きく鮮明なら、その人物のスコアが高くなるためです。

対象の選択を、フレーム単位の検出ではなく時系列マッチングとして扱います。各候補について、次の値を組み合わせます。

  • 直前の対象との中心位置の距離
  • 顔 Bounding Box の面積変化
  • 現在の検出信頼度
  • 初期フレームにおける画面中心からの距離

スコアは概念的に次のように表せます。

$$
S=w_cC-w_dD-w_aA
$$

(C) は検出信頼度、(D) は中心位置の距離、(A) は面積変化、(w_c)、(w_d)、(w_a) は各項目の重みです。

履歴のない初期フレームでは、信頼度が高く、ある程度大きく、画面中央に近い顔を優先します。後続フレームでは、位置とサイズの時間的連続性を重視します。

これは完全な Face Re-identification ではありませんが、「毎フレーム最高スコアを選ぶ」方式より安定します。また、マッチングに確信が持てないフレームでは、別人に効果を適用せず元フレームを維持する方が安全です。

7. 生成後にも、従来型の画像処理が必要

7.1 マスクのモルフォロジー処理

生成器が出力するマスクには、穴、ノイズ、途切れた境界が含まれることがあります。そのまま合成すると、境界がフレームごとにちらつきます。

後処理は次のように構成できます。

元の Mask
  ↓
二値化
  ↓
膨張:局所的な欠損を埋める
  ↓
収縮:外周のノイズを除去する
  ↓
再収縮:合成領域を適度に内側へ寄せる
  ↓
Box Blur:滑らかな Alpha を生成する
  ↓
境界保護 Mask:Crop 境界の直線を消す

膨張と収縮には separable sliding window を利用できます。水平、垂直の順に処理することで、各画素から 2 次元近傍全体を走査する実装より計算量を抑えられます。

半径 (r) の 2 次元モルフォロジー演算を直接実装した場合、計算量はおおむね次のようになります。

$$
O(W \times H \times r^2)
$$

separable sliding window では、次に近い計算量まで削減できます。

$$
O(W \times H)
$$

ニューラルネットワークの後に置かれる従来型アルゴリズムも、すべてのフレームで動く以上、最適化対象です。

7.2 補正範囲を制限した色合わせ

ソース画像と対象動画の色温度が大きく異なる場合、生成された顔と首、額の境界に色差が現れます。

有効なマスク領域で生成結果と対象顔の平均値・標準偏差を計算し、チャンネルごとに補正します。

$$
I'=\frac{\sigma_t}{\sigma_s}(I-\mu_s)+\mu_t
$$

(\mu_s,\sigma_s) は生成顔、(\mu_t,\sigma_t) は対象顔の平均値と標準偏差です。

補正を無制限に適用すると、ノイズを増幅したり、極端な照明条件で不自然な色になったりします。そのため、スケールとシフトを制限します。

const scale = clamp(targetStd / sourceStd, 0.78, 1.22);
const shift = clamp(targetMean - sourceMean * scale, -0.12, 0.12);

さらに、補正後の値で完全に置き換えるのではなく、元の生成結果とブレンドします。肌色の段差を減らしながら、モデルが復元した局所テクスチャを維持できます。

8. ブラウザキャッシュでは、モデル URL がバージョン ID になる

本番環境で次のような URL を使い続けるのは危険です。

repository/resolve/main/model.onnx

URL が変わらなくても、リモートファイルの内容は更新される可能性があります。

  • 同じフロントエンドでも推論結果が変わる
  • ブラウザキャッシュは旧ファイル、配信元は新ファイルになる
  • 回帰が起きても実際のモデルバージョンを特定できない
  • 複数ミラーの同期タイミングがずれる

本番環境では、モデル URL を immutable revision に固定します。

repository/resolve/<immutable-revision>/model.onnx

あわせて、ファイルサイズ、SHA-256、用途、モデルライセンス、入出力 Tensor の定義を記録します。

ダウンロード後にはサイズまたはハッシュを検証し、設定値と一致しない場合は Session を作成しません。CDN のエラーページや不完全なファイルを ONNX モデルとして読み込む事故を防止できます。

9. 1 回の推論時間より、長時間処理のメモリ管理が難しい

動画処理では、複数種類のリソースが同時に存在します。

  • デコード済みフレーム
  • Canvas の画素
  • Float32 入力 Tensor
  • ONNX 出力 Tensor
  • オプティカルフロー用のグレースケール画像
  • 中間フレーム Blob
  • エンコーダーの Buffer

JavaScript の GC だけに任せると、数十フレーム後からメモリ使用量が増え続けることがあります。代表的なリソースは明示的に解放します。

tensor.dispose?.();
bitmap.close();
mat.delete();
input.dispose();
URL.revokeObjectURL(url);
worker.terminate();

ONNX Tensor、ImageBitmap、OpenCV Mat、Object URL は、それぞれ異なるランタイムに管理され、解放方法も統一されていません。

特に OpenCV.js の Mat は WASM Heap を利用します。JavaScript 側の参照がなくなっても、下位のメモリが直ちに解放されるとは限らないため、delete() の明示的な呼び出しが必要です。

10. キャンセルは処理パイプラインの最深部まで伝播させる

進捗ダイアログを閉じるだけでは、タスクをキャンセルしたことになりません。有効なキャンセル処理は、次のすべてへ伝播する必要があります。

  • モデルのダウンロード
  • 動画フレームの読み出し
  • 顔検出
  • オプティカルフロー
  • フレームごとの生成
  • 中間フレームの圧縮
  • 最終動画のエンコード

メインスレッドでは AbortController でタスクを管理し、requestId を付けたキャンセルメッセージを Worker へ送ります。

controller.abort();

worker.postMessage({
  type: "cancel",
  requestId,
});

Worker は、ダウンロードループ、推論の前後、タスク切り替え時にキャンセル状態を確認します。キャンセル後はエンコードを続行せず、不完全な結果を素材ライブラリへ追加しません。

これにより、UI 上では終了したように見えても、バックグラウンドで GPU、CPU、メモリを消費し続ける「見かけ上のキャンセル」を防げます。

11. 再現できるパフォーマンス測定の残し方

「数秒で完了した」という表現だけでは、再現可能な性能情報になりません。ブラウザ AI のベンチマークでは、少なくとも次の条件を記録します。

端末:
GPU:
OS:
ブラウザとバージョン:
WebGPU Adapter:
動画コーデック:
動画解像度:
動画の長さ:
出力 FPS:
検出アンカー FPS:
初回実行か:
モデル初期化時間:
フレーム処理時間:
生成器の累積推論時間:
エンコード時間:
ピークメモリ:

結果は Cold Start と Warm Start に分けます。

Cold Start

モデル取得、サイズまたはハッシュの検証、ONNX Session の作成、Shader コンパイル、ソース ID の抽出、動画処理、エンコードを含みます。主に、モデル配信とデバイス初期化の体験を示します。

Warm Start

モデルはキャッシュ済みで、Session とソース ID の Weight を再利用できる状態とします。動画デコード、アンカー検出、オプティカルフロー、フレーム生成、後処理、動画エンコードだけを計測します。

Warm Start は、同じページで複数のクリップを連続処理する利用状況に近い値です。Cold Start と Warm Start を混在させたり、速い方だけを掲載したりしないことが重要です。

12. 日本向けに公開・運用する際の確認事項

ブラウザローカルで処理する設計は、素材を推論サーバーへ送らずに済むという利点があります。しかし、ローカル処理であること自体が、顔画像や生成物の利用に関する権利・責任を消すわけではありません。

公開または実運用の前に、少なくとも次を確認します。

  • 顔画像・映像について、本人および権利者から対象用途の許諾を得ているか
  • 写真、動画、モデル Weight、サンプルコードの著作権とライセンス条件を満たしているか
  • 個人を識別できる顔画像や顔特徴量を扱う場合、個人情報保護法上の取り扱いを確認したか
  • 生成物が実写、ニュース映像、本人の発言・行動であるとの誤認を招かないか
  • AI 加工であることを、用途と表示媒体に応じた方法で明示しているか
  • 未成年者、著名人、第三者の素材を無断で利用していないか
  • 詐欺、名誉・信用の毀損、嫌がらせ、なりすましなどに転用されない設計になっているか
  • 不適切な処理を止められるキャンセル、削除、通報などの導線があるか

Qiita へ記事を投稿する場合も、技術的な知見を中心にし、宣伝を主目的にしないこと、AI が生成した記述を含めて内容を検証すること、引用・画像・コードの権利を確認することが必要です。本記事では実在人物の顔画像や生成例を掲載せず、処理構成と実装上の知見に対象を限定しています。

まとめ

ブラウザ上の動画顔差し替えは、ONNX モデルを Web ページに置くだけでは成立しません。「動くデモ」から「継続的に使える処理」へ進めるには、少なくとも次の 4 項目を一体として設計する必要があります。

  1. 計算範囲を限定する:必要な解像度と ROI だけをモデルへ入力する
  2. リソースを適切にスケジュールする:並列ダウンロード、直列初期化、ゼロコピーに近い転送を組み合わせる
  3. 結果の信頼性を評価する:双方向オプティカルフロー、時系列マッチング、マスク品質判定で誤適用を防ぐ
  4. ランタイムを管理する:モデルバージョンを固定し、リソースを解放し、実処理まで届くキャンセルを実装する

WebGPU はブラウザへ計算能力を提供しますが、その能力を安定したユーザー体験へ変換するのは周辺の設計です。ここで紹介した考え方は、人物セグメンテーション、超解像、対象追跡、動画スタイル変換など、ほかのブラウザ動画 AI にも応用できます。

実装の詳細や改善案に興味があれば、Timeline Studio の GitHub リポジトリをご覧ください。

参考資料

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?