【JavaScript】でハンドジェスチャーを認識する

手をカメラの前にかざしてください。

はじめに

Webアプリケーションはさまざまなインタラクティブな機能を持っています。スマートフォンやPC上で、ユーザーがカメラに向かって手を動かすだけで操作できる仕組みを実装できたら、と考えたことはありませんか? 本記事では、Webカメラから入力される映像をもとに、ユーザーの手の形状(指の開閉など)を検出する仕組みを実現する方法を解説します。

今回利用するのは、Googleが提供している「MediaPipe Hands」と呼ばれるライブラリです。MediaPipeは、さまざまな機械学習モデルをブラウザ上で動作させるためのツールキットであり、今回のハンドジェスチャー認識もこのライブラリをベースにしています。 このブログ記事では、初めてコーディングに触れる方でも理解できるよう、以下のトピックについて段階的に解説していきます。

  • Webカメラの映像を取得する方法
  • Canvas要素を使った描画の基本
  • MediaPipe Handsライブラリの利用方法
  • 手のランドマーク(関節)の検出とその活用法
  • 指の角度を算出し、ジェスチャー(グー、チョキ、パー)を認識するアルゴリズムの解説

順を追って理解を深めながら、ぜひご自身でコードを実際に動かし、仕組みを体感してみてください。

1. コード全体の構造

下記が、今回のハンドジェスチャー認識プログラムの全体コードです。コード全体は大きく以下のセクションに分かれます。

  1. HTML部分
    • <video>タグ:Webカメラからの映像を取得するための要素
    • <canvas>タグ:解析結果や描画(ランドマークや手の繋がり)を表示するための要素
    • <div id="message">:認識結果(例:「右手:グー」)を表示するための要素
  2. CSS部分
    • スタイルの設定:<video>要素を非表示にし、キャンバスのサイズや位置を調整
  3. JavaScript部分
    • 外部ライブラリの読み込み:MediaPipe関連のライブラリ群(camera_utils.js, control_utils.js, drawing_utils.js, hands.js
    • DOM要素の取得:document.getElementById() を用いてHTML要素を取得
    • 補助関数の定義:角度を計算する calculateAngle() 関数、距離を計算する calculateDistance() 関数など
    • コールバック関数 onResults() :MediaPipeの解析結果をCanvasに描画し、ジェスチャーを解析する
    • MediaPipe Handsのインスタンスの作成とオプション設定
    • MediaPipe Cameraのインスタンスを用いた、Webカメラからの映像取得とフレームごとの処理

以下に、コード全文を再掲します。理解を深めるために、記事内で随所に解説を加えていきます。

<!DOCTYPE html>
<html>
<head>
  <title>ハンドジェスチャー認識</title>
  <style>
    video {
      display: none;
      width: 640px;
      height: 480px;
    }
    canvas {
      top: 0;
      left: 0;
      width: 640px;
      height: 480px;
    }
  </style>
</head>
<body>
  <video id="webcam" autoplay playsinline></video>
  <canvas id="output_canvas" width="640" height="480"></canvas>
  <div id="message"></div>
  
  <script src="https://cdn.jsdelivr.net/npm/@mediapipe/camera_utils/camera_utils.js" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/@mediapipe/control_utils/control_utils.js" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
  <script src="https://cdn.jsdelivr.net/npm/@mediapipe/hands/hands.js" crossorigin="anonymous"></script>
  
  <script>
    const videoElement = document.getElementById('webcam');
    const canvasElement = document.getElementById('output_canvas');
    const canvasCtx = canvasElement.getContext('2d');
    const message = document.getElementById('message');

    // 3点間の角度を計算する関数(点A, 点B, 点C のうち点Bでの角度)
    function calculateAngle(A, B, C) {
      const BA = { x: A.x - B.x, y: A.y - B.y };
      const BC = { x: C.x - B.x, y: C.y - B.y };
      const dot = BA.x * BC.x + BA.y * BC.y;
      const magBA = Math.hypot(BA.x, BA.y);
      const magBC = Math.hypot(BC.x, BC.y);
      const angleRad = Math.acos(dot / (magBA * magBC));
      return angleRad * (180 / Math.PI);
    }

    function onResults(results) {
      canvasCtx.save();
      canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);
      
      // ミラー表示用にキャンバスを左右反転
      canvasCtx.translate(canvasElement.width, 0);
      canvasCtx.scale(-1, 1);
      
      // 映像描画
      canvasCtx.drawImage(results.image, 0, 0, canvasElement.width, canvasElement.height);
      
      if (results.multiHandLandmarks && results.multiHandedness) {
        results.multiHandLandmarks.forEach((landmarks, index) => {
          // handedness の反転(ミラー表示に合わせる)
          const classification = results.multiHandedness[index].label;
          const flippedClassification = (classification === "Right") ? "Left" : "Right";
          
          // ランドマーク描画(元の座標を使用、キャンバス変換が反映される)
          drawConnectors(canvasCtx, landmarks, HAND_CONNECTIONS, { color: '#0088FF77', lineWidth: 5 });
          drawLandmarks(canvasCtx, landmarks, { color: '#0000FF77', lineWidth: 2 });
          
          // 各指の角度を計算(MCP, PIP, TIP の順)
          const indexAngle = calculateAngle(landmarks[5], landmarks[6], landmarks[8]);
          const middleAngle = calculateAngle(landmarks[9], landmarks[10], landmarks[12]);
          const ringAngle = calculateAngle(landmarks[13], landmarks[14], landmarks[16]);
          const pinkyAngle = calculateAngle(landmarks[17], landmarks[18], landmarks[20]);
          // 親指は、IP(landmark 3)を使うか、もしくは単純な距離比較(ここでは従来の方法)
          const thumbExtended = (calculateDistance(landmarks[4], landmarks[2]) > 0.05); // 調整可能な閾値
          
          // 指の状態判定のしきい値(調整可能)
          const extendedThreshold = 160; // 伸びているとみなす角度
          const bentThreshold = 100;     // 曲がっているとみなす角度
          
          // 判定結果
          const isIndexExtended = indexAngle > extendedThreshold;
          const isMiddleExtended = middleAngle > extendedThreshold;
          const isRingExtended = ringAngle > extendedThreshold;
          const isPinkyExtended = pinkyAngle > extendedThreshold;
          
          let gesture = "不明";
          // チョキ:人差し指と中指が伸び、輪指と小指が曲がっている
          if (isIndexExtended && isMiddleExtended && ringAngle < bentThreshold && pinkyAngle < bentThreshold) {
            gesture = "チョキ";
          }
          // パー:すべての指が伸びている
          else if (thumbExtended && isIndexExtended && isMiddleExtended && isRingExtended && isPinkyExtended) {
            gesture = "パー";
          }
          // グー:人差し指・中指・輪指・小指がすべて曲がっている(角度が低い)
          else if (indexAngle < bentThreshold && middleAngle < bentThreshold && ringAngle < bentThreshold && pinkyAngle < bentThreshold) {
            gesture = "グー";
          }
          
          message.innerHTML = flippedClassification + "手:" + gesture;
        });
      }
      
      canvasCtx.restore();
    }
    
    // 補助:2点間の距離(親指判定用)
    function calculateDistance(p1, p2) {
      return Math.hypot(p1.x - p2.x, p1.y - p2.y);
    }

    const hands = new Hands({
      locateFile: (file) => { return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`; }
    });
    hands.setOptions({
      maxNumHands: 1,
      modelComplexity: 1,
      minDetectionConfidence: 0.5,
      minTrackingConfidence: 0.5
    });
    hands.onResults(onResults);

    const camera = new Camera(videoElement, {
      onFrame: async () => { await hands.send({ image: videoElement }); },
      width: 640,
      height: 480
    });
    camera.start();
  </script>
</body>
</html>

このコードは、非常にシンプルな構成ながらも、複雑な動作を実現しています。以降、このコードの各部分について、さらに詳しく解説していきます。

HTML部分の詳細解説

まず、HTMLの部分を詳しく見ていきましょう。HTMLはWebページの骨格を定義するものであり、今回のプログラムでは以下の要素が使われています。

1. <video>タグ

<video id="webcam" autoplay playsinline></video>
  • 役割 このタグはWebカメラから取得した映像を受け取るためのものです。
    • id="webcam":JavaScriptからこの要素にアクセスするための識別子です。
    • autoplay:ページがロードされたときに自動的に映像再生を開始します。
    • playsinline:モバイルブラウザ上で全画面表示にせず、インラインでの再生を可能にします。
  • ポイント CSSで display: none; と指定して、バックグラウンドで利用します。映像情報はキャンバスに転写(描画)します。

2. <canvas>タグ

<canvas id="output_canvas" width="640" height="480"></canvas>
  • 役割 この要素は、Webカメラの映像を元に手のランドマーク情報やコネクション(指と手首のつながり)を描画するための領域です。
  • ポイント Canvas APIは、JavaScriptによる動的な描画を可能にするため、MediaPipe Handsから得られたデータを使って、手の各点を描画するのに非常に便利です。

3. <div id="message">

<div id="message"></div>
  • 役割 この要素は、認識結果のテキスト(例:「右手:グー」など)を表示するために使用しています。
  • ポイント 手のジェスチャーが認識されるたびに、JavaScript側で内容を書き換え、ユーザーにその結果を分かりやすく伝えます。

CSSで見た目の設定

CSSはHTML要素の見た目を制御するために使われます。今回のプログラムでは、以下のように指定されています。

video {
  display: none;
  width: 640px;
  height: 480px;
}
canvas {
  top: 0;
  left: 0;
  width: 640px;
  height: 480px;
}

1. <video>要素の設定

  • display: none; これにより、Webカメラからの映像はブラウザ上に直接表示されません。代わりに、映像は左右反転した状態でCanvasに描画されます。
  • widthheight Webカメラの映像のサイズを指定しています。Canvasのサイズと一致させることで、描画時にサイズのずれが生じないようにしています。

2. <canvas>要素の設定

  • 位置指定 top: 0; と left: 0; によって、キャンバスの描画位置を画面の左上に固定します。
  • サイズ指定 HTMLで指定した幅と高さ(640×480)をここでも明示することで、確実な描画エリアを確保しています。

このようなCSSの設定は、後述するJavaScriptによる描画処理と連携して、最終的にユーザーにとって見やすいインターフェースを実現します。

JavaScript部分の詳細解説

JavaScriptは今回のプロジェクトの「脳」にあたる部分です。手の解析、描画、Webカメラとの連携など、すべてのロジックはここで記述されています。以下、主要な関数や処理を解説します。

1. DOM要素の取得と変数の初期化

const videoElement = document.getElementById('webcam');
const canvasElement = document.getElementById('output_canvas');
const canvasCtx = canvasElement.getContext('2d');
const message = document.getElementById('message');
  • 解説 ここでは、HTML内の各要素に対してJavaScriptからアクセスするために、document.getElementById() を使って変数に格納しています。
    • videoElement:Webカメラからの映像を保持する <video> 要素。
    • canvasElementcanvasCtx:Canvas要素とその描画用コンテキスト。Canvas APIを使って描画する際の重要なオブジェクトです。
    • message:ジェスチャーの認識結果を表示する <div> 要素です。

2. 角度計算のための補助関数:calculateAngle()

function calculateAngle(A, B, C) {
  const BA = { x: A.x - B.x, y: A.y - B.y };
  const BC = { x: C.x - B.x, y: C.y - B.y };
  const dot = BA.x * BC.x + BA.y * BC.y;
  const magBA = Math.hypot(BA.x, BA.y);
  const magBC = Math.hypot(BC.x, BC.y);
  const angleRad = Math.acos(dot / (magBA * magBC));
  return angleRad * (180 / Math.PI);
}
  • 目的 この関数は、3つの点(A、B、C)のうち、Bを頂点とする角度を計算します。
  • 計算の流れ
    1. ベクトル作成:
      • ベクトルBA:点Bから見た点Aへのベクトル
      • ベクトルBC:点Bから見た点Cへのベクトル
    2. 内積(dot product):
      • 2つのベクトルがどれだけ同じ方向を向いているかを示す値
    3. ノルム(大きさ)の計算:
      • Math.hypot() を使って座標のユークリッド距離(ベクトルの大きさ)を求めます。
    4. 角度の算出:
      • 内積とベクトルの大きさから、Math.acos() で角度(ラジアン)を取得し、度(deg)に変換して返します。
  • ポイント 指の状態(伸びているか曲がっているか)は、この角度の大きさを基に判断されます。角度が大きい=伸びている、小さい=曲がっている、といった基準を用います。

3. 補助関数:calculateDistance()

function calculateDistance(p1, p2) {
  return Math.hypot(p1.x - p2.x, p1.y - p2.y);
}
  • 目的 主に親指の判定に使われます。親指の延長(伸びているかどうか)は、2点間の距離によって判断され、ある閾値を超えると「伸びている」とみなします。
  • 計算方法
    • ユークリッド距離を計算するために Math.hypot() を使用し、シンプルながら正確な距離を算出しています。

4. MediaPipe Handsのセットアップ

const hands = new Hands({
  locateFile: (file) => { return `https://cdn.jsdelivr.net/npm/@mediapipe/hands/${file}`; }
});
hands.setOptions({
  maxNumHands: 1,
  modelComplexity: 1,
  minDetectionConfidence: 0.5,
  minTrackingConfidence: 0.5
});
hands.onResults(onResults);
  • ライブラリの読み込み MediaPipe Handsは、手のランドマーク検出のための事前学習済みモデルを提供します。locateFile オプションを設定することで、必要なファイルを外部CDNから取得します。
  • オプション設定
    • maxNumHands: 1:1つの手のみ認識する(必要に応じて変更可能)
    • modelComplexity:モデルの複雑さの設定。数値が大きいほど、より正確だが負荷も大きい
    • minDetectionConfidenceminTrackingConfidence:検出・追跡のための信頼度の最小値。これらの値を調整することで、認識の精度と反応速度のバランスをとります。
  • コールバックの設定
    • hands.onResults(onResults):MediaPipeは毎フレームごとに手のランドマーク等の解析結果を返し、そのたびに onResults 関数が呼び出されます。

5. カメラ処理の初期化

const camera = new Camera(videoElement, {
  onFrame: async () => { await hands.send({ image: videoElement }); },
  width: 640,
  height: 480
});
camera.start();
  • 役割 Webカメラから取得した映像をMediaPipe Handsへ渡すための処理です。
  • 処理内容
    • onFrame コールバックで、Webカメラの各フレームごとに hands.send() を呼び出し、最新の画像を解析させます。
    • widthheight は、映像とキャンバスのサイズを一致させるためのものです。

onResults 関数の仕組み

onResults() 関数は、MediaPipe Handsからの解析結果を受け取り、その結果に基づいてCanvas上に描画し、認識したジェスチャーを画面に表示するための中心的な関数です。 この関数の処理の流れは以下の通りです。

1. キャンバスのコンテキスト保存とクリア

canvasCtx.save();
canvasCtx.clearRect(0, 0, canvasElement.width, canvasElement.height);

現在のキャンバスの状態を保存し、前のフレームの描画内容を消去します。

2. ミラー表示の設定

canvasCtx.translate(canvasElement.width, 0);
canvasCtx.scale(-1, 1);

Webカメラの映像は左右反転して表示することで、ユーザーにとって自然な鏡のような表示にします。

3. 背景映像の描画

canvasCtx.drawImage(results.image, 0, 0, canvasElement.width, canvasElement.height);

MediaPipeから取得した現在の映像をキャンバスに描画します。

4. 手のランドマーク解析

if (results.multiHandLandmarks && results.multiHandedness) {
  results.multiHandLandmarks.forEach((landmarks, index) => {
    // handedness の反転
    ...
    // ランドマークの描画
    drawConnectors(canvasCtx, landmarks, HAND_CONNECTIONS, { color: '#0088FF77', lineWidth: 5 });
    drawLandmarks(canvasCtx, landmarks, { color: '#0000FF77', lineWidth: 2 });
    ...
  });
}
  • results.multiHandLandmarks には、検出された各手のランドマーク(21点)が配列で格納されています。
  • results.multiHandedness には、どちらの手かという情報("Right"または"Left")が含まれています。ミラー表示のため、左右が逆転する点に注意が必要です(右手の場合は左手として表示されるなど)。

5. 指の角度計算とジェスチャー認識

各指(人差し指、中指、輪指、小指)の角度を、関節の座標点から計算します。具体的には、

  • 人差し指:landmarks[5](MCP)、landmarks[6](PIP)、landmarks[8](TIP)
  • 中指:landmarks[9]、landmarks[10]、landmarks[12]
  • 輪指:landmarks[13]、landmarks[14]、landmarks[16]
  • 小指:landmarks[17]、landmarks[18]、landmarks[20]

また、親指に関しては、単純な距離計算による「伸び具合」の判定を行っています。
以下がそのサンプルコードです:

const indexAngle = calculateAngle(landmarks[5], landmarks[6], landmarks[8]);
const middleAngle = calculateAngle(landmarks[9], landmarks[10], landmarks[12]);
const ringAngle = calculateAngle(landmarks[13], landmarks[14], landmarks[16]);
const pinkyAngle = calculateAngle(landmarks[17], landmarks[18], landmarks[20]);
const thumbExtended = (calculateDistance(landmarks[4], landmarks[2]) > 0.05);

各指の状態は、設定した「伸びている」・「曲がっている」の閾値を使って判断されます。たとえば、下記のような条件でジェスチャーを認識します:

  • チョキ:人差し指と中指が伸び、かつ輪指と小指が曲がっている(それぞれの角度が特定の数値以下)
  • パー:すべての指が伸びている
  • グー:すべての指が曲がっている

これらの閾値は、実際の手の動きと計測結果に合わせて調整可能です。

以下に、閾値の設定例を簡単な表にまとめます:

6. 認識結果の表示

最後に、検出された手の向き(左右)とジェスチャー名を、HTML内の <div id="message"> に表示します。

message.innerHTML = flippedClassification + "手:" + gesture;

ここで flippedClassification は、ミラー表示による左右反転を考慮した手の方向の情報となります。

7. キャンバスの状態復元

canvasCtx.restore();
  1. キャンバスの状態を元に戻すことで、次回の描画に影響が出ないようにしています。

以上が、onResults() 関数の基本的な処理の流れです。MediaPipe Handsからの解析結果を受け取り、ユーザーに直感的に分かる形で描画と結果表示をおこなっている点が、このプログラムの鍵となっています。

MediaPipeライブラリの利用と手のランドマーク

1. MediaPipe Handsとは?

MediaPipe Handsは、Googleが提供するマルチモーダルな機械学習ソリューション群の一部で、手の位置や関節のランドマークをリアルタイムに検出できるライブラリです。以下のような特徴があります。

  • リアルタイム検出:Webカメラの映像を解析し、即座に手の各関節(21点)を検出します。
  • 高精度な追跡:複数の指の動きや位置の変化も認識できるほど精度が高い。
  • クロスプラットフォーム:Webブラウザ上やモバイルアプリなど、さまざまな環境で動作します。

2. ランドマークの構造

MediaPipe Handsが返すランドマークは、各手に対して21個の点で構成されます。

インデックス部位の名称所属部位
0手首(Wrist)共通
1親指:CMC(Carpometacarpal)Thumb
2親指:MCP(Metacarpophalangeal)Thumb
3親指:IP(Interphalangeal)Thumb
4親指:先端(Tip)Thumb
5人差し指:MCP(Metacarpophalangeal)Index Finger
6人差し指:PIP(Proximal Interphalangeal)Index Finger
7人差し指:DIP(Distal Interphalangeal)Index Finger
8人差し指:先端(Tip)Index Finger
9中指:MCP(Metacarpophalangeal)Middle Finger
10中指:PIP(Proximal Interphalangeal)Middle Finger
11中指:DIP(Distal Interphalangeal)Middle Finger
12中指:先端(Tip)Middle Finger
13輪指:MCP(Metacarpophalangeal)Ring Finger
14輪指:PIP(Proximal Interphalangeal)Ring Finger
15輪指:DIP(Distal Interphalangeal)Ring Finger
16輪指:先端(Tip)Ring Finger
17小指:MCP(Metacarpophalangeal)Pinky Finger
18小指:PIP(Proximal Interphalangeal)Pinky Finger
19小指:DIP(Distal Interphalangeal)Pinky Finger
20小指:先端(Tip)Pinky Finger
  • 手首と指の関節 各指は、手首から始まり、次々に関節が連結される構造になっています。たとえば、人差し指の場合は、MCP(基節)、PIP(中節)、そしてTIP(先端)といった具合です。
  • ポイントごとのインデックス コード内では、特定のインデックス番号を利用して指の状態を判定しています。たとえば、中指では landmarks[9] から landmarks[12]、小指では landmarks[17] から landmarks[20] が使われています。

3. 指の状態を角度で判定する理由

コンピュータビジョンにおいて、手のジェスチャーを認識する際のポイントは、相対的な位置情報です。 絶対値ではなく、近接点間の角度や距離を利用することで、手の大きさや撮影距離が異なっても、安定してジェスチャーを認識できるようになります。 例えば、指が伸びている状態は、隣接する関節の角度が大きくなるはずです。一方、指が曲がっている場合は、その角度が小さくなります。これを踏まえて、先ほどの calculateAngle() 関数や閾値の設定がなされているのです。

まとめ

本記事では、以下の点について詳細に解説しました。

  1. MediaPipe Handsの活用 手のランドマーク検出と、その結果をもとにジェスチャーを認識する方法
    • ランドマークとは何か
    • 各関節間の角度計算の意義
    • 指の状態を示す閾値設定
  2. 実際のコード解析 HTML、CSS、JavaScriptそれぞれの役割とその連携の仕組み
    • <video><canvas> の利用方法
    • Canvas APIによる描画とミラー表示のテクニック
    • 補助関数( calculateAnglecalculateDistance )による数理計算の実装

本記事を通じて、手のジェスチャー認識という一見難しそうなテーマであっても、基本的なWeb技術と数学的概念の組み合わせによって、初心者でも取り組むことができるということが分かっていただけたなら幸いです。 最初は小さな一歩かもしれませんが、こうした試みが将来の大きなプロジェクトの基盤となります。ぜひ、今回のコードを参考にして、あなた自身のオリジナルアプリケーションを作ってみてください。