VtuberになってWebRTCする

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77159/f74e6df1-39c8-a769-af95-f6ef91b27394.gif)

この記事は [SkyWay Advent Calendar](https://qiita.com/advent-calendar/2019/skyway) の20日目です

Webカメラの映像をアバターに反映し、その映像をSkyWayに投げて~~殺伐な~~テレビ会議を楽しくしちゃうぞ

モデルは[three-vrm-girl](https://github.com/pixiv/three-vrm/blob/master/examples/models/three-vrm-girl.vrm)をお借りしました:kawaii:

WebGLはさっぱりなんですが、まずはthree.jsを触ってみようとやってみました
SkyWayにMediaStreamを渡すまでがメイン

リポジトリは一番下へ

## 流れ
### 顔認識
1. WebCamからVideoStream取得
2. (face-api.js)学習済みモデルのロード
3. (face-api.js)HTMLVideoElementをInputとして、68landmarks、表情を取得
4. デバッグ用のcanvasに68landmarksを表示

### VRMの表示・モーション制御
1. (three.js) レンダラ、カメラ、ライティング、シーンの初期化、canvasへレンダリング
2. (three-vrm) VRMのロード・初期化・シーンへの追加
3. (three-vrm) 顔認識の結果をアバターに反映

### リアルタイムコミュニケーション
1. VRMを表示しているcanvasからMediaStream(Video)を作成
2. マイクデバイスからMediaStream(Audio)を取得
3. MediaStream(Audio)からAudioTrackを取得
4. 2.で作ったMediaStream(Video)に3.のAudioTrackを追加
5. (SkyWay) Peer初期化
6. (SkyWay) 4.でできたMediaStreamを元に、p2pやroomを初期化

## ライブラリ
### face-api.js

https://github.com/justadudewhohacks/face-api.js/

画像ベースの顔認識を行うためにはAzure Face APIなどクラウドに画像をHTTPで投げてその結果を取得できるというタイプが多いですが、遅延が大きいという欠点があります

最近ではリアルタイムで分析できるようになっているAPIもありますが、while文でフレームを投げて表示するためコマ送りのように表示されてしまうみたいです

face-api.jsはtensorflowのコア(tfjs-core)を用いた顔認識を**ブラウザ**で行うライブラリです
学習済みのモデルを用いて簡単に顔認識ができます。Readmeが丁寧に書かれているのでオススメです

いわゆるエッジAIって言うんですかね　~~これで俺もAIエンジニア~~

[デモ](https://justadudewhohacks.github.io/face-api.js/)でみる感じ低遅延で :star: も8kくらいついてたので使ってみました

### three-vrm

https://github.com/pixiv/three-vrm

去年ドワンゴがOSSとして出したVRMを触ったことがなかったのでやってみようと思い、こちらのthree.jsでロードできるライブラリを利用しました

### SkyWay

https://webrtc.ecl.ntt.com/

言わずもがな！
WebRTCプラットフォームです


## 実装
face-api.js, three-vrm共にExampleコードがあったので、良い感じに混ぜ合わせていきます

### 顔認識

最初にindex.htmlにwebcamの映像をいれておくvideoタグと、ランドマークを表示するcanvasタグを仕込んでおきます

```html:index.html
<video id="webcam-video"></video>
<canvas id="landmarks"></canvas>
```

`getUserMedia()`で映像取得

```js:index.js
const $video = document.getElementById('webcam-video')
$video.srcObject = await navigator.mediaDevices.getUserMedia({video: true})
```

HTMLMediaElement#play()させたら、学習済みモデルのロードを行います
リポジトリの[weights](https://github.com/justadudewhohacks/face-api.js/tree/master/weights)をDLしてプロジェクトルートに置いておきます

今回は軽量なtinyFaceDetectorを用いました
頭の回転(ヨー回転)・リップシンク・表情反映を行いたかったので、68個のランドマークが取れるモデル(FaceLandmarkModel)と感情認識が行えるモデル(FaceExpressionModel)をロードします

```js:index.js
import * as faceapi from 'face-api.js'

const $video = document.getElementById('js-video')
$video.srcObject = await navigator.mediaDevices.getUserMedia({video: true})

$video.play().then(async () => {
  // Load learned models
  await faceapi.nets.tinyFaceDetector.load('/weights')
  await faceapi.loadFaceLandmarkModel('/weights')
  await faceapi.loadFaceExpressionModel('/weights')
})
```

Exampleを参考にしたループ関数

```js:index.js
$video.play().then(async () => {
  ...
  const loop = async () => {
    if (!faceapi.nets.tinyFaceDetector.params) {
      return setTimeout(() => loop())
    }
    // Exampleを参考に設定
    const option = new faceapi.TinyFaceDetectorOptions({ inputSize: 224, scoreThreshold: 0.5 })
    const result = await faceapi.detectSingleFace($video, option).withFaceLandmarks().withFaceExpressions()
    if (result) {
      // デバッグをしつつ決めた値をスレッショルドとする(表情筋が硬い場合は下げようね！)
      if (result.expressions.happy > 0.7) {
        smiling = true
      }
      // 頭部回転角度を鼻のベクトルに近似する
      // 68landmarksの定義から鼻のベクトルを求める
      const upperNose = result.landmarks.positions[27]
      const lowerNose = result.landmarks.positions[30]
      let noseVec = lowerNose.sub(upperNose)
      noseVec = new THREE.Vector2(noseVec.x, noseVec.y)
      // angle関数はx+方向を基準に角度を求めるため、π/2引いておく。逆回転なのでマイナスをかける
      headRowAngle = -(noseVec.angle() - (Math.PI / 2))
      // リップシンク
      // 68landmarksの定義から、口の垂直距離を測る
      const upperLip = result.landmarks.positions[51]
      const lowerLip = result.landmarks.positions[57]
      lipDist = lowerLip.y - upperLip.y
      // デバッグ用にcanvasに表示する
      const dims = faceapi.matchDimensions($landmarkCanvas, $video, true)
      const resizedResult = faceapi.resizeResults(result, dims)
      faceapi.draw.drawFaceLandmarks($landmarkCanvas, resizedResult)
    }
    setTimeout(() => loop())
  }
  loop()
  })
})
```

#### 頭部回転
face-api.jsには頭部回転角度を提供するAPIがまだないです
https://github.com/justadudewhohacks/face-api.js/issues/107

厳密にやるとすれば、PnP問題を解いて2次元データから3次元の回転角度が推定できるようですが、もっとイージーにやりたかったので別の方法を考えました

それは、鼻筋のベクトル角度≒頭部回転角度で近似する方法です(首振りながら68landmarksを眺めて気づいた)
ざっくりですが、ヨー方向だけならこれでいいかなと思い実装しました
結果、プレゼンス剥げない程度の精度でいい感じだと思います

#### リップシンク
上唇と下唇の距離を使います
68landmarksで画像検索するとインデックスを振った定義画像があるのでそれを参考に唇の位置を取ってきます

アバターに反映させるときは距離ではなくブレンドシェイプを使うので、0~1.0の間の値になります
なので、デバッグ段階で閉じた唇の距離を計測しておき、これを0とし
MAXまで口を大きく開けてその値を最大値の1.0とし、距離からratioを作ってこれをブレンドシェイプの値にします

### アバターの表示とアニメーション反映

まずはthree.jsの初期化
カメラ位置とかは職人技で決めました

```index.js
import * as THREE from 'three'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { VRM, VRMSchema } from '@pixiv/three-vrm'

const width = 1024
const height = 768
const scene = new THREE.Scene()
const loader = new GLTFLoader()
const camera = new THREE.PerspectiveCamera(30.0, width / height, 0.1, 20.0)
const renderer = new THREE.WebGLRenderer()
const light = new THREE.DirectionalLight(0xffffff, 1)

renderer.setClearColor(0xeeeeee)
renderer.setSize(width, height)
camera.position.set(0.0, 1.35, 0.8)
light.position.set(0, 100, 30)
scene.add(light)

const gridHelper = new THREE.GridHelper(10, 10)
scene.add(gridHelper)
const axesHelper = new THREE.AxesHelper(5)
scene.add(axesHelper)
```

#### まばたき
まばたきがあると、アバターのプレゼンスがグッと上がります

今の所、face-api.jsにはまばたきを検出するAPIがないです
https://github.com/justadudewhohacks/face-api.js/issues/176

なので、いい感じのランダムでまばたきしてもらいます
今回は毎秒15%の確率でまばたきフラグを立てます

```index.js
let blinking = false
...
setInterval(() => {
  if (Math.random() < 0.15) {
    blinking = true
  }
}, 1000)
```

周期関数(sin)を作ってこの値で目の開き具合を調整します

```index.js
const render = () => {
  if (vrm) {
    ...
    const deltaTime = clock.getDelta()
    let s = Math.sin(Math.PI * clock.elapsedTime)
    ...
    // まばたきするスピード
    s *= 5
    vrm.blendShapeProxy.setValue(VRMSchema.BlendShapePresetName.Blink, s)
  }
})
```

このブレンドシェイプの設定だけでOKというのがとてもよい

```js
vrm.blendShapeProxy.setValue(VRMSchema.BlendShapePresetName.Blink, s)
```




#### 頭部回転(首振り)

render関数の中で、角度を代入するだけ(単位はラジアン)
ボーンアニメーションというやつです。ブレンドシェイプはsetterだったのに対し、こちらは直で代入

顔認識のところで得た`headYawAngle`を用いて

```js
vrm.humanoid.getBoneNode(VRMSchema.HumanoidBoneName.Head).rotation.y = headYawAngle
```

これだけで髪が揺れたり、自然な感じで頭部が同期される :sugoi:
(UnityだとTransform.rotate()とかTransform.rotationに代入すると首だけ回転するイメージ)

そのまま代入してみるとなんと、静止状態でも顔がプルプルしちゃいました
そこで前フレームとのdiffを取って、変化がそこそこあるときに反映するようにします

0.02と決め打ちにしていますが、この値が大きくなるとカクつきます

```index.js
const render = () => {
  if (vrm) {
    ...
    if (headYawAngle) {
      if (Math.abs(prevHeadYawAngle - headYawAngle) > 0.02) {
        // 変化を増幅させる
        const y = headYawAngle * 2.5
        // 顔が90度以上回転するとこわいからやめようね
        if (Math.abs(y) < Math.PI / 2) {
          vrm.humanoid.getBoneNode(VRMSchema.HumanoidBoneName.Head).rotation.y = y
        }
      }
      prevHeadYawAngle = headYawAngle
    }
    ...
  }
})
```

#### リップシンク
顔認識のところで得た`lipDist`を使います
”あ”という口を元にリップシンクしていきます

```js
const render = () => {
  if (vrm) {
    ...
    // 口を閉じた時に0、MAXまで開けた時を1となるように調整
    let lipRatio = (lipDist - 30) / 25
    if (lipRatio < 0) {
      lipRatio = 0
    } else if (lipRatio > 1) {
      lipRatio = 1
    }
    vrm.blendShapeProxy.setValue(VRMSchema.BlendShapePresetName.A, lipRatio)
  }
})
```

#### 表情
顔認識のところで得た`smiling`を使い、まばたきと同じ処理をします

```js
vrm.blendShapeProxy.setValue(VRMSchema.BlendShapePresetName.Joy, s)
```

### リアルタイムコミュニケーション
まずは仕上がったCanvasからMediaStreamを作ります。`captureStream()`でいけます

```js
const $avatarCanvas = document.querySelector('#avatar-canvas')
const stream = $avatarCanvas.captureStream(30)
```

会話もできるように、マイクからAudioTrackを取ってきて、上のMediaStrem(Video)とがっちゃんこします

```js
const audioStream = await navigator.mediaDevices.getUserMedia({ audio: true })
const audioTrack = audioStream.getAudioTracks()[0]
stream.addTrack(audioTrack)
```

これで欲しいMediaStreamは手に入れました
あとは、Peerを初期化して、p2pなりroomなりに渡せばおしまいです

以下はSFURoomの例

```js
const peer = new Peer({
  key: API_KEY
})
peer.on('open', peerId => {
  ...
  // 入室ボタンクリックなど
  const room = peer.joinRoom(roomName, { 
    mode: 'sfu',
    stream
  })
  room.on('open', () => {
    console.log('入室しました')
    ...
  })
  ...
})
```

<img width="1120" alt="スクリーンショット 2019-12-12 23.00.43.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/77159/67d76843-8ac2-3503-91a9-3d0ba4f3327d.png">

しっかり映像と音声が送受信できてました！ :yattane:

## リポジトリ

https://github.com/sandabu/rtc-vtuber

## そのほか
後から知りましたが、こっちのライブラリだと頭部回転をそのまま取れるらしいです
https://github.com/jeeliz/jeelizFaceFilter

男性の場合はボイスチェンジャーをかまして、SoundflowerなどのVirtual Micを経由させればコードを変えずに女の子になりきれます


## 参考
先にサービスとしてやっていた先駆者の方
https://facevtuber.com/

https://blog.camph.net/event/pixiv-2019/

明日はジャンボさんの記事！