任意のバーチャル背景を使えるページを作った

# TL; DR

* ウェブカムの入力から人物部分のみをくりぬいて背景画像と合成するWebページを作成した
  * https://knok.github.io/virtbg/
  * 上記URLにアクセスし、"Start video"ボタンを押すと下に合成された動画が表示される
    * "Stop video"で停止
    * ドラッグ&ドロップで背景画像を差し替え可能
    * ソースコード: https://github.com/knok/virtbg
* OBS Studio + 各種OS向けプラグインで任意のビデオ会議ツールに利用可能

[jitsi](https://jitsi.org/)での動作例:
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/c9ad3e1b-4823-6ec6-d163-45c1d62873eb.png)

## 動機

非常に多くの企業・団体等がバーチャル背景を提供しています(例: [Web会議で使える「 #バーチャル背景 」配布がブームに　企業まとめ (1/12) - ITmedia NEWS](https://www.itmedia.co.jp/news/articles/2005/01/news010.html))。
あらかじめバーチャル背景をサポートした配信ツール(Zoom, Microsoft Teams等)では利用が容易ですが、そうでないツール(Google meet, Jitsi等)もあります。[Snap Cameraを使う方法](https://note.com/tsukamoto/n/n7fcdbd2aa7e6)もあるのですが、レンズを自作した場合無条件で公開になってしまうようです。となると、再配布可能な素材でしか使えないことになります。

その問題を解決すべく取り組んで得られた知見を紹介します。

## docker+v4l loopbackを使った方法

最初に見つけたのは、[Open Source Virtual Background | BenTheElder](https://elder.dev/posts/open-source-virtual-background/)という記事です。
この記事では、GPU対応版Tensorflowjs-nodeをコンテナとして扱いつつ、Python+OpenCVで背景合成を行い、[pyfakewebcam](https://github.com/jremmons/pyfakewebcam)と[v4l2loopback](https://github.com/umlaeute/v4l2loopback)を使って合成した結果をvideo4linuxデバイスとして見せるような挙動をします。構成図は以下のような感じです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/5a61f9be-b1a5-2dce-3834-7e6d41ed05ef.png)


悪くはないのですが、いくつか問題があります。

* docker+GPUコンテナの利用が前提
  * httpを経由した画像のやりとりのオーバーヘッドが気になる
* Linuxでの利用が前提(v4l2loopback)

この段階で、まずは「BodyPixをpythonで動かせないか?」ということを考えました。

## PythonでBodyPixの推論をする(半分成功)

[simple_bodypix_python](https://github.com/ajaichemmanam/simple_bodypix_python)というコードが公開されていました。これは、[https://storage.googleapis.com/tfjs-models](https://storage.googleapis.com/tfjs-models)で公開されている訓練済みtfjs向けBodyPixモデルをTensorFlow形式に変換して使うためのコードです。
これを試してみたところ、量子化しないモデルではうまく推論できましたが、量子化モデルでは期待した動作になりませんでした。どんな画像を与えても、すべて1が返ってくるような結果しか得られません。tfjs側のコードとも見比べてみたのですが、特別なにかしらの前処理をしているようには見えませんでした。何か見落としがあるかもしれませんが…

一点、量子化モデルでは非常に荒い推論結果(50~60x30~40ピクセル程度)しか求めておらず、その結果をbilinear補完でで入力サイズに拡大した結果を返しているということがわかったのは収穫です。floatモデルだと、入力画像と同じサイズの出力を直接返します。これは相当計算量がかかっているだろうということは推測できました。

## ブラウザで完結させることを考える

Pythonに変換させるアプローチをいったんあきらめ、ブラウザで処理を行う方向に転換しました。ブラウザ上なら、GPUもうまいこと使ってくれるかもしれません。

これに関しては以下の記事が参考になりました。

* [Tensorflow.jsのBodyPix 2.0を使ってみた｜PARTY｜note](https://note.com/prty/n/ne2d2090148dc)
* [tensorflow.jsとWebRTCを組み合わせて、プライバシー保護のビデオチャットを作ってみた（前編） - Qiita](https://qiita.com/massie_g/items/af3f8eb1e2bf5e807ba6)

特に後者の記事は非常に参考になりました。既にやりたいことの半分はできています。行っているのはBodyPixのtoMaskメソッドを使って背景をグレーにしているだけなので、グレーにする代わりに任意の背景画像に置き換えれば済む話です。

ここであらためてオリジナルのBodyPix.toMaskがどういう処理を行っているかを追いかけてみました。結局のところ、[単純にImageオブジェクトの全ピクセルへアクセスしているだけ](https://github.com/tensorflow/tfjs-models/blob/body-pix-v2.0.5/body-pix/src/output_rendering_util.ts#L192)だということを突き止めました。
これで十分パフォーマンスが出ているので、自分も単純にforループでセグメンテーション結果と突き合わせながら、前景・背景ピクセルをピックアップするだけで実現できるはずです。

書いたコードの一部を以下に示します。

```py
// segmentation: bodypix.segmentPerson(image)の返り値 (2次元配列)
//   0: 背景, 1: 前景(人体)
// data_img: 前景画像(mediaから取得したwebcam画像)
// data_bg: 背景画像
        function drawToCanvas(canvas, segmentation, img, data_img, data_bg) {
            const ctx = canvas.getContext("2d");
            canvas.width = img.width;
            canvas.height = img.height;
            let width = img.width;
            let height = img.height;
            let imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
            let pixels = imageData.data;
            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] = data_img.data[base + 0];
                        pixels[base + 1] = data_img.data[base + 1];
                        pixels[base + 2] = data_img.data[base + 2];
                        pixels[base + 3] = data_img.data[base + 3];
                    } else {
                        pixels[base + 0] = data_bg.data[base + 0];
                        pixels[base + 1] = data_bg.data[base + 1];
                        pixels[base + 2] = data_bg.data[base + 2];
                        pixels[base + 3] = data_bg.data[base + 3];
                    }
                }
            }
            ctx.putImageData(imageData, 0, 0);
        }
```

こうして出来上がったのが https://knok.github.io/virtbg/ です。
アニメーション処理周りは[後者の記事](https://qiita.com/massie_g/items/af3f8eb1e2bf5e807ba6)を大いに参考にしています。

## OBS Studio + [OBS-VirtualCam](https://github.com/CatxFish/obs-virtual-cam) (Windows)

Windows環境では、OBS-StudioにOBS-VirtualCamプラグインを組み合わせることで、任意のウィンドウ表示領域をDirectShow deviceに見せることができます。

### OBS Studioの設定

OBS StudioとOBS-VirtualCamのインストールは完了しているものとします。

新規プロファイルを作成し、ソースの"+"を押し、「ウィンドウキャプチャ」を選択する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/95b5276e-6e65-e759-3745-bf6e924082ab.png)


適当な名前をつける。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/7f813338-c169-298f-b2e7-6ab7ad3f571b.png)

先のページを表示したウィンドウを選択する。適当にリサイズしておき、合成画面だけが表示されるようにしておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/110e668b-12e2-a525-dfd9-83ed3321f809.png)

適当に移動・リサイズ・クリッピングをして表示領域を拡大しておく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/29b9606f-bcd4-75ab-5765-958c8b127c1e.png)

メニューの「ツール」から「VirtualCam」を選択。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/d6ce7d21-51f5-ede9-2c65-e19bf615f840.png)

ダイアログで「Start」ボタンを押す。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/38ce4dd1-eac8-a890-5b4a-39dc999be315.png)

ブラウザのカメラ設定で「OBS Camera」が選択できることを確認。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/7235aa66-4a8c-199d-f317-e69ebf38b8f4.png)

### [OBS (macOS) Virtual Camera](https://github.com/johnboiles/obs-mac-virtualcam)

Mac環境でも同様のプラグインがあります(手元に最近のMac環境がないため未検証)。

* 参考: [Mac 版 OBSでZoomやMicrosoft Teams、Google Meetにバーチャルカメラを利用する方法 | 技術的な何か。](https://level69.net/archives/27119)

### [obs-v4l2sink](https://github.com/CatxFish/obs-v4l2sink)

Linux環境ではv4l2loopbackに出力するプラグインがあります。

#### Debian 10での導入

```
# 適当な作業ディレクトリに移動
sudo apt install obs-studio libobs-dev
# 必要なkbuildは入れておく
sudo apt install v4l2loopback-dkms v4l2loopback-utils
# この時点でv4l2loopback.koがビルドされる
# プラグインのビルドにソース本体が必要
## deb-srcをapt lineに入れておく必要あり
apt source obs-studio
git clone https://github.com/CatxFish/obs-v4l2sink
cd obs-v4l2sink
mkdir build
cd build
# ここでapt sourceで取得したソースを参照
cmake -DLIBOBS_INCLUDE_DIR=../../obs-studio-22.0.3+dfsg1/libobs -DCMAKE_INSTALL_PREFIX=/opt/obs ..
make
# プラグインディレクトリに直接コピー(作法としては良くない)
sudo cp v4l2sink.so  /usr/lib/x86_64-linux-gnu/obs-plugins
# chromeでの利用にはこのオプションが必要とのこと
sudo modprobe  v4l2loopback exclusive_caps=1
obs
# modprobeしたときに増えた/dev/video%dを指定
```


## まとめ

TensorFlow.js + BodyPixを用いることで、Webブラウザを介してバーチャル背景の合成をする方法を実現しました。OBS StudioとプラグインにOS依存な部分を任せることで、マルチプラットフォームでバーチャル背景を利用することができます。

### 課題

* 背景画像の入れ替えが面倒
  * ~~Drag&Dropで背景画像を入れ替えられるようにしたい~~
  * 一番下の画像領域にDrag&Dropで背景画像を入れ替えられるようにしました
    * 参考: [画像をドラッグ＆ドロップで登録してプレビュー表示 - Qiita](https://qiita.com/tonkatu_tanaka/items/77fd4300f543a4f9682d)
* CPUパワーがそれなりに必要
  * 現状、BodyPixモデルの設定をかなり低め(MobileNetv1, multiplier: 0.50)にしてあり、ノートでもそこそこ動く
  * ウィンドウサイズ等も含め可変にしたい
* カメラデバイスの選択が面倒
  * ページ内で選択できるようにしたい ([BodyPix公式デモ](https://storage.googleapis.com/tfjs-models/demos/body-pix/index.html)では選べる)
* OBS Studio依存
  * 機種依存部分を全部こちらに投げた
  * ブラウザ拡張として実装し、getUserMediaをフックして実装する方法もあり
    * [Google MeetのWebカメラを加工してみよう！ - SSTエンジニアブログ](https://techblog.securesky-tech.com/entry/2020/04/30/)

サンプル動作例 (Windows10, Chrome 81, i5-4670K, GTX-1060, multiplier: 0.75)
![y4069-ju7wv.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/31714/07218b62-6c6d-34ab-d382-bf4ebace5361.gif)

* このスペックならそれなりにスムーズ
  * multiplier: 0.50でノート(i7-8550U)で確認したところ、体感10fpsを切る程度