🧊

JavaScriptでもバーチャル背景する

やってやれないことはなかったです。
ぱっと思いついた2通りのアプローチで実装してみたので、その学びをメモっておきます。

ただ、個人的には別にいらない機能かな・・。

バーチャル背景とは

バーチャル背景 – Zoom ヘルプセンター

たとえばWeb会議のZoom(のデスクトップクライアント)で使える機能で、汚部屋や背景をさくっと隠せて便利!というもの。

JavaScriptでもこういうことやりたいなーと思ったときに、どういうアプローチがあって、どういう仕上がりになるか?をやってみた話です。

案1: クロマキー合成

クロマキー - Wikipedia

TVでおなじみ(ガチャピンが消えてたやつ)の手法。

グリーンバックとかブルーバックとか、とにかくベタ塗りの背景の前に立って撮る。
そして撮ったデータのピクセルの色を判定し、そのベタ塗りの色なら透過にして、背景と合成する。

実装

コードもシンプルで、

  • 事前に抜きたい色を決めておく
  • `canvas`に映像を貼り付ける
  • 指定された色を探して見つかったらそこを透過にする
  • 背景の上に貼り付ける

ってだけ。

そんなコードを使いやすくラップしてライブラリにしたのがこちら。

GitHub - leader22/chromakey: Minimum chroma key processing for your camera stream.

コードはこんな感じで使えるようにした。

import { create } from "chromakey";

(async () => {
  // setup input and output HTMLVideoElement
  const [$in, $out] = document.querySelectorAll("video");
  $in.muted = $in.playsInline = true;
  $out.muted = $out.playsInline = true;

  // get media to process
  const stream = await navigator.mediaDevices.getUserMedia({ video: true });
  $in.srcObject = stream;
  await $in.play();

  // create ChromaKey instance w/ base img, video, or canvas
  const ck = create($in);

  ck.setColor([255, 255, 255]);
  ck.setThreshold(100);

  // your background
  ck.setBackgroundColor([
    Math.random() * 255,
    Math.random() * 255,
    Math.random() * 255,
  ]);
  // ck.setBackgroundMedia();

  // run it
  ck.start();

  // display processed output
  $out.srcObject = ck.destination.captureStream();
  await $out.play();

  // ck.stop();
})();

指定の色かどうかを判定する部分はある程度のしきい値をもうけて調整できるようにしてみた。

`video`を受け取って`canvas`を返すAPIになってるので、`captureStream()`すればWebRTCで飛ばすこともできます。

パフォーマンス

そういうベタ塗り画像の背景を消す用途にはバッチリ使えた。

ただWeb会議に組み込んで使うとなると・・・、そもそもグリーンバックがない!!
家の白っぽい壁だと、照明との兼ね合いで調整がだいぶ難しいし、顔が白飛びしたりする!

という感じで、ちゃんとした設備があるなら使えるけど、そうでないならだいぶ厳しかったのが正直なところ。

デモもあるのでこの厳しさをぜひお試しあれ。

https://leader22.github.io/chromakey

案2: 機械学習

やはり時代はマシンラーニング!
画像データに対して「人物っぽい部分」を判定して、そこを切り抜くアプローチ。

実装

幸いなことにTensorFlowにずばりそれ用の`BodyPix`というモデルがあったのでそれを使ってみる。

tfjs-models/body-pix at master · tensorflow/tfjs-models · GitHub

試したバージョンはこのとおり。

  • tfjs@1.5.2
    • tf.setBackend("wasm")
  • body-pix@2.0.4

コードはこんな感じになる。

(async () => {
  const net = await bodyPix.load();

  // input source
  const $video = document.querySelector("video");
  // output source
  const $destCanvas = document.querySelector("canvas");

  const stream = await navigator.mediaDevices.getUserMedia({ video: true });
  $video.srcObject = stream;

  document.querySelector("button").onclick = async () => {
    // THESE LINES ARE REQUIRED!
    $video.width = $destCanvas.width = $video.videoWidth;
    $video.height = $destCanvas.height = $video.videoHeight;

    const destCtx = $destCanvas.getContext("2d");

    // to remove background, need another canvas
    const $tempCanvas = document.createElement("canvas");
    $tempCanvas.width = $video.videoWidth;
    $tempCanvas.height = $video.videoHeight;
    const tempCtx = $tempCanvas.getContext("2d");

    (async function loop() {
      requestAnimationFrame(loop);

      // create mask on temp canvas
      const segmentation = await net.segmentPerson($video);
      const mask = bodyPix.toMask(segmentation);
      tempCtx.putImageData(mask, 0, 0);

      // draw original
      destCtx.drawImage($video, 0, 0, $destCanvas.width, $destCanvas.height);
      // then overwrap, masked area will be removed
      destCtx.save();
      destCtx.globalCompositeOperation = "destination-out";
      destCtx.drawImage($tempCanvas, 0, 0, $destCanvas.width, $destCanvas.height);
      destCtx.restore();
    })();
  };
})();

注意点としては、`segmentPerson()`に渡すインプットで、これに`width`と`height`のプロパティに値が必須なこと。(`video`ならとりあえず`videoHeight`と`videoWidth`をコピーするだけでいい)
これに気付かずめっちゃハマった・・・。

仕組みとしてはMLでセグメンテーションして、その結果を使ってマスクを作って、あわせて描画してるだけ。
`canvas`の`globalCompositeOperation`で重なりをどう合成するか指定するシンプルなコードになったはず。

`tfjs/body-pix`は、こういった描画の部分でいくつか便利メソッドを公開してて、体の一部をぼかすとか、背景をぼかすのがあったりするけど、バーチャル背景用途に透過するものはなかった。
あったら嬉しくない?ってIssue立てたけど、いまだ返事はない・・。

ちなみに背景ボケはこんな感じでできちゃう。

const img = document.getElementById('image');
const canvas = document.getElementById('canvas');

const net = await bodyPix.load();
const segmentation = await net.segmentPerson(img);

const backgroundBlurAmount = 3;
const edgeBlurAmount = 3;
const flipHorizontal = false;

bodyPix.drawBokehEffect(
  canvas, img, segmentation, 
  backgroundBlurAmount, edgeBlurAmount, flipHorizontal
);

簡単すぎる・・。

パフォーマンス

こっちはライブラリにはしてないけど、さっきのクロマキーのリポジトリに簡単なデモだけ置いといた。

https://leader22.github.io/chromakey/tfjs-body-pix/index.html

かがくのちからってすげー!
どうみてもこれが顧客が欲しかったものであり、バーチャル背景って聞いたらこっちのアプローチになるんかなー。

ただ長時間やってるとファンが結構回ってくる(WASMバックエンドにしてもそれなりに)ので、学習モデルのパラメータを調整したりする余地はあるかなーという感じ。
なのでZoomもデスクトップクライアントでだけ使えるんかなーと。