LoginSignup
13
12

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-07-26

先日#web1weekで気軽にバーチャル旅行をしようということでバーチャル背景をつけるアプリを開発しました。

ここでは、create next-appの状態から完成に至るまでを1から書きます。
以下のアプリが作れるようになります。
ドゴーン.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の動かし方は前に書いたので割愛しますが、気になる方はこちらの記事をご参照ください。

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

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

TypeScriptでTensorFlow.jsを使う場合、少し注意が必要です。
TypeScriptが入っていなければ、公式通り

$ yarn add @tensorflow/tfjs

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

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

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

今回はbody-pixというモデルも使うので合わせてインストールしています。

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

公式に書いてあるとおり、使い方はとてもシンプルです。
①bodyPixをインポート②それをロードする③ロードが完了したら、segmentPerson関数で引数に解析したい画像を入れる、だけです。

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

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)を入れることで、解析できます。(以下の部分)

const segmentation = await bodypixnet.segmentPerson(webcam);

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

image.png

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

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

そこで、調べたらcanvasのdestination-outを利用してる例があったので、その使い方をすることにしました。

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

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

image.png

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

image.png

さて、ロジック部分が固まったので、書いていきます。(とはいえ、この部分は多少書き換えただけでcanvasのdestination-outを利用してる例とほとんど同じです。)

  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の引数にクラス名を入れるようにしました。

  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側は例えば以下のようになってます。

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

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

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

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

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

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

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

あとがき

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

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

13
12
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
12