このBlogはHéctor Zelayaがこちらで公開した記事を日本語化したものです。
ソーシャルメディアアプリに映る自分の顔にフィルターをかけようとしたことはありますか?おかしな帽子や格好いいメガネ、ネコの耳などをセルフィーやビデオチャットに追加して遊んだ経験があるのではないでしょうか。
こうしたフィルターを使用したことがある方は、このテクノロジーはどう機能しているのだろうと考えたことがあるかもしれません。このようなアプリでは、顔検出ソフトウェアを活用し、写真やビデオ入力で顔を検出し、顔の特定パーツの上に画像を配置しています。
このチュートリアルでは、顔検出機能を使用し、ビデオ会議アプリケーションにフィルターを追加する方法を説明します。このビデオ会議アプリは、TypeScriptで書かれ、Twilio Programmable Video、React、React Hooks、FaceAPIを使用しています。
一般的に、顔認識技術は、画像やビデオ内の顔の有無を判断(検出)し、顔の詳細な情報を評価(解析)し、本人確認(認証/検証)を試みる目的で使用されます。
このチュートリアルでは検出機能のみ使用しますが、顔認識技術を使用する際には、倫理面やプライバシーに関する懸念について十分配慮することが重要です。
顔認識ソフトウェアを使用するアプリをリリースする場合は、ユーザーに承諾するかを尋ねる機能を必ず組み込み、顔認識ソフトウェアの使用を許可するかどうかをユーザーに決めてもらうようにします。
顔認識その他のAIの倫理的使用についての詳細は、以下のリンクを参照してください。
- Algorithmic Justice League
- Electronic Frontier Foundation
前提条件
このチュートリアルの実行に必要なものは、以下のとおりです。
- 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
このリポジトリは、finalとstartという2つの主要フォルダで構成されています。finalフォルダには、完成版のアプリケーションが格納されており、アプリケーションの動作をすぐに確認できます。一つひとつの手順を確認しながら構築したい方は、startフォルダを使用してください。ビデオ会議機能のみが格納されており、フィルタリングや顔検出用のコードは含まれていません。
アプリケーションの全体的な構造は、こちらの別の記事で説明している構造を基本としています。アプリケーションはTypeScriptで書かれており、React Functional Componentを用いてReactフックを活用しています。
まずは、Twilioの資格情報を設定しましょう。同じフォルダにあるstart/.env.exampleファイルを複製し、.envというファイル名に変更します。任意のコードエディタを開き、TWILIO_ACCOUNT_SID
、TWILIO_API_KEY
、TWILIO_API_SECRET
にそれぞれ値を入力します。
アカウントSIDはTwilioコンソールにあります。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つ目のターミナルウィンドウを起動し、複製したリポジトリのルートフォルダに移動します。以下のように依存関係をインストールしてください。
顔検出機能の使い方
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](ルームに参加)ボタンをクリックしてビデオルームに入室します。数秒後、以下の画面が表示されます。
いいエフェクトでしょ!
フィルターの選択
この段階で、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;
ここでは、Sunglasses
とCoolerSunglasses
という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]);
ブラウザに戻り、ビデオアプリをチェックしてみましょう。フィルター名をクリックし、フィルターを切り替えます。一段と格好よくなりましたね!
ここまでの動作はすべてローカルで発生しています。そこで、あるユーザーがどのようなフィルターを選択したかを他の参加者にも知らせ、各エンドでも適用できるようにする方法が必要になります。その目的のために、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]); } ...
これで完成です。アプリケーションを実行し、フィルターを適用して見た目をカスタマイズできます。
かくれたミーム参照に気づいた方もいるかもしれません。偶然ながらDanielは私のミドルネームです!
まとめ
FaceAPIやTensorFlowなどのパワフルなツールのおかげで、Webアプリケーションに顔検出機能を簡単に追加できるようになりました。HTML Canvas、React、Reactフックなど、優れたWebビルディングブロックと組み合わせて使用すると、最新機能を装備した高度なアプリケーションを開発できます。このようなことができるのも、Twilio Programmable VideoとDataTrack APIがあればこそです。
完全版のコードは、Githubリポジトリで確認できます。よろしければ、私のTwitterもフォローしてください。
Héctorは、エルサルバドル出身のコンピューターシステムエンジニアです。コンピューターの前にいないときは、音楽の演奏やビデオゲームを楽しんだり、大切な人たちと時間を過ごしたりしています。