📝

[JavaScript] - TensorFlow.js(tfjs)を使って人物の背景を合成(バーチャル背景)する

2023/10/01に公開

はじめに

Web会議ではもう標準となっている背景画像合成ですが、JavaScript を使ってブラウザ上で実現できるようだったので、試してみました。
Google の TensorFlow を使った TensorFlow.js で実現しているとのこと。
公式は以下です。

https://www.tensorflow.org/js?hl=ja

背景合成に使う諸々の公式 Github リポジトリはこちら。

https://github.com/tensorflow/tfjs-models/tree/master

今回はこの中の「body-segmentation」を使います。

https://github.com/tensorflow/tfjs-models/tree/master/body-segmentation

概略

できること

こんなように人物の後ろの背景を任意に合成できます。

流れ

以下のようにすごく簡単です。

  1. tfjsの必要ファイルをモジュールとしてインポートする。
  2. Config を作って Segmenterオブジェクト を用意する。
  3. Segmenterオブジェクト を使ってマスクデータを作成する。
  4. マスクデータを使って背景合成処理を行う。

たった4ステップです。
むしろ画像読込やCanvas、カメラ周りの制御の方が煩雑なくらいです。

手軽で簡単に使えるようにして頂いているのは大変ありがたいですね。
(こういうのを触ると、いつも「自分もこういうのを生み出す側に回りたい」と思います)

実装説明

1. 必要ファイルのインポート

最初に tfjs の必要ファイルをインポートします。
公式にある通り、HTML側で読み込んでもいいですし ECMAScript が使えるブラウザであれば import で読み込んでもいいです。

以下、コードを記述していくJavaScriptファイル名を sample.js として記載していきます。

HTML側で読み込む

HTMLのヘッダー部分に以下を記述して読み込む。

<head>
    
    <script src="https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/body-segmentation"></script>
    
    <script src="./sample.js"></script>

</head>

※ もちろん、他に必要なヘッダ要素はご自身で追記してください。

ECMAScript(import) で読み込む

私はこちらの方が好きなので以後こちらを前提に進めていきます。
対応ブラウザが新しいものに限られますが、いま使われているほとんどのブラウザは対応しているんじゃないかと思うので、とくに問題ないかと思います。

まず、HTMLファイルの方で <script> タグの中に type="module" を記述しておく。

<head>
    <script src="./sample.js" type="module"></script>
</head>

そして、JavaScriptファイルの先頭に以下を記述して読み込む。

//import
import "https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl";
import "https://cdn.jsdelivr.net/npm/@tensorflow-models/body-segmentation";

注意点

注意その1

importの順番を入れ替えると動かないことがあります。
エラーが出たり動かなかったりした時は順番を入れ替えてみてください。

なんでこういうところ、変に「同期的」なんでしょうね?

注意その2

https:// から始まるURLで指定されていることからわかるように、インターネットに接続している環境でないと動きません。
閉域網で利用する方、ご注意ください。

補足

この5つのファイルをダウンロードしてローカルに置き、相対パス指定で読み込んだところ、ネット接続切った状態でも動きました。
中身まで詳しく調べていないので本当にネット接続なしで動くのかわかりませんが、ダウンロードすれば閉域網でも使えるかもしれません。(この後のConfig作成のところで model file を指定するところがあります。その箇所も同時になんとかすれば完全オフライン環境で使用可能かもしれません。)
その場合、ダウンロードしたファイルに拡張子 .js をつけてやらないと、インポートの時点で「MIME Typeチェック」で怒られます。
以下のようにしてインポートしてあげてください。

//import
import "./selfie_segmentation.js";
import "./tfjs-core.js";
import "./tfjs-converter.js";
import "./tfjs-backend-webgl.js";
import "./body-segmentation.js";

2. Segmenterオブジェクトの用意

Segmenterオブジェクトは、人物抜き出しを実行してくれるオブジェクトです。
model と Config から作ります。

model 選択

MediaPipeSelfieSegmentationBodyPix の2つから選べます。
公式には、

MediaPipe SelfieSegmentation:
MediaPipe SelfieSegmentation segments the prominent humans in the scene. It can run in real-time on both smartphones and laptops. The intended use cases include selfie effects and video conferencing, where the person is close (< 2m) to the camera.

BodyPix:
BodyPix can be used to segment an image into pixels that are and are not part of a person, and into pixels that belong to each of twenty-four body parts. It works for multiple people in an input image or video.

とあります。
今回はWeb会議を想定した背景合成なので、MediaPipeSelfieSegmentation の方を使います。

modelオブジェクト を用意します。
以下で用意できます。

const model = bodySegmentation.SupportedModels.MediaPipeSelfieSegmentation;

Config 作成

次に Config を用意します。
公式の説明は以下。

segmenterConfig is an object that defines MediaPipeSelfieSegmentation specific configurations for MediaPipeSelfieSegmentationMediaPipeModelConfig:

runtime:
  Must set to be 'mediapipe'.

modelType:
  specify which variant to load from MediaPipeSelfieSegmentationModelType (i.e., 'general', 'landscape'). If unset, the default is 'general'.

solutionPath:
  The path to where the wasm binary and model files are located.

locateFile:
  The function to return URLs of the wasm binary and model files. If specified at the same time as solutionPath, solutionPath is ignored.

実質、 modelType くらいしか自由度ないです。
以下のようにJSON形式で設定します。

const segmenterConfig = {
        runtime: 'mediapipe', 
        solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation',
        modelType: 'general'
      }

Segmenter 作成

modelオブジェクトとConfigを使って、Segmenterを作成します。

const segmenter = await bodySegmentation.createSegmenter(model, segmenterConfig);

3. マスクデータの作成

まず segmentPeople関数 を使って人物認識を実行します。
引数には元となる画像データを渡してやります。
以下の3つどれを渡しても動いてくれます。

  • Canvasオブジェクト
  • Imageオブジェクト
  • Videoオブジェクト

便利です。
今回は Canvasオブジェクト(cvs1)を渡しています。

let segDATAs = await segmenter.segmentPeople(cvs1);

この segDATAs にはこのような要素が一つのオブジェクト配列(?)が返ってきます。

この返ってきたオブジェクトの中の toImageData関数 を呼び出してやると、マスクに使う画像データが取得できます。
0番目に入っていることに注意して、以下で取り出します。

let segmentDATA = await segDATAs[0].mask.toImageData();

2つに分けずに以下のようにまとめて記載してもいいです。

let segmentDATA = await segmenter.segmentPeople(cvs1).then(
      (result) => {
          return result[0].mask.toImageData();
      }
    );

すると、このようなオブジェクトが返ってきます。

segmentDATA.data にマスクに使う rgba (r:Red, g:Green, b:Blue, a:Alpha channel) の値が格納されています。
画像にするとこんな感じです。

配列には rgba (Red, Green, Blue, Alpha) の順で一列に、一次元配列の形でデータが入っています。
g,b の箇所には 0 しか入っていません。
人の領域と認識されたピクセルの箇所の r に 255 が、a に透過率が 0 〜 255 の範囲で格納されています。

このデータをマスクとして利用します。

4. マスクデータを使った背景合成処理

マクスデータを使ってどう合成するかは自由です。
r の部分を使って人の領域かそうでないかを 0, 1 判定して合成している例もありましたが、私は alpha の透過率を使う方法で合成しました。

rgba の a の部分に透過率が入っているので、座標 (x,y) の位置にあるピクセルの透過率は、

4 * ({\rm width} * y + x) + 3

の配列の位置に格納されています。
a の値が 0 の時には背景を、255 の時には人物を、その中間の値の時には値に従った透過率で合成したいので、以下のように画素の値を計算します。

\begin{aligned} w &= a / 255 \\ {\rm new\_pix} &= \lfloor {\rm front\_pix} * w + {\rm back\_pix} * (1 - w) \rfloor \end{aligned}
  • new_pix は合成後のピクセル値
  • front_pix は元画像のピクセル値
  • back_pix は背景画像のピクセル値

です。
rgba 値は 0〜255 の整数値でなければならないので、床関数 \lfloor x \rfloor を使って小数点以下を切り捨てています(四捨五入や天井関数だと、255 を超える可能性があるので床関数を使用)。

コードで書くとこんな感じになります。

    let DspImgData = ctxt2.getImageData(0, 0, cvs2.width, cvs2.height);
    let f_img_data = ctxt1.getImageData(0, 0, cvs1.width, cvs1.height);
    let b_img_data = bctxt.getImageData(0, 0, bcvs.width, bcvs.height);

    let segmentDATA = await segmenter.segmentPeople(cvs1).then(
         (result) => {
             return result[0].mask.toImageData();
         }
     );

    for(let i = 0; i < cvs1.height; i++){
        for(let j = 0; j < cvs1.width; j++){
            const n = i * cvs1.width + j;
            let w = segmentDATA.data[4 * n + 3] / 255;

            DspImgData.data[4 * n + 0] = Math.floor( f_img_data.data[4 * n + 0] * w + b_img_data.data[4 * n + 0] * (1 - w) );
            DspImgData.data[4 * n + 1] = Math.floor( f_img_data.data[4 * n + 1] * w + b_img_data.data[4 * n + 1] * (1 - w) );
            DspImgData.data[4 * n + 2] = Math.floor( f_img_data.data[4 * n + 2] * w + b_img_data.data[4 * n + 2] * (1 - w) );
            DspImgData.data[4 * n + 3] = Math.floor( f_img_data.data[4 * n + 3] * w + b_img_data.data[4 * n + 3] * (1 - w) );
        }
    }

    ctxt2.putImageData(DspImgData, 0, 0);
  • bcvs : 背景画像読込済みCanvas
      bctxt : bcvsのコンテキスト
  • cvs1 : 元画像Canvas
      ctxt1 : cvs1のコンテキスト
  • cvs2 : 合成結果表示Canvas
      ctxt2 : cvs2のコンテキスト

各Canvasに画像データを読み込んだ後にピクセル単位で合成処理をして、処理結果のデータを putImageData で表示用Canvasに反映しています。


これで終わりです。
とても簡単に実現できて大変ありがたいです。

サンプルコード

以上をまとめて、ページを開くとカメラを起動してリアルタイムに背景を合成するサンプルを以下に置いておきます。
マシンのスペックにも寄りますが、私の環境ではほぼタイムラグなしで合成できていました。
試してみてください。

sample.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <style type="text/css">body{margin:0; padding:0;}</style>
    <script src="./sample.js" type="module"></script>
</head>
<body>
    <canvas id="cvs1">Canvas_1</canvas>
    <canvas id="cvs2">Canvas_2</canvas>
</body>
</html>
sample.js
"use strict";

//import
import "https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-core";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-converter";
import "https://cdn.jsdelivr.net/npm/@tensorflow/tfjs-backend-webgl";
import "https://cdn.jsdelivr.net/npm/@tensorflow-models/body-segmentation";

//変数
const WIDTH  = 640;
const HEIGHT = 480;

let cvs1, ctxt1;
let cvs2, ctxt2;
let bcvs, bctxt;
let video;

let stream;
let segmenter;

//ページ読込時に実行
window.addEventListener("DOMContentLoaded",
    async () => {
      await Promise.all([
              Get_Element(),
              Load_Images(),
              Camera_Open(),
              Load_Modules()
        ]);
      Exe_Loop();
    }
)

//HTML Element取得
async function Get_Element(){
    cvs1    = document.getElementById("cvs1");
    cvs2    = document.getElementById("cvs2");
    bcvs    = document.createElement("canvas");
    
    ctxt1   = cvs1.getContext("2d");
    ctxt2   = cvs2.getContext("2d");
    bctxt   = bcvs.getContext("2d");

    cvs1.width  = cvs2.width  = bcvs.width  = WIDTH;
    cvs1.height = cvs2.height = bcvs.height = HEIGHT;

    video   = document.createElement("video");
}

//画像読込関数
async function load_image(path){
    const t_img = new Image();
    return new Promise(
        (resolve) => {
            t_img.onload = () => { resolve(t_img); };
            t_img.src = path;
        }
    );
}

//画像読込
async function Load_Images(){
    const b_img = await load_image("./back_img.jpg");   //背景画像
    await bctxt.drawImage(b_img, 0, 0, bcvs.width, bcvs.height);
}

//モジュール類用意
async function Load_Modules(){
    const segmenterConfig = {
        runtime: 'mediapipe', 
        solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/selfie_segmentation',
        modelType: 'general'
      }

    const model = bodySegmentation.SupportedModels.MediaPipeSelfieSegmentation;
    segmenter = await bodySegmentation.createSegmenter(model, segmenterConfig);
}

//カメラ開始
async function Camera_Open(){

    stream = await navigator.mediaDevices.getUserMedia({
        video:  true,
        audio:  false
    });

    video.srcObject = stream;
    video.play();

}

//実行ループ
async function Exe_Loop(){

    await ctxt1.drawImage(video, 0, 0, 640, 480);
    await back_comp_execute();

    window.requestAnimationFrame(Exe_Loop);

}



//背景合成
async function back_comp_execute(){

    let DspImgData = ctxt2.getImageData(0, 0, cvs2.width, cvs2.height);
    let f_img_data = ctxt1.getImageData(0, 0, cvs1.width, cvs1.height);
    let b_img_data = bctxt.getImageData(0, 0, bcvs.width, bcvs.height);

    let segmentDATA = await segmenter.segmentPeople(cvs1).then(
        (result) => {
            return result[0].mask.toImageData();
        }
    );

    for(let i = 0; i < cvs1.height; i++){
        for(let j = 0; j < cvs1.width; j++){
            const n = i * cvs1.width + j;
            let w = segmentDATA.data[4 * n + 3] / 255;

            DspImgData.data[4 * n + 0] = Math.floor( f_img_data.data[4 * n + 0] * w + b_img_data.data[4 * n + 0] * (1 - w) );
            DspImgData.data[4 * n + 1] = Math.floor( f_img_data.data[4 * n + 1] * w + b_img_data.data[4 * n + 1] * (1 - w) );
            DspImgData.data[4 * n + 2] = Math.floor( f_img_data.data[4 * n + 2] * w + b_img_data.data[4 * n + 2] * (1 - w) );
            DspImgData.data[4 * n + 3] = Math.floor( f_img_data.data[4 * n + 3] * w + b_img_data.data[4 * n + 3] * (1 - w) );
        }
    }

    ctxt2.putImageData(DspImgData, 0, 0);
}

感想等

数年前までは実行するのに苦労したことが手軽にできるようになってるな、と思います。
便利になったなぁと思いつつ、無償で提供してくれている方々に深く感謝します。

私も何か作って提供できるようになりたいと思います。

いいねなど、反応もらえると嬉しいです。
間違いなどありましたらご連絡ください。

Discussion