LoginSignup
24
24

More than 3 years have passed since last update.

tensorflow.jsとWebRTCを組み合わせて、プライバシー保護のビデオチャットを作ってみた(前編)

Last updated at Posted at 2019-12-14

はじめに

これはInfocom Advent Calendar 2019 15日目の記事です。

過去に社内でWebRTCを使ったビデオ会議のデモをした際に、「在宅勤務で使うときに、部屋の様子(背景)を隠して通話できないか?」というリクエストを受けたことがありました。プライバシー保護言えば顔や人物を隠すことを想定していましたが、周囲の方を隠したいとの要望があることにちょっと驚きました。

今回はそれができるように、次の要素を組み合わせたサンプルを作ったので紹介します。

tensorflow.js

tensorflow.js は、Googleが開発している機械学習用のフレームワークtensorflowを、JavaScriptで実装したものです。ブラウザ上やNode.jsで利用することができます。

当初は学習済みモデルを使った推論に特化していましたが、現在は学習もできるようになっています。すでに学習済みモデルが多数公開されているのが嬉しいところで、今回利用するtfjs-models/body-pix も、その一つです。

Body-pixによる人体検出

tensorflow.js の body-pix の使い方は、公式サイトの説明を見るのが詳しいです。と言ってしまうと終わりなので、以下に説明します。

JSの読み込み

    <script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs@1.2"></script>
    <script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/body-pix@2.0"></script>

モデルのロード

モデルのロードは非同期なので、今回はawaitを使って完了を待ちます。

    let bodyPixNet = null;
    async function loadModel() {
      const net = await bodyPix.load(/** optional arguments, see below **/);
      bodyPixNet = net;
    }
    loadModel();

画像から人体を検出

人体の検出はセグメンテーションと呼ばれます。こちらも非同期処理です。imgタグと、先ほど読み込んだモデル(net)を渡して、プロミスを返しています。

    function segmentPerson(img, net) {
      /**
      * One of (see documentation below):
      *   - net.segmentPerson
      *   - net.segmentPersonParts
      *   - net.segmentMultiPerson
      *   - net.segmentMultiPersonParts
      * See documentation below for details on each method.
      */
      const option = {
        flipHorizontal: false,
        internalResolution: 'medium',
        segmentationThreshold: 0.7,
        maxDetections: 4,
        scoreThreshold: 0.5,
        nmsRadius: 20,
        minKeypointScore: 0.3,
        refineSteps: 10
      };

      return net.segmentPerson(img, option);
    }

オプションは色々指定できますが、基本的には公式のサンプルの値を使い、人体の最大数 maxDetections だけ変更しました。

画像をマスク

先ほど取得したセグメンテーションを使って、画像をマスクします。マスクを作って、それを使ってキャンバスに描画する、という2ステップになります。

    // セグテーションを実行
    const segment = await segmentPerson(img, bodyPixNet)
    maskCanvas(canvas, img, segment);

    // 画像をマスクする
    function maskCanvas(canvas, img, segmentation) {
      // マスクを作成
      const fgColor = { r: 255, g: 0, b: 0, a: 128 };  // 人物は赤、半透明
      const bgColor = { r: 0, g: 0, b: 0, a: 0 }; // 背景は透明(色は黒だが無関係)
      const colorMask = bodyPix.toMask(segmentation, fgColor, bgColor);

      // マスクを使って描画
      const opacity = 1.0;
      const flipHorizontal = false;
      const maskBlurAmount = 0;
      bodyPix.drawMask(
        canvas, img, colorMask, opacity, maskBlurAmount,
        flipHorizontal);
    }

ここまでのデモ(画像をマスク)

ここまでのデモがこちらです。

人体の移った画像ファイルを選択して、数秒~10秒待つと、元の画像と人体が赤く半透明にマスクされた画像が表示されます。

動画から連続して人体検出

動画から継続して人体を検出&マスクし続けるには、次の2つが必要です。

  • (a) 動画のコマから人体を検出して、マスクを作成
  • (b) 動画のコマ毎に、マスクを使って描画

(b)はなるべくフレームレートを上げて、動画がコマ落ちさせたくないところです。なので、window.requestAnimationFrame()を使って、マシンのパフォーマンスが許す限り描画します。

一方(a)の人体検出(セグメンテーション)は非同期なので、(b)とは切り離してsetTimeout()で断続的に実行するします。連携は原始的にグローバル変数を使っちゃいました(ショボい)

(a) 動画のコマから人体検出

動画から人体を検出し、マスクを作成する処理はこちらです。今回マスクの種類は3種類対応しました。

  • person ... 人物をマスクする
  • room ... 背景(周囲の部屋の様子)をマスクする
  • none ... マスク無し
    function updateSegment() {
      const segmeteUpdateTime = 10; // ms

      const option = {
        // 省略 
      };

      // マスク無しなら、マスクをクリアして終了
      if (maskType === 'none') {
        bodyPixMaks = null;
        if (contineuAnimation) {
          // 次の人体セグメンテーションの実行を予約する
          segmentTimerId = setTimeout(updateSegment, segmeteUpdateTime);
        }
        return;
      }

      // ビデオから、人体をセグメンテーション
      bodyPixNet.segmentPerson(localVideo, option)
        .then(segmentation => {
          if (maskType === 'room') {  // 背景をマスクする場合
            const fgColor = { r: 0, g: 0, b: 0, a: 0 }; // 人体部分は透明
            const bgColor = { r: 127, g: 127, b: 127, a: 255 }; // 周囲はグレイ、不透明
            const personPartImage = bodyPix.toMask(segmentation, fgColor, bgColor);
            bodyPixMaks = personPartImage; // マスクをグローバル変数に保持
          }
          else if (maskType === 'person') {  // 背景をマスクする場合
            const fgColor = { r: 127, g: 127, b: 127, a: 255 };  // 人体部分はグレイ、不透明
            const bgColor = { r: 0, g: 0, b: 0, a: 0 }; // 周囲は透明
            const roomPartImage = bodyPix.toMask(segmentation, fgColor, bgColor);
            bodyPixMaks = roomPartImage; // マスクをグローバル変数に保持
          }
          else {
            bodyPixMaks = null;
          }
          if (contineuAnimation) {
            // 次の人体セグメンテーションの実行を予約する
            segmentTimerId = setTimeout(updateSegment, segmeteUpdateTime);
          }
        })
        .catch(err => {
          console.error('segmentPerson ERROR:', err);
        })
    }

(b) マスクを使って動画のコマを描画

先ほど用意したマスクをつかって、videoからCanvasに描画しています。これを requestAnimationFrame() を使って、連続的に実行しています。今回はmaskBlurAmount を使って、マスクの境界にボケ効果を入れています。

    function updateCanvas() {
      drawCanvas(localVideo);
      if (contineuAnimation) {
        animationId = window.requestAnimationFrame(updateCanvas);
      }
    }

    function drawCanvas(srcElement) {
      const opacity = 1.0;
      const flipHorizontal = false;
      //const maskBlurAmount = 0;
      const maskBlurAmount = 3; // マスクの周囲にボケ効果を入れる
      // 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, srcElement, bodyPixMaks, opacity, maskBlurAmount,
        flipHorizontal
      );
    }

カメラ映像の取得

順番が前後しましたが、元にするカメラ映像の取得には、getUserMedia()を使います。

    async function startVideo() {
      //const mediaConstraints = {video: true, audio: true}; 
      //const mediaConstraints = {video: true, audio: false}; 

      // 今回は、640x480、オーディオなしで取得
      const mediaConstraints = { video: { width: 640, height: 480 }, audio: false };
      disableElement('start_video_button');

      localStream = await navigator.mediaDevices.getUserMedia(mediaConstraints).catch(err => {
        console.error('media ERROR:', err);
        enableElement('start_video_button');
        return;
      });

      localVideo.srcObject = localStream;
      await localVideo.play().catch(err => console.error('local play ERROR:', err));
      localVideo.volume = 0;

      // ... 省略 ...
    }

ここまでのデモ(動画をマスク)

ここまでのデモがこちらです。Webカメラが必要です。

[Start Video]ボタンをクリックすると、カメラへのアクセスの許可を求められます。許可を与えて数秒~10秒程度末と、元のカメラ映像と、マスクされた映像が表示されます。
ラジオボタンでマスクの種類を動的に切り替えることができます。

つづく

力尽きたので、今回はここまでにします。近日中に音声変換と通信のところを書く予定です(願望)

24
24
0

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
24
24