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

# はじめに

これは[Infocom Advent Calendar 2019](https://qiita.com/advent-calendar/2019/infocom) 15日目の記事です。

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

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

- [tensorflow.js](https://github.com/tensorflow/tfjs) の人体検出モデル [tfjs-models/body-pix](https://github.com/tensorflow/tfjs-models/tree/master/body-pix) (Apache 2.0 ライセンス)
- WebAudioを使ったピッチシフト [Pitch shifter](https://github.com/urtzurd/html-audio) (MIT ライセンス)
- WebRTC シグナリングサービス [Ayame Lite](https://ayame-lite.shiguredo.jp/beta)（株式会社時雨堂)
    - そのSDK [ayame-web-sdk](https://github.com/OpenAyame/ayame-web-sdk)(Apache 2.0 ライセンス)

# tensorflow.js

[tensorflow.js](https://github.com/tensorflow/tfjs) は、Googleが開発している機械学習用のフレームワーク[tensorflow](https://www.tensorflow.org/)を、JavaScriptで実装したものです。ブラウザ上やNode.jsで利用することができます。

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

# Body-pixによる人体検出

tensorflow.js の body-pix の使い方は、[公式サイトの説明](https://github.com/tensorflow/tfjs-models/tree/master/body-pix#usage)を見るのが詳しいです。と言ってしまうと終わりなので、以下に説明します。

## JSの読み込み

```html
    <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を使って完了を待ちます。

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

## 画像から人体を検出

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

```js
    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ステップになります。

```js
    // セグテーションを実行
    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);
    }
```

## ここまでのデモ（画像をマスク）

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

- ソースコード(GitHub) [image_mask.html](https://github.com/mganeko/bodypix_ayame/blob/master/image_mask.html)
- デモページ(GitHub Pages) [image_mask.html](https://mganeko.github.io/bodypix_ayame/image_mask.html)

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


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

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

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

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

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

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

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

- person ... 人物をマスクする
- room ... 背景（周囲の部屋の様子）をマスクする
- none ... マスク無し

```js
    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 を使って、マスクの境界にボケ効果を入れています。

```js
    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()を使います。

```js
    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カメラが必要です。

- ソースコード(GitHub) [video_mask.html](https://github.com/mganeko/bodypix_ayame/blob/master/video_mask.html)
- デモページ(GitHub Pages) [video_mask.html](https://mganeko.github.io/bodypix_ayame/video_mask.html)

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

# つづく

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