【React】MediaPipe/Face Mesh で顔を検知する

<!-- ======================================================== -->
# 概要
<!-- ======================================================== -->
googleから公開されている**MediaPipe/Face Mesh**の**Reactでの実装例**をまとめました。

https://nemutas.github.io/app-mediapipe-facemesh-demo/
![output(video-cutter-js.com) (6).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/9398ff85-4d7c-0b75-c0a1-f1ce7ef69fd5.gif)

:::note info
Webカメラの接続が必要です。
:::

以下の記事とほぼ同じ手順で実装できます。

https://qiita.com/nemutas/items/32ce13ae31360877baa5

<!-- ======================================================== -->
# ドキュメント
<!-- ======================================================== -->

https://google.github.io/mediapipe/solutions/face_mesh.html#refine_landmarks

<!-- ======================================================== -->
# パッケージ
<!-- ======================================================== -->

* react - 17.0.2
* typescript - 4.4.4
* react-webcam - 6.0.0
* leva - 0.9.14
* @emotion/css - 11.5.0
* @mediapipe/face_mesh - 0.4.1633559619
* @mediapipe/camera_utils - 0.3.1632432234
* @mediapipe/drawing_utils - 0.3.1620248257

<!-- ======================================================== -->
# 実装
<!-- ======================================================== -->
実装例がgoogleのドキュメントにあるので、それを元にコーディングしました。

https://google.github.io/mediapipe/solutions/face_mesh.html#javascript-solution-api

### 全体のコード
```.tsx:App.tsx
import { button, useControls } from 'leva';
import React, { FC, useCallback, useEffect, useRef } from 'react';
import Webcam from 'react-webcam';
import { css } from '@emotion/css';
import { Camera } from '@mediapipe/camera_utils';
import {
	FaceMesh, FACEMESH_LEFT_EYE, FACEMESH_LIPS, FACEMESH_RIGHT_EYE, Results
} from '@mediapipe/face_mesh';
import { draw } from '../utils/drawCanvas';

export const App: FC = () => {
	const webcamRef = useRef<Webcam>(null)
	const canvasRef = useRef<HTMLCanvasElement>(null)
	const resultsRef = useRef<Results>()

	// コントローラーの追加
	const datas = useControls({
		bgImage: false,
		landmark: {
			min: 0,
			max: 477,
			step: 1,
			value: 0
		},
		result: button(() => {
			OutputData()
		})
	})

	/** 検出結果をconsoleに出力する */
	const OutputData = () => {
		const results = resultsRef.current!
		console.log(results.multiFaceLandmarks[0])
		console.log('FACEMESH_LEFT_EYE', FACEMESH_LEFT_EYE)
		console.log('FACEMESH_RIGHT_EYE', FACEMESH_RIGHT_EYE)
		console.log('FACEMESH_LIPS', FACEMESH_LIPS)
	}

	/** 検出結果（フレーム毎に呼び出される） */
	const onResults = useCallback(
		(results: Results) => {
			// 検出結果の格納
			resultsRef.current = results
			// 描画処理
			const ctx = canvasRef.current!.getContext('2d')!
			draw(ctx, results, datas.bgImage, datas.landmark)
		},
		[datas]
	)

	useEffect(() => {
		const faceMesh = new FaceMesh({
			locateFile: file => {
				return `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}`
			}
		})

		faceMesh.setOptions({
			maxNumFaces: 1,
			refineLandmarks: true, // landmarks 468 -> 478
			minDetectionConfidence: 0.5,
			minTrackingConfidence: 0.5
		})

		faceMesh.onResults(onResults)

		if (webcamRef.current) {
			const camera = new Camera(webcamRef.current.video!, {
				onFrame: async () => {
					await faceMesh.send({ image: webcamRef.current!.video! })
				},
				width: 1280,
				height: 720
			})
			camera.start()
		}

		return () => {
			faceMesh.close()
		}
	}, [onResults])

	return (
		<div className={styles.container}>
			{/* capture */}
			<Webcam
				ref={webcamRef}
				style={{ visibility: 'hidden' }}
				audio={false}
				width={1280}
				height={720}
				mirrored
				screenshotFormat="image/jpeg"
				videoConstraints={{ width: 1280, height: 720, facingMode: 'user' }}
			/>
			{/* draw */}
			<canvas ref={canvasRef} className={styles.canvas} width={1280} height={720} />
		</div>
	)
}

// ==============================================
// styles

const styles = {
	container: css`
		position: relative;
		width: 100vw;
		height: 100vh;
		overflow: hidden;
		display: flex;
		justify-content: center;
		align-items: center;
	`,
	canvas: css`
		position: absolute;
		width: 1280px;
		height: 720px;
		background-color: #1e1e1e;
		border: 1px solid #fff;
	`
}
```

```.ts:drawCanvas.ts
import { drawConnectors } from '@mediapipe/drawing_utils';
import {
	FACEMESH_FACE_OVAL, FACEMESH_LEFT_EYE, FACEMESH_LEFT_EYEBROW, FACEMESH_LEFT_IRIS, FACEMESH_LIPS,
	FACEMESH_RIGHT_EYE, FACEMESH_RIGHT_EYEBROW, FACEMESH_RIGHT_IRIS, FACEMESH_TESSELATION,
	NormalizedLandmark, Results
} from '@mediapipe/face_mesh';

/**
 * canvasに描画する
 * @param ctx コンテキスト
 * @param results 検出結果
 * @param bgImage capture imageを描画するか
 * @param emphasis 強調するlandmarkのindex
 */
export const draw = (
	ctx: CanvasRenderingContext2D,
	results: Results,
	bgImage: boolean,
	emphasis: number
) => {
	const width = ctx.canvas.width
	const height = ctx.canvas.height

	ctx.save()
	ctx.clearRect(0, 0, width, height)

	if (bgImage) ctx.drawImage(results.image, 0, 0, width, height)

	if (results.multiFaceLandmarks) {
		const lineWidth = 1
		const tesselation = { color: '#C0C0C070', lineWidth }
		const right_eye = { color: '#FF3030', lineWidth }
		const left_eye = { color: '#30FF30', lineWidth }
		const face_oval = { color: '#E0E0E0', lineWidth }

		for (const landmarks of results.multiFaceLandmarks) {
			// 顔の表面（埋め尽くし）
			drawConnectors(ctx, landmarks, FACEMESH_TESSELATION, tesselation)
			// 右の目・眉・瞳
			drawConnectors(ctx, landmarks, FACEMESH_RIGHT_EYE, right_eye)
			drawConnectors(ctx, landmarks, FACEMESH_RIGHT_EYEBROW, right_eye)
			drawConnectors(ctx, landmarks, FACEMESH_RIGHT_IRIS, right_eye)
			// 左の目・眉・瞳
			drawConnectors(ctx, landmarks, FACEMESH_LEFT_EYE, left_eye)
			drawConnectors(ctx, landmarks, FACEMESH_LEFT_EYEBROW, left_eye)
			drawConnectors(ctx, landmarks, FACEMESH_LEFT_IRIS, left_eye)
			// 顔の輪郭
			drawConnectors(ctx, landmarks, FACEMESH_FACE_OVAL, face_oval)
			// 唇
			drawConnectors(ctx, landmarks, FACEMESH_LIPS, face_oval)

			// landmarkの強調描画
			drawPoint(ctx, landmarks[emphasis])
		}
	}
	ctx.restore()
}

/**
 * 特定のlandmarkを強調する
 * @param ctx
 * @param point
 */
const drawPoint = (ctx: CanvasRenderingContext2D, point: NormalizedLandmark) => {
	const x = ctx.canvas.width * point.x
	const y = ctx.canvas.height * point.y
	const r = 5

	ctx.fillStyle = '#22a7f2'
	ctx.beginPath()
	ctx.arc(x, y, r, 0, Math.PI * 2, true)
	ctx.fill()
}
```

### MediaPipeの設定
[**faceMesh.setOptions**](https://google.github.io/mediapipe/solutions/face_mesh.html#configuration-options)では、顔の検知の設定を行います。

```App.tsx
faceMesh.setOptions({
	maxNumFaces: 1,
	refineLandmarks: true, // landmarks 468 -> 478
	minDetectionConfidence: 0.5,
	minTrackingConfidence: 0.5
})
```
特に、

* `maxNumFaces`：認識する顔の数
* `refineLandmarks`：顔を詳細に認識するか。trueにすることで、目や唇まわりのメッシュを細かくし、瞳のランドマークも出力します。これによりランドマークの出力数が、468から478箇所に増えます。

### 検出結果のデータ構造
検出結果の`results.multiFaceLandmarks`は、**検知された顔**と顔ごとの**ランドマーク**の2次元配列になっています。
例えば、以下のように値を取り出します。

```.tsx
// 1つ目の顔の100個目のランドマーク
const face1_landmark100 = results.multiFaceLandmarks[0][99]
```
そして、ランドマークはそれぞれ、3次元座標を持っています。

```.tsx
face1_landmark100 = {x: 0.1, y: 0.1, z: -0.5}
```
* x, y：描画範囲に対して正規化された座標（0～1）
* z：頭の中心位置からの座標（ほぼx座標と同じスケール）

https://google.github.io/mediapipe/solutions/face_mesh.html#output

### ランドマークの特定方法


---
<font color='red'>追記（2022.01.15）</font>
ランドマークは、以下のようになっています。
※ 数字のフォントサイズが小さいので、画像をクリックして拡大して見てください。

><img src='https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/679222fc-e317-ebb2-2451-1e0a14de9c90.png' width='600' />
>https://github.com/google/mediapipe/blob/master/mediapipe/modules/face_geometry/data/canonical_face_model_uv_visualization.png

---

468箇所（478箇所）のランドマークが顔のどの位置にあたるかは、ドキュメント上に公開されていません。（おそらく）
また、小一時間調べても確度の高い情報が見つからなかったので、自分で特定する手段を実装しました。

特定するために、まずコントローラーを追加します。
![スクリーンショット 2021-11-18 022053.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/a29d6d22-c491-2536-7637-d76556b2fe32.png)

```App.tsx
const datas = useControls({
	bgImage: false,
	landmark: {
		min: 0,
		max: 477,
		step: 1,
		value: 0
	},
	result: button(() => {
		OutputData()
	})
})
```
* `bgImage`：canvasにキャプチャした顔（自分の顔）を描画するかどうか。
* `landmark`：この値を調整することで、canvas上の<font color='#22a7f2'>青い点</font>を指定したランドマーク上に描画します。記事TOPのgifで、上唇にある青い点は、ランドマークのインデックスが0の位置です。
* `result`：このボタンを押すことで、その瞬間の検出結果をコンソールに出力します。

ただし、468箇所のランドマークをこの方法で特定するのは大変です。
そこで、描画処理で引数として渡されている、**LandmarkConnectionArray**を見てランドマークにあたりをつけます。

```App.tsx
const OutputData = () => {
	const results = resultsRef.current!
	console.log(results.multiFaceLandmarks[0])
	console.log('FACEMESH_LEFT_EYE', FACEMESH_LEFT_EYE)
	console.log('FACEMESH_RIGHT_EYE', FACEMESH_RIGHT_EYE)
	console.log('FACEMESH_LIPS', FACEMESH_LIPS)
}
```
たとえば`FACEMESH_LEFT_EYE`は、左目のランドマークをつなぐための配列が格納されています。
374と386は、それぞれ左目の下瞼、上瞼にあたります。
![Inkedスクリーンショット 2021-11-17 152308_LI.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/605f98d3-471f-c9e2-773a-89fc16a445c8.jpeg)

<!-- ======================================================== -->
# リポジトリ
<!-- ======================================================== -->

https://github.com/nemutas/app-mediapipe-facemesh-demo