LoginSignup
8
4

More than 1 year has passed since last update.

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

Last updated at Posted at 2021-11-11

概要

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

https://nemutas.github.io/app-mediapipe-hands-demo/
output(video-cutter-js.com) (3).gif

あたりまえ体操ですが、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のドキュメントにあるので、それを元にコーディングしました。

全体のコード

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;
    `
}
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では、検出の設定をしています。
    特に、maxNumHandsでは、一度に検出する手の数を指定しています。
App.tsx
hands.setOptions({
    maxNumHands: 2,
    modelComplexity: 1,
    minDetectionConfidence: 0.5,
    minTrackingConfidence: 0.5
})
  • cameraの設定では、描画範囲を揃える必要があります。
    実装例では、1280x720pxに揃えています。
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}
/>

検出結果のデータ構造

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

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は以下のような構造になっています。

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次元配列になっています。
手の骨格の番号は、以下の対応付けになっています。


https://google.github.io/mediapipe/solutions/hands#hand-landmark-model

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

描画

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

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

人差し指の間の青い〇の描画では、正規化された座標をcanvasの座標に戻しています。
そして、人差し指の間の中点と円の半径を計算しています。

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

リポジトリ

参考

8
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
4