FaceAPI、React Hooks、TypeScriptを使用し、Twilioビデオアプリケーションにフィルターを追加する方法

April 28, 2021
執筆者
Héctor Zelaya
寄稿者
Twilio の寄稿者によって表明された意見は彼ら自身のものです
レビュー担当者
Mia Adjei
Twilion

faceapi-reacthooks-ts-twilio-video-filter

This article is for reference only. We're not onboarding new customers to Programmable Video. Existing customers can continue to use the product until December 5, 2024.


We recommend migrating your application to the API provided by our preferred video partner, Zoom. We've prepared this migration guide to assist you in minimizing any service disruption.

このBlogはHéctor Zelayaこちらで公開した記事を日本語化したものです。

ソーシャルメディアアプリに映る自分の顔にフィルターをかけようとしたことはありますか?おかしな帽子や格好いいメガネ、ネコの耳などをセルフィーやビデオチャットに追加して遊んだ経験があるのではないでしょうか。

こうしたフィルターを使用したことがある方は、このテクノロジーはどう機能しているのだろうと考えたことがあるかもしれません。このようなアプリでは、顔検出ソフトウェアを活用し、写真やビデオ入力で顔を検出し、顔の特定パーツの上に画像を配置しています。

このチュートリアルでは、顔検出機能を使用し、ビデオ会議アプリケーションにフィルターを追加する方法を説明します。このビデオ会議アプリは、TypeScriptで書かれ、Twilio Programmable VideoReactReact HooksFaceAPIを使用しています。

一般的に、顔認識技術は、画像やビデオ内の顔の有無を判断(検出)し、顔の詳細な情報を評価(解析)し、本人確認(認証/検証)を試みる目的で使用されます。

このチュートリアルでは検出機能のみ使用しますが、顔認識技術を使用する際には、倫理面やプライバシーに関する懸念について十分配慮することが重要です。

顔認識ソフトウェアを使用するアプリをリリースする場合は、ユーザーに承諾するかを尋ねる機能を必ず組み込み、顔認識ソフトウェアの使用を許可するかどうかをユーザーに決めてもらうようにします。

顔認識その他のAIの倫理的使用についての詳細は、以下のリンクを参照してください。

前提条件

このチュートリアルの実行に必要なものは、以下のとおりです。

  • Twilioアカウント(このリンクからアカウントを作成すると、アカウントのアップグレード時に10ドルのクレジットを取得できます。)
  • NPM 6
  • Node.js 14
  • Git
  • ターミナルエミュレータアプリケーション
  • 任意のコードエディタ

プロジェクトのセットアップ

このチュートリアルでは、すべての要素を自分で構築する必要はありません。基本的なビデオチャットリポジトリがすでに用意されているため、まずはGitHubからこのリポジトリを複製してください。ターミナルウィンドウを起動し、プロジェクトの保存先に移動し、以下の手順で複製します。

cd your/favorite/path
git clone https://github.com/agilityfeat/twilio-filters-tutorial.git && cd twilio-filters-tutorial

このリポジトリは、finalstartという2つの主要フォルダで構成されています。finalフォルダには、完成版のアプリケーションが格納されており、アプリケーションの動作をすぐに確認できます。一つひとつの手順を確認しながら構築したい方は、startフォルダを使用してください。ビデオ会議機能のみが格納されており、フィルタリングや顔検出用のコードは含まれていません。

アプリケーションの全体的な構造は、こちらの別の記事で説明している構造を基本としています。アプリケーションはTypeScriptで書かれており、React Functional Componentを用いてReactフックを活用しています。

まずは、Twilioの資格情報を設定しましょう。同じフォルダにあるstart/.env.exampleファイルを複製し、.envというファイル名に変更します。任意のコードエディタを開き、TWILIO_ACCOUNT_SIDTWILIO_API_KEYTWILIO_API_SECRETにそれぞれ値を入力します。

アカウントSIDTwilioコンソールにあります。APIキー秘密キーのペアは、コンソールのAPIキーセクションで生成することができます。

続いて、必須の依存関係をインストールします。ターミナルウィンドウに戻り、プロジェクトのルートフォルダから以下のコマンドを実行します。

# install dependencies
cd start
npm install

# then run the application
npm start

作業中の内容をすぐに確認したい場合は、finalフォルダでこれまでの手順を実施してください。

FaceAPIの基本

このチュートリアルの特長は、フィルターを追加するだけでなく、顔検出機能も実装することです。ソーシャルメディアアプリの機能によくあるように、顔に合わせてフィルターを適用することが可能になります。

それを実現するのはFaceAPIという顔認識ツールです。

FaceAPIは、TensorFlow上で動作します。ブラウザやNode.js向けに、AIを活用して顔の検出、描写、認識を行う機能を提供する目的で導入します。

FaceAPIのインストール

face-apiは、npmを使用してインストールできます。2つ目のターミナルウィンドウを起動し、複製したリポジトリのルートフォルダに移動します。以下のように依存関係をインストールしてください。

cd path/to/project/twilio-filters-tutorial
npm install @vladmandic/face-api@1.1.5

顔検出機能の使い方

FaceAPIをインストールしたところで、早速使い始めたいと思うかもしれません。その前に、以下のコードを確認し、プロジェクトでFaceAPIを使用する方法を理解しましょう。このチュートリアルでは、入力ソースのどこに顔があるかを検出する機能のみを使用します。

顔検出機能を実装するには、まずfaceapi.netsを使用して必要なモデルを読み込みます。以下のコード行が該当します。

await faceapi.nets.ssdMobilenetv1.loadFromUri('/model');

次に、faceapi.detectAllFaces()関数を使用し、画像やHTML要素(ビデオ)などの入力ソースに映るすべての顔を検出します。

この結果、1つのオブジェクトを取得し、そこからX座標、Y座標、顔領域全体の幅などのプロパティが得られます。

const results = await faceapi.detectAllFaces(localVideoRef.current);

こうした得られた情報とHTMLの<canvas>要素window.requestAnimationFrame`関数を組み合わせ、カスタムのメディア要素を顔に合わせて描画することができます。ソーシャルメディアアプリでよく見られるフィルターは、まさにこの仕組みを使用しています。

FaceAPIモデルの読み込み

FaceAPIを使用した顔検出の基本を理解したところで、アプリケーションのセットアップに進みましょう。

FaceAPIモデルが格納されたフォルダが、startフォルダ内のpublicフォルダにすでに追加されています。以下を参照し、start/src/App.tsxファイルを更新してください。


// start/src/App.tsx
...
import { connect, Room as RoomType } from 'twilio-video';
import * as faceapi from '@vladmandic/face-api';
...
function App() {
  ...
  return (
    ...
          <button
            ...
            onClick={async () => {
                ...
                const room = await connect(data.accessToken, {
                  name: 'cool-room',
                  audio: true,
                  video: { width: 640, height: 480 }
                });

                await faceapi.nets.ssdMobilenetv1.loadFromUri('/model');
                setRoom(room);

                ...
  )
}
...

このコードを使用すると、アプリケーションの起動時にモデルが読み込まれます。以降はストリームの操作に必要なコードに注力しましょう。

HTMLのcanvas要素を使用したストリームの操作

入力ストリームのすべての顔を識別できる状態になりました。次に<canvas>要素を使用し、顔の上に表示したいアイテムをプログラムで描写します。これはReactで開発されたアプリケーションであり、Functional Componentを使用しているため、レンダリング後にDOMを操作する方法を考える必要があります。

こうしたタスクにはフック、具体的に言うと、useEffectフックを使うとよいでしょう。これは、信頼性に優れ、Class Componentの古いライフサイクルメソッドであるcomponentDidMountの代わりに使用できます。

また、canvas要素をプログラムで操作したり、window.requestAnimationFrameを呼び出したりできるように、DOM情報を保持する方法も考える必要があります。これは標準的なReactのレンダリング範囲を超えているため、ここでもフックの使用が合理的です。この場合、useRefフックを使用するのが最適です。

では、start/src/Track.tsxファイルを開いて、リファレンスをいくつか追加しましょう。音声トラックとビデオトラックにTrackコンポーネントが使用されているため、両方にHTML要素を追加します。DOM操作を以下のように少しリファクタリングしてください。

// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack }) {
  let divRef = useRef<HTMLDivElement>(null);
  // adding additional refs
  let canvasRef = useRef<HTMLCanvasElement>(null);
  let localAudioRef = useRef<HTMLAudioElement | null>(null);
  let localVideoRef = useRef<HTMLVideoElement | null>(null);
  let requestRef = useRef<number>();

  useEffect(() => {
    // refactoring a bit
    if (props.track) {
      divRef.current?.classList.add(props.track.kind);
      switch (props.track.kind) {
        case 'audio':
          localAudioRef.current = props.track.attach();
          break;
        case 'video':
          localVideoRef.current = props.track.attach();
          break;
      }
    }
  }, []);

  return (
    <div className="track" ref={divRef}>
      {props.track.kind === 'audio' &&
        <audio autoPlay={true} ref={localAudioRef} />
      }
      {props.track.kind === 'video' &&
        <>
          <video autoPlay={true} ref={localVideoRef} />
          <canvas width="640" height="480" ref={canvasRef} />
        </>
      }
    </div>
  );

これで、すべてのFaceAPIとcanvas要素を追加できます。まずは、face-apiライブラリをインポートします。drawFilterという内部関数を、既存のuseEffectフックに追加します。

// start/src/Track.tsx

import * as faceapi from '@vladmandic/face-api';

function Track(props: { track: AudioTrack | VideoTrack }) {
  ...
  useEffect(() => {
    function drawFilter() {
      let ctx = canvasRef.current?.getContext('2d');
      let image = new Image();
      image.src = 'sunglasses.png';

      async function step() {
        const results = await faceapi.detectAllFaces(localVideoRef.current);
        ctx?.drawImage(localVideoRef.current!, 0, 0);
        // eslint-disable-next-line array-callback-return
        results.map((result) => {
          ctx?.drawImage(
            image,
            result.box.x + 15,
            result.box.y + 30,
            result.box.width,
            result.box.width * (image.height / image.width)
          );
        });
        requestRef.current = requestAnimationFrame(step);
      }

      requestRef.current = requestAnimationFrame(step);
    }

   ...
  }, [])
  ... 
}
...

その後に、ビデオ要素の再生を開始したときのために、drawFilter関数をリスナーとして設定します。

// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack }) {
  ...
  useEffect(() => {
    ...
    if (props.track) {
      ...
      case 'video':
          localVideoRef.current = props.track.attach();
          localVideoRef.current?.addEventListener('playing', drawFilter);
          break;
       ...
    }
  }
}
...

window.requestAnimationFrameに加え、リスナーも追加しているため、少し整理してメモリリークを防止する必要があります。

React Functional Componentを使用している場合は、Class ComponentのときのようにcomponentWillUnmountライフサイクルメソッドを使用することができません。

ここでも有効なのがフックです。useEffectフックは、componentWillUnmountメソッドの代わりに使用できる関数を返すため、以下のようにTrackコンポーネントを更新します。


// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack }) {
  ...
  useEffect(() => {
    ...
    if (props.track) {
      divRef.current?.classList.add(props.track.kind);
      switch (props.track.kind) {
        case 'audio':
          localAudioRef.current = props.track.attach();
          break;
        case 'video':
          localVideoRef.current = props.track.attach();
          localVideoRef.current?.addEventListener('playing', drawFilter);
          break;
      }
    }

    return () => {
      if (props.track && props.track.kind === 'video') {
        localVideoRef.current?.removeEventListener('playing', drawFilter);
        cancelAnimationFrame(requestRef.current!);
      }
    }
  }, []);

  ...
}

ここでアプリケーションの動作を確認してみましょう。npm startを実行してアプリケーションを開始し、ブラウザの読み込みを待ち、入力フィールドが表示されたら自分の名前を入力します。[Join Room](ルームに参加)ボタンをクリックしてビデオルームに入室します。数秒後、以下の画面が表示されます。

video with filter on face

                                                        いいエフェクトでしょ!

フィルターの選択

この段階で、Sunglassesという名前のハードコーディングされたフィルターを、Twilio Videoトラックにローカルで適用することができます。しかし、フィルターに人気がある理由は、選択肢がたくさんあり、ユーザーが好きなフィルターを使用できることにあります。このチュートリアルでは選択肢を数多く追加する手順は説明しませんが、アプリケーションのユーザーが2種類のフィルターから選択できるようにします。作業を簡単にするため、先ほどと同じタイプのフィルターに別の画像を用いて新しいフィルターを作成します。

start/srcの下に新しいファイルを作成し、名前をFilterMenu.tsxとします。ファイルに以下のコードを追加します。

// start/src/FilterMenu.tsx
import React from 'react';

function FilterMenu(props: { changeFilter: (filter: string) => void }) {
  const filters = ['Sunglasses', 'CoolerSunglasses'];

  return (
    <div className="filterMenu">
      {
        filters.map(filter => 
          <div className={`icon icon-${filter}`} 
            onClick={() => props.changeFilter(filter)}>
              {filter}
          </div>  
        )
      }
    </div>
  );
}

export default FilterMenu;

ここでは、SunglassesCoolerSunglassesという2種類のフィルターを定義しています。コンポーネントにプロパティとして渡されるchangeFilterハンドラを起動するリストに、これらのフィルターをレンダリングします。

新規に作成したフィルターをstart/src/Participant.tsxファイルに追加します。コンポーネントのステートに選択したフィルターを設定します。これにより、ユーザーが別のフィルターを選択した場合、UIが変更を反映させて再度レンダリングされます。

// start/src/Participant.tsx
...
import FilterMenu from './FilterMenu';

function Participant(props: { localParticipant: boolean, participant: LocalParticipant | RemoteParticipant }) {
  ...
  const [tracks, setTracks] = useState(nonNullTracks);
  const [filter, setFilter] = useState('Sunglasses');
  ...
  return (
    <div className="participant" id={props.participant.identity}>
      <div className="identity">{props.participant.identity}</div>
      {
        props.localParticipant
        ? <FilterMenu changeFilter={(filter) => {
            setFilter(filter);
          }} />
        : ''
      }

      {
        tracks.map((track) =>
          <Track key={track!.name} track={(track as VideoTrack | AudioTrack)} filter={filter} />)
      }
    </div>
  )
}

filterプロパティがTrackコンポーネントに追加されました。追加のパラメーターを送信することになるため、以下のようにTrackのプロパティ属性タイプを更新します。

// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack, filter: string }) {

次にTrackコンポーネント内の行を、このように置き換えます。

// replace this
image.src = 'sunglasses.png';

// with this
image.src = props.filter === 'Sunglasses' ? 'sunglasses.png' : 'sunglasses-style.png';

2つのフィルターの切り替えができるようになるまで、あともう少しです!残された作業はあと1つです。初期設定では、レンダリングのたびにuseEffectが実行されますが、それが望ましくない場合もあります。このような状況を防止するため、無名関数のほかに、useEffectに第2パラメーターとして空配列を渡すことができます。これにより、useEffectブロック内のコードが1回だけ実行されるようになります。

この配列を使用し、特定のプロパティが変更された場合以外にuseEffectフックの実行をスキップすることもできます。ここではフィルターを変更しているため、変更が発生した際にフックを再実行し、Trackコンポーネントを更新する必要があります。

そのため、以下のように、props.filterの値を空配列に追加してください。

// change this
}, []);

// to this
}, [props.filter]);

ブラウザに戻り、ビデオアプリをチェックしてみましょう。フィルター名をクリックし、フィルターを切り替えます。一段と格好よくなりましたね!

sunglass - hector

ここまでの動作はすべてローカルで発生しています。そこで、あるユーザーがどのようなフィルターを選択したかを他の参加者にも知らせ、各エンドでも適用できるようにする方法が必要になります。その目的のために、Twilio DataTrack APIを使用できます。フィルター情報など、任意のデータを他の参加者に送信できるのです。

フィルター情報の送信

フィルター情報を送信するには、まずデータトラックチャネルを設定します。新しいLocalDataTrackインスタンスを作成し、publishTrack()メソッドを用いてルームにそのインスタンスを公開します。

start/src/App.tsxファイルを開き、以下のコードを入力します。

...
import { connect, Room as RoomType, LocalDataTrack } from 'twilio-video';
...
function App() {
  ...
         const room = await connect(data.accessToken, {
           name: 'cool-room',
           audio: true,
           video: { width: 640, height: 480 }
         });

         const localDataTrack = new LocalDataTrack();
         await room.localParticipant.publishTrack(localDataTrack);
         await faceapi.nets.ssdMobilenetv1.loadFromUri('/model');
}

すべてのユーザーが、ローカルトラックのリストにデータトラックを追加したことを確認します。データトラックを使用し、フィルター情報が変更されるたびにその情報を送信する必要があります。また、ビデオ通話の全参加者のフィルター情報を受信し、必要に応じて更新します。

この動作はすべてstart/src/Participant.tsxファイルで発生しています。このファイルを開き、以下のコードを入力してください。

// start/src/Participant.tsx
...
import { LocalParticipant, RemoteParticipant, LocalTrackPublication, RemoteTrackPublication, VideoTrack, AudioTrack, LocalDataTrack, DataTrack } from 'twilio-video';
...
function Participant(props: { localParticipant: boolean, participant: LocalParticipant | RemoteParticipant }) {
  ...
  useEffect(() => {
    if (!props.localParticipant) {
      ...
      // here the user adds the data track to the list of local tracks
      props.participant.on('trackPublished', track => {
        setTracks(prevState => ([...prevState, track]));
      });
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    ...
      {
        props.localParticipant
        ? <FilterMenu changeFilter={(filter) => {
            // when the user changes the filter, notify all other users
            // retrieve the dataTrack from the list of tracks
            const dataTrack = tracks.find(track => track!.kind === 'data') as LocalDataTrack;
            // send filter information
            dataTrack!.send(filter);
            setFilter(filter);
          }} />
        : ''
      }

      {
        tracks.map((track) =>
          <Track key={track!.name} track={(track as VideoTrack | AudioTrack | DataTrack)} filter={filter} setFilter={setFilter}/>)
      }
    ...
  )
}

Trackコンポーネント宛てに送信される新しいプロパティがありますね。これは、mutate関数のsetFilterです。これでDataTrackを通じてフィルター情報を送信できるようになりました。続いて、メッセージをリッスンし、必要に応じてビデオ通話の各エンドでフィルターを更新できるようにします。以下のコードを使用し、start/src/Track.tsxファイルを更新してください。

// start/src/Track.tsx
...
import { AudioTrack, VideoTrack, DataTrack } from 'twilio-video';
...
function Track(props: { track: AudioTrack | VideoTrack | DataTrack, filter: string, setFilter: (filter: string) => void }) {
  ...
  useEffect(() => {
    ...
    if (props.track) {
      ...
      switch (props.track.kind) {
        ...
        case 'data':
          // when receiving a message, update the filter
          props.track.on('message', props.setFilter);
          break;
      }
    }
    ...
  }, [props.filter]);
   
}
...

これで完成です。アプリケーションを実行し、フィルターを適用して見た目をカスタマイズできます。

filtering - comple

かくれたミーム参照に気づいた方もいるかもしれません。偶然ながらDanielは私のミドルネームです!

まとめ

FaceAPIやTensorFlowなどのパワフルなツールのおかげで、Webアプリケーションに顔検出機能を簡単に追加できるようになりました。HTML Canvas、React、Reactフックなど、優れたWebビルディングブロックと組み合わせて使用すると、最新機能を装備した高度なアプリケーションを開発できます。このようなことができるのも、Twilio Programmable VideoとDataTrack APIがあればこそです。

完全版のコードは、Githubリポジトリで確認できます。よろしければ、私のTwitterもフォローしてください。

Héctorは、エルサルバドル出身のコンピューターシステムエンジニアです。コンピューターの前にいないときは、音楽の演奏やビデオゲームを楽しんだり、大切な人たちと時間を過ごしたりしています。