PHONE APPLI Engineer blog

エンジニアブログ

動物たちになりきってWebRTCしたい

5幕目のソロテントを購入しました。

リサーチデベロップメントのたかはしとし( @doushiman_jp ) です。

あだ名は避難所です。

前回のブログphoneappli.hatenablog.com

では、face-api.js を使用して、顔認識をして顔部分を(文字通りの)マスキングをしました。

こんなの

f:id:doushiman_jp:20210720142748p:plain

その中で

一度Canvas 上で画像をビデオとマスクを合成してからStream を生成すれば、WebRTC にも応用できるのかな〜。

と書いてしまったので、今回はそれをやってみたいと思います。

WebRTC 部分は僕の各ブログではおなじみのSkyWay を使います。

SkyWay 部分のコードはほぼこちらに公開されているサンプルコードをそのまま使います。

前回は、顔認識をして得た動画内の顔の位置座標の上にCanvas でマスクを描画して、それをvideoタグの上に乗せているだけのものでした。

この状態だと、カメラから得た映像と、マスク画像は別々のタグに描画されているだけですので、マスキングした状態の画像をWebRTC では送れません。

なので、一度ビデオ画像とマスク画像を1枚の画像に合成し、その画像を相手側に送ることが必要です。

画像合成

まずは普通に、getUserMedia でカメラの画像を取得してvideoタグに紐付けます。

let camera = document.getElementById('js-local-stream'),

navigator.mediaDevices.getUserMedia(constraints)
    .then((stream) => {
      camera.srcObject = stream;
      camera.onloadedmetadata = (e) => {
        playCamera();
        checkFace()
      };
    })

ただし、今回はこの画像は直接描画をしません。

合成の材料にするだけなので、CSS ではdisplay:none としています。

今度はvideo に表示している動画をCanvas タグに描画し、その上にマスク画像を合成します。

checkFace = async () => {
  let faceData = await faceapi.detectAllFaces(
    camera, new faceapi.TinyFaceDetectorOptions()
  ).withFaceExpressions();

  if(faceData.length){
     const setDetection = () => {
      ctx.drawImage(localVideo, 0, 0, canvasW, canvasH); // ローカルビデオ画像をCanvasに描画
      let box = faceData[0].detection.box;
          x = box.x,
          y = box.y,
          w = box.width,
          h = box.height;
      ctx.drawImage(chara, x, y, w, h); // マスク画像を顔の位置に描画
    };
    setDetection();
  }
  requestAnimationFrame(checkFace)
};

以下簡単な説明です。

まず最初に顔認識を行います。

ビデオ画像内に顔を検出したら、そのビデオ画像をCanvas のコンテキストを使って描画します。

ctx.drawImage(localVideo, 0, 0, canvasW, canvasH);

これをCanvas内の背景的に使用します。

この時点で、Canvas はカメラから取得した動画が描画されている状態です。

次に、顔認識で取得したX,Y の位置に、マスク画像を描画します。

let box = faceData[0].detection.box;
    x = box.x,
    y = box.y,
    w = box.width,
    h = box.height;
ctx.drawImage(chara, x, y, w, h);

requestAnimationFrame によって、フレームレートに準じて顔認識 → 画像合成が行われます。

ここまでで、ビデオ画像とマスク画像が一枚の画像として、ローカルで描画されていることになります。

合成画像のStreamをセットする

ここからが今回の本題です。

GitHub から入手したP2P でのビデオチャットのサンプルコードに手を入れます。

getUserMedia でビデオ画像を取得するところは、顔認識のところで既に行っているため、サクッと消します。

その後、SkyWay でのコネクションを確立するときに、peer に合成した画像のStream(mixedStream)を渡します。

const mediaConnection = peer.call(remoteId.value, mixedStream);

通常なら、mixedStream のところは、ローカルビデオのStream を渡すのですが、代わりに合成した画像のStream を渡すことで、マスキング済みの画像が先方に届くので、相手側にもマスクを被った状態で映ります。

これだけですと、接続に行った方はマスク画像になるのですが、接続された方も同様に、マスクを合成済みの画像を返す必要があります。

ですので、メディアコネクションがコールされた時に、ローカルのビデオではなく、合成済み画像のStream をセットすることで実現できます。

peer.on('call', mediaConnection => {
  mediaConnection.answer(mixedStream);

これで、双方がマスクを被った画像を、送ることができるはずです。

では、P2P でのWeb会議を開始してみましょう...

動物たちのWeb会議

結果はこんな感じです。

f:id:doushiman_jp:20210805131950p:plain

どうぶつさんたちだいしゅうごうだわいわい (C)岡崎体育

これで、双方が動物の皮を被ったままで、ビデオチャットが可能になりました。

体を動かしても、動物は動物のままでした。ちゃんと追従します。

ただ、難点もいくつかあって、

  • video -> Canvasへの出力のせいか、動画に若干ラグがある
  • 顔認識など重めの処理のためか、ファンがぶんぶん回る
  • ビデオ画像を大きくすると、さらにファンがぶんぶん回る
  • requestAnimationFrame はブラウザのタブがアクティブじゃ無いと更新頻度が下がる(内職しているとバレる

重めの処理をしているからフレームレートが下がって、それにひきづられて顔認識 → 描画がラグっているのかなと思います。

ここは環境に依存するので、解消は難しいかもしれません。

これを流用すれば、VTuberっぽいビデオ会議や、動くピクトグラムなビデオ会議とか作れるかもですね。

ではでは。


PHONE APPLIについて

phoneappli.net
phoneappli.net