HoloLensを使ってドローンを遠隔操作してみました。
#環境
- HoloLens
- Unity 1.2
- MixedRealityToolkit-Unity for Unity v1.2017.2.0
- Node.js v8.8
- Firebase
- Raspberry Pi Zero W
- node-rolling-spider
- Parrot MAMBO
#動作デモ
HoloLensでドローンを操作してみました。 #HoloLensJP #HoloLens #MR #ドローン https://t.co/nyvnEcs3kK
— がおまる@HoloLens研究者 (@gaomar) 2017年11月21日
#導入
Raspberry Piにドローンプログラムを導入します。
Raspberry Pi Zero WはBluetoothとWi-Fiが既に内蔵されていますので、直ぐに使うことが出来ました。
OS環境構築は割愛させて頂きます。
###Raspberry PiにNode.jsとnpmをインストール
$ sudo apt-get install -y nodejs npm
###node-rolling-spiderをインストール
$ mkdir work/drone
$ cd work/drone
$ npm install rolling-spider --save
途中このようなエラーが出た場合はインストールします。
fatal error: libudev.h
$ sudo apt-get install libudev-dev
###Firebaseをインストール
$ npm install firebase --save
#Raspberry Piからドローンを操作してみる
ここまで環境が出来たら、Raspberry Piから制御出来るか確認してみましょう。
work/droneの直下にapp.jsファイルを作成してプログラムを書いてみます。
※ドローンの操縦コマンドはコチラを参照してください。
https://github.com/voodootikigod/node-rolling-spider/blob/master/README.md
// モジュール読み込み
const Drone = require("rolling-spider");
const drone = new Drone();
// 各種変数
drone.isActive = false; // ドローンがアクティブか否か
// ドローンの初期設定
drone.connect( () => { // BLE でドローンに接続し、接続できたらコールバック
drone.setup( () => { // ドローンを初期設定してサービスや特徴を取得、その後コールバック
drone.flatTrim(); // トリムをリセット
drone.startPing(); // 継続的に接続させる
drone.flatTrim(); // トリムをリセット
drone.isActive = true; // ドローンをアクティブ状態にする
console.log(drone.name + " is ready."); // 準備OKなことをコンソール出力
// 離陸させる場合
drone.takeOff();
// 着陸させる場合
drone.land();
// 上昇 ※stepsの値は好きに変えてください。数字が多いと沢山移動します
drone.up( {steps: 10});
// 下降
drone.down( {steps: 10} );
// 右旋回
drone.clockwise( {steps: 10} );
// 左旋回
drone.counterClockwise( {steps: 10} );
// 右移動
drone.right( {steps: 10} );
// 左移動
drone.left( {steps: 10} );
// 宙返り
drone.backFlip();
/*
// バッテリーと電波強度を表示したい場合このコメントを削除してください
drone.on('battery', function () {
console.log('Battery: ' + drone.status.battery + '%');
drone.signalStrength(function (err, val) {
console.log('Signal: ' + val + 'dBm');
});
});
*/
});
});
#Firebaseの設定
次にHoloLensからの命令を受信するためにFirebaseにプロジェクトを新規作成します。
適当なプロジェクトを作成してください。
###Realtime Databaseに書き込みルールを記述
このままでは誰も書き込めないので、ルールを記述します。
既に書いてあるルールは削除して、下記のように記述します。
{
"rules": {
".read": true,
".write": true
}
}
###データ格納場所を記述する
今回はdroneノードを追加してその下にwordを記述しました。
これでFirebaseの環境は整いました。
#Node.jsにFirebase環境構築
先程Node.jsにFirebaseをインストールしてもらいました。
次は、Node.jsがFirebaseの情報を受信出来るようにプログラムを修正していきます。
###Firebaseのアクセスキーを取得する
Firebaseにアクセスするためのアクセスキーを管理画面から取得します。
ウェブアプリにFirebaseを追加
をクリックして、表示されるアクセスキーをメモしておきます。
###app.jsを修正する
先程のドローンを操縦するプログラムを修正して、Firebaseからデータを受信したら対応したアクションを行うようにします。
// モジュール読み込み
const Drone = require("rolling-spider");
const drone = new Drone();
var request = require('superagent');
var firebase = require("firebase");
// 各種変数
drone.isActive = false; // ドローンがアクティブか否か
// ドローンの初期設定
drone.connect( () => { // BLE でドローンに接続し、接続できたらコールバック
drone.setup( () => { // ドローンを初期設定してサービスや特徴を取得、その後コールバック
drone.flatTrim(); // トリムをリセット
drone.startPing(); // 継続的に接続させる
drone.flatTrim(); // トリムをリセット
drone.isActive = true; // ドローンをアクティブ状態にする
console.log(drone.name + " is ready."); // 準備OKなことをコンソール出力
/*
// バッテリーと電波強度を表示したい場合
drone.on('battery', function () {
console.log('Battery: ' + drone.status.battery + '%');
drone.signalStrength(function (err, val) {
console.log('Signal: ' + val + 'dBm');
});
});
*/
});
});
//firebase config
var config = {
apiKey: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
authDomain: "xxxxxxxxx.firebaseapp.com",
databaseURL: "https://xxxxxxxx.firebaseio.com",
projectId: "xxxxxxxx",
storageBucket: "xxxxxxxx.appspot.com",
messagingSenderId: "xxxxxxxxxx"
};
firebase.initializeApp(config);
//database更新時
var db = firebase.database();
db.ref("/drone").on("value", function(changedSnapshot) {
//値取得
var value = changedSnapshot.child("word").val();
if (value) {
if (value.split(" ")[0] == '離着陸') {
if (!drone.status.flying) {
drone.takeOff();
} else {
drone.land();
}
} else if (value.split(" ")[0] == '離陸') {
drone.takeOff();
} else if (value.split(" ")[0] == '着陸') {
drone.land();
} else if (value.split(" ")[0] == '右旋回') {
drone.clockwise( {steps: 10} );
} else if (value.split(" ")[0] == '左旋回') {
drone.counterClockwise( {steps: 10} );
} else if (value.split(" ")[0] == '左移動') {
drone.left( {steps: 10} );
} else if (value.split(" ")[0] == '右移動') {
drone.right( {steps: 10} );
} else if (value.split(" ")[0] == '上昇') {
drone.up( {steps: 10});
} else if (value.split(" ")[0] == '下降') {
drone.down( {steps: 10} );
} else if (value.split(" ")[0] == '宙返り') {
drone.backFlip();
}
//firebase clear
db.ref("/drone").set({"word": ""});
}
});
###app.jsを実行する
app.jsを実行してみましょう。
$ sudo node app.js
ドローンとの接続が完了した後、
Firebaseからまずは離着陸
命令を投げてみます。
するとすぐさまに受信されてコマンドが実行されます。
#HoloLensからFirebaseに命令文字列を書き込む
最後の仕上げです。HoloLensのジェスチャーから対応したコマンドをFirebaseに書き込みます。
HoloLensの環境構築は割愛します。
###ジェスチャープログラムを実装する
ジェスチャーを受け取ったらFirebaseに書き込むまでの処理のサンプルです。
ジャスチャーはコチラを参考にさせて頂きました。
Firebaseに書き込む処理はコチラも参考にしてください。
サンプルプログラムは以下の通りです。
using HoloToolkit.Unity.InputModule;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.VR.WSA.Input;
public class UDRLGestureDetector : MonoBehaviour, IInputHandler
{
string fb_url = "https://xxxxxxxx.firebaseio.com/xxxxxxx/word.json";
public TextMesh GestureDebugText;
public enum Direction
{
Neutral, Up, Down, Right, Left
}
public enum GestureEventType
{
DETECTING, DETECTED
}
private static readonly Dictionary<Direction, char> DirectionCodeDic = new Dictionary<Direction, char>()
{
{Direction.Up, 'U' },
{Direction.Down, 'D' },
{Direction.Right, 'R' },
{Direction.Left, 'L' },
};
public class GestureEventArgs : EventArgs
{
public GestureEventType Type { get; set; }
public Direction Direction { get; set; }
public string Pattern { get; set; }
}
public static EventHandler<GestureEventArgs> GestureDetected;
public float GestureThreshold = 0.05f;
private bool notifyGesture;
private Vector3 lastPos;
private Direction currentDirection;
private StringBuilder gesturePattern = new StringBuilder();
private float accumulatedDx;
private float accumulatedDy;
private float presstime;
private float taptime; // ダブルタップタイム格納用
private float PressIntervalTime = 0.5f;// ダブルタップタイム判定時間間隔
private void Start()
{
}
private void OnDestroy()
{
}
void OnEnable()
{
InteractionManager.SourcePressed += OnSourcePressed;
InteractionManager.SourceReleased += OnSourceReleased;
InteractionManager.SourceUpdated += OnSourceUpdated;
InteractionManager.SourceLost += OnSourceLost;
InteractionManager.SourceDetected += OnSourceDetected;
}
void OnDisable()
{
InteractionManager.SourcePressed -= OnSourcePressed;
InteractionManager.SourceReleased -= OnSourceReleased;
InteractionManager.SourceUpdated -= OnSourceUpdated;
InteractionManager.SourceLost -= OnSourceLost;
InteractionManager.SourceDetected -= OnSourceDetected;
}
private void OnSourceDetected(InteractionSourceState state)
{
Vector3 pos;
if (state.properties.location.TryGetPosition(out pos))
{
this.lastPos = pos;
}
this.notifyGesture = false;
this.currentDirection = Direction.Neutral;
this.accumulatedDx = 0;
this.accumulatedDy = 0;
}
private void OnSourcePressed(InteractionSourceState state)
{
// タップ開始時にジェスチャー通知を有効化
presstime = Time.time;
// ※タップ開始前から、ジェスチャーの追跡は行っている。
if (!this.notifyGesture)
{
this.notifyGesture = true;
gesturePattern.Remove(0, gesturePattern.Length);
// タップし始めたタイミングで、事前の移動量を多少引き継ぐ
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;
}
}
private void OnSourceUpdated(InteractionSourceState state)
{
// ジェスチャーを更新
TraceGesture(state);
}
private void OnSourceReleased(InteractionSourceState state)
{
// ダブルタップ判定
if (Time.time - presstime < PressIntervalTime)
{
if (Time.time - taptime < PressIntervalTime)
{
GestureDebugText.text += "\r\n Double Tap!";
StartCoroutine(Put(fb_url, "\"離着陸\""));
}
}
taptime = Time.time;
// タップ終了時にジェスチャーを確定
NotifyGestureDetected();
}
private void OnSourceLost(InteractionSourceState state)
{
GestureDebugText.text = "";
// トラッキングロスト時にジェスチャーを確定
NotifyGestureDetected();
}
private void TraceGesture(InteractionSourceState state)
{
// カメラの上方向をy軸、右方向をx軸とする
var axisY = Camera.main.transform.up;
var axisX = Camera.main.transform.right;
// 手の位置を取得
Vector3 pos;
if (state.properties.location.TryGetPosition(out pos))
{
// 手の移動量
var diff = pos - this.lastPos;
this.lastPos = pos;
// x,y軸方向の移動量を取得する
float dx = Vector3.Dot(axisX, diff);
float dy = Vector3.Dot(axisY, diff);
// 誤差の蓄積で暴発しないように、dx, dyの大きい要素のみ加算する
// TODO: これむしろダメかも
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;
}
if (dir != Direction.Neutral && this.currentDirection != dir && this.notifyGesture)
{
Debug.LogFormat("Gesture Detect {0}", dir);
if (this.GestureDebugText != null)
{
this.GestureDebugText.text += string.Format("\r\n Detect Gesture: {0}", dir);
// 対応したジェスチャーのコマンドをFirebaseに書き込む
if (dir == Direction.Up)
{
StartCoroutine(Put(fb_url, "\"上昇\""));
} else if (dir == Direction.Down)
{
StartCoroutine(Put(fb_url, "\"下降\""));
} else if (dir == Direction.Left)
{
StartCoroutine(Put(fb_url, "\"左移動\""));
} else if (dir == Direction.Right)
{
StartCoroutine(Put(fb_url, "\"右移動\""));
}
}
this.currentDirection = dir;
this.accumulatedDx = 0;
this.accumulatedDy = 0;
this.gesturePattern.Append(DirectionCodeDic[dir]);
}
}
else
{
Debug.Log("InteractionSourceLocation::TryGetPosition failed.");
if (this.GestureDebugText != null) this.GestureDebugText.text += "\r\n InteractionSourceLocation::TryGetPosition failed";
}
}
private void NotifyGestureDetected()
{
if (this.notifyGesture)
{
// Clear
this.notifyGesture = false;
this.currentDirection = Direction.Neutral;
this.accumulatedDx = 0;
this.accumulatedDy = 0;
}
}
public void OnInputDown(InputEventData eventData)
{
this.notifyGesture = true;
this.currentDirection = Direction.Neutral;
this.accumulatedDx = 0;
this.accumulatedDy = 0;
}
public void OnInputUp(InputEventData eventData)
{
this.notifyGesture = false;
}
// Firebaseに書き込む
public IEnumerator Put(string url, string jsonStr)
{
var request = new UnityWebRequest();
request.url = url;
byte[] body = Encoding.UTF8.GetBytes(jsonStr);
request.uploadHandler = new UploadHandlerRaw(body);
request.downloadHandler = new DownloadHandlerBuffer();
request.SetRequestHeader("Content-Type", "application/json; charset=UTF-8");
request.method = UnityWebRequest.kHttpVerbPUT;
yield return request.Send();
if (request.isNetworkError)
{
Debug.Log(request.error);
}
else
{
if (request.responseCode == 200)
{
Debug.Log("success");
Debug.Log(request.downloadHandler.text);
}
else
{
Debug.Log("failed");
}
}
}
}
#まとめ
簡単にドローンとHoloLensを連携させることが出来ました。
やってる事は全て単純なものですが、
SF映画に出てきた世界にかなり近づいてきた感じを受けました。