【Next.js + TensorFlowでweb cameraにバーチャル背景をつける】バーチャル旅行を体験できるアプリを作った

先日#web1weekで[気軽にバーチャル旅行をしよう](https://crieit.net/boards/web1week-202107/7510face763fc6998cc884bf20561721)ということでバーチャル背景をつけるアプリを開発しました。

ここでは、``create next-app``の状態から完成に至るまでを１から書きます。
以下のアプリが作れるようになります。
![ドゴーン.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/ef01fefe-1441-c7ab-245c-aa4616130f22.png)
DEMO→https://travel-app-three.vercel.app/
github→https://github.com/yuikoito/tensorflow-bodypix-sample

ちなみに週一でなにかアプリを作って発信するということを続けていますが、この記事で**10週目**になります。
あとがきにこれまでの記事をまとめておきますので、よければそちらも読んでいただけると嬉しいです！

## 雛形作成

```
$ yarn create next-app <app-name>
```

これにTypeScriptを入れます。

```
$ touch tsconfig.json
$ yarn add --dev typescript @types/react
```

.jsファイルを.tsxに変更したら導入完了です。

今回カメラにはreact-webcamを使いますので、インストールします。

```
$ yarn add react-webcam @types/react-webcam
```

なお、react-webcamの動かし方は前に書いたので割愛しますが、気になる方は[こちらの記事](https://qiita.com/yuikoito/items/b8062b9f89f72c6865ed)をご参照ください。

これで雛形ができました。

## TensorFlow.jsをインストールする

TypeScriptでTensorFlow.jsを使う場合、少し注意が必要です。
TypeScriptが入っていなければ、[公式](https://www.npmjs.com/package/@tensorflow/tfjs)通り

```
$ yarn add @tensorflow/tfjs
```

とするだけでOKなのですが、TypeScriptの場合はそれだとインストールは正常に行われるものの、実際に使ってみると型エラーが起こってしまいます。
一部まだTypeScriptに対応していない部分があるのでしょうか..。（この辺ちゃんと調べてないので推測ですが）

そこで、今回必要なのはその中でも**tfjs-core**と**tfjs-converter**と**tfjs-backend-webgl**なので以下のようにインストールします。

```
$ yarn add @tensorflow-models/body-pix @tensorflow/tfjs-core @tensorflow/tfjs-converter @tensorflow/tfjs-backend-webgl
```

今回は[body-pix](https://github.com/tensorflow/tfjs-models/tree/master/body-pix)というモデルも使うので合わせてインストールしています。

## bodyPixを使うためのセットアップをする


[公式](https://github.com/tensorflow/tfjs-models/tree/master/body-pix)に書いてあるとおり、使い方はとてもシンプルです。
①bodyPixをインポート②それをロードする③ロードが完了したら、segmentPerson関数で引数に解析したい画像を入れる、だけです。

今回はNext.jsで書いてるので、以下のような書き方をします。

```react:index.tsx
// まずは使うものをインポートする
import { useRef, useState, useEffect } from "react";
import Head from "next/head";
import "@tensorflow/tfjs-core";
import "@tensorflow/tfjs-converter";
import "@tensorflow/tfjs-backend-webgl";
import styles from "../styles/Home.module.scss";
import * as bodyPix from "@tensorflow-models/body-pix";
import Webcam from "react-webcam";

function Home() {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const webcamRef = useRef<Webcam>(null);
  // bodypixnetをuseStateで状態管理する
  const [bodypixnet, setBodypixnet] = useState<bodyPix.BodyPix>();

  // 最初にページがロードされたときのみ実行する
  useEffect(() => {
    bodyPix.load().then((net: bodyPix.BodyPix) => {
      setBodypixnet(net);
    });
  }, []);
    
  // 以下の関数でcanvasへの上書きをする
  const drawimage = async (
    webcam: HTMLVideoElement
  ) => {
    const segmentation = await bodypixnet.segmentPerson(webcam);
    console.log(segmentation);
  };

  const clickHandler = async () => {
    const webcam = webcamRef.current.video as HTMLVideoElement;
    const canvas = canvasRef.current;
    // canvas部分とwebcam部分、動画サイズをすべて同じサイズにする
    webcam.width = canvas.width = webcam.videoWidth;
    webcam.height = canvas.height = webcam.videoHeight;
    const context = canvas.getContext("2d");
    context.clearRect(0, 0, canvas.width, canvas.height);
    // bodypixnetがセットされるまでにクリックされたらエラーになるので一応
    if (bodypixnet) {
      drawimage(webcam);
    }
  };
  return (
    <div className={styles.container}>
      <Head>
        <title>Travel App</title>
        <meta name="description" content="Generated by create next app" />
        <link rel="icon" href="/static/logo.jpg" />
      </Head>
      <header className={styles.header}>
        <h1 className={styles.title}>タイトル</h1>
      </header>
      <main className={styles.main}>
        <div className={styles.videoContainer}>
          <Webcam audio={false} ref={webcamRef} className={styles.video} />
          <canvas ref={canvasRef} className={styles.canvas} />
        </div>
        <div className={styles.right}>
          <h4 className={styles.title}>{t.select}</h4>
          <div className={styles.buttons}>
            <button onClick={clickHandler}>
              ボタン
            </button>
          </div>
        </div>
      </main>
    </div>
  );
}

export default Home;

```

drawimage関数で今後動画をcanvasに書いたりという作業をしていくのですが、まずは正しくbodypixが使えていることを確認します。

ボタンがクリックされたタイミングでwebcamとcanvasと中身の動画データのサイズをすべてそろえます。
その後、bodypixnetがある場合に限り、drawimageを実行します。
drawimageではbodypixnet.segmentPersonの引数に持ってきたvideoデータ（webcam）を入れることで、解析できます。（以下の部分）

```react
const segmentation = await bodypixnet.segmentPerson(webcam);
```

これで正しくsegmentationがログに出力されていればOKです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/6208d7b4-b7ba-3da8-fdcd-59eab04c83fb.png)

ちなみに、背景をぼかすとか、人物とそれ以外で色分けするとかだけなら、以下のように簡単にかけます。
（https://github.com/tensorflow/tfjs-models/tree/master/body-pix#output-visualization-utility-functions より）

```react
const coloredPartImage = bodyPix.toMask(segmentation);
const opacity = 0.7;
const flipHorizontal = false;
const maskBlurAmount = 0;
const canvas = document.getElementById('canvas');
// Draw the mask image on top of the original image onto a canvas.
// The colored part image will be drawn semi-transparent, with an opacity of
// 0.7, allowing for the original image to be visible under.
bodyPix.drawMask(
    canvas, img, coloredPartImage, opacity, maskBlurAmount,
    flipHorizontal);
```

bodyPix.drawMaskでは透明度を指定したり、境界線をぼかしたり、左右を反転させたりなどを簡単に指定することができます。

今回はbodyPix.drawMaskは使わずに直接canvasにdrawImage()で書く方法でやります。

## 背景を抜いてバーチャル背景にする

今回、**背景を抜いて**とは書いてますが、実際に背景をくり抜いてpngのような透過画像を作っているわけではありません。
色々調べてたのですが、完全に背景を抜きたい場合は、WebGLを使う必要がある感じがしました。（png画像をそのままcanvasに乗っけたら背景が黒くなってしまいますしね。）

参考→[Using BodyPix segmentation in a WebGL shader](https://jameshfisher.com/2020/09/24/using-bodypix-segmentation-in-a-webgl-shader/)

そこで、調べたら[canvasのdestination-outを利用してる例](https://lealog.hateblo.jp/entry/2020/02/13/100329)があったので、その使い方をすることにしました。

ちなみに``xor``でもできます。

destination-outは以下の図の通り、新たな画像と重なりあう部分だけが消えるので、もう一つ仮想的なcanvas要素を用意して、その上にbodyPix.toMask()で取得した画像イメージを載せたら今回実現したいバーチャル背景は実現できるからです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/02e1ebff-af50-4a98-4d8a-937b6cb52074.png)

ちなみに、bodyPix.toMask()の中身はログを見るとImageDataとして入っていることがわかります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/670446/3e7caa20-fc9d-65c3-14ab-ea8f70d5721e.png)

さて、ロジック部分が固まったので、書いていきます。（とはいえ、この部分は多少書き換えただけで[canvasのdestination-outを利用してる例](https://lealog.hateblo.jp/entry/2020/02/13/100329)とほとんど同じです。）

```react
  const drawimage = async (
    webcam: HTMLVideoElement,
    context: CanvasRenderingContext2D,
    canvas: HTMLCanvasElement
  ) => {
    // tempCanvasを作る
    const tempCanvas = document.createElement("canvas");
    tempCanvas.width = webcam.videoWidth;
    tempCanvas.height = webcam.videoHeight;
    const tempCtx = tempCanvas.getContext("2d");
    (async function drawMask() {
      requestAnimationFrame(drawMask);
      // maskをtempCanvasにのせる
      const segmentation = await bodypixnet.segmentPerson(webcam);
      const mask = bodyPix.toMask(segmentation);
      tempCtx.putImageData(mask, 0, 0);
      // もとのオリジナルイメージ（動画）を描画する
      context.drawImage(webcam, 0, 0, canvas.width, canvas.height);
      // destination-outを使って重なり合う部分（マスク部分）を透過させる
      context.save();
      context.globalCompositeOperation = "destination-out";
      context.drawImage(tempCanvas, 0, 0, canvas.width, canvas.height);
      context.restore();
    })();
  };
```

これで、クリックするタイミングでcanvas本体に背景画像を入れれば完了です。

直接canvas.style.backgroundImageで指定をしても良いんですが、即時に画像が読み込めなかったりと、タイミングを揃えるのが難しかったので、css上で予め読み込んでおき、クラス名に応じて背景をつけるようにしました。

そのため、clickHandlerの引数にクラス名を入れるようにしました。

```react
  const clickHandler = async (className: string) => {
    const webcam = webcamRef.current.video as HTMLVideoElement;
    const canvas = canvasRef.current;
    webcam.width = canvas.width = webcam.videoWidth;
    webcam.height = canvas.height = webcam.videoHeight;
    const context = canvas.getContext("2d");
    context.clearRect(0, 0, canvas.width, canvas.height);
    canvas.classList.add(className);
    if (bodypixnet) {
      drawimage(webcam, context, canvas);
    }
  };
```

css側は例えば以下のようになってます。

```css
.turky {
  background-image: url(../assets/turky.jpg);
  background-size: cover;
}
```

これでturkyクラスを付与されたらそのタイミングで/assets/turky.jpgが背景画像として設置されるようになりました。

ちなみに、今回私が作ったみたいにいくつもクラス名を用意しないといけない場合は、addするタイミングで過去のclassNameをremoveする必要があるので、そのへんはご自由に行ってもられば良いと思います。
（私は以下のようにしました。）

```react
    if (prevClassName) {
      canvas.classList.remove(prevClassName);
      setPrevClassName(className);
    } else {
      setPrevClassName(className);
    }
    canvas.classList.add(className);
```

後はデプロイして完成です！

背景画像は全部私が以前撮った画像を利用してます。
はやく旅行行けるようになりたいですね〜。

あと、これは宣伝なのですが、ボタンのレイアウトには自作の[ui-components](https://www.ui-components.com/Button)をコピペして使いました。
やはりコピペである程度レイアウト整うと嬉しいですね。

## あとがき

これで10週目の週イチ発信となりました。
今回ちょうど#web1weekが開催されていたので、そのお題にのっとって作りましたが、お題があると考えやすいですね。
2ヶ月以上毎週アプリを作ってることもあり、ネタ切れ感があったので、大変助かりました。笑

良ければこれまでの週イチ発信も見て下さい！
ではでは〜。

- [【React + Typescriptで顔認識】tensorflowを使って画像にマスクをかけるアプリを作った](https://qiita.com/Yui_active/items/b8062b9f89f72c6865ed)
- [【React + Typescript】ボタン一つでコンポーネントのscssをコピーできるサイトを作った](https://qiita.com/Yui_active/items/1288fed0dcc24c71e159)
- [【アップデート】ui-componentsに18個のコンポーネントを追加した](https://qiita.com/yuiko_ito/items/c1d0599e1e7da7fa42e0)
- [【Nuxt.js × Tailwind CSS】ボタン一つで有名絵画風の画像にできるサービスをリリースした！](https://qiita.com/yuikoito/items/126b0e2d36a707bfd36e)
- [【GASでLINE Bot作成】現在地の近くのおすすめのごはん屋さんを教えてくれるLINE Botを作った](https://qiita.com/yuikoito/items/c1aef147cef4458f9d2e)
- [【動的OGP】Next.js + TypeScript + Vercelデプロイで動的OGPを実現する](https://qiita.com/yuikoito/items/619120c592d99f9d3053)
- [【LambdaでOpenCVを利用】AWSとOpenCVを利用してポケモン画像でアスキーアート風に変換するAPIを作った](https://qiita.com/yuikoito/items/7dcf1deeed46f46b2f2e)
- [ポケモン画像でアスキーアート風に変換するwebアプリを作った](https://qiita.com/yuikoito/items/da5954a5d78da9de6a4e)
- [ボタン一つで漫画風の画像にできるサービスを作った](https://qiita.com/yuikoito/items/a76ea5bf05f04a44df8d)