【FaceAPI + Next.js】マッチングアプリで使える！モテ写真を自動撮影する📸

## これはなに? 🤔

恋活・婚活マッチングアプリで、異性とマッチングする為の肝となるプロフィール写真。

ただ実際にはプロフ写真が

- 「カメラから遠すぎて顔がよくわからない」
- 「顔が近すぎて暑苦しい」
- 「表情がイマイチ」

と、ちょっと残念なケースも多いようです。
写真ってムズカシイ。。。

そこで。

**顔の位置**、**大きさ**や**表情**が最適どうか？を判定して、
**自動でモテ写真を撮影**するWebアプリを作ってみました。

## 作ったWebアプリ

https://next-webcam.web.app

※スタート時にカメラへのアクセス許可プロンプトが出ます

![motecam-preview1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681323/fba0e83e-e932-d9ab-30de-61224054a249.png)


## 使ったあれこれ 🛠️

- face-api
- Next.js
- TypeScript
- ChakraUI
- Firebase Hosting（デモ公開用）

## セットアップ 🎒
### Next.js と TypeScript
まずはNext.jsをTypeScriptでセットアップ。

```bash
npx create-next-app --typescript
```

詳しくはこちら

https://nextjs.org/docs/getting-started#automatic-setup


### Chakra UI

手軽にレイアウトを組めて便利。

https://chakra-ui.com/getting-started

インストール
```bash
yarn add @chakra-ui/react @emotion/react @emotion/styled framer-motion
```

### FaceAPI

「face-api.js」はブラウザなどで顔検出・顔認識、年齢、性別、表情の予測が行えるライブラリ。TensorFlow/JSを使って実装されています。

オリジナルプロジェクトは[こちら](https://github.com/justadudewhohacks/face-api.js)ですが、今回はメンテが続いている「vladmandic/face-api」を利用させて頂きます。

https://github.com/vladmandic/face-api

NodeJSプロジェクトでは、「tensorflow/tfjs-node」を追加でインストールします。

```bash
yarn add @vladmandic/face-api @tensorflow/tfjs-node
```

## 動作の流れ 💫
至ってシンプルです。

1. 開始ボタン押下
2. カメラ起動
3. 顔を検知
4. モテ写真判定
    1. 満たしていない項目のアドバイスを表示
    2. 判定OKだったら自動で撮影
5. 撮影写真をプレビュー
6. 写真を保存

## 「顔認識」の準備 😀
まず顔認識を行う為に、以下の準備をしていきます。

- TensorFlow
- FaceAPI
- カメラ
- 判定用Canvas

### TensorFlow

```typescript
const setupTensorFlow = async () => {
    await faceapi.tf.setBackend('webgl');
    await faceapi.tf.enableProdMode();
    await faceapi.tf.ENV.set('DEBUG', false);
    await faceapi.tf.ready();
}
```

### FaceAPI

**トレーニング済みのModelを読み込む**
今回がサイズが小さめの `tinyFaceDetector` を使用しました。
精度の違いは体感的にそこまで感じませんでした。

```typescript
const setupModel = async () => {
    await faceapi.nets.tinyFaceDetector.load(MODEL_PATH);
    await faceapi.nets.ageGenderNet.load(MODEL_PATH);
    await faceapi.nets.faceLandmark68Net.load(MODEL_PATH);
    await faceapi.nets.faceRecognitionNet.load(MODEL_PATH);
    await faceapi.nets.faceExpressionNet.load(MODEL_PATH);
}
```

### カメラ映像を取得
カメラデバイスから取得した映像（MediaStream）をvideoタグに紐づけてプレビューします。

```typescript
const constraints = {
    audio: false,
    video: true,
};
let stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
```

### Canvas
認識した顔情報の大きさや位置などの判定用に使用するcavasです。
Videoタグとサイズを合わせる形で設定していきます。

```typescript
const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
// Canvas Settings
canvas.width = settings.width ? settings.width : 0
canvas.height = settings.height ? settings.height : 0
canvas.style.width = "100%"     
canvas.style.height = "100%"
```


### 処理開始

videoタグのonloadeddata()で、データのロード完了時に認識処理をスタートさせます。
そのままだと、Canvasへの出力が左右逆になってしまうので反転させておきます。
そして顔情報を処理するためのdetectHandler()を呼び出します。

```typescript
video.onloadeddata = async () => {
        video.play();
        const ctx = canvas.getContext('2d');
        // Invert Canvas
        ctx?.scale(-1,1);
        ctx?.translate(-canvas.width, 0);          
        await detectHandler()
};
```

### detectHandler()

ここでvideoタグから得たストリームデータを取得して、顔認識処理を実行します。
requestAnimationFrame使い、再帰的に呼び出して繰り返し実行していきます。

`requestAnimationFrame()` は、画面が描画される度に呼ばれる処理で、呼び出されるタイミングが固定値である`setInterval()`を使うよりもより滑らかな体感を得ることが出来ます。

```typescript
const video = videoRef.current as HTMLVideoElement

const detectedFace = await faceapi
    .detectSingleFace(video, detectorOptions)
    .withFaceLandmarks()
    .withFaceExpressions()
    .withAgeAndGender()
                
// Check Detected Face
checkFace(detectedFace)
// For Performance
requestAnimationFrame(
    () => detectHandler()
);
```

## モテ判定 🤖
判定基準は、マッチングアプリ「with（ウィズ）」を参考にさせて頂き

https://with.is/welcome

プロフィール写真のベストプラクティス

- 写真に顔がキチンと写っているか
    - 基準
        - 真ん中に来ているか？
        - 大きさが適切か？

と
- 表情がイケてるか？

をチェック。
後ついでなので、顔から推定される年齢も出してみます。

### まずは顔が真ん中にあるか？

認識した顔の位置がcanvasの中央にあるか、を判定します。

上下左右には少し遊びをつけて判定しています。


**検知した顔の矩形情報**

```typescript
const detectedFace = await faceapi
	.detectSingleFace(video, detectorOptions)
	.withFaceLandmarks()                                        
	
const faceFrame = detectedFace.detection.box
```

これを元に、**顔位置のセンター判定**を行います

```typescript
// Center of frame
const frameCenter:{x: number, y: number} = {x: frame.w/2, y: frame.h/2}
// margin
const cordinator:{w: number, h: number} = { w: 200, h: 200 }

// Allowable U D L R
const toleranceRange: {
    left: number,
    right: number,
    top: number,
    bottom: number,  
} = {
    left: frameCenter.x - cordinator.w,
    right: frameCenter.x + cordinator.w,
    top: frameCenter.y - cordinator.h,
    bottom: frameCenter.y + cordinator.h,
}

// Center of face
const centerOfFace:{x: number, y: number} = {
    x: faceFrame.x + (faceFrame.width/2),
    y: faceFrame.y + (faceFrame.height/2)
}

// 判定
// horizontal
let horizontal = false
if( toleranceRange.left < centerOfFace.x 
&& centerOfFace.x < toleranceRange.right ){

    horizontal = true
}
// vertical
let vertical = false
if( toleranceRange.top < centerOfFace.y
&& centerOfFace.y < toleranceRange.bottom ){

    vertical = true
}

const isJustRight = (horizontal && vertical)
```

### 顔の大きさが適切か？

フレームに対して丁度良いサイズに収まっているか、を判定します。

**検知した顔の矩形情報**

```typescript
const detectedFace = await faceapi
    .detectSingleFace(video, detectorOptions)
    .withFaceLandmarks()                                        

const faceFrame = detectedFace.detection.box
```

**顔の大きさ判定**

今回は枠に対して顔の占める割合が30％〜50％に収まっていればOKとしました。

```typescript
const frameArea = frame.w * frame.h
const faceArea = faceFrame.width * faceFrame.height

// 30% to 50%
const lowRatio = 0.2
const highRatio = 0.3
const ratio = faceArea / frameArea

let isSufficient = (lowRatio <= ratio && ratio <= highRatio)
```

### 表情がイケてるか？

ここではFaceAPIが予測した表情が「happy」であるかどうか、を確認していきます。

**表情の認識**

```typescript
const detectedFace = await faceapi
	.detectSingleFace(video, detectorOptions)
	.withFaceExpressions()
                            
const expressions = detectedFace.expressions            
```

FaceAPIが返す**表情のタイプ** はこちら👇

- angry
- disgusted
- fearful
- happy
- neutral
- sad
- surprised

各Keyの予測値はその表情の確率を表します。

```typescript
export type FaceExpression = {
    neutral: number
    happy: number
    sad: number
    angry: number
    fearful: number
    disgusted: number
    surprised: number
}
```

予測値が一番高い表情が「Happy」であればOKとします。

```typescript
export type FaceExp = {
    expression: string;
    prediction: number;
}

// expression <- FaceExpression
const faceExps: FaceExp[] = []
for (const [key, value] of Object.entries(expression)) {
    const faceExp: FaceExp = {
        expression: key,
        prediction: value
    }
    faceExps.push(faceExp)
}
const sorted = faceExps.sort( (prev, current) => {
	return (prev.prediction > current.prediction) ? -1 : 1
})

console.log(sorted[0].expression)
```

### 顔から推定される年齢

FaceAPIが推定する年齢を取得しています。

**推定年齢**
```typescript
const detectedFace = await faceapi
	.detectSingleFace(video, detectorOptions)
	.withAgeAndGender()

const age = detectedFace.age
```
これは年齢をそのまま数値で取得出来ます。

## View 👀
今回のデモではChakraUIを利用しています。
おかげで直感的にレイアウト出来ました。

### MoteCamComponent

![motecam-main-view.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681323/2b73bd3b-8a4c-f1ca-2006-964e49b963a3.png)

**メインView**
- 撮影プレビュー用 videoタグ
- 顔認識判定用 canvasタグ
- 撮影ボタン

```tsx
<>
  <VStack my={4}>
      {
          (moteCam.isStarted === true && moteCam.isReady === false) && 
              <Spinner
              thickness='4px'
              speed='0.65s'
              emptyColor='gray.200'
              color='blue.500'
              size='xl'
              my={8}
              />
      }
      <Box my={4} id="video-frame" className={styles.videoFrame} display={ moteCam.isReady === true ? 'block' : 'none' }>
          <video ref={moteCam.videoRef} playsInline className={styles.videoWebcam}></video>
          <canvas ref={moteCam.canvasRef} id="canvas" className={styles.drawCanvas} />
      </Box>
      <Button my={4} mx='auto' size='lg' w='80%' colorScheme='blue' onClick={moteCam.startAndStop}>
				{ moteCam.isStarted ? "Stop" : "Start" }
			</Button>
      { moteCam.isStarted && <MoteCamMessage {...moteCam.moteCamAdvice} />}                
  </VStack>        
</>
```

**撮影した写真を表示するModal**

- Imageタグ
- 保存ボタン

```tsx
  <Modal isOpen={moteCam.isTakenPhoto} onClose={moteCam.dismissTakenPhoto}>
      <ModalOverlay />
      <ModalContent>
          <ModalHeader>{localizedStrings.PHOTO_COMPLETION_TITLE}</ModalHeader>
          <ModalCloseButton />
          <ModalBody>
              <Image ref={moteCam.photoRef} id='photo' alt="photo"></Image>
          </ModalBody>

          <ModalFooter>
              <Button my={4} colorScheme='blue' onClick={moteCam.downloadPhoto}>写真を保存</Button>
          </ModalFooter>
      </ModalContent>
  </Modal>
```

## 撮影時のアドバイス 📢

モテモテ写真を撮影するために

🤖「もう少し右」「カメラに顔を近づけて」

といったメッセージをアドバイスします。

画面上にテキストを表示するだけでは、目線がカメラから離れてしまいます。

そこで音声でもアドバイスを行うことで、カメラ目線で写真が撮影出来るようにします。

### テキストでのアドバイス

**MoteCamMessage**

アドバイスのテキストメッセージを表示します

```tsx
<Box my={8}>
    <VStack mx={4}>
        <HStack>
            <Text>{expression.message}</Text>
        </HStack>
        <HStack>
            <Text>{age.message}</Text>
        </HStack>
        <HStack>
            <Text>{faceSize.message}</Text>
        </HStack>
        <HStack>
            <Text>{facePosition.message}</Text>
        </HStack>              
    </VStack>
</Box>
```

### 音声をつかったアドバイス

**useSpeech**

Web Speech API の `SpeechSynthesis` を使ってテキストメッセージを音声再生します。

https://developer.mozilla.org/ja/docs/Web/API/SpeechSynthesis

**メッセージを読み上げる**

```typescript
const speakAdvice = ( message: string ) => {
	  if( message !== 'undefined' && typeof speechSynthesis !== 'undefined' 
	      && speechSynthesis.speaking === false ){
	      speechSynthesis.cancel(); // for chrome bugs
	      const utterance = new SpeechSynthesisUtterance();
	      utterance.text = message;
	      utterance.lang = languageCode;
	      speechSynthesis.speak(utterance)
	  }  
}
```

💡 **iOS Safari ではしゃべり始めにユーザ操作が必要** 

iOS Safariでは、ユーザーのインタラクションがないと音声の再生が出来ません。
例えば「ページのロード完了」をトリガーにして、いきなり自動再生というのはNG。

なので

- ユーザーに「スタートボタンを押す」などのアクションをしてもらう
- その後、一度APIを呼んで何か喋らせる（「開始します」など）

このステップを踏むことで、その後は自動再生を連続して行うことが出来ます。


## モテ写真の撮影 📸

顔の**位置**、**大きさ**、**表情**の条件が全て整うと自動で撮影が実行されます。

流れ

- videoタグに流していたMediaStream を使って、Canvasに画像を描画する
- 描画データをImageタグにsrcに指定してプレビューする

```typescript
const stream: MediaStream = video.srcObject as MediaStream
if( stream === null ) return

const track = stream.getVideoTracks()[0];
const settings = track.getSettings();
const width = settings.width ? settings.width : 0
const height = settings.height ? settings.height : 0  

canvasForDraw.width = width
canvasForDraw.height = height

const ctx: CanvasRenderingContext2D = canvasForDraw.getContext('2d')!

// Inversion
ctx.scale(-1,1);
ctx.translate(-width, 0);

ctx.drawImage(video, 0, 0, width, height);
photo.src = canvasForDraw.toDataURL('image/png');
```

**撮影写真の保存**

撮影写真は画像部分を長押ししても保存可能ですが、ボタンアクションでのダウンロード方法も用意しておきます。

```typescript
const downloadPhoto = () => {
	  if( photoRef.current ){
	      const a = document.createElement("a");      
	      const photo = photoRef.current as HTMLImageElement
	      document.body.appendChild(a);
	      a.download = 'best_shot.png';
	      a.href = photo.src;          
	      a.click();
	      a.remove();
	  }
}
```

## デモサイトの公開 🙌

今回は動作デモ用にFirebase Hostingを使いました。

静的にエクスポートしたものをアップロードするだけなので、お手軽に公開出来て良かったです。

firebase-toolsをインストール

```shell
npm install -g firebase-tools
```

認証後、トークン取得

```shell
firebase login:ci
```

初期化

```shell
firebase init
```

公開ディレクトリ設定では、`out` ディレクトリを設定。

```
What do you want to use as your public directory?
```

Firebase Hosting の詳細はこちら

[https://firebase.google.com/docs/hosting](https://firebase.google.com/docs/hosting)

ビルド＆エクスポート＆デプロイ

```bash
npx next build && npx next export && firebase serve --only hosting --token $FIREBASE_TOKEN
```

### Demo

https://next-webcam.web.app/


## おわりに ☕
ソースコード全体はこちら

https://github.com/BostonTerrier-Bon/mote-camera

今回、Firebase Hostingを使うにあたって静的にエクスポートしてアップロードする必要がありました。
そのため、デモ版の方はローカライズ周りなどの実装が若干異なります。

多少なり、プロフ写真の参考になればうれしいです🙌