14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2018-12-01

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

##バイタルセンサとVRを組み合わせてみました

  1. ANT+で脈拍数の取得
  2. VR空間で脈拍数を表示
  1. 脈拍数に応じてエフェクトの変化

##構成図
◆構成図はこんな感じ
image.png
◆ハードウェアはこんな感じ
IMG_20181128_190149.jpg
・スマートウォッチ:GARMIN vívosmart®4 (ANT+対応のものが必要で、GARMIN社がANT+の生みの親)
・ANT+ドングル:AZ4U (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を下記のように改造して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
NodeJs.csを下記のように改造する。
(変更点→起動したいNode.jsのパスを指定、出力先を2つ定義)

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か所に出力させる)

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

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

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まで
ここでは割愛します。こちらの資料を参考にどうぞ。

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

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

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

14
14
24

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
14
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?