BodyPixとWebGLを組み合わせて3次元的な次世代バーチャル背景を作る

N.Mです。仮想カメラ、バーチャル背景関連で[BodyPix](https://developers-jp.googleblog.com/2019/04/bodypix-tensorflowjs.html)を今更調査していました。

BodyPixを使用することで、ビデオ映像から人物部分を抽出することができます。BodyPixで人物映像を抽出し、その映像をWebGL側に渡すことで、抽出された人物映像を用いた3次元的な表現ができました。以下のような、ただ背景を隠しただけではないバーチャル背景表現がブラウザ上でできます。

※2022/08/27 追記: 
2022年8月時点でBodyPixはDeprecatedとなっております。同じような人物映像の抽出は後継の[MediaPipe版BlazePose](https://github.com/tensorflow/tfjs-models/tree/master/pose-detection/src/blazepose_mediapipe)で行うことができます。画像のバーチャル背景もBlazePoseに切り替えております。[この記事](https://qiita.com/HexagramNM/items/004056bfdb6360884545)でBodyPixからBlazePoseに切り替える方法を取り上げているので、こちらもぜひご覧ください。

![NM_MicDisplayForWeb_img.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/1f4e9bc0-5e4e-581f-7676-71e228529571.png)
（ちなみに[このサイト](https://hexagramnm.coresv.com/NM_MicDisplay_Web/index.html)で公開し、ソースコードも[GitHub](https://github.com/HexagramNM/NM_MicDisplayForWeb)に公開しております。）

# この記事で触れる内容
- **ビデオ映像からBodyPixで背景をマスクし、WebGLで使用できるように映像を送る手段**
BodyPixで抽出した人物のみの画像を非表示のキャンバスに描画し、そのキャンバスをWebGLのテクスチャに送るということをしております。

# この記事で触れない内容
- **BodyPixの使用方法やWebGL（シェーダなど）の話**
BodyPixについては[この記事](https://www.kasacchiful.net/blog/2020/08/15/virtual-background-using-bodypix/)、WebGLについては[wgld.org](https://wgld.org/)が参考になると思います。

- **作成したバーチャル背景の配信方法**
[前に書いた記事](https://qiita.com/HexagramNM/items/8493350d40608433421c)のように仮想カメラを作成するか、素直にOBSなどを使用する必要があります。

# サンプルコード
[GitHubのリポジトリ](https://github.com/HexagramNM/BodyPixWebGL_sample)に、人物のみのビデオ映像を奥行き方向に傾けて表示するサンプルを公開しました。こちらのサンプルを参照していただきながら、この記事をご覧ください。BodyPixの解析とWebGLの描画、それぞれにかかった1フレームあたりの平均時間も表示するようにしております。

# BodyPixによる人物映像をWebGLに送るまでに行ったこと
[index.html](https://github.com/HexagramNM/BodyPixWebGL_sample/blob/main/index.html)からわかるように、人物映像のテクスチャを得るために3枚の非表示キャンバスを使用しております。（"output"は出力用キャンバスです。）

- video...Webカメラからの映像を表示するキャンバス。
- intermediate...BodyPixへの入力画像を表示するキャンバス。
- virtualBackTexture...WebGLに渡すテクスチャを表示するキャンバス。WebGLのテクスチャ用画像サイズは2の累乗ピクセルにしたほうが望ましいです。そのため、サンプルでは512px×512pxにしております。

## BodyPixからの結果取得
BodyPixでの解析には少し時間がかかりますが、BodyPixの処理はasync/awaitで非同期実行できるようになっております。そのため、現在のフレームをBodyPixで解析している間に、前のフレームの画像でWebGL用のテクスチャを作成できるようにしております。（1フレーム遅れますが、特にマスク境界のぼかし処理などをテクスチャ作成時に追加した場合に、fpsの低下を軽減できます。）

```javascript:bodyPixPart.js
async function BodyPixPart_main() {

    /*中略*/

    //videoComponentにはvideoキャンバスのHTMLElement、
    //intermediateCanvasにはintermediateキャンバスのHTMLElement、
    //intermediateCanvasCtxにはintermediateキャンバスの2dコンテキストが格納されております。
    //intermediateCanvasSizeはintermediateキャンバスのサイズです。
    var ctxIntermediateImage = intermediateCanvasCtx.getImageData(0, 0, intermediateCanvasSize.width, intermediateCanvasSize.height);
    intermediateCanvasCtx.drawImage(videoComponent, 0, 0, intermediateCanvasSize.width, intermediateCanvasSize.height);

    //この時点でintermediateのキャンバスには現在のフレームが描画されており、
    //ctxIntermediateImageには前フレームのビデオ画像が、processedSegmentResultには
    //前フレームのビデオ画像でのBodyPix解析結果が格納されております。

    //現在フレームについてBodyPixによる解析の開始
    var bodyPixPromise = bodyPixNet.segmentPerson(intermediateCanvas, {
        flipHorizontal: false
    });

    //BodyPixによる解析中にvirtualBackTextureキャンバスに前フレームの人物画像を描画
    if (processedSegmentResult) {
        BodyPixPart_drawTextureCanvas(ctxIntermediateImage, processedSegmentResult);
    }
    processedSegmentResult = await bodyPixPromise;

    /*中略*/

    setTimeout(arguments.callee, 1000/60);
}

```

## 人物のみ抽出した映像をテクスチャ用キャンバスに描画
WebGLのテクスチャはサイズが2の累乗ピクセルにするのが望ましいです。そのため、intermediateキャンバスの画像とBodyPixの解析結果から人物画像を作成するだけでなく、2の累乗ピクセルのサイズにリサイズする必要もあります。

`mapTextureXToCanvas`, `mapTextureYToCanvas`という、intermediateキャンバスの座標からvirtualBackTextureキャンバスの座標に変換するための配列を初期化時点で作成しております。これらの配列を利用することでリサイズを実現しております。

※2022/04/03 追記: 
[この記事](https://spphire9.wordpress.com/2014/06/22/canvas%E3%81%AEimagedata%E3%81%B8%E3%81%AE%E6%9B%B8%E3%81%8D%E8%BE%BC%E3%81%BF%E9%80%9F%E5%BA%A6/)で触れられておりますが、ImageDataのピクセルデータにアクセスする際、Uint32Arrayを介して一度に32bit書き込むようにすると、ImageData.dataに直接8bitずつ書き込むよりも処理速度が上がります。今回、別スレッドで行っているBodyPixの処理がボトルネックであるため、fpsが改善されるわけではありませんが、高速化される分cpuのアイドル時間が増え、複数アプリを起動している状態でも処理落ちしにくくなります。


```javascript:bodyPixPart.js
//intermediateキャンバスのサイズ
var intermediateCanvasSize = {width: 480, height: 360};

//virtualBackTextureキャンバスのサイズ（縦横同一サイズ）
var virtualBackTextureSize = 512;

var mapTextureXToCanvas = new Array(virtualBackTextureSize);
var mapTextureYToCanvas = new Array(virtualBackTextureSize);

/*中略*/

//virtualBackTextureキャンバスに書き込むピクセルデータを格納するバッファ配列
var virtualBackOutputImageBuf = new ArrayBuffer(virtualBackTextureSize * virtualBackTextureSize * 4);

//virtualBackOutputImageBufにアクセスするための変数。
//ImageDataにコピーする際はUint8ClampledArrayを介する必要があり、
//ピクセルデータを書き込む際はUint32Arrayを介してアクセスした方が処理が高速になる。
var virtualBackOutputImageBuf8 = new Uint8ClampedArray(virtualBackOutputImageBuf);
var virtualBackOutputImageData = new Uint32Array(virtualBackOutputImageBuf);
var virtualBackOutputImage = new ImageData(virtualBackTextureSize, virtualBackTextureSize);

/*中略*/

function BodyPixPart_drawTextureCanvas(i_ctxInputImage, i_processedSegmentResult) {
    var inputBytes = i_ctxInputImage.data;
    
    var outputPixIdx = 0;
    var resultColor = [0.0, 0.0, 0.0, 0.0]
    var resultColorUint32 = 0;
    //virtualBackTextureキャンバスの座標でfor文を回す
    for (var y = 0; y < virtualBackTextureSize; y++) {
        var yInputIdx = mapTextureYToCanvas[y] * intermediateCanvasSize.width;
        for (var x = 0; x < virtualBackTextureSize; x++) {
            //intermediateキャンバスでの座標を計算
            var inputPixIdx = yInputIdx + mapTextureXToCanvas[x];
            var byteBaseInputIdx = 4 * inputPixIdx;
            var byteBaseOutputIdx = 4 * outputPixIdx;

            //i_processedSegmentResult.dataにはピクセルごとのBodyPixによる解析結果が格納され、
            //0の場合は背景、1の場合は人物という判定になります。
            //32bit一度に書き込めるように、rgbaの値をresultColorUint32にまとめます。
            if (i_processedSegmentResult.data[inputPixIdx] == 1) {
                for (var colorIdx = 0; colorIdx < 3; colorIdx++) {
                    resultColor[colorIdx] = inputBytes[byteBaseInputIdx + colorIdx]
                }
                resultColor[3] = 255;
                resultColorUint32 = (resultColor[0] | (resultColor[1] << 8) | (resultColor[2] << 16) | (resultColor[3] << 24));
            }
            else {
                resultColorUint32 = 0x00;
            }
            virtualBackOutputImageData[outputPixIdx] = resultColorUint32;
            outputPixIdx++;
        }
    }
    virtualBackOutputImage.data.set(virtualBackOutputImageBuf8);
    virtualBackTextureCanvasCtx.putImageData(virtualBackOutputImage, 0, 0);
}

async function BodyPixPart_init(videoStream) {
    //初期化処理でintermediateキャンバスの座標からvirtualBackTextureキャンバスの座標に
    //変換するための配列を作成
    for (var idx = 0; idx < virtualBackTextureSize; idx++) {
        mapTextureXToCanvas[idx] = parseInt(idx * intermediateCanvasSize.width / virtualBackTextureSize + 0.5);
        mapTextureYToCanvas[idx] = parseInt(idx * intermediateCanvasSize.height / virtualBackTextureSize + 0.5);
    }

    /*中略*/

}

```

## テクスチャ用キャンバスからWebGLテクスチャの作成
キャンバス要素から`gl.texImage2D`関数でテクスチャを作成することができます。今回はvirtualBackTextureキャンバスからテクスチャを作成するように、`create_texture_from_canvas`関数でテクスチャを登録をし、毎フレーム実行してほしい処理は変数`textureProcess`に関数を登録しております。

```javascript:webGLPart.js
function create_texture_from_canvas(canvasId, number) {
    //texture_maxにはgl.getParameter(gl.MAX_COMBINED_TEXTURE_IMAGE_UNITS)で得られる
    //登録可能な最大テクスチャ数、
    //webGLPart_loadedTextureNumには登録されたテクスチャ数を格納しております。
	if (number >= texture_max) {
		return;
	}
	var sourceCanvas = document.getElementById(canvasId);
	var tex = gl.createTexture();
	dynamicTextureSetting();
	texture[number] = tex;
	textureProcess.push(dynamicTextureSetting);
	webGLPart_loadedTextureNum++;

    //現在のキャンバスの状態でテクスチャを設定
    //（WebGL上でも動画としてテクスチャが動くように、
    //その時点でのキャンバスでテクスチャを毎フレーム登録する必要があります。）
	function dynamicTextureSetting() {
		gl.activeTexture(gl["TEXTURE" + number.toString()]);
		gl.bindTexture(gl.TEXTURE_2D, tex);
		gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, sourceCanvas);
		gl.generateMipmap(gl.TEXTURE_2D);
		gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST_MIPMAP_LINEAR);
		gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
	}
}
```

# 処理時間
以下のスペックのノートPC環境で試したところ、BodyPix側の処理で1フレーム約50msec、WebGLの描画で1フレーム約0.2msecの処理時間がかかっておりました。20fps程度なので、ノートPCだと少しカクつくくらいの印象で、インタラクティブには動きます。

CPU: Intel Core i7-6567U @ 3.30GHz
RAM: 16.0GB
GPU: Intel Iris Graphics 550

![processingTime.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/152791/4bec7249-a38a-1c71-7166-1589aa6a704b.png)

# まとめ
キャンバスをテクスチャに渡すことで、BodyPixとWebGLを組み合わせることができ、3次元的な面白いバーチャル背景の表現ができるという話でした。この記事やサンプルから、さらに面白いバーチャル背景を作成してくれる方が出てくれれば良いなぁと思います。