Web上で使える璃奈ちゃんボードを作った話

この記事は [N/S高等学校 Advent Calendar 2023](https://qiita.com/advent-calendar/2023/n-s-highschool) 20日目の記事となります。

https://qiita.com/advent-calendar/2023/n-s-highschool

# はじめに
こんにちは。marukun_と申します。去年に引き続き、今年もN/S高等学校 Advent Calendarに参加させていただきます。
今回はWeb上で手軽に動作する璃奈ちゃんボードを作成したので、いくつかの実装を抜粋しながら紹介していきます。
# 璃奈ちゃんボードとは
璃奈ちゃんボードとは、アニメ「ラブライブ！虹ヶ咲学園スクールアイドル同好会」に登場する感情表現が苦手な女の子、天王寺璃奈(CV.田中ちえ美)が感情表現を行うために使うボードです。璃奈ちゃんは普段、あらかじめペンで表情を描いておいたスケッチブック版璃奈ちゃんボードを使用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2204837/0d884492-5d80-e07f-a310-8d8c8fc9d7d2.png)

ただ、流石にスケッチブックを持ったまま歌って踊るわけにはいかないので、ステージに立つ際には、感情を自動で認識し表情を切り替えるモニター付きヘッドホン型の「オートエモーションコンバート 璃奈ちゃんボード」を使用しています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2204837/78387777-213f-d8b5-a25d-c978cb49505f.png)

そこで今回は、「オートエモーションコンバート 璃奈ちゃんボード」をWeb上で使えるWebアプリとして再現してみました。

# 作ったもの

https://rinachan-board-web.vercel.app

Webカメラからの入力、若しくはマイクからの音声入力に対応し、それぞれの方法で感情分析を行った結果がボードの表情に反映されます。

# 使ったもの
フロントエンドをNext.js+TailwindCSS、表情認識ライブラリにface-api.js、音声認識はWebブラウザ標準搭載のWebSpeechAPIで文字起こしを行い、テキスト感情分析APIに渡すことで実現しました。

あらかじめSpreadSheetをポチポチして璃奈ちゃんボードの表情差分を作成しておき、Contextに渡された感情認識の結果によって表示する画像を切り替えています。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2204837/125c9ee1-f6de-7a09-b43b-f1756b513be1.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2204837/bc4511b2-b634-f7e4-b4ce-23215269fc0a.png)

# Install
いつも通りyarnでインストールします。
```shell
yarn add face-api.js
```
face-api.jsの[Githubリポジトリ](https://github.com/justadudewhohacks/face-api.js/tree/master/weights)からモデルをダウンロードし、public配下に配置しておきます。


# 表情認識
表情認識機能は、以下のように実装しました。
```jsx
import * as faceapi from 'face-api.js';
import { useEffect } from 'react';
import { useState } from 'react';
import { useContext } from 'react';
import { currentExpressions } from '@/app/page';

export default function FERpage() {
    const { expressions, setExpressions } = useContext(currentExpressions)
    const [isExpressionsRecognized, setIsExpressionsRecognized] = useState(false); //顔が認識されているかどうか

    useEffect(() => {
        const video = document.getElementById("video");

        async function loadModel() {
            await faceapi.nets.tinyFaceDetector.loadFromUri("./") //顔検出model
            await faceapi.nets.faceExpressionNet.loadFromUri("./") //表情認識model
        }

        //カメラ映像の認識を開始
        function startVideo() {
            navigator.mediaDevices
                .getUserMedia({ video: true })
                .then(function (stream) {
                    video.srcObject = stream;
                })
                .catch(function (err) {
                    console.error(err);
                });
        }

        video.addEventListener("play", async () => {
            async function detectExpressions() {
                try {
                    //表情認識を開始
                    const detectionsWithExpressions = await faceapi
                        .detectAllFaces(video, new faceapi.TinyFaceDetectorOptions())
                        .withFaceExpressions();

                    //表情オブジェクトを配列に変換
                    var expressionsArray = Object.entries(detectionsWithExpressions[0].expressions)

                    //スコアが高い順にソート
                    expressionsArray.sort((a, b) => {
                        return b[1] - a[1]
                    })

                    //感情を取得
                    var expressions = expressionsArray[0][0]

                    setExpressions(expressions)

                    setIsExpressionsRecognized(true)

                } catch (err) {
                    setIsExpressionsRecognized(false)
                }

                requestAnimationFrame(detectExpressions)
            }

            detectExpressions();
        });

        loadModel();

        startVideo();
    });

    return (
        <div>
            <h1 className='px-10'>{isExpressionsRecognized ? expressions : "表情を認識中..."}</h1>
            <video id="video" width={500} height={500} autoPlay muted className='hidden'></video>
        </div>
    )
}
```
face-api.jsの表情認識の結果は以下のような感情名:スコアのオブジェクトで返されるため、
```
{
  angry: 0.00033354622428305447
  disgusted: 0.025459259748458862
  fearful: 0.00001721922126307618
  happy: 0.23955923318862915
  neutral: 0.46917790174484253
  sad: 0.2652508020401001
  surprised: 0.00020209986541885883
}
```
一度配列に変換したのちにスコアが高い順にソート、スコアが一番高い感情をContextに渡しています。
```jsx
const detectionsWithExpressions = await faceapi
    .detectAllFaces(video, new faceapi.TinyFaceDetectorOptions())
    .withFaceExpressions();

//表情オブジェクトを配列に変換
var expressionsArray = Object.entries(detectionsWithExpressions[0].expressions)

//スコアが高い順にソート
expressionsArray.sort((a, b) => {
    return b[1] - a[1]
})

//感情を取得
var expressions = expressionsArray[0][0]

setExpressions(expressions)
```
# 音声認識
音声認識機能は、以下のように実装しました。
```jsx
import { useEffect } from "react";
import { useContext } from 'react';
import { useState } from "react";
import { currentExpressions } from '@/app/page';

export default function SpeechEmotionRecognition() {
    const { expressions, setExpressions } = useContext(currentExpressions)
    const [speechRecognitions, setSpeechRecognitions] = useState([])

    async function emotionRecognitionFromText(text) {
        var url = "" //感情分析APIのURL

        var body = { //RequestBody
            text: text
        }

        var headers = { //HTTPHeader
            'Content-Type': 'application/json'
        }

        var post = await fetch(url, { //Post
            method: "POST",
            headers: headers,
            body: JSON.stringify(body),
        });

        var emotionRecognitionResult = await post.json(); //結果をJsonに変換

        var expressionsArray = await Object.entries(emotionRecognitionResult.emotions) //配列に変換

        //スコアが高い順にソート
        await expressionsArray.sort((a, b) => {
            return b[1] - a[1]
        })

        //感情を取得
        var expressions = await expressionsArray[0][0]

        //face-apiの感情名に合わせてset
        switch (expressions) {
            case "Joy":
                setExpressions("happy")
                break;
            case "Sadness":
                setExpressions("sad")
                break;
            case "Surprise":
                setExpressions("surprised")
                break;
            case "Anger":
                setExpressions("angry")
                break;
            case "Fear":
                setExpressions("fearful")
                break;
            case "Disgust":
                setExpressions("disgusted")
                break;
            case "Anticipation":
                setExpressions("happy")
                break;
            case "Trust":
                setExpressions("happy")
                break;
        }
    }

    useEffect(() => {
        //Web Speech APIの設定
        const speech = new webkitSpeechRecognition();
        speech.lang = 'ja-JP';

        const btn = document.getElementById('btn');

        //ボタンクリックで音声認識を開始
        btn.addEventListener('click', function () {
            speech.start();
        });

        //結果を取得して感情分析を実行
        speech.onresult = function (e) {
            speech.stop();
            if (e.results[0].isFinal) {
                var resultText = e.results[0][0].transcript

                setSpeechRecognitions(prevState => [...prevState, [resultText]])

                emotionRecognitionFromText(resultText)
            }
        }

        speech.onend = () => {
            speech.start()
        };
    })

    return (
        <div className='px-10'>
            <button id="btn" className="btn py-5">start</button>

            <div className="overflow-y-scroll h-16 py-5 w-64">
                {speechRecognitions.map((value) => {
                    return (
                        <h1>{value}</h1>
                    )
                })}
            </div>

            <h1>{expressions}</h1>
        </div>
    )
}
```
まず、WebSpeechAPIで文字起こしを行い、その結果をテキスト感情分析APIへPOSTします。
その後、レスポンスを同様にスコア順にソート、スコアが一番高い感情をface-apiの感情名に合わせてContextに渡しています。
```jsx
var emotionRecognitionResult = await post.json(); //結果をJsonに変換

var expressionsArray = await Object.entries(emotionRecognitionResult.emotions) //配列に変換

//スコアが高い順にソート
await expressionsArray.sort((a, b) => {
    return b[1] - a[1]
})

//感情を取得
var expressions = await expressionsArray[0][0]
```
音声認識の結果は都度Stateに保持しておき、一覧で表示しています。
```jsx
//結果を取得して感情分析を実行
speech.onresult = function (e) {
    speech.stop();
    if (e.results[0].isFinal) {
        var resultText = e.results[0][0].transcript

        setSpeechRecognitions(prevState => [...prevState, [resultText]])

        emotionRecognitionFromText(resultText)
    }
}
```
```jsx
<div className="overflow-y-scroll h-16 py-5 w-64">
    {speechRecognitions.map((value) => {
        return (
            <h1>{value}</h1>
        )
    })}
</div>
```
# おわりに
face-api.jsとWebSpeechAPI+テキスト感情分析APIを使用することで、比較的簡単にカメラとマイクからの感情認識を用いたWeb版璃奈ちゃんボードを作成することができました。

現状では感情認識の結果によって画像を切り替えているだけなので口パクなどの機能は実装できていませんが、いずれはface-api.jsの顔ランドマーク検出機能などを使用して口パクにも対応していきたいと思います。
# 参考文献

https://note.com/npaka/n/nc9c244b11089

https://qiita.com/hamahamabe/items/9216df345bb908eb5199

https://qiita.com/yuikoito/items/a494402993ac4be46d97