概要
googleから公開されているMediaPipeを使用して、ハンドトラッカーをReactで実装します。
実装例では、手の骨格と、両手の人差し指の間に円を描画しました。
https://nemutas.github.io/app-mediapipe-hands-demo/
あたりまえ体操ですが、Webカメラの接続が必要です。
ドキュメント
パッケージ
- 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)で作成します。
npx create-react-app . --template typescript --use-npm
ReactでWebカメラを扱うためのパッケージをインストールします。
npm i react-webcam
スタイリングには、emotion/cssを使用しています。
npm i @emotion/css
MediaPipe関連のパッケージをインストールします。
npm i @mediapipe/hands @mediapipe/camera_utils @mediapipe/drawing_utils
@mediapipe/hands
と@mediapipe/camera_utils
は必須です。
@mediapipe/drawing_utils
は、canvasへの描画を簡単にするものなので、必要に応じて入れます。
実装
実装例がgoogleのドキュメントにあるので、それを元にコーディングしました。
全体のコード
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;
`
}
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では、検出の設定をしています。
特に、maxNumHands
では、一度に検出する手の数を指定しています。
hands.setOptions({
maxNumHands: 2,
modelComplexity: 1,
minDetectionConfidence: 0.5,
minTrackingConfidence: 0.5
})
- cameraの設定では、描画範囲を揃える必要があります。
実装例では、1280x720pxに揃えています。
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}
/>
検出結果のデータ構造
検出結果はフレーム単位で取得されるので、データ構造が見たい場合は、ボタンを用意して押したタイミングのデータをコンソールに出力するといいと思います。
/**
* 検出結果(フレーム毎に呼び出される)
* @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
は以下のような構造になっています。
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次元配列になっています。
手の骨格の番号は、以下の対応付けになっています。
手の骨格のxy座標はそれぞれ、描画範囲に対して**正規化された座標(0~1)**を持っています。
z座標は、カメラからの距離になっています。
描画
手の骨格を描画している以下の2つの関数は、ユーティリティとして提供されています。
drawConnectors(ctx, landmarks, HAND_CONNECTIONS, { color: '#00FF00', lineWidth: 1 })
drawLandmarks(ctx, landmarks, { color: '#FF0000', lineWidth: 1, radius: 2 })
人差し指の間の青い〇の描画では、正規化された座標をcanvasの座標に戻しています。
そして、人差し指の間の中点と円の半径を計算しています。
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
リポジトリ
参考