【React】MediaPipe/Hands で手を検知する

<!-- ================================================== -->
# 概要
<!-- ================================================== -->
googleから公開されている**MediaPipe**を使用して、ハンドトラッカーを**React**で実装します。
実装例では、手の骨格と、両手の人差し指の間に円を描画しました。

https://nemutas.github.io/app-mediapipe-hands-demo/
![output(video-cutter-js.com) (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/24b901e0-dc45-dba0-d47e-33836f5dd896.gif)

:::note info
あたりまえ体操ですが、Webカメラの接続が必要です。
:::

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

https://google.github.io/mediapipe/

https://google.github.io/mediapipe/solutions/hands

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

* react - 17.0.2
* typescript - 4.4.4
* react-webcam - 6.0.0
* @emotion/css - 11.5.0
* @mediapipe/hands - 0.4.1635986972
* @mediapipe/camera_utils - 0.3.1632432234
* @mediapipe/drawing_utils - 0.3.1620248257

プロジェクトは、CRA（Create React App）で作成します。

```shell
npx create-react-app . --template typescript --use-npm
```

ReactでWebカメラを扱うためのパッケージをインストールします。

```shell
npm i react-webcam
```

スタイリングには、[emotion/css](https://emotion.sh/docs/@emotion/css)を使用しています。

```shell
npm i @emotion/css
```

MediaPipe関連のパッケージをインストールします。

```shell
npm i @mediapipe/hands @mediapipe/camera_utils @mediapipe/drawing_utils
```
`@mediapipe/hands`と`@mediapipe/camera_utils`は必須です。
`@mediapipe/drawing_utils`は、canvasへの描画を簡単にするものなので、必要に応じて入れます。

https://google.github.io/mediapipe/getting_started/javascript#supported-plaforms

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

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

### 全体のコード
```.tsx:App.tsx
import React, { useCallback, useEffect, useRef, VFC } from 'react';
import Webcam from 'react-webcam';
import { css } from '@emotion/css';
import { Camera } from '@mediapipe/camera_utils';
import { Hands, Results } from '@mediapipe/hands';
import { drawCanvas } from '../utils/drawCanvas';

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

	/**
	 * 検出結果（フレーム毎に呼び出される）
	 * @param results
	 */
	const onResults = useCallback((results: Results) => {
		resultsRef.current = results

		const canvasCtx = canvasRef.current!.getContext('2d')!
		drawCanvas(canvasCtx, results)
	}, [])

	// 初期設定
	useEffect(() => {
		const hands = new Hands({
			locateFile: file => {
				return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`
			}
		})

		hands.setOptions({
			maxNumHands: 2,
			modelComplexity: 1,
			minDetectionConfidence: 0.5,
			minTrackingConfidence: 0.5
		})

		hands.onResults(onResults)

		if (typeof webcamRef.current !== 'undefined' && webcamRef.current !== null) {
			const camera = new Camera(webcamRef.current.video!, {
				onFrame: async () => {
					await hands.send({ image: webcamRef.current!.video! })
				},
				width: 1280,
				height: 720
			})
			camera.start()
		}
	}, [onResults])

	/** 検出結果をconsoleに出力する */
	const OutputData = () => {
		const results = resultsRef.current as Results
		console.log(results.multiHandLandmarks)
	}

	const videoConstraints = {
		width: 1280,
		height: 720,
		facingMode: 'user'
	}

	return (
		<div className={styles.container}>
			{/* capture */}
			<Webcam
				audio={false}
				style={{ visibility: 'hidden' }}
				width={1280}
				height={720}
				ref={webcamRef}
				screenshotFormat="image/jpeg"
				videoConstraints={videoConstraints}
			/>
			{/* draw */}
			<canvas ref={canvasRef} className={styles.canvas} />
			{/* output */}
			<div className={styles.buttonContainer}>
				<button className={styles.button} onClick={OutputData}>
					Output Data
				</button>
			</div>
		</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: #fff;
	`,
	buttonContainer: css`
		position: absolute;
		top: 20px;
		left: 20px;
	`,
	button: css`
		color: #fff;
		background-color: #0082cf;
		font-size: 1rem;
		border: none;
		border-radius: 5px;
		padding: 10px 10px;
		cursor: pointer;
	`
}
```

```.ts:utils/drawCanvas.ts
import { drawConnectors, drawLandmarks } from '@mediapipe/drawing_utils';
import { HAND_CONNECTIONS, NormalizedLandmarkListList, Results } from '@mediapipe/hands';

/**
 * cnavasに描画する
 * @param ctx canvas context
 * @param results 手の検出結果
 */
export const drawCanvas = (ctx: CanvasRenderingContext2D, results: Results) => {
	const width = ctx.canvas.width
	const height = ctx.canvas.height

	ctx.save()
	ctx.clearRect(0, 0, width, height)
	// canvas の左右反転
	ctx.scale(-1, 1)
	ctx.translate(-width, 0)
	// capture image の描画
	ctx.drawImage(results.image, 0, 0, width, height)
	// 手の描画
	if (results.multiHandLandmarks) {
		// 骨格の描画
		for (const landmarks of results.multiHandLandmarks) {
			drawConnectors(ctx, landmarks, HAND_CONNECTIONS, { color: '#00FF00', lineWidth: 1 })
			drawLandmarks(ctx, landmarks, { color: '#FF0000', lineWidth: 1, radius: 2 })
		}
		// 円の描画
		drawCircle(ctx, results.multiHandLandmarks)
	}
	ctx.restore()
}

/**
 *  人差し指の先端と人差し指の先端の間に円を描く
 * @param ctx
 * @param handLandmarks
 */
const drawCircle = (ctx: CanvasRenderingContext2D, handLandmarks: NormalizedLandmarkListList) => {
	if (handLandmarks.length === 2 && handLandmarks[0].length > 8 && handLandmarks[1].length > 8) {
		const width = ctx.canvas.width
		const height = ctx.canvas.height
		const [x1, y1] = [handLandmarks[0][8].x * width, handLandmarks[0][8].y * height]
		const [x2, y2] = [handLandmarks[1][8].x * width, handLandmarks[1][8].y * height]
		const x = (x1 + x2) / 2
		const y = (y1 + y2) / 2
		const r = Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) / 2

		ctx.strokeStyle = '#0082cf'
		ctx.lineWidth = 3
		ctx.beginPath()
		ctx.arc(x, y, r, 0, Math.PI * 2, true)
		ctx.stroke()
	}
}
```

### MediaPipeの設定

* [**hands.setOptions**](https://google.github.io/mediapipe/solutions/hands#configuration-options)では、検出の設定をしています。<br>
特に、`maxNumHands`では、一度に検出する手の数を指定しています。

```.tsx:App.tsx
hands.setOptions({
	maxNumHands: 2,
	modelComplexity: 1,
	minDetectionConfidence: 0.5,
	minTrackingConfidence: 0.5
})
```

* cameraの設定では、描画範囲を揃える必要があります。<br>
実装例では、1280x720pxに揃えています。

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

// ・・・
{/* capture */}
<Webcam
	audio={false}
	style={{ visibility: 'hidden' }}
	width={1280}
	height={720}
	ref={webcamRef}
	screenshotFormat="image/jpeg"
	videoConstraints={videoConstraints}
/>
```

### 検出結果のデータ構造
検出結果はフレーム単位で取得されるので、データ構造が見たい場合は、ボタンを用意して押したタイミングのデータをコンソールに出力するといいと思います。

```.tsx:App.tsx
/**
 * 検出結果（フレーム毎に呼び出される）
 * @param results
 */
const onResults = useCallback((results: Results) => {
	resultsRef.current = results
	// ・・・
}, [])

/** 検出結果をconsoleに出力する */
const OutputData = () => {
	const results = resultsRef.current as Results
	console.log(results.multiHandLandmarks)
}

// ・・・
{/* output */}
<div className={styles.buttonContainer}>
	<button className={styles.button} onClick={OutputData}>
		Output Data
	</button>
</div>
```

検出結果をみると、`multiHandLandmarks`は以下のような構造になっています。

```console
Array(2)
	0: Array(21)
		0: {x: 0.2183218151330948, y: 0.18258927762508392, z: -5.6708209683620225e-8, visibility: undefined}
		1: {x: 0.2609894573688507, y: 0.23454424738883972, z: -0.05740904435515404, visibility: undefined}
		・・・
		length: 21
		[[Prototype]]: Array(0)
	1: (21) [{…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}, {…}]
	length: 2
	[[Prototype]]: Array(0)
```

`multiHandLandmarks`は、**検知された手**と**手の骨格**の2次元配列になっています。
手の骨格の番号は、以下の対応付けになっています。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/d0e187f4-163f-2410-4b92-27d495bbb1cc.png" width="600">
https://google.github.io/mediapipe/solutions/hands#hand-landmark-model

手の骨格のxy座標はそれぞれ、描画範囲に対して**正規化された座標（0～1）**を持っています。
z座標は、カメラからの距離になっています。

### 描画
手の骨格を描画している以下の2つの関数は、ユーティリティとして提供されています。

```.ts:utils/drawCanvas.ts
drawConnectors(ctx, landmarks, HAND_CONNECTIONS, { color: '#00FF00', lineWidth: 1 })
drawLandmarks(ctx, landmarks, { color: '#FF0000', lineWidth: 1, radius: 2 })
```

人差し指の間の<font color='#0082cf'>青い〇</font>の描画では、正規化された座標をcanvasの座標に戻しています。
そして、人差し指の間の中点と円の半径を計算しています。

```.ts:utils/drawCanvas.ts
const width = ctx.canvas.width
const height = ctx.canvas.height
const [x1, y1] = [handLandmarks[0][8].x * width, handLandmarks[0][8].y * height]
const [x2, y2] = [handLandmarks[1][8].x * width, handLandmarks[1][8].y * height]
const x = (x1 + x2) / 2
const y = (y1 + y2) / 2
const r = Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) / 2
```

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

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

<!-- ================================================== -->
# 参考
<!-- ================================================== -->

https://google.github.io/mediapipe/solutions/hands

https://google.github.io/mediapipe/getting_started/javascript.html

https://github.com/google/mediapipe

https://stackoverflow.com/questions/67674453/how-to-run-mediapipe-facemesh-on-a-es6-node-js-environment-alike-react

https://www.npmjs.com/package/react-webcam