0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

動画をアップロードせずにAIで透かしを消す:MI-GAN × ONNX Runtime Web × WebGPUによるブラウザ内動画修復

0
Posted at

動画編集サービスにおいて、ウォーターマーク(透かし)やロゴ、不要な小物体の除去は需要の高い機能です。

一般的な実装では、動画をサーバーへアップロードし、GPUでフレームごとに修復した後、再エンコードしたファイルをユーザーへ返します。しかし、この方式にはアップロード時間、プライバシー、GPUコスト、処理待ちなどの課題があります。

そこで、オープンソースのブラウザ動画編集ツール Timeline Studio に、動画を外部へ送信せず、ブラウザ内だけで動作するAI透かし除去機能を実装しました。

この記事では、MI-GAN、ONNX Runtime Web、WebGPU、Web Worker、Canvas、FFmpeg WASMを組み合わせ、画像だけでなく動画をフレーム単位で修復するまでに解決した技術的なポイントを紹介します。

実装した機能

今回の実装は、単一画像に対するモデルのデモではなく、動画編集ソフトの機能として利用できることを目標にしました。

  • 画像と動画の透かし除去
  • 複数の修復領域
  • 領域ごとの開始時刻と終了時刻
  • 動く透かしに対応する位置キーフレーム
  • WebGPUによるローカル推論
  • 修復前後を比較するドラッグ可能な境界線
  • フレーム列からの動画再生成
  • 元の音声トラックの保持
  • 処理のキャンセル
  • 結果を編集素材として追加
  • デスクトップとモバイルへの対応

技術スタック

主に次の技術を使用しています。

  • React / Vite
  • MI-GAN
  • ONNX Runtime Web
  • WebGPU
  • Web Worker
  • HTMLVideoElement
  • Canvas / ImageBitmap
  • Blob / Object URL
  • FFmpeg WASM

ローカルで試す場合は、次のコマンドで起動できます。

git clone https://github.com/MartinDelophy/ai-video-editor.git
cd ai-video-editor
npm install
npm run dev

WebGPUを利用するため、新しいバージョンのChromeまたはEdgeを推奨します。

なぜMI-GANを選んだのか

透かし除去は単純なぼかし処理ではありません。透かしに隠された部分を、周囲の画素から推定して生成するImage Inpaintingの問題です。

高品質なInpaintingモデルの中には、ブラウザで利用するには重すぎるものもあります。MI-GANは比較的軽量で、矩形マスクによる局所修復が速く、ONNX Runtime WebとWebGPUで動作させやすい点が今回の用途に合っていました。

モデル推論はWeb Workerへ分離しています。メインスレッドはReactの描画、領域編集、タイムライン操作を担当し、Workerは次の処理を担当します。

  1. ONNXモデルの読み込み
  2. WebGPU Sessionの初期化
  3. 入力画像とマスクのTensor化
  4. MI-GAN推論
  5. 出力画素の変換
  6. 修復結果のBlob生成

これにより、モデル実行中も編集画面の操作性を維持できます。

修復領域は相対座標で保存する

ユーザーがプレビュー上で選択した領域は、画面ピクセルではなく0〜1の相対座標で保持します。

const selection = {
  x: 0.78,
  y: 0.88,
  width: 0.20,
  height: 0.09,
};

推論時に、元フレームの解像度へ変換します。

const pixelRegion = {
  x: Math.round(selection.x * frameWidth),
  y: Math.round(selection.y * frameHeight),
  width: Math.round(selection.width * frameWidth),
  height: Math.round(selection.height * frameHeight),
};

相対座標にすることで、プレビューの拡大率、端末サイズ、動画のアスペクト比に依存せず、同じ領域設定を推論と書き出しで共有できます。

動画では領域ごとに時間範囲が必要

動画の透かしは常に表示されるとは限りません。最初の数秒だけ表示される場合や、途中で位置が変わる場合があります。

そこで、各修復領域に独立した時間範囲を持たせています。

const repairRegion = {
  id: 'repair-region-1',
  start: 0,
  end: 4,
  selection: {
    x: 0.78,
    y: 0.88,
    width: 0.20,
    height: 0.09,
  },
  keyframes: [],
};

フレームを処理するときは、現在時刻に有効な領域だけを選びます。

const activeRegions = repairRegions.filter((region) => {
  return (
    timelineTime >= region.start &&
    timelineTime <= region.end
  );
});

有効な領域がないフレームはMI-GANを実行せず、元フレームをそのまま保持します。これにより、不要な推論を減らせます。

複数領域を順番に修復する

1つの動画に、サービスロゴ、ユーザー名、AI生成ラベルなど、複数の透かしが存在することがあります。

同じフレームで複数領域が有効な場合は、前の修復結果を次の入力として順番に処理します。

let bitmap = originalBitmap;

for (const region of activeRegions) {
  const result = await repairMiganFrame({
    bitmap,
    selection: region.selection,
    signal,
  });

  bitmap.close();
  bitmap = await createImageBitmap(result.blob);
}

これにより、1フレーム内のすべての対象領域を修復した最終画像を生成できます。

動く透かしは位置キーフレームで追従する

時間範囲だけでは、移動する透かしに対応できません。そのため、領域には位置キーフレームも記録できます。

const keyframe = {
  time: 2.5,
  selection: {
    x: 0.12,
    y: 0.82,
    width: 0.20,
    height: 0.08,
  },
};

2つのキーフレームの間では、位置とサイズを線形補間します。

function interpolateSelection(before, after, time) {
  const ratio =
    (time - before.time) /
    Math.max(0.001, after.time - before.time);

  return Object.fromEntries(
    ['x', 'y', 'width', 'height'].map((key) => [
      key,
      before.selection[key] +
        (after.selection[key] - before.selection[key]) * ratio,
    ]),
  );
}

ユーザーはすべてのフレームで領域を調整する必要がなく、いくつかの代表位置を記録するだけで移動経路を作成できます。

表示用動画と計算用動画を分離する

最初の実装では、画面に表示しているvideo要素をそのままフレーム抽出にも使用していました。

video.currentTime = targetTime;

しかし、この方法では左側の元動画が先に次のフレームへ移動し、右側の修復結果はモデル推論が終わるまで前のフレームに残ります。比較線の左右が別の時刻を表示する問題が発生しました。

そこで、表示用動画と計算用動画を分離しました。

  • 非表示のvideo要素: シーク、デコード、フレーム抽出、推論入力
  • 表示用のvideo要素: 完了したフレームの表示だけを担当

計算用動画を目的時刻へ移動し、シーク完了後にImageBitmapを作成します。

await seekVideoFrame(hiddenVideo, sourceTime, signal);
const bitmap = await createImageBitmap(hiddenVideo);

表示側は、修復結果が完成するまで次のフレームへ進みません。これにより、元フレームと修復フレームを同じ時刻で比較できます。

比較スライダーの実装

修復前後の画像は横に並べず、同じ位置に重ねています。修復後のレイヤーだけをclip-pathで切り取ります。

.repair-compare-result {
  position: absolute;
  inset: 0;
  clip-path: inset(
    0
    0
    0
    var(--compare-position)
  );
}

この方式なら、元画像と結果画像のサイズ、位置、ズーム、アスペクト比を完全に一致させたまま、境界線を左右へ動かせます。

動画処理完了後は、元動画と修復動画のcurrentTimeplaybackRate、再生、停止も同期させています。

画面・再生ヘッド・進捗を同期する

動画処理には複数の進捗があります。

  • モデルのダウンロード
  • WebGPUの初期化
  • 1フレーム内の推論
  • 完了したフレーム数
  • PNGのデコード
  • 最終動画のエンコード

これらを1つのパーセントへ単純に合成すると、進捗バーだけが先に進み、画面が前のフレームに残ることがあります。

今回の実装では、UI上の動画進捗を「表示まで完了したフレーム」に統一しました。

await commitVisibleFrame({
  blob: resultBlob,
  index,
  time: timelineTime,
});

reportCompletedFrame(index);

修復対象外のフレームも、進捗には含める必要があります。

if (!activeRegions.length) {
  const originalFrame = await captureOriginalFrame(hiddenVideo);

  await commitVisibleFrame({
    blob: originalFrame,
    index,
    time: timelineTime,
    repaired: false,
  });

  reportCompletedFrame(index);
  return originalFrame;
}

これで、左右の画像、現在時刻、再生ヘッド、完了率が同じフレームを基準に更新されます。

Blobを表示する前に画像デコードを待つ

モデル出力からPNG Blobを生成し、Object URLをimg.srcへ設定しても、ブラウザの画像デコードが完了したとは限りません。

高解像度フレームでは、進捗と再生ヘッドが更新された後も、右側に前のフレームが短時間残ることがありました。

そのため、新しいフレームをUIへ反映する前にImage.decode()を待ちます。

const url = URL.createObjectURL(frameBlob);
const image = new Image();
image.src = url;

try {
  await image.decode();
} catch {
  // decode非対応環境ではObject URLの通常読み込みへフォールバック
}

新フレームを表示した次の描画フレームで、古いObject URLを解放します。

const previousUrl = previewRef.current?.url;

setPreview({
  blob: frameBlob,
  url,
  time: timelineTime,
});

await new Promise((resolve) => {
  requestAnimationFrame(resolve);
});

if (previousUrl) {
  URL.revokeObjectURL(previousUrl);
}

この順序により、フレーム切り替え時の点滅とメモリリークの両方を抑えられます。

動画の再エンコードと音声保持

すべてのフレーム処理が終わったら、PNGフレーム列から動画を再生成します。

処理は次の段階に分けています。

  1. 動画フレームの準備
  2. MI-GANモデルの読み込み
  3. WebGPUの初期化
  4. フレーム単位の修復
  5. 動画エンコーダーの準備
  6. 修復済み動画の合成
  7. 元音声の結合
  8. 新しい編集素材の生成

AI推論の完了率と動画エンコードの進捗を分離することで、ユーザーは現在どの処理を待っているのか理解できます。

生成したMP4 Blobは新しい素材として追加し、ユーザーが「動画に適用」を選ぶまでは元のクリップを変更しません。

キャンセル処理

長い動画の処理では、本当に停止できるキャンセル機能が必要です。ダイアログを閉じるだけでは不十分です。

同じAbortSignalを、フレーム抽出、モデル推論、画像デコード、動画エンコードまで伝播させています。

const controller = new AbortController();

await repairVideo({
  signal: controller.signal,
});

function cancelRepair() {
  controller.abort();
}

キャンセル時には、残りのフレーム処理を停止し、Workerリクエスト、ImageBitmap、video要素、Object URL、エンコーダーの一時リソースを解放します。

途中結果を自動的に適用したり、不完全な動画をダウンロードしたりすることはありません。

現在の制約

ブラウザ内のMI-GAN修復は、次のような用途に向いています。

  • 小さなサービスロゴ
  • ユーザー名
  • AI生成ラベル
  • 日付や時刻
  • 小面積の文字
  • 静止またはゆっくり移動する透かし

一方、大きな顔の遮蔽、複雑な文字、非常に速く移動する対象、長時間の高解像度動画では、品質やメモリ使用量に課題があります。

今後は、光フローによる追跡、前後フレームの情報融合、時系列一貫性を持つ動画修復モデル、WebCodecsによるエンコードなどを検討できます。

まとめ

ブラウザで動画の透かし除去を製品機能として成立させるには、ONNXモデルを動かすだけでは足りません。

今回、次の課題をまとめて実装しました。

  • MI-GANのWebGPU推論
  • Web Workerによる推論分離
  • 複数領域と独立した時間範囲
  • 動く透かしの位置キーフレーム
  • 非表示動画によるフレーム抽出
  • 修復前後の同期比較
  • 画面、再生ヘッド、進捗の同期
  • PNGデコードとObject URL管理
  • 動画の再エンコードと音声保持
  • 処理のキャンセル

WebGPU、ONNX Runtime Web、WebCodecsなどのブラウザAPIが成熟するにつれ、これまでサーバーGPUが必須だったAI動画処理の一部を、ユーザーのローカル環境へ移せるようになっています。

実装はGitHubで公開しています。

改善案や不具合があれば、IssueやPull Requestを歓迎します。

0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?