Tensorflow.jsで人物セグメンテーションを使いWebカメラ映像から背景を透過する

こんにちは。GMOグローバルサイン・ホールディングスの城戸(@sutobu000)です。
普段はビジュアルシステムの研究開発、インハウスの映像制作・配信など担当しております。

今回は「TensorFlow」という機械学習とディープラーニングのためのOSSライブラリを使って、GoogleMeetのような背景合成を試してみましょう。

TensorFlow.jsとは

TensorFlow.jsは、機械学習ライブラリ「TensorFlow」をJavaScriptで使用可能にしたもの。

ブラウザ上で機械学習モデルを利用することができるようになります。
これによりWebフロントエンド開発者でも機械学習技術を容易に利用することができます。

機械学習モデルには以下のようなものがあります。

  • オブジェクト検出
  • 画像分類・セグメンテーション
  • ジェスチャー認識
  • 顔検出
  • 姿勢ランドマーク検出
  • テキスト分類
  • 言語検出機能

など、多くの用途に合うモデルを活用することができます。

これらの機械学習モデルはTensorFlowに備わっているものもありますが、MediaPipeというマルチモーダル機械学習パイプラインを構築するオープンソースのクロスプラットフォームフレームワークも使用します。

MediaPipeとは

MediaPipeは、オープンソースのクロスプラットフォームフレームワークです。主にビジョン、音声、ジェスチャーなどのマルチモーダルな機械学習パイプラインを構築するために使用されます。

高度なコンピュータビジョンタスクを実行するためのパイプラインを簡単に構築できるように設計されており、ビデオや画像の処理、オブジェクト検出、姿勢推定、手のジェスチャー認識など、主にリアルタイム処理などに特化しています。

TensorFlowもMediaPipeどちらもGoogleが開発しており、MediaPipeの機械学習モデルはGoogleMeetなどにも使われています。

GoogleMeet開発記事:https://research.google/blog/background-features-in-google-meet-powered-by-web-ml/

Body Segmentationを試す

今回はGoogleMeetと同じ人体認証であるBody Segmentationを実行するためにTensorFlow.jsとMediaPipeのモデルを用いてWebカメラの映像から人物以外の背景を透過させるまで試します。

背景透過といえば、グリーンバックのスクリーンを使うクロマキー処理といったものが主流かつ一般的かと思います。

その背景をクロマキー処理のように削除するために、MediaPipeのBody Segmentationのモデルにより、人物を検出した前景とそれ以外の背景とで区別し背景のalphaを0にするといった処理を実行していきます。

環境

静的環境で実行するため特にこれといった環境は用意しません。
今回は私が慣れているSvelteを使って実装していきます。

SvelteKitのSkelton Projectから始めます。

npm create svelte@lastest my-app

Which Svelte app template?
│  Skeleton project (テンプレートはBlankであるSkeltonを選択)
~~
cd my-app
npm install
npm run dev -- --open (ブラウザが開いて、Welcome to SvelteKitが表示されたらOK)

Canvas用意

  • src/routes
    • +page.svelte (これを編集)

以降全て+page.svelteに記載

<!-- Webカメラ映像取得用 -->
<video id="video" width="1920" height="1080">
  <track kind="captions" />
</video>
<!-- 出力用キャンバス -->
<canvas id="output"></canvas>

<style>
  video {
  display: none;
  }
  canvas {
  width: 640px;
  height: auto;
  aspect-ratio: 16/9;
  }
</style>

Webカメラ映像をCanvasに描画

Webカメラの映像を使用するため、その映像を取得してCanvasに試しに描画しています。

<script lang="ts">
  import { onMount } from 'svelte';

  let video: HTMLVideoElement;
  let canvas: HTMLCanvasElement;
  let ctx: CanvasRenderingContext2D;

  onMount(async () => {
    // DOM要素の取得
    video = document.getElementById('video') as HTMLVideoElement;
    canvas = document.getElementById('output') as HTMLCanvasElement;
    ctx = canvas.getContext('2d');

    // カメラからの映像ストリームを取得し、ビデオ要素にセット
    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: 1920 },
        height: { ideal: 1080 }
      }
    });
    video.srcObject = stream;
    video.play();

    // ビデオのメタデータが読み込まれたら、キャンバスのサイズを設定し初期化
    video.onloadedmetadata = () => {
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;

      processFrame();
    };
  });

  async function processFrame() {
    if (!video || !ctx) return;

      // ビデオフレームをCanvasに描画
    ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

    // 次のフレームの処理
    requestAnimationFrame(processFrame);
  }
</script>

TensorFlowとMediaPipeを追加

こちらを参考に進めます↓
https://github.com/tensorflow/tfjs-models/tree/master/body-segmentation

npm i @tensorflow/tfjs-core @tensorflow/tfjs-converter @tensorflow/tfjs-backend-webgl @mediapipe/selfie_segmentation @tensorflow-models/body-segmentation
  • @tensorflow/tfjs-core: TensorFlow.jsのコアライブラリ
  • @tensorflow/tfjs-converter: TensorFlowモデルをフロントで使用できるように変換
  • @tensorflow/tfjs-backend-webgl: GPUを利用して高速な数値計算を行う
  • @mediapipe/selfie_segmentation: MediaPipeのモデル
  • @tensorflow-models/body-segmentation: TensorFlow.js用のモデル

参照: https://github.com/tensorflow/tfjs

TensorFlowとMediaPipeの処理追加

  import { onMount } from 'svelte';

+ import '@mediapipe/selfie_segmentation';
+ import '@tensorflow/tfjs-core';
+ import '@tensorflow/tfjs-backend-webgl';
+ import * as bodySegmentation from '@tensorflow-models/body-segmentation';


  let video: HTMLVideoElement;
  let canvas: HTMLCanvasElement;
  let ctx: CanvasRenderingContext2D;
+ let segmenter: any;

TensorFlow.jsとMediaPipeをimportしてsegmenterも用意。


  onMount(async () => {
    // DOM要素の取得
    video = document.getElementById('video') as HTMLVideoElement;
    canvas = document.getElementById('output') as HTMLCanvasElement;
    ctx = canvas.getContext('2d');

    // カメラからの映像ストリームを取得し、ビデオ要素にセット
    const stream = await navigator.mediaDevices.getUserMedia({
      video: {
        width: { ideal: 1920 },
        height: { ideal: 1080 }
      }
    });
    video.srcObject = stream;
    video.play();

    // ビデオのメタデータが読み込まれたら、キャンバスのサイズを設定し初期化
    video.onloadedmetadata = () => {
      canvas.width = video.videoWidth;
      canvas.height = video.videoHeight;

-     processFrame();
+     init();
    };

+   async function init() {
+     // ボディセグメンテーションモデルの作成
+     await createBodySegmentation();
+     // フレーム処理の開始
+     processFrame();
+   }

モデルを作成してから processFrame() を実行するために追加と一部書き換え。


  async function processFrame() {
    if (!video || !ctx) return;

+   // 人物のセグメンテーションを実行
+   const segmentation = await segmentPeople();

+   const coloredPartImage = await bodySegmentation.toBinaryMask(segmentation);
+   const opacity = 0.7;
+   const flipHorizontal = false;
+   const maskBlurAmount = 0;
+   await bodySegmentation.drawMask(canvas, video, coloredPartImage, opacity, maskBlurAmount, flipHorizontal);

-   // ビデオフレームをCanvasに描画
-   ctx.drawImage(video, 0, 0, canvas.width, canvas.height);

    // 次のフレームの処理
    requestAnimationFrame(processFrame);
  }

+ async function createBodySegmentation() {
+   // ボディセグメンテーションモデルの設定
+   const model = bodySegmentation.SupportedModels.MediaPipeSelfieSegmentation;
+   const segmenterConfig = {
+     runtime: 'mediapipe' as const,
+     solutionPath: '/node_modules/@mediapipe/selfie_segmentation',
+     modelType: 'general'
+   };
+   // セグメンターの作成
+   segmenter = await bodySegmentation.createSegmenter(model, segmenterConfig);
+ }

+ async function segmentPeople() {
+   // ビデオフレームから人物のセグメンテーションを実行
+   const estimationConfig = { flipHorizontal: false };
+   return await segmenter.segmentPeople(video, estimationConfig);
+ }
});

processFrame() は Webカメラからの映像をただ描画するだけではなく、人物のセグメンテーションを実行するために bodySegmentation.toBinaryMask(segmentation); を追加し実行しています。

その後の await bodySegmentation.drawMask() では引数にoptionを追加することで背景マスクの透過具合やマスクする境界のブラー数値なども指定ができます。

createBodySegmentation() では人物のセグメンテーション、ボディセグメンテーションをimportしたMediaPipeから指定し、セグメンターを作成を行なっています。

segmentPeople() は作成したセグメンターを使ってWebカメラの映像から人物のセグメンテーションを実行します。

実行すると以下の画像のように人物の前景とそれ以外の背景で区分けできているのが確認できます。

背景のアルファを0にする

ここまでで前景と背景を分けて処理することができました。
opacityで背景マスクの透過具合を指定できますが、背景自体を透過するものでは無いので、次はこの背景のカラー情報からアルファ値を0にします。

    // 人物のセグメンテーションを実行
    const segmentation = await segmentPeople();

+   const foregroundColor = {r: 12, g: 12, b: 12, a: 12};
+   const backgroundColor = {r: 16, g: 16, b: 16, a: 16};

-   const coloredPartImage = await bodySegmentation.toBinaryMask(segmentation);
+   const backgroundDarkeningMask = await bodySegmentation.toBinaryMask(segmentation, foregroundColor, backgroundColor);

-   const opacity = 0.7;
-   const flipHorizontal = false;
-   const maskBlurAmount = 0;
-   await bodySegmentation.drawMask(canvas, video, coloredPartImage, opacity, maskBlurAmount, flipHorizontal);
+   const opacity = 1;
+   const maskBlurAmount = 10;
+   const flipHorizontal = false;
+   await bodySegmentation.drawMask(canvas, video, backgroundDarkeningMask, opacity, maskBlurAmount, flipHorizontal);

+   const mask = backgroundDarkeningMask;
+   const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

+   // マスクを使用して背景ピクセルを透明に設定
+   for (let i = 0; i < imageData.data.length; i += 4) {
+     const maskValue = mask.data[i];
+     if (maskValue > 12) { // 閾値を調整して結果を改善できます
+       imageData.data[i + 3] = 0; // アルファチャンネルを0に設定して透明にする
+     }
+   }

+   // 処理後の画像データをキャンバスに描画
+   ctx.putImageData(imageData, 0, 0);

    // 次のアニメーションフレームで再度処理を実行
    requestAnimationFrame(processFrame);

背景と前景を foregroundColorbackgroundColor で色をベタ塗りします。
このベタ塗りの色で区分けすることで指定の色だけアルファ値を0に指定するという処理を行う準備ができました。

その色分けしたmask情報はImageDataとしてピクセルデータの配列として出力します。
Webカメラからの映像も同様に getImageData() を使いピクセルデータを配列で出力します。
これらは同じ映像から出力された配列で、ピクセル位置は互いに一致しています。

その後for文ではmask情報のピクセル配列から閾値を参照して背景の色と一致すれば、Webカメラからの映像のピクセル配列を同様に参照してアルファを0にする処理を行っています。

実行すると以下の画像のように前景のみ描画され、背景は透過されています。

これでアルファを0にして背景を抜いて前景だけを表示させることができました。

しかし、上の画像ではアルファが0になっているか分かりづらいため、背景のbackgroundをcssでグラデーションしてみると以下の画像のようになります。

これでアルファを0にして背景を抜いて前景だけを表示させられたことがわかりやすくなったと思います。

まとめ

TensorFlow.jsでGoogleMeetのようなWebカメラの映像から背景を透過する、人物セグメンテーションの技術について紹介でした。

映像の背景透過をリアルタイム処理する技術自体は、ZoomやXSplitなどのネイティブアプリケーションでは珍しくはありませんでしたが、Webフロントから実行できるようになったのは大きな変化だったと思います。
それを実現したのがMediaPipeによるパイプライン構築でありました。

GoogleMeetの背景ぼかし機能も今回の背景透過機能と同じモデルにある、 bodySegmentation.drawBokehEffect() で簡単に実行できました。
参考: https://github.com/tensorflow/tfjs-models/tree/master/body-segmentation

リモート会議などで多用するGoogleMeetの背景壁紙差し替えや背景ぼかし機能なども自身で実装でき、今後の開発などにも活用ができるのはとても有益だと改めて感じました。

今回は人物セグメンテーションでしたが、それ以外にも顔認証など手軽にできるだろうモデルはあるので、ぜひ試してみてください。

最後までお読みいただきありがとうございました。