ワシの部屋は汚いから、会議の時に見せたくないんじゃ!
どうも、久々にQiitaで記事を書いています。
今回は興味本位でSkyWayを使ってみました。
今回のやること
- SkyWayを使ったオンライン会議システム
 - オンライン会議システムに背景ぼかし機能を追加したい
 
今回説明しないこと
- 全体のコード説明
 - コードの共有(まだ作り切れていないので笑)
 
SkyWayでできること
SkyWayは、ビデオ・音声・データ通信機能をアプリケーションに簡単に実装できる
SDK&APIです。ビデオ会議や、オンライン診療、遠隔ロボットなど、リアルタイムな
コミュニケーションを実現します。
要するにSkyWayはビデオ会議システムを作る際に非常に重宝できます。
あくまでビデオ会議に限っていて、SDKではそれ以上の用途は自分で実装する必要があります。
それでも簡単にビデオ会議のシステムが作れるのは非常にいいことです。
Body Segmentationとは
Body SegmentationはGoogleのオープンソース機械学習モデルの一つです。
Body Segmentationは人物の境界を認識し領域を分割してくれ、MediaPipeとTensorflow.jsを使う2種類があります。
今回はTensorflow.jsのBodyPixモデルを使って作っていきましょう。
下準備
今回はReactに乗せて作っていきますのでnpm / yarn / pnpmを使ってSDKを取得しましょう。
細かいところは省きますがSkywayは手順通りに実行してもらえればOKです
次にBody Segmentation用のモジュールも用意しましょう。
https://blog.tensorflow.org/2022/01/body-segmentation.html
こちらの記事にある Body Segmentation API Installationの項目を参考にすると良いです。
これで下準備は整いました。
実装の流れ
実装は下記のようになります
- ユーザーへのプレビュー用のvideo、ぼかしで使うためのvideo、ぼかし映像を出力するcanvasを用意する
 - ビデオカメラの映像を取得しStreamをぼかしで使うためのvideo上で配信する
 - 上記の要素をBody Segmentation APIを通してぼかし映像を出力するcanvasに描画する
 - ユーザーがぼかしをするかどうかを選択している状態に応じてぼかし映像を出力するcanvasのStreamを使うか生のStreamを使うかを決める
 - 上記で決めたStreamをSkyWayに配信する
 
と言う流れです。
今回はSkyWayに映像を配信するところまで一通り説明したいと思います。
ユーザーへのプレビュー用のvideo、ぼかしで使うためのvideo、ぼかし映像を出力するcanvasを用意する
まずは3つの要素を用意します。
ユーザーへのプレビュー用のvideoタグはどの映像が配信されるか見て分かるように用意してあげます。
残る2つの要素は非表示項目です。
Body Segmentationは画像や動画のDOMを使いCanvasに描画するためにぼかしで使うためだけのvideoタグが必要になります。
Streamを直接当てられたら楽でしたが・・・笑
そのためあくまでデータを生成するためだけに使用するのでユーザーには見せる必要はありません。
この時videoとcanvasのDOMに高さと幅があたっていないとエラーになるので適当な値を当てておいてください。
今回は300*500としています。
また、今後DOMを関数の引数に当てたりするのでそれぞれの要素からrefを取れるようにしておきます。
import { useRef } from 'react'
export const PreviewPage = () => {
  const previewVideoRef = useRef<HTMLVideoElement | null>(null)
  const localVideoRef = useRef<HTMLVideoElement | null>(null)
  const segmentCanvasRef = useRef<HTMLCanvasElement | null>(null)
  
  return (
    <div>
      {/* プレビュー用のVideo */}
      <video ref={previewVideoRef} autoPlay muted playInline /> 
      {/* 非表示項目 */}
      <div style={{ display: 'none' }}>
        <video ref={localVideoRef} autoPlay muted playInline width="500" height="300" />
        <canvas ref={segmentCanvasRef} width="500" height="300" />
      </div>
    </div>
  )
}
ビデオカメラの映像を取得しStreamをぼかしで使うためのvideo上で配信する
カメラの映像を取得する
それでは次は映像を取得しましょう。
ちなみにSkyWayを使うと簡単に設定できるのですが、今回はMediaStreamの形式のまま使いたいので使用しません。
その場合、下記のような実装になります。
// 出力したいvideoの要素、useRefのものでもOK
const localVideo = document.getElementById('local-video');
// SkyWayStreamFactoryのcreateMicrophoneAudioAndCameraStreamでAudioとVideoのLocalStreamを取得する
const { audio, video } = await SkyWayStreamFactory.createMicrophoneAudioAndCameraStream();
// 引数で渡したDOM要素にvideoの情報を設定する
video.attach(localVideo); // または videoRef.current
// videoのDOMのplayメソッドを呼び出してプレビューの再生を行う
await localVideo.play();
今回はこの方法ではないのでnavigatorからMediaStreamを取得してきます。
こういうものはコンポーネントに直接書かずにhooksにしてしまいましょう。
import { useEffect, useState } from 'react'
/**
 * MediaStreamを取得し配布するhooks
 */
export const useLocalMediaStream = () => {
  const [localMediaStream, setLocalMediaStream] = useState<MediaStream | null>(null)
  useEffect(() => {
    const get = async () => {
      const userMediaStream = await navigator.mediaDevices.getUserMedia({
        audio: true,
        video: {
          height: { ideal: 500 },
          frameRate: { min: 10, max: 30 },
        },
      })
      setLocalMediaStream(userMediaStream)
    }
    get()
  }, [])
  return localMediaStream
}
Streamをvideoタグ上で配信する
自分のMediaStreamを取得できたらそれをvideoタグに設定しちゃいましょう。
現時点ではまだプレビュー用には設定しなくてもいいのですが、進捗を確認するために一度設定しましょうか。
import { useRef, useEffect } from 'react'
import { useLocalMediaStream } from './hooks'
export const PreviewPage = () => {
  const localMediaStream = useLocalMediaStream()
  const previewVideoRef = useRef<HTMLVideoElement | null>(null)
  const localVideoRef = useRef<HTMLVideoElement | null>(null)
  const segmentCanvasRef = useRef<HTMLCanvasElement | null>(null)
  // 非表示のvideoタグに映像を設定する
  useEffect(() => {
    if (!localMediaStream) {
      return
    }
    localVideoRef.current.srcObject = localMediaStream
  }, [localMediaStream])
  // こっちはなくてもいいが映像を確認したいと思うので設定しています
  useEffect(() => {
    if (!localMediaStream) {
      return
    }
    previewVideoRef.current.srcObject = localMediaStream
  }, [localMediaStream])
  
  return (
    <div>
      {/* プレビュー用のVideo */}
      <video ref={previewVideoRef} autoPlay muted playInline /> 
      {/* 非表示項目 */}
      <div style={{ display: 'none' }}>
        <video ref={localVideoRef} autoPlay muted playInline width="500" height="300" />
        <canvas ref={segmentCanvasRef} width="500" height="300" />
      </div>
    </div>
  )
}
上記の要素をBody Segmentation APIを通してぼかし映像を出力するcanvasに描画する
Body Segmentationの準備
まずはBody Segmentationの準備をしていきましょう。
色々と厄介な設定があるのでもう一度リリースのブログを参考にします。
import '@tensorflow/tfjs-backend-core';
import '@tensorflow/tfjs-backend-webgl';
import * as bodySegmentation from '@tensorflow-models/body-segmentation';
// Uncomment the line below if you want to use TensorFlow.js runtime.
// import '@tensorflow/tfjs-converter';
// Uncomment the line below if you want to use MediaPipe runtime.
// import '@mediapipe/selfie_segmentation';
注意ですが @tensorflow/tfjs-backend-core はinstallしないので @tensorflow/tfjs-core で読み替えると良いです。
もしかしたらバージョンによって違いがあるかもしれませんが自分はエラーになったので追加しました。
install時にオプションのような記載がありましたが、 @mediapipe/selfie_segmentation もinstallが必要です。
参照エラーが出るためインストールすることをおすすめします。
それではBody Segmentationのインスタンスを作成していきます。
こちらも楽に使えるようにhooksにしてしまいましょう。
今回はMediaPipeではなくTensorflowを使うので使用するモデルはBodyPixになります。
import { useEffect, useState } from 'react'
import '@tensorflow/tfjs-core'
import '@tensorflow/tfjs-backend-webgl'
import '@tensorflow/tfjs-converter'
import * as bodySegment from '@tensorflow-models/body-segmentation'
export const useBodypixInstance = () => {
  const [bodyPix, setBodyPix] = useState<bodySegment.BodySegmenter | null>(null)
  useEffect(() => {
    const load = async () => {
      const segmenter = await bodySegment.createSegmenter(
        bodySegment.SupportedModels.BodyPix,
        {
          runtime: 'tfjs', // or mediapipe
          modelType: 'general',
        },
      )
      setBodyPix(segmenter)
    }
    load()
  }, [])
  return bodyPix
}
Canvasにぼかしの映像を出力する
さてそれではここからCanvasにぼかし映像を出力させていきます。
まずはぼかしの映像を作る必要がありますね。
先ほどのコンポーネントのファイルで先ほどのインスタンスをもらってきて描画させていきましょう。
import { useRef, useEffect, useReducer } from 'react'
import '@tensorflow/tfjs-core'
import '@tensorflow/tfjs-backend-webgl'
import '@tensorflow/tfjs-converter'
import * as bodySegmentation from '@tensorflow-models/body-segmentation'
import { useLocalMediaStream, useBodypixInstance } from './hooks'
export const PreviewPage = () => {
  // videoの描画が完了していることを示すフラグ
  const [loading, finishLoading] = useReducer(() => false, true)
  // ぼかしのStreamを保持しておくState
  const [blurMediaStream, setBlurMediaStream] = useState<MediaStream | null>(null)
  const bodypixInstance = useBodypixInstance()
  const localMediaStream = useLocalMediaStream()
  const previewVideoRef = useRef<HTMLVideoElement | null>(null)
  const localVideoRef = useRef<HTMLVideoElement | null>(null)
  const segmentCanvasRef = useRef<HTMLCanvasElement | null>(null)
  // videoタグにデバイスから取得した映像を設定する
  useEffect(() => {
    if (!localMediaStream) {
      return
    }
    localVideoRef.current.srcObject = localMediaStream
    localVideoRef.current.addEventListener('loadedmetadata', () => {
      // もしエラーが出る場合はここで映像の幅と高さをCanvasに設定してください
      canvasRef.current!.width = videoRef.current!.videoWidth
      canvasRef.current!.height = videoRef.current!.videoHeight
      // ロードが終わったらフラグを終了させる
      finishLoading()
    })
  }, [localMediaStream])
  // bodypixを使いぼかしの映像を作りcanvasに描画する
  useEffect(() => {
    if (loading || !bodypixInstance) {
      return
    }
    const draw = async () => {
      // 人物の境界データを生成する
      const segmentation = await bodypixInstance.segmentPeople(
        localVideoRef.current!,
        { multiSegmentation: false, segmentBodyParts: true },
      )
      const foregroundThreshold = 0.5
      // 背景ぼかしの強度
      const backgroundBlurAmount = 3
      const edgeBlurAmount = 3
      bodySegmentation.drawBokehEffect(
        canvasRef.current!,     // 出力先のCanvas
        localVideoRef.current!, // 映像の元データのVideoタグ、画像でもOK
        segmentation,
        foregroundThreshold,
        backgroundBlurAmount,
        edgeBlurAmount
      )
      requestAnimationFrame(draw)
    }
    draw()
    // Canvasに描画されているMediaStreamを取得しStateに設定する
    const canvasStream = canvasRef.current!.captureStream(30)
    setBlurMediaStream(canvasStream)
  }, [loading, bodypixInstance])
  
  return (
    <div>
      {/* プレビュー用のVideo */}
      <video ref={previewVideoRef} autoPlay muted playInline /> 
      {/* 非表示項目 */}
      <div style={{ display: 'none' }}>
        <video ref={localVideoRef} autoPlay muted playInline width="500" height="300" />
        <canvas ref={segmentCanvasRef} width="500" height="300" />
      </div>
    </div>
  )
}
ユーザーがぼかしをするかどうかを選択している状態に応じてぼかし映像を出力するcanvasのStreamを使うか生のStreamを使うかを決める
さてこれでカメラから取得した生データのStreamとぼかし設定をしたStreamを作ることができました。
あとは適当のスイッチ用のボタンを作り、どちらを使うか決めると良いです。
export const PreviewPage = () => {
  // 省略
  const [isBlur, setIsBlur] = useState(true)
  // 選択状態に応じてぼかしの映像を使うか元の映像を使うか決める
  const targetMediaStream = isBlur ? blurMediaStream : localMediaStream
  // 決めたデータをプレビューのvideoに設定する
  useEffect(() => {
    previewVideoRef.current.srcObject = targetMediaStream
  }, [targetMediaStream])
  // 省略
}
これで通信をしない状態でのぼかし機能ができました。
他にはカメラのON/OFFなどの機能も入れると良いでしょう。
実際に動かすと次のような感じになるでしょう。
StreamをSkyWayに配信する
さてここまできたらあとはこのStreamをSkyWayに配信してあげるだけです。
SkyWayは独自のフォーマットを使用しているのでStreamをSkyWay用Streamのオブジェクトに変換して送信します。
細かい実装は省きますが、公式に沿って実装してもらうとわかりやすいと思います。
トークンの生成は SkyWay Auth Token を作る を参考にしてください。
今回はgetTokenという自前の関数を作りトークンが取得できるという前提で進めます。
const [me, setMe] = useState<LocalP2PRoomMember | null>(null)
/** Roomに参加する */
const joinRoom = async () => {
  // トークンを取得しRoom情報を取得する
  const token = getToken()
  const context = await SkyWayContext.Create(token);
  const room = await SkyWayRoom.FindOrCreate(context, {
    type: 'p2p',
    name: 'RoomのIDをこちらに設定する',
  });
  const me = await room.join();
  // オーディオは元映像のオーディオをLocalAudioStreamに変換して設定する
  await me.publish(new LocalAudioStream(localStream!.getAudioTracks()[0]));
}
/** 配信映像を切り替えるEffect */
useEffect(() => {
  if (!me) {
    return
  }
  let id = ''
  const publish = async () => {
    // LocalVideoStreamに変換したものを設定する
    const resultPublication = await me.publish(
      new LocalVideoStream(targetMediaStream.getVideoTracks()[0]),
    )
    id = resultPublication.id
  }
  await me.publish(video);
  // 切り替えのたびにpublishしてしまうので切り替えが起きたらunpublishする(※ 細かい動作確認まではしてないのでもしかしたら他にいい方法があるかも)
  return () => {
    if (!id) {
      return
    }
    me.unpublish(id)
  }
}, [me, targetMediaStream])
となり実際に画面を少し整えるとこのようになります。
プレビュー画面はちょっとGoogle Meetっぽい感じにしてみました。
さいごに
SkyWayとBodySegmentationを使い背景ぼかし機能を作成しました。
使い方さえわかれば簡単に背景ぼかしができるので背景に画像を設定することも当然可能です。
自分のPCスペックではそこまで重いという感じがしませんでしたがもしかしたら低スペック機では重い感じが出るかもしれません。
新しいSkyWayはシンプルなインターフェイスで非常に使いやすいです。
今回はそこまで会議画面を作り込みませんでしたがぜひこちらの記事が参考になればと思います。
またデータ送信もできるらしいのでテキストを某笑顔動画さんのように横から流すみたいなのもやってみたいですね笑

