HoloLensで手の位置を検知してスワイプジェスチャー認識する

# はじめに
今回はHoloLensで手の位置を検知して、上下左右のスワイプジェスチャーを検知する方法と、それを使ったIPの入力UIサンプルについて説明します。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">手のトラッキングができるので、SharingのIP入力UIにしてみた。視線を集中する必要がないのと、ブラインドタッチできるのがメリット。<br>効果音のおかげで大分愉快 <a href="https://t.co/zAdJmbNuQc">pic.twitter.com/zAdJmbNuQc</a></p>&mdash; だん (@arcsin16) <a href="https://twitter.com/arcsin16/status/892744771428532228">2017年8月2日</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

ソースコードは [github](https://github.com/arcsin16/GestureInputTest/) にアップしてありますので、もしよろしければご利用ください。

# ジェスチャー検知処理

## 1.手の位置を取得する
こちらで解説されているように、InteractionManager.SourceUpdated にコールバックを登録することで、手の位置のXYZ座標を取得することができます。
[HoloLensで俺のこの手が光ってうなる](http://qiita.com/yuujii/items/0d13e963914e1143cebe)

また今回はAirTapの開始と終了を検知するためSourcePressed、SourceReleased、手の位置の検出の開始と終了を検知するためSourceDetected、SourceLostにもコールバックを登録します。

```csharp:
        void OnEnable()
        {
            InteractionManager.SourcePressed += OnSourcePressed;
            InteractionManager.SourceReleased += OnSourceReleased;
            InteractionManager.SourceUpdated += OnSourceUpdated;
            InteractionManager.SourceLost += OnSourceLost;
            InteractionManager.SourceDetected += OnSourceDetected;
        }
```

SourceUpdatedは定期的にコールバックされるので、前回位置と今回位置の差分を取ることで、手の移動ベクトルを取得します。

```chsarp:
    // 手の位置を取得
    Vector3 pos;
    if (state.properties.location.TryGetPosition(out pos))
    {
        // 手の移動量
        var diff = pos - this.lastPos;
        this.lastPos = pos;
```

## 2.上下左右の移動量を求める
ただ、このままでは世界座標ベースのベクトルなので、ユーザが向いている方向に対して上下左右を求めるために、カメラの方向を基準としたXY座標へ変換する必要があります。
この変換には内積を用います。カメラの上方向を示す単位ベクトルと移動ベクトルの内積からY軸方向の移動量が、右方向を示す単位ベクトルと移動ベクトルの内積からX軸方向の移動量が求まります。

```csharp:
        // x,y軸方向の移動量を取得する
        float dx = Vector3.Dot(this.axisX, diff);
        float dy = Vector3.Dot(this.axisY, diff);

```

内積の説明については以下をご参照ください。
[【数学】「内積」の意味をグラフィカルに理解すると色々見えてくる その１](http://qiita.com/kenmatsu4/items/a144047c1b49aa8c7eb0)


## 3.ジェスチャーを判定する
これで上下左右の移動量が求まりますので、移動量を蓄積しておいて、閾値を超えた場合、対応する方向のジェスチャーが行われたと判定します。

その際、細かい処理ですが、「誤差の蓄積で暴発しないように、dx, dyの大きい要素のみ加算」したり、「検知中の方向への加算は0に抑制」したりしています。

```csharp:
        // 誤差の蓄積で暴発しないように、dx, dyの大きい要素のみ加算する
        if (Mathf.Abs(dx) > Mathf.Abs(dy))
        {
            // 検知中の方向への加算は0に抑制する
            if (this.currentDirection == Direction.Right && dx > 0)
            {
                this.accumulatedDx += 0;
            }
            else if (this.currentDirection == Direction.Left && dx < 0)
            {
                this.accumulatedDx += 0;
            }
            else
            {
                this.accumulatedDx += dx;
            }
        }
        else
        {
            // 検知中の方向への加算は0に抑制する
            if (this.currentDirection == Direction.Up && dy > 0)
            {
                this.accumulatedDy += 0;
            }
            else if (this.currentDirection == Direction.Down && dy < 0)
            {
                this.accumulatedDy += 0;
            }
            else
            {
                this.accumulatedDy += dy;
            }
        }

        // 蓄積した移動量が閾値以上になれば、ジェスチャーと判定
        // Right
        Direction dir = Direction.Neutral;
        if (this.accumulatedDx > GestureThreshold)
        {
            dir = Direction.Right;
        }
        // Left
        else if (this.accumulatedDx < -GestureThreshold)
        {
            dir = Direction.Left;
        }
        // Up
        else if (this.accumulatedDy > GestureThreshold)
        {
            dir = Direction.Up;
        }
        // Down
        else if (this.accumulatedDy < -GestureThreshold)
        {
            dir = Direction.Down;
        }
```

今回、単体のジェスチャーだけでなく、「上→右→下→左」のような連続したジェスチャーを利用したかったため、認識したジェスチャーを記録しておき、Air Tapを終了したタイミング、手のトラッキングをロストしたタイミングでも記録したジェスチャーのシーケンスを通知するようにしています。

## 4.調整
実際に動かしてみると、手を移動させながらAir Tapを開始することが多く、AirTap開始後の移動量だけで判定を行うと、想定より手を大きく移動させる必要があることがわかりました。

なので、本実装ではAirTap開始前から手の移動量の蓄積を行っておいて、AirTapを開始したタイミングで、下駄をはかせることで、スムーズに操作できるように工夫しています。

```csharp:
        // タップし始めたタイミングで、事前の移動量を多少引き継ぐ
        this.accumulatedDx = Mathf.Min(this.accumulatedDx, GestureThreshold);
        this.accumulatedDy = Mathf.Min(this.accumulatedDy, GestureThreshold);
        this.accumulatedDx = Mathf.Max(this.accumulatedDx, -GestureThreshold);
        this.accumulatedDy = Mathf.Max(this.accumulatedDy, -GestureThreshold);
        this.accumulatedDx /= 2;
        this.accumulatedDy /= 2;
```

# IP入力サンプル
元々、ジェスチャー認識を実装しようとした経緯として、Sharingをやろうとすると任意のIPを指定できる仕組みが欲しくなるのですが、HoloLensで文字入力をする場合、視線を合わせてAir Tapで入力を行うのが割と良くある方法なのですが、結構集中力が必要でストレスだったため、何か大体の手段はないかと考えたのが発端です。

で、できたのがこんな感じのものになります。

<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">手のトラッキングができるので、SharingのIP入力UIにしてみた。視線を集中する必要がないのと、ブラインドタッチできるのがメリット。<br>効果音のおかげで大分愉快 <a href="https://t.co/zAdJmbNuQc">pic.twitter.com/zAdJmbNuQc</a></p>&mdash; だん (@arcsin16) <a href="https://twitter.com/arcsin16/status/892744771428532228">2017年8月2日</a></blockquote>
<script async src="//platform.twitter.com/widgets.js" charset="utf-8"></script>

これは、Air Tapをしたまま、上→左と手を移動させ、AirTapをやめる（指を戻す）と１、が入力されるような動作となります。

実装について簡単に説明しますと、上記で説明したジェスチャー認識を行うUDRLGestureDetectorにイベントハンドラーを登録して、

```csharp:
        void OnEnable()
        {
            UDRLGestureDetector.GestureDetected += GestureDetected;
        }

        void OnDisable()
        {
            UDRLGestureDetector.GestureDetected -= GestureDetected;
        }
```

ジェスチャーが認識されるたびに、ユーザに現在何のジェスチャーを認識しているのか通知して、
（この際、視覚的な通知以外に音声の通知もあるとわかりやすくてよいです。）

```csharp:
        void GestureDetected(object sender, UDRLGestureDetector.GestureEventArgs args)
        {
            // ジェスチャー中：現在選択中の入力を表示する
            if (args.Type == UDRLGestureDetector.GestureEventType.DETECTING)
            {
                KeyCode keyCode = GetKeyCode(args);
                // 選択中の表示を更新
                if (!IsValidInput(this.ipText, keyCode))
                {
                    IpText.text = PROMPT + this.ipText + "[]";
                }
                else
                {
                    if (keyCode == KeyCode.Delete)
                    {
                        IpText.text = PROMPT + this.ipText + "[DEL]";
                    }
                    else
                    {
                        IpText.text = PROMPT + this.ipText + "[" + (char)keyCode + "]";
                    }
                }

                // TODO: 現在認識しているジェスチャーを視覚的に表示する

                // ジェスチャーが認識されたタイミングを、効果音で通知する
                AudioClip clip = GetAudioClipOrNull(args);
                if (clip != null)
                    AudioSource.PlayClipAtPoint(clip, this.transform.position, 0.8f);

            }
```

ジェスチャーが確定したタイミングで、ジェスチャーに対応する入力を判定して、

```csharp:
    private static readonly Dictionary<string, KeyCode> GesturePatternDic = new Dictionary<string, KeyCode>()
    {
        {"UL", KeyCode.Alpha1 },
        {"U",  KeyCode.Alpha2 },
        {"UR", KeyCode.Alpha3 },
        {"LU", KeyCode.Alpha4 },
        {"L",  KeyCode.Alpha5 },
        {"LD", KeyCode.Alpha6 },
        {"RU", KeyCode.Alpha7 },
        {"R",  KeyCode.Alpha8 },
        {"RD", KeyCode.Alpha9 },
        {"DL", KeyCode.Delete },
        {"D",  KeyCode.Alpha0 },
        {"DR", KeyCode.Period },
    };

    private static KeyCode GetKeyCode(UDRLGestureDetector.GestureEventArgs args)
    {
        KeyCode value;
        return GesturePatternDic.TryGetValue(args.Pattern, out value) ? value : KeyCode.None;
    }
```

バリデーションや

```csharp:

        /// <summary>
        /// 入力コードのバリデーション
        /// </summary>
        /// <param name="ip"></param>
        /// <param name="input"></param>
        /// <returns></returns>
        private bool IsValidInput(string ip, KeyCode input)
        {
            if (input == KeyCode.None)
                return false;

            var editingBlock = ip.Split('.').LastOrDefault() ?? string.Empty;

            // 空白時のDelete
            if (input == KeyCode.Delete && ip.Length == 0)
                return false;

            // . の連続
            if (input == KeyCode.Period && editingBlock.Length == 0)
                return false;


            // 数値入力
            if (input != KeyCode.Period && input != KeyCode.Delete)
            {
                // 0～255 の範囲外
                Debug.LogFormat("Input {0}", (char)input);
                int val = int.Parse(editingBlock + (char)input);
                if (val < 0 || val > 255)
                    return false;
            }

            return true;
        }
```

入力文字列の更新を行えば、OKとなります。

```csharp:
                // 削除処理
                if (keyCode == KeyCode.Delete)
                {
                    // 直前の文字が.の場合２文字消す
                    if (this.ipText.EndsWith(".") && this.ipText.Length > 1)
                    {
                        this.ipText = this.ipText.Remove(this.ipText.Length - 2);
                    }
                    // 通常は１文字
                    else if (this.ipText.Length > 0)
                    {
                        this.ipText = this.ipText.Remove(this.ipText.Length - 1);
                    }
                }
                else if (keyCode == KeyCode.Period)
                {
                    var blocks = this.ipText.Split('.');
                    if (blocks.Length == 4)
                    {
                        // IP 入力完了
                        this.InputCompleted();
                    }
                    else
                    {
                        this.ipText += '.';
                    }
                }
                // 文字入力
                else
                {
                    this.ipText += (char)keyCode;

                    var blocks = this.ipText.Split('.');
                    var editingBlock = blocks.LastOrDefault() ?? string.Empty;
                    // . を自動保管
                    if (editingBlock.Length == 3)
                    {
                        if (blocks.Length != 4)
                        {
                            this.ipText += ".";
                        }
                        else
                        {
                            this.InputCompleted();
                        }
                    }
                }

                IpText.text = PROMPT + this.ipText;
```

# さいごに
視線を手中させる必要なく入力できるので、その点については結構ストレスフリーにできたかなと思います。
ただ、慣れてきてすっすっとやろうとすると、誤認識が起こりやすくイラっとするので、その点をもう少し改善できれば、入力インタフェースとして面白いんじゃないかと、可能性を感じました。