ANT+で脈拍数を取得してUnityで作ったVR空間に表示させる

大熊 元気と申します。
IoTとVRを組み合わせてデジタルツインとか、何か付加価値を出したいと思いながら日々 遊んでいます。

##バイタルセンサとVRを組み合わせてみました
1. ANT+で脈拍数の取得
2. VR空間で脈拍数を表示
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">スマートウォッチからの脈拍データをVR内に持ってきたのですが、何かに使えませんか？ <a href="https://twitter.com/hashtag/xrtech?src=hash&amp;ref_src=twsrc%5Etfw">#xrtech</a> <br>とりあえずアルコールが入って脈拍がうなぎ登りです。 <a href="https://t.co/wMh7if2Pi5">pic.twitter.com/wMh7if2Pi5</a></p>&mdash; 大熊 元気＠デジタルツイン (@JNTEST23) <a href="https://twitter.com/JNTEST23/status/1053923503244623872?ref_src=twsrc%5Etfw">2018年10月21日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

3. 脈拍数に応じてエフェクトの変化
<blockquote class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">スマートウォッチで計測した脈拍数をVR空間に持ってきて、値の表示とエフェクトの変更ができました。<br>コントローラーをぶん回すと脈拍数が上昇して、世界が暗くなってプールから光が出てくるという謎設定ですが、思い通りに動いてくれました。<a href="https://twitter.com/hashtag/Unity3d?src=hash&amp;ref_src=twsrc%5Etfw">#Unity3d</a> <a href="https://twitter.com/hashtag/SteamVR?src=hash&amp;ref_src=twsrc%5Etfw">#SteamVR</a> <a href="https://twitter.com/hashtag/WinMR?src=hash&amp;ref_src=twsrc%5Etfw">#WinMR</a> <a href="https://t.co/cGzt28uygU">pic.twitter.com/cGzt28uygU</a></p>&mdash; 大熊 元気＠デジタルツイン (@JNTEST23) <a href="https://twitter.com/JNTEST23/status/1067070379582087168?ref_src=twsrc%5Etfw">2018年11月26日</a></blockquote>
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


##構成図
◆構成図はこんな感じ
![image.png](https://qiita-image-store.s3.amazonaws.com/0/147524/765a7964-115a-b2d1-87ef-4e14e4480168.png)
◆ハードウェアはこんな感じ
![IMG_20181128_190149.jpg](https://qiita-image-store.s3.amazonaws.com/0/147524/5dbba6b1-9466-fb97-2ba1-97d01c8488fd.jpeg)
・スマートウォッチ：GARMIN [vívosmart®4](https://www.garmin.co.jp/products/intosports/vivosmart-4-black-r/) （ANT+対応のものが必要で、GARMIN社がANT+の生みの親）
・ANT+ドングル：[AZ4U](http://amzn.asia/d/9ihtGGD) （ANT+をPCで受けるために必要。適当に選定）
・PC、HMD：SteamVRが動くやつ（参考→https://qiita.com/GenkiOkuma/items/62d1c7f20f8e75d0662e）  
◆Version
Unity : 2018.2.14f1
SteamVR : 2.0.1

##ANT+のUSBドングルからNode.jsまで
USBドングルから脈拍数を取得するためにこちらのライブラリを使います。https://github.com/Loghorn/ant-plus
これをインストールして、USBドングルから脈拍数を取得するNoede.jsを書きます。

~~~
npm install ant-plus
~~~

サンプルに入っている[two-sticks.js](https://github.com/Loghorn/ant-plus/blob/master/sample/two-sticks.js)を下記のように改造してUSBドングルからUnityへと脈拍数を中継するサーバとします。
<変更点>
・stick（ANT+のUSBドングル）2本を1本に変更
・deviceIDを区別するように変更

~~~Node.js
const Ant = require('ant-plus');
const stick = new Ant.GarminStick2;
const sensor = new Ant.HeartRateSensor(stick);
var deviceID = null;

if (process.argv.length >= 3) {
    deviceID = process.argv[2];
}
sensor.on('hbData', function (data) {
    if (deviceID == null) {
        console.log(data.DeviceID, data.ComputedHeartRate);
    } else if (deviceID == data.DeviceID) {
        console.log(data.ComputedHeartRate);
    }
});
 
stick.on('startup', function () {
    sensor.attach(0, 0);
});
 
if (!stick.open()) {
    console.log('Stick not found!');
}
~~~
これでスマートウォッチからANT+のドングルを経由してPCまで脈拍数を持ってこれました。

##Node.jsからUnityまで
凹み様のAssetを使います。https://github.com/hecomi/NodejsUnity
関連記事はこちら→http://tips.hecomi.com/entry/2014/04/21/001606
これによって、Unityのexeから外部のNode.jsを起動して連携できます。ここでは上記で作成した脈拍数を取得するNode.jsを仕込みます。

<使い方>
・UnityのAssetとしてNodejsUnityをインポート
・NodejsUnity/Assets/StreamingAssets/node.exe　が古いので新しいものに差し替える
・起動したいNode.jsをUnity Project/Assets/StreamingAssetsの下に置く
![image.png](https://qiita-image-store.s3.amazonaws.com/0/147524/b7413e7e-5fa2-70a2-0add-908f144fd28a.png)
・[NodeJs.cs](https://github.com/hecomi/NodejsUnity/blob/master/Assets/NodeJsUnity/Scripts/NodeJs.cs)を下記のように改造する。
（変更点→起動したいNode.jsのパスを指定、出力先を2つ定義）

~~~cs:C#
    #region [ member variables ]
    public string DeviceID = "";
    private string scriptPath = "HeartRate/main.js";
    private System.Diagnostics.Process process_ = null;
    private List<string> logs_ = new List<string>();
    public int logLength = 10;
    public string log = "";
    public Text DisplayedText;
    public Text DisplayedText1;
    private SynchronizationContext Context;
~~~

（変更点→脈拍数をエフェクトの変化に使うためにSensorValueを定義、Contextでスレッドを分ける、脈拍数をテキスト2か所に出力させる）

~~~cs:C#
    void OnOutputData(object sender, System.Diagnostics.DataReceivedEventArgs e)
    {
        if (logs_.Count > logLength) logs_.RemoveAt(0);
        logs_.Add(e.Data);
        log = "";
        logs_.ForEach(line => { log += line + "\n"; });

            Context.Post(_ => {
                float.TryParse(e.Data, out SensorValue);
                if (DisplayedText != null)
                {
                    DisplayedText.text = e.Data;
                }
                if (DisplayedText1 != null)
                {
                    DisplayedText1.text = e.Data;
                }
            }, null);
    }
~~~
・表示先のテキストをドラッグする。
（ここでは、目の前に常に表示されるテキストと空間に固定されたテキストの2か所を作りました。）
![image.png](https://qiita-image-store.s3.amazonaws.com/0/147524/55a71f37-5677-5e6c-0134-3a05d34da0ca.png)

スクリプト全体はこんな感じ  

~~~cs:C#
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
using UnityEngine.UI;
using System.Threading;

public class HeartRateSensorJs : SensorDeviceBase
{
    #region [ static members ]
#if UNITY_STANDALONE_WIN
    private static readonly string NodeBinPath = "node.exe";
#endif
#if UNITY_STANDALONE_OSX
	private static readonly string NodeBinPath = "node";
#endif
    private static readonly string NodeScriptPath = ".node/";
    private static string NodeBinFullPath;
    private static string NodeScriptFullPath;
    private static bool IsInitialized = false;
    #endregion

    #region [ member variables ]
    public string DeviceID = "";
    private string scriptPath = "HeartRate/main.js";
    private System.Diagnostics.Process process_ = null;
    private List<string> logs_ = new List<string>();
    public int logLength = 10;
    public string log = "";
    public Text DisplayedText;
    public Text DisplayedText1;
    private SynchronizationContext Context;


    #endregion

    #region [ getter / setter ]
    public bool isRunning { get; set; }
    #endregion

    #region [ member functions ]
    private void SetFullPath()
    {
        if (!IsInitialized)
        {
            NodeBinFullPath = System.IO.Path.Combine(Application.streamingAssetsPath, NodeBinPath);
            NodeScriptFullPath = System.IO.Path.Combine(Application.streamingAssetsPath, NodeScriptPath);
            IsInitialized = true;
        }
    }

    void Awake()
    {
        Context = SynchronizationContext.Current;
        isRunning = false;
        SetFullPath();
        Run();
    }

    void OnApplicationQuit()
    {
        if (process_ != null && !process_.HasExited)
        {
            process_.Kill();
            process_.Dispose();
        }
    }

    void Run()
    {
        if (isRunning)
        {
            Debug.LogError("Already Running: " + scriptPath);
            return;
        }

        process_ = new System.Diagnostics.Process();
        process_.StartInfo.WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden;
        process_.StartInfo.FileName = NodeBinFullPath;
        process_.StartInfo.Arguments = NodeScriptFullPath + scriptPath + " " + DeviceID;
        process_.StartInfo.CreateNoWindow = true;
        process_.StartInfo.RedirectStandardOutput = true;
        process_.StartInfo.RedirectStandardError = true;
        process_.StartInfo.UseShellExecute = false;
        process_.StartInfo.WorkingDirectory = NodeScriptFullPath;
        process_.OutputDataReceived += OnOutputData;
        //        process_.ErrorDataReceived += OnOutputData;
        process_.EnableRaisingEvents = true;
        process_.Exited += OnExit;
        process_.Start();
        process_.BeginOutputReadLine();
        process_.BeginErrorReadLine();
        isRunning = true;
    }

    void Stop()
    {
        if (!isRunning)
        {
            Debug.LogError("Already Stopping: " + scriptPath);
            return;
        }
        isRunning = true;
        process_.Kill();
        process_.Dispose();
    }

    void OnOutputData(object sender, System.Diagnostics.DataReceivedEventArgs e)
    {
        if (logs_.Count > logLength) logs_.RemoveAt(0);
        logs_.Add(e.Data);
        log = "";
        logs_.ForEach(line => { log += line + "\n"; });

            Context.Post(_ => {
                float.TryParse(e.Data, out SensorValue);
                if (DisplayedText != null)
                {
                    DisplayedText.text = e.Data;
                }
                if (DisplayedText1 != null)
                {
                    DisplayedText1.text = e.Data;
                }
            }, null);
    }

    void OnExit(object sender, System.EventArgs e)
    {
        isRunning = false;
        if (process_.ExitCode != 0)
        {
            Debug.LogError("Error! Exit Code: " + process_.ExitCode);
        }
    }

    private void OnDestroy()
    {
        if (process_ != null && !process_.HasExited)
        {
            process_.Kill();
            process_.Dispose();
            process_ = null;
        }

    }
    #endregion
}
~~~


##UnityからHMDまで
ここでは割愛します。こちらの資料を参考にどうぞ。
<iframe src="//www.slideshare.net/slideshow/embed_code/key/9flic6GsnlloSt" width="595" height="485" frameborder="0" marginwidth="0" marginheight="0" scrolling="no" style="border:1px solid #CCC; border-width:1px; margin-bottom:5px; max-width: 100%;" allowfullscreen> </iframe> <div style="margin-bottom:5px"> <strong> <a href="//www.slideshare.net/GenkiOkuma/vr-201811" title="VRで日本語音声認識の作り方 201811" target="_blank">VRで日本語音声認識の作り方 201811</a> </strong> from <strong><a href="https://www.slideshare.net/GenkiOkuma" target="_blank">Genki Okuma</a></strong> </div>


脈拍数に比例してエフェクトやライトを変化させたりできます。ご要望があれば記事書きます。

##まとめ＆考察
・デジタル空間と物理空間がリアルタイムにシンクロしてデジタルツインな感じになった。
・プレイヤーの（自覚がない）状態によってVRコンテンツが動的に変化するイメージがわいた。
・何の役に立つかは不明...。とりあえず楽しい！

以上です。ありがとうございました。
アドベントカレンダー、この後も楽しいエンジニアが続きます！