MRが楽しい

MRやVRについて学習したことを書き残す

HoloLens2で頷きや首振り、首傾げの頭部ジェスチャーを検知する

本日は HoloLens2 の小ネタ枠です。
HoloLens2で頷きや首振り、首傾げの頭部ジェスチャーを検知するスクリプトを作成してみます。

頷きと首振りの検出ロジック

頷きと首振りの検出ロジックは以下の記事を参考に、頭部位置を時系列に保存して回転の最大値、最小値、平均からチェックしました。
framesynthesis.jp

首傾げの検出ロジックは以下と同様です。
bluebirdofoz.hatenablog.com

サンプルスクリプト

以下の頭部ジェスチャーを検出してアクションを実行するサンプルスクリプトを作成しました。
・HeadGestureRecognizer.cs

using UnityEngine;
using System;
using System.Collections.Generic;
using System.Linq;

/// <summary>
/// 頭部ジェスチャーの判定クラス
/// </summary>
public class HeadGestureRecognizer : MonoBehaviour
{
    /// <summary>
    /// 頭部の回転ジェスチャー種別
    /// </summary>
    public enum HeadRotateGesture
    {
        /// <summary>
        /// 無し(デフォルト)
        /// </summary>
        Nothing = 0,
        /// <summary>
        /// 頷き
        /// </summary>
        Nod = 1,
        /// <summary>
        /// 首振り
        /// </summary>
        Shake = 2,
        /// <summary>
        /// 首傾げ
        /// </summary>
        Tilt = 3,
    }

    /// <summary>
    /// 回転ポーズのサンプリングデータ型
    /// </summary>
    public struct PoseSample
    {
        // タイムスタンプ
        public readonly float Timestamp;
        // 回転方向
        public Quaternion Orientation;
        // オイラー角
        public Vector3 EulerAngles;

        public PoseSample(float timestamp, Quaternion orientation)
        {
            Timestamp = timestamp;
            Orientation = orientation;

            EulerAngles = orientation.eulerAngles;
            EulerAngles.x = WrapDegree(EulerAngles.x);
            EulerAngles.y = WrapDegree(EulerAngles.y);
            EulerAngles.z = WrapDegree(EulerAngles.z);
        }

        /// <summary>
        /// オイラー角を 180度 ~ -180度 の範囲に変換する
        /// </summary>
        public float WrapDegree(float degree)
        {
            if (degree > 180f)
            {
                return degree - 360f;
            }
            return degree;
        }
    }


    /// <summary>
    /// 頭部の回転ジェスチャー種別
    /// </summary>
    [SerializeField, Tooltip("頭部の回転ジェスチャー種別")]
    private HeadRotateGesture p_HeadRotateGesture = HeadRotateGesture.Nothing;

    /// <summary>
    /// 通常時イベント
    /// </summary>
    public Action EventNothing;

    /// <summary>
    /// 頷きイベント
    /// </summary>
    public Action EventNod;

    /// <summary>
    /// 首振りイベント
    /// </summary>
    public Action EventShake;

    /// <summary>
    /// 首傾げイベント
    /// </summary>
    public Action EventTilt;


    /// <summary>
    /// 回転ポーズのキュー
    /// </summary>
    public readonly Queue<PoseSample> PoseSamples = new Queue<PoseSample>();


    /// <summary>
    /// イベントのインターバル時間(秒)
    /// </summary>
    [SerializeField]
    private float recognitionInterval = 0.5f;

    /// <summary>
    /// 前回のジェスチャー発生時刻
    /// </summary>
    private float prevGestureTime;

    /// <summary>
    /// ジェスチャーの実行フラグ
    /// </summary>
    private bool p_GesturedFlg;

    /// <summary>
    /// 定期処理
    /// </summary>
    void Update()
    {
        // 現在の頭部のローカル回転を取得する
        var orientation = Camera.main.transform.localRotation;
        
        // 回転情報をタイムスタンプとともにキューに保存する
        PoseSamples.Enqueue(new PoseSample(Time.time, orientation));
        if (PoseSamples.Count >= 120)
        {
            // キューの保存は 120 個まで
            PoseSamples.Dequeue();
        }

        // 前回のジェスチャーイベントからインターバル時間の間
        // 新たなジェスチャー判定は行わない
        if (!(prevGestureTime < Time.time - recognitionInterval)) return;

        // ジェスチャーの判定フラグをオフにする
        p_GesturedFlg = false;

        // 頷きジェスチャーの判定を行う
        if (!p_GesturedFlg)
        {
            if (IsRecognizeNod())
            {
                p_HeadRotateGesture = HeadRotateGesture.Nod;

                // イベントを実行する
                EventNod?.Invoke();

                // ジェスチャーの判定時間を記録し、フラグをオンにする
                prevGestureTime = Time.time;
                p_GesturedFlg = true;
            }
        }

        // 首振りジェスチャーの判定を行う
        if (!p_GesturedFlg)
        {
            if (IsRecognizeShake())
            {
                p_HeadRotateGesture = HeadRotateGesture.Shake;

                // イベントを実行する
                EventShake?.Invoke();

                // ジェスチャーの判定時間を記録し、フラグをオンにする
                prevGestureTime = Time.time;
                p_GesturedFlg = true;
            }
        }

        // 首傾げジェスチャーの判定を行う
        if (!p_GesturedFlg)
        {
            if (IsRecognizeTilt())
            {
                p_HeadRotateGesture = HeadRotateGesture.Tilt;

                // イベントを実行する
                EventTilt?.Invoke();

                // ジェスチャーの判定時間を記録し、フラグをオンにする
                prevGestureTime = Time.time;
                p_GesturedFlg = true;
            }
        }

        // 全てのジェスチャー判定が失敗したか
        if (!p_GesturedFlg)
        {
            // ジェスチャー無しと判定する
            p_HeadRotateGesture = HeadRotateGesture.Nothing;

            // イベントを実行する
            EventNothing?.Invoke();
        }
    }

    /// <summary>
    /// 指定時間範囲の回転ポーズを取得する
    /// </summary>
    IEnumerable<PoseSample> Range(float startTime, float endTime) =>
        PoseSamples.Where(sample =>
            sample.Timestamp < Time.time - startTime &&
            sample.Timestamp >= Time.time - endTime);

    /// <summary>
    /// 頷き判定チェック
    /// </summary>
    private bool IsRecognizeNod()
    {
        bool isNod = false;
        try
        {
            // 0.4秒前から0.2秒前までの間の縦回転の平均を取得する
            var averagePitch = Range(0.2f, 0.4f).Average(sample => sample.EulerAngles.x);
            // 0.2秒前から現在までの間の縦回転の最大値(正方向:下回転)を取得する
            var maxPitch = Range(0.01f, 0.2f).Max(sample => sample.EulerAngles.x);
            // 最新の縦回転の角度を取得する
            var pitch = PoseSamples.Last().EulerAngles.x;

            // 下方向の最大回転角が平均の回転角より5度以上で
            // かつ、最新の回転角が下方向の最大回転角より2.5度以上戻っているか
            if (!(maxPitch - averagePitch > 5.0f)
                || !(maxPitch - pitch > 2.5f)) return isNod;

            Debug.Log("Nod last : " + pitch + ", average : " + averagePitch
                + ", max : " + maxPitch);

            // 頷きが発生したと判定する
            isNod = true;
        }
        catch (InvalidOperationException)
        {
            // Range contains no entry
        }
        return isNod;
    }

    private bool IsRecognizeShake()
    {
        bool isShake = false;
        try
        {
            // 0.4秒前から0.2秒前までの間の横回転の平均を取得する
            var averageYaw = Range(0.2f, 0.4f).Average(sample => sample.EulerAngles.y);
            // 0.2秒前から現在までの間の横回転の最大値(正方向:右回転)を取得する
            var maxYaw = Range(0.01f, 0.2f).Max(sample => sample.EulerAngles.y);
            // 0.2秒前から現在までの間の横回転の最小値(負方向:左回転)を取得する
            var minYaw = Range(0.01f, 0.2f).Min(sample => sample.EulerAngles.y);
            // 最新の横回転の角度を取得する
            var yaw = PoseSamples.Last().EulerAngles.y;

            // 最大の回転角が平均の回転角より10度以上大きくない場合、首振りではない
            if (!(maxYaw - averageYaw > 5.0f) ||
                !(averageYaw - minYaw > 5.0f)) return isShake;

            Debug.Log("Shake last : " + yaw + ", average : " + averageYaw
                + ", max : " + maxYaw + ", min : " + minYaw) ;

            // 首振りが発生したと判定する
            isShake = true;
        }
        catch (InvalidOperationException)
        {
            // Range contains no entry
        }
        return isShake;
    }

    /// <summary>
    /// 首傾げ判定チェック
    /// </summary>
    private bool IsRecognizeTilt()
    {
        bool isTilt = false;
        try
        {
            // 0.4秒前から現在までの間の正面回転の平均を取得する
            var averageTilt = Range(0.01f, 0.4f).Average(sample => sample.EulerAngles.z);
            // 最新の正面回転の角度を取得する
            var tilt = PoseSamples.Last().EulerAngles.z;

            // 正面方向の平均の回転角が20度以上であるか
            if (!(averageTilt > 20.0f) &&
                !(averageTilt < -20.0f)) return isTilt;

            Debug.Log("Tilt last : " + tilt + ", average : " + averageTilt);

            // 首傾げが発生したと判定する
            isTilt = true;
        }
        catch (InvalidOperationException)
        {
            // Range contains no entry
        }
        return isTilt;
    }
}

f:id:bluebirdofoz:20210621185419j:plain

サンプルアプリで動作確認を行ってみます。
スクリプトの各ジェスチャーイベントを受け取ってオブジェクトのカラーを変更する以下のスクリプトを用意しました。
・ColorChanger.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

using UniRx;

public class ColorChanger : MonoBehaviour
{
    public MeshRenderer p_MeshRenderer;

    public HeadGestureRecognizer p_HeadGestureRecognizer;

    // Start is called before the first frame update
    void Start()
    {
        // 各イベントの呼び出し関数を登録する
        p_HeadGestureRecognizer.EventNothing += CallHeadNothing;
        p_HeadGestureRecognizer.EventNod += CallHeadNod;
        p_HeadGestureRecognizer.EventShake += CallHeadShake;
        p_HeadGestureRecognizer.EventTilt += CallHeadTilt;
    }

    /// <summary>
    /// ジェスチャー無し時
    /// </summary>
    public void CallHeadNothing()
    {
        p_MeshRenderer.material.SetColor("_Color", Color.white);
    }

    /// <summary>
    /// 頷きジェスチャー時
    /// </summary>
    public void CallHeadNod()
    {
        p_MeshRenderer.material.SetColor("_Color", Color.blue);
    }

    /// <summary>
    /// 首振りジェスチャー時
    /// </summary>
    public void CallHeadShake()
    {
        p_MeshRenderer.material.SetColor("_Color", Color.red);
    }

    /// <summary>
    /// 首傾げジェスチャー時
    /// </summary>
    public void CallHeadTilt()
    {
        p_MeshRenderer.material.SetColor("_Color", Color.yellow);
    }
}

f:id:bluebirdofoz:20210621185434j:plain

適当な3Dオブジェクトにスクリプトを設定したらシーンの作成は完了です。
f:id:bluebirdofoz:20210621185446j:plain

動作確認

HoloLens2 にアプリをインストールして動作を確認してみます。
通常時、Sphere オブジェクトは白色のままです。
f:id:bluebirdofoz:20210621185456j:plain

顔を頷くように上下に動かすと、頷きを検知してオブジェクトが青色になります。
f:id:bluebirdofoz:20210621185505j:plain

顔を左右に大きく素早く振ると、首振りを検知してオブジェクトが赤色になります。
f:id:bluebirdofoz:20210621185513j:plain

顔を傾けると、首傾げを検知してオブジェクトが黄色になります。
f:id:bluebirdofoz:20210621185522j:plain