Google Meetでもバーチャル背景を使いたい

# はじめに

最近色々なWeb会議システムを使うことが増えました。未経験だったZoomや、久しぶりにSkypeも使ってます。
Zoomのバーチャル背景が人気ですが、確かに便利です。が、残念ながら私の仕事で標準のGoogle Meetでは、まだ使うことはできません。

Snap Cameraや、OBSなどOSで仮想カメラとして認識されるアプリを使えば可能ですが、そこはWebシステムなのでJavaScriptでなんとかしたいですよね。そこで無理やりやってみた、という話です。(Chrome限定です）

<img width="403" alt="meet_fuji.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/b37316ea-a48d-983e-0a80-b6e372ae9fb8.png">

## Chrome ウェブストアで公開しました

こちらの拡張機能を公開しました(2020.06.03)

- [Chrome Virtual Camera](https://chrome.google.com/webstore/detail/chrome-virtual-camera/dphplfdpilhebmikohheeiblilajfmin/)

※種類やファイルを指定してから、映像を取得してください。Google Meetの場合はカメラを一旦オフにして、再度オンにしてください

# 使える材料

## tensorflow.js + body-pix

以前の私のの記事で紹介したように、tensorflow.js + body-pix を使えば人体検出+マスク処理ができます。これを使えば自分で作ったアプリなら、バーチャル背景はできそうです。

- 
[tensorflow.jsとWebRTCを組み合わせて、プライバシー保護のビデオチャットを作ってみた（後編）](https://qiita.com/massie_g/items/6018861a7d2ca3f3b1de)

実際にこちらのかたの記事では、色々組み合わせてバーチャル背景を実現しています。（BodyPix.toMask()の解析は、とても参考になりました）

- [任意のバーチャル背景を使えるページを作った](https://qiita.com/knok/items/b3eb87769151ac04efeb)

## Chrome Extension

上記の処理を、Google MeetのようにすでにあるWebアプリに反映する方法がわからず途方に暮れていたところ、次の記事を発見しました。

- [Google MeetのWebカメラを加工してみよう！](https://techblog.securesky-tech.com/entry/2020/04/30/)

これだ！ ということで早速真似させていただきました。

# 実装

ソースコードはGitHubで公開しています。それを抜粋して紹介します。

- https://github.com/mganeko/chrome_virtual_camera

## 画像を背景に合成

Body-pixを使った人体検出は、以前の記事を参照ください（[前編](https://qiita.com/massie_g/items/af3f8eb1e2bf5e807ba6)、[後編](https://qiita.com/massie_g/items/6018861a7d2ca3f3b1de)）。今回は画像を背景に合成する部分のコードの概要を記載します。

```js
// canvas ... 合成に使うcanvas
// segmentation ... Body-pixで抽出したセグメンテーデョン
// frontElement ... 人物が映った映像(video要素)
// backElement ... 背景にする画像(img要素)
function _drawFrontBackToCanvas(canvas, segmentation, frontElement, backElement) {
  const ctx = canvas.getContext("2d");
  const width = canvas.width;
  const height = canvas.height;

  // 先に人物が映った映像（前景）を描画し、イメージとして取っておく
  ctx.drawImage(frontElement, 0, 0);
  const front_img = ctx.getImageData(0, 0, width, height);

  // 背景になる画像を描画し、イメージを取り出す
  const srcWidth = backElement.naturalWidth;
  const srcHeight = backElement.naturalHeight;
  ctx.drawImage(backElement, 0, 0, srcWidth, srcHeight,
    0, 0, width, height
  );
  let imageData = ctx.getImageData(0, 0, width, height);

  // セグメントを走査し、人体の部分だったら前景の画像の値を背景の画像に合成する
  for (let y = 0; y < height; y++) {
    for (let x = 0; x < width; x++) {
      let base = (y * width + x) * 4;
      let segbase = y * width + x;
      if (segmentation.data[segbase] == 1) { // is fg
        // --- 前景 ---
        pixels[base + 0] = front_img.data[base + 0]; // R
        pixels[base + 1] = front_img.data[base + 1]; // G
        pixels[base + 2] = front_img.data[base + 2]; // B
        pixels[base + 3] = front_img.data[base + 3]; // α
      }
    }
  }

  // 合成した画像を、改めてキャンバスに描画する
  ctx.putImageData(imageData, 0, 0);
}
```

## mediaDevices.getUserMedia()のフック

今回は画像との合成を自分の作ったアプリを対象とするのではなく、Google Meetのような既存のWebアプリに対して行うのが目的です。

- 参考：[Google MeetのWebカメラを加工してみよう！](https://techblog.securesky-tech.com/entry/2020/04/30/)


そこで、次のように mediaDevices.getUserMedia()をフックして、こちらで用意した処理に差し替えます。

<img width="832" alt="hook_getusermedia.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/6e8099c8-3edc-d955-d5c9-877e86bad9f5.png">


実際に差し替える処理は次のようになります。

```js
  function _replaceGetUserMedia() {
    if (navigator.mediaDevices._getUserMedia) {
      // すでに置き換え済みなら、何もしない
      return;
    }

    // 元の処理を取っておく
    navigator.mediaDevices._getUserMedia = navigator.mediaDevices.getUserMedia

    // 自分で用意した関数に置き換える
    navigator.mediaDevices.getUserMedia = _modifiedGetUserMedia;
  }
```

getUserMedia()を置き換える、Body-pixを使った処理はこちらです。（上記の_modifiedGetUserMedia()から、呼び出しています）

```js
  function _startBodyPixStream(withVideo, withAudio, constraints) {
    return new Promise((resolve, reject) => {
      // まずはデバイスの映像を取得する（指定されていれば音声も）
      navigator.mediaDevices._getUserMedia(constraints).
        then(async (stream) => {
          // 映像が取得できたら、非表示のvideo要素でsaisei
          video.srcObject = stream;
          await video.play().catch(err => console.error('local play ERROR:', err));
          video.volume = 0.0;

          // Canvasを更新する処理を、requestAnimationFrame()で呼び出す
          requestAnimationFrame(_updateCanvasWithMask);

          // Canvasから映像ストリームを取り出す
          const canvasStream = canvas.captureStream(10);
          if (!canvasStream) {
            reject('canvas Capture ERROR');
          }
          keepAnimation = true;

          // 定期的にbody-pixによる人体セグメンテーション検出を呼び出す
          _bodypix_updateSegment();

          // 利用側で映像の停止が呼び出されたら、元のデバイスの映像も停止させるように処理を追加
          const videoTrack = canvasStream.getVideoTracks()[0];
          if (videoTrack) {
            videoTrack._stop = videoTrack.stop;
            videoTrack.stop = function () {
              keepAnimation = false;
              videoTrack._stop();
              stream.getTracks().forEach(track => {
                track.stop();
              });
            };
          }

          // --- 音声が指定されていたら、デバイスからの音声をCanvasの映像に追加する
          if (withAudio) {
            const audioTrack = stream.getAudioTracks()[0];
            if (audioTrack) {
              canvasStream.addTrack(audioTrack);
            }
          }

          resolve(canvasStream);
        })
        .catch(err => {
          reject(err);
        });
    });
  }
```

ここでは説明を省きますが、_updateCanvasWithMask()の内部で先に説明した_drawFrontBackToCanvas()を呼び出して、背景の合成を行います。

## Chrome Extension の Contents Script の利用

上記のコードを使って既存のWebアプリをフックするのは、Chrome Extension を利用します。対象となるページにJavaScriptを差し込んだり、DOM要素をいじったいるすることができます。

### manifest.json

Chrome Extension を使うには、manifest.json を用意します。今回は次のような感じです。

```json:manifest.json
{
  "manifest_version": 2,
  "content_scripts": [
    {
      "matches": [
        "http://localhost:*/*",
        "https://meet.google.com/*",
      ],
      "js": [
        "loader.js"
      ],
      "run_at": "document_start"
    }
  ],
  "permissions": [
    "https://localhost:*/",
    "https://meet.google.com/",
  ],
  "web_accessible_resources": [
    "cs.js"
  ]
}

```

- content_scripts
    - maches: 対象とするサイトのURL（ここでは、localhostと、Google Meet）
    - js: 実行するJavaScript（用意しておいたjsファイルを、対象サイトに差し込む処理を担う）
    - run_at: 上記jsを実行するタイミング。この例では、元のサイトに元々含まれるJavaScriptよりも先に実行する
- permissions ... このExtensionが操作できる対象サイトを指定（ここでは、localhostと、Google Meet）
- web_accessible_resources ... Extensionに埋め込んで配布するリソース
    - ここでは、サイトに差し込むJavaScriptファイル。実際の人体検出、背景合成処理を行う

### コンテンツを差し込む、loader.js

対象サイト読み込み時に先立って実行される loader.js の処理は、次のようになっています。

```js:loader.js
async function load() {
  // cs.jsの内容を読み込み、scriptタグを作ってdocumentに差し込む
  const res = await fetch(chrome.runtime.getURL('cs.js'), { method: 'GET' })
  const js = await res.text()
  const script = document.createElement('script')
  script.textContent = js
  document.body.insertBefore(script, document.body.firstChild)

  // 外部の tfjsの内容を読み込み、scriptタグを作ってdocumentに差し込む
  // --- tfjs ---
  const res_tf = await fetch('https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@1.2', { method: 'GET' })
  const js_tf = await res_tf.text();
  const script_tf = document.createElement('script');
  script_tf.textContent = js_tf;

  // body-pixの処理を読み込むscriptタグを作って、documentに差し込む（後で読み込ませる）
  // --- bodypix ---
  const script_bp = document.createElement('script');
  script_bp.src = 'https://cdn.jsdelivr.net/npm/@tensorflow-models/body-pix@2.0';

  document.body.insertBefore(script_bp, document.body.firstChild);
  document.body.insertBefore(script_tf, document.body.firstChild);
}

// window.onload()イベントで実行する
window.addEventListener('load', async (evt) => {
  _loaderlog('event load'); // 元のindex.html の中の処理より後に呼ばれる
  await load();
}, true); // use capture

```

tfjsとbody-pixを読み込むタイミングを調整するために読み込み方法を変えています。将来的にはタイミングが変わってきてしまうかも知れません。

### 差し込まれるコンテンツ cs.js

差し込まれる cs.js は、色々な処理を行っていますが、主要な役割は次の3つです。

- navigator.meidaDevices.getUserMedia()をフックして、自前の処理を呼ぶように置き換える
- tfjs + body-pixを用いたバーチャル背景の合成を行う
    - ※実際には他にも様々な仮想的カメラ映像を作れるように処理を用意しています
- 仮想映像の種類を選んだり、背景に使う画像を選択するUIを提供する

最初の2つはすでに概要を示しています。最後のUIを提供するのは、次のようにHTMLにDOM要素を差し込んでいます。

```js
  // --- ファイル選択GUIを挿入 ---
  function _insertPanel(node) {
    try {
      // 最小化された状態のパネルを、左上に表示する
      const html1 =
        `<div id="gum_panel" style="border: 1px solid blue; position: absolute; left:2px; top:2px;  z-index: 2001; background-color: rgba(192, 250, 192, 0.5);">
        <div><span id="gum_pannel_button">[+]</span><span id="gum_position_button">[_]</span></div>
        <table id="gum_control" style="display: none;">
          <tr>
            <td><label for="video_type">種類</label></td>
            <td>
              <select id="video_type" title="Google Meetではいったんカメラをオフ→オンしてください">
                <option value="camera" selected="1">デバイス</option>
                <option value="file">ファイル</option>
                <option value="clock">時計</option>
                <option value="screen">画面キャプチャー</option>
              </select>
            </td>
            <td colspan="2"><span id="message_span">message</span></td>
          </tr>
          <tr>
            <td><label for="video_file">動画</label></td>
            <td><input type="file" accept="video/mp4,video/webm" id="video_file"></td>
            <td><label for="image_file">背景</label></td>
            <td><input type="file" accept="image/*" id="image_file"></td>
          </tr>
        </table>
        </div>`;

      // 最後に差し込む
      node.insertAdjacentHTML('beforeend', html1);

      // イベントハンドラを追加する 
      node.querySelector('#video_file').addEventListener('change', (evt) => {
        _startVideoPlay();
      }, false);

    } catch (e) {
      console.error('_insertPanel() ERROR:', e);
    }
  }

```

しょぼいUIですが、[+][_] という簡易ボタンが並んだパネルを左上に表示しています。

- [+]をクリックすると、パネルが広がって仮想カメラの種類や、背景画像が選択できます
- [_]をクリックすると、パネルが左下に移動します

## contents scriptが動くタイミング

- loader.js  ... run_at:"document_start" を指定しているため、元のページのJavaScriptよりも前に動く
- cs.js ... 実際に差し込まれるのは window.onload()イベントの最初。元のページのbodyに直接書かれていたJavaScriptよりは後になる
    - そのため、なのでbodyで直接 getUserMedia()が呼び出されていると、フックする前に元々のgetUserMedia()が呼び出されてしまう

残念ながら、ユーザーの操作を待たずにカメラ映像を取得していまうサイトでは、今回のExtensionによる差し替えは通用しません。

# バーチャル背景を使ってみる

## Chrome Extension の読み込み

Chrome Web App には登録していないので、自分でChromeに読み込む必要があります。

- コードを https://github.com/mganeko/chrome_virtual_camera からダウンロード
    - git clone https://github.com/mganeko/chrome_virtual_camera.git
- 使いたいサイトに合わせ、manifest.json を編集
    - content_scripts, permissions のセクションを編集し、使いたいサイトを追加する
    - 同様に、使いたくないサイトを除外する
- 拡張機能の設定画面(chrome://extensions/)を開く
- 右上の「デペロッパーモード」を有効に
- 「パッケージ化されていない拡張機能を読み込む」からダウンロードしたリポジトリのフォルダーを選択し、読み込む
- 拡張機能のページで、読み込んだ「Chrome Virtual Camera」が表示されれ、有効になっているのを確認

<img width="951" alt="chrome_extension_page.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/67e981b0-cfc8-b63e-9db2-8adf3963c6da.png">


## 対象サイトでの利用

- Chrome で、対象のサイトにアクセスする
    - 左上に [+][_] ボタンを持つ小さなパネルがオーバーレイ表示される
- ※こちらのシンプルなテストページでも利用できます
    - https://mganeko.github.io/simple_usermedia_testpage/

<img width="274" alt="google_meet_small_panel.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/8c2ae8cf-c7fb-a2ac-4d87-40289dd82aad.png">

- [+]ボタンをクリックしてパネルを拡大し、仮想カメラの種類を選ぶ。バーチャル背景以外もサポート
    - デバイス ... マシンのカメラ/マイクを利用（デフォルト）
    - ファイル ... 動画ファイルを選択し、その映像/音声を利用
    - 時計 ... Canvas/WebAudioを利用した、デジタル時計
    - 画面キャプチャー ... getDisplayMedia()を利用した、画面キャプチャーを利用
    - 背景を塗りつぶし ... 人物を検出、背景をグレーで塗りつぶす
    - 背景を画像で隠す ... **いわゆるバーチャル背景。**人物を検出、背景を画像ファイルで隠す
    - 人物を塗りつぶし ... 人物を検出、人物をグレーで塗りつぶす

<img width="621" alt="google_meet_panel_select.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/fb902a86-06a6-ef9e-464b-9184909d5c93.png">

- 動画ファイルや背景の画像ファイルを選ぶ
    - 種類が「ファイル」の場合、動画ファイルを選択
    - **種類が「背景を画像で隠す(バーチャル背景)」の場合、背景の画像ファイルを選択**
- 対象サイトで、カメラ映像/マイク音声の取得を開始、通信を開始
  - mediaDevices.getUserMedia()が呼び出されると、フックした処理が動く
　- 選択した種類の映像、音声が取得される

※ Google Meet の場合は、パネルで種類を選択する前にカメラ映像が取得されています。その場合、種類と必要なファイルを選択後に、改めてカメラをオフ --> オンすると、選択された種類の映像が取得されます。


# 仮想カメラの例

今回、いわゆるバーチャル背景以外の仮想カメラも用意しています。いくつか紹介します。

## 時計

シンプルなデジタル時計です。Audioが指定されている場合は、1秒ごとに音も鳴ります。開発中のテストで、自分の姿を見続けたくない場合に便利です。

<img width="507" alt="meet_clock.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/859ddfba-3978-6e15-7435-2e5f8f60dac2.png">

Google Meetや多くのWeb会議システムでは、自分の映像は鏡のように左右反転して表示されるので、時計の文字も左右反転になっています。相手には反転なしで表示されます。

## ファイル

動画ファイルを指定して、カメラの代わりに映像/音声を流します。[Big Buck Bunnyのサンプル動画](https://test-videos.co.uk/bigbuckbunny/webm-vp9)を使っている例がこちらです。

<img width="697" alt="meet_bunny_video.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/7c8c1907-d235-f80a-a3ab-e17beb50627d.png">


## 背景を塗りつぶし、人物を塗りつぶし

<img width="345" alt="virtual_camera_paint.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/19403/6df2b0aa-3016-5a24-0c0e-fe2b119dd3ca.png">


## 画面キャプチャー

画面全体をキャプチャーしたり、特定のアプリのウィンドウをキャプチャーすることが可能です。

- PCのカメラを使って顔を認識して、アバターを動かすアプリ
- iPhone でアバターを動かすアプリ + macOS + QuickTimeで、ミラーリング

等を使うと、好きなアバターの映像をカメラの代わりに使ってGoogle Meetに参加できます。

# おわりに

もちろんSnap Cameraなどいろいろな仮想カメラソフトがあるので、それを使えばGoogle Meetでもバーチャル背景やARカメラなどが利用できます。が、今回のようにChrome Extensionで実現できれば、いろいろな映像や効果を自分で追加することができます。リモート会議を楽しくするためにも、お試しください。