前回はタッチした位置の深度を測り、そこにPlaneを置くことを試みましたが、今回はFloor(床)の判断を試したいと思います。

Floor認識例

Floorを認識させ、Plane(緑)を表示しています。デフォルトではもっと広いPlaneが表示されるのですが、分かり辛かったので縮小させています。
----->
Floorが(分かり難いですが)膝より上に表示されています 端末を膝より下に移動させても、Floorが足首より上に表示されています

上記のように当初膝より上に表示されていたFloor向けのPlaneがデバイスを地面に近づけても、どんどんFloor向けのPlaneが地面に近づいていきます。恐らく写っている足は認識されていないようです。

実装概要

前回作成したプロジェクトをベースに作成しています。

TangoPointCloudFloorのインスタンス化

「Tango Point Cloud Floor」Prefabを追加し、インタンス化しています。 Assets/Scripts/DepthPerceptionMainController.cs - OnTangoPermissionsメソッド
mTangoPointCloudFloor = FindObjectOfType();

// デフォルトではFloorが表示されて調査し辛かったので、一旦非表示化
mTangoPointCloudFloor.gameObject.SetActive(false);

アプリで用意したボタンを押下することで、Floorを探し始めるようにしています。
Assets/Scripts/DepthPerceptionMainController.cs - FindFloorメソッド
if (!mTangoPointCloud.m_floorFound)
{
    try
    {
        if (!mFindingFloor)
        {
            mFindingFloor = true;    // TangoPointCloud.FindFloorの多重コール抑止
            mTangoPointCloud.FindFloor();
        }

        // TangoPointCloud.m_floorFoundがON(Floorが見つかる)になるまで、コルーチンを回す
        while (!mTangoPointCloud.m_floorFound)
        {
            yield return new WaitForEndOfFrame();
        }
    }
    finally
    {
        mFindingFloor = false;
    }
}

if ((mTangoPointCloudFloor != null) && (mTangoPointCloudFloor.gameObject.activeInHierarchy == false))
{
    // TangoPointCloudFloorで管理しているPlaneを表示させる
    mTangoPointCloudFloor.gameObject.SetActive(true);
}

サンプルアプリはこちらにアップしてありますので、興味がありましたら、参照してみてください。
UI AutomatorからSpinnerを選択した後に表示されるドロップダウンメニュー内の項目を選択する場合についてです。
======>
Spinner選択による
ドロップダウン表示

このときのドロップダウンメニューはListViewにて表現されています。

なので、こちらに記載したときと同じように、このListViewに対して選択したいアイテムを表示させた後に、選択する必要があります。

サンプルコード: Spinnerに設定されているリソースID名称は"jp.eq_inc.testapplication:id/sprContent"とします。
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
UiObject spinnerObject = device.findObject(new UiSelector().resourceId("jp.eq_inc.testapplication:id/sprContent");
spinnerObject.click();  // Spinnerをクリックして、ドロップダウンメニューを表示させる

UiSelector uiSelector = new UiSelector().className("android.widget.ListView");
UiObject dropdownListObject = device.findObject(uiSelector);
UiScrollable dropdownListScrollable = new UiScrollable(uiSelector);  // ListViewへのUiObjectをUiScrollableへ変換
dropdownListScrollable.scrollTextIntoView("text 40");  // "text 40"が見えるところへスクロールする

UiObject selectItemObject = device.findObject(new UiSelector().text("text 40"));
selectItemObject.click();

余談ですが、上記のコードではドロップダウンメニューを表現しているListViewをクラス名称から探していましたが、Activity側にもListViewが存在した場合、正常に動作しないかと疑われたため、以下のような構成でも試してみました。結論して正常に取得できたので、UiDevice.findObjectは上位レイヤから一致するものを探しているようです。
======>
Spinner選択による
ドロップダウン表示
全体がListViewで、
その1要素として
Spinnerが存在している
今回はDepth Perception(深度認識)についてです。

Depth Perceptionとは

Depth Perceptionとは、カメラで撮影している世界において、奥行きを認識する機能となります。
深度カメラで深度を認識しているため、デバイスの性能によっては、近い距離だと認識に失敗するようです。
また、細かくは測定していないようで、ちょっとした段差だと認識に失敗することもありました。

深度認識例

ユーザが画面をタッチした箇所の深度を測定し、そこにPlaneを設置するアプリを作ってみました。
実際に使用したときの例が以下のような感じになります。

赤枠内にある灰色のPlaneがアプリで表示させたものとなります。
概要
左上のPlaneがティッシュボックスの側面をタッチしたことによるもの
右下のPlaneがスマートフォンをタッチしたことによるもの
ノートPCのアームレストをタッチしたことによるもの
ノートPCのパネル部分をタッチしたことによるもの
拡張ディスプレイのパネル部分をタッチしたことによるもの

Planeの色合いから、深度と傾きが考慮された表示に見えるかと思います。

実装概要

※ Tango Manager、Tango Point Cloud、Tango Camera prefabの追加設定については、以下のサイトを参照してください。
Setup for All Unity Tango Apps
Unity How-go Guide: Depth Perception

ただし、今回はTango Point Cloudのポイント表示機能を使用したくなかったので、以下のTango Point Cloudの設定はオフにしています。

TangoApplication、TangoPointCloudのインスタンス化

MonoBehaviour.Startをオーバーライドして、TangoApplicationとTangoPointCloudをインスタンス化しています。
そして、権限確認の実施とTangoPointCloudの開始します。

Assets/Scripts/DepthPerceptionMainController.cs - Startメソッド
mTangoApplication = FindObjectOfType<TangoApplication>();
if(mTangoApplication != null)
{
    // Depth Perceptionの有効化
    mTangoApplication.EnableDepth = true;
    // コールバックを受けるインスタンスの登録
    mTangoApplication.Register(this);
    // 権限要求 -> ユーザ確認 -> OnTangoPermissionsコールバック
    mTangoApplication.RequestPermissions();
}

Assets/Scripts/DepthPerceptionMainController.cs - OnTangoPermissionsメソッド
if (permissionsGranted) // 権限取得判定
{
    // TangoPointCloudのインスタンス化
    mTangoPointCloud = FindObjectOfType<TangoPointCloud>();
    if (mTangoPointCloud != null)
    {
        // Tango Point Cloudを開始 -> 本インスタンスにて後程Depth Perceptionを実施
        mTangoPointCloud.Start();
    }

    // Tango Serviceとの接続開始
    mTangoApplication.Startup(null);
}

タッチ位置にPlaneを設置

タッチした位置をハンドリングして、Tangoで管理しているPlaneを見つけます。見つかったら、そこに自分で用意したPlane型のPrefabをGameObject化して表示しています。
Assets/Scripts/DepthPerceptionMainController.cs - Updateメソッド
if (Input.touchCount >= 1)
{
    Touch touch = Input.touches[0];
    if (touch.phase == TouchPhase.Ended)
    {
        StartCoroutine(DisplayFoundPlane(touch));
    }
}
Assets/Scripts/DepthPerceptionMainController.cs - DisplayFoundPlaneメソッド
UnityEngine.Camera camera = UnityEngine.Camera.main;
UnityEngine.Vector3 foundPlaneCenter = new UnityEngine.Vector3();
UnityEngine.Plane foundPlane = new UnityEngine.Plane();

// タッチ位置の深度認識結果をPlaneとその中心座標で取得。
if (!mTangoPointCloud.FindPlane(camera, touch.position, out foundPlaneCenter, out foundPlane))
{
    mLogger.CategoryLog(LogCategoryMethodTrace, "not found plane");
    yield break;
}

// 表示するPlaneの重複判定
if (!mPlaneObjectTable.ContainsKey(foundPlane))
{
    mLogger.CategoryLog(LogCategoryMethodTrace, "first find plane: plane center = " + foundPlaneCenter.ToString());

    // Ensure the location is always facing the camera.  This is like a LookRotation, but for the Y axis.
    Vector3 up = foundPlane.normal;
    Vector3 forward;
    if (Vector3.Angle(foundPlane.normal, camera.transform.forward) < 175)
    {
        Vector3 right = Vector3.Cross(up, camera.transform.forward).normalized;
        forward = Vector3.Cross(right, up).normalized;
    }
    else
    {
        // Normal is nearly parallel to camera look direction, the cross product would have too much
        // floating point error in it.
        forward = Vector3.Cross(up, camera.transform.right);
    }

    /*
     * タッチされた場所の深度を測定して、そこのplaneに合うplaneを表示しようとしているけど、スクリーン座標のタッチ座標はZ軸方向の値が存在しない。
     * その状態でCamera.ScreenToWorldPointを実行するとXY座標もずれてしまうので、見つかったplaneの中心座標(ワールド座標)をスクリーン座標化し、
     * それで得られたplaneの中心座標のZ軸の値を疑似的にタッチ位置のZ軸方向の座標として使用する。
     */
    // planeの中心座標(ワールド座標)をスクリーン座標に変換
    Vector3 screenFoundPlaneCenter = camera.WorldToScreenPoint(foundPlaneCenter);

    // タッチ座標(XY軸方向のみのスクリーン座標)にplaneの中心座標(スクリーン座標)のZ軸方向の値を設定した上で、ワールド座標に変換
    Vector3 worldTouchPoint = camera.ScreenToWorldPoint(new Vector3(touch.position.x, touch.position.y, screenFoundPlaneCenter.z));

    // 疑似的に算出されたタッチ座標(ワールド座標)にオブジェクトを生成
    GameObject basePlane = Instantiate(mPlaneBase, worldTouchPoint, Quaternion.LookRotation(forward, up));
    basePlane.SetActive(true);
    mPlaneObjectTable[foundPlane] = basePlane;
}
今回は画面タッチから、そのままTangoPointCloud.FindPlaneを実施していますが、あくまで既に認識済みのPlaneから一致するものを見つけているだけのようです。
なので、Googleが推奨する実装方法としては、以下のようにTangoPointCloud.FindPlaneを実施する前にTangoApplication.SetDepthCameraRate(TangoEnums.TangoDepthCameraRate.MAXIMUM)に変更して、深度認識を一旦促進し、結果が出てからTangoPointCloud.FindPlaneをコールするようです。

https://github.com/googlesamples/tango-examples-unity.git - UnityExamples/Assets/TangoSDK/Examples/AreaLearning/Scripts/AreaLearningInGameController.cs - _WaitForDepthAndFindPlane
m_findPlaneWaitingForDepth = true;// <- OnTangoDepthAvailableにてfalseに戻される

// Turn on the camera and wait for a single depth update.
m_tangoApplication.SetDepthCameraRate(TangoEnums.TangoDepthCameraRate.MAXIMUM);
while (m_findPlaneWaitingForDepth)
{
    // OnTangoDepthAvailableがコールバックされるまで処理なし
    yield return null;
}

m_tangoApplication.SetDepthCameraRate(TangoEnums.TangoDepthCameraRate.DISABLED);

// Find the plane.
Camera cam = Camera.main;
Vector3 planeCenter;
Plane plane;
if (!m_pointCloud.FindPlane(cam, touchPosition, out planeCenter, out plane))
{
    yield break;
}

ただ、1回の深度認識を挟むことで、必ずしも知りたい部分の深度が認識されているかは保証されないので、必要に応じてGoogle推奨の方式にするか、今回弊社で試したような方式にするかを決めればよいかと思います。
上記(弊社作成の)サンプルアプリはこちらにアップしているので、興味がありましたら、参照してみてください。

Web フロントエンドの開発には Chromenpm が欠かせないものとなっています。
(※ Eq でも misupopoimage-ghost という画像のリサイズツールを公開しているので使ってみてください。)

そのため、ローカルの開発環境に Chrome や npm を使ってテスト環境が構築できれば、新たにアプリケーションをインストールする必要が無いというメリットがあります。
そこで前回前々回Headless Chromechrome-remote-interface の使い方を紹介してきました。

しかし、これで追加のインストール無しに環境を構築できるのはローカル環境だけです。
たとえば Jenkins を使った CI 環境を構築する場合には、別途 Chrome や Chromium をインストールする必要があります。
これでも十分、楽ではありますが npm install コマンドを叩くだけの場合と比べると、やはり少々面倒です。

だったら npm からインストール可能な Nightmare を使ってみたらどうだろう?
そう考えて、Protractor のサイトにあるテストのサンプルと同じ内容のテストを、今回は Nightmare を使って書いてみることにしました。


Nightmare

Nightmare の紹介を見ると「A high-level browser automation library」とあります。
ここでいうブラウザは Chrome や Firefox など私達が普段から使っているようなブラウザではなく Electron を指します。

Electron は Node.js と Chromium の技術を使って Web の技術でデスクトップアプリケーションを開発できるフレームワークです。
Nightmare ではこれをブラウザとして使用し JavaScript から操作ができるようになっています。

Chromium が使われているということは、レンダリングされる内容や JavaScript の動作などは Headless Chrome と同じはずです。
また Headless Chrome ではできなかった、クエリセレクターで要素を指定してクリックイベントを発生させるといったことが可能です。

使い方については、Nightmare のリポジトリに含まれる README.md を見るだけで判ると思います。
また nightmare-examples というリポジトリでは使用例や、ドキュメントが公開されています。


環境構築

今回も vagrant を利用して Ubuntu 16.04 上に環境を構築し、次のような環境を構築しました。

Name Version
OS Ubuntu Linux 16.04(Xenial)64bit
Node.js 8.1.4
nightmare 2.10.0

環境構築に利用した Vagrantfile と Ansible の playbook、テストのソースコードは GitHub で公開しています。
nightmare-test を clone して vagrant up を実行するだけで、簡単に同じ環境を構築して、試していただけると思います。


テスト

Nightmare では文字を入力する type や、クリックイベントを発生させる click などのメソッドが用意されています。
そのため chrome-remote-interface のように自分でメソッドを用意しなくても、簡単にテストを書き始めることができます。

注意点があるとすれば evaluate が返した値を then で取得した後、さらに click などのメソッドをつなげることが、できない点でしょうか。
この辺りは、上で紹介した nightmare-examples に含まれるドキュメントの「Asynchronous operations and loops」が参考になると思います。

'use strict';

const expect = require('expect.js');
const nightmare = require('nightmare');

describe('Nightmare test', function () {
    describe('Protoractor test sample', function () {
        describe('Should complete test', function (done) {
            it('Multi evaluate', function (done) {
                (async () => {
                    const client = nightmare({show: false}),
                        text = 'write first protractor test';

                    client.goto('https://angularjs.org/')
                        .type('input[ng-model="todoList.todoText"]', text)
                        .click('input[value="add"]')
                        .evaluate(function () {
                            return document.querySelectorAll('li[ng-repeat="todo in todoList.todos"]');
                        }).then(function (result) {
                            expect(Object.keys(result)).to.have.length(3);

                            return client.evaluate(function () {
                                const elements = document.querySelectorAll('li[ng-repeat="todo in todoList.todos"]');

                                return elements[2].querySelector('span').innerText;
                            });
                        }).then(function (result) {
                            expect(result).to.be(text);

                            return client.click('li[ng-repeat="todo in todoList.todos"]:nth-child(2) input')
                                .evaluate(function () {
                                    return document.querySelectorAll('.done-true');
                                });
                        }).then(function (result) {
                            expect(Object.keys(result)).to.have.length(2);

                            client.end().then(function () {
                                done();
                            });
                        });
                })();
            });
        });
    });
});

上の例ではプログラムの流れを、もとのコードに合わせているため evaluate メソッドが複数回、呼ばれています。

好みの問題ではありますが evaluate に書かれている内容は、次のようにまとめることもできます。
値を検証する部分が 1 箇所にまとまるので、こちらのほうが都合のいいこともあると思います。

client.viewport(1024, 768)
    .goto('https://angularjs.org/')
    .type('input[ng-model="todoList.todoText"]', text)
    .click('input[value="add"]')
    .evaluate(function () {
        const elements = document.querySelectorAll('li[ng-repeat="todo in todoList.todos"]');
        elements[2].querySelector('input').click();

        return {
            elements: elements.length,
            completes: document.querySelectorAll('.done-true').length,
            text: elements[2].querySelector('span').innerText
        };
    }).then(function (result) {
        expect(result.elements).to.be(3);
        expect(result.completes).to.be(2);
        expect(result.text).to.be(text);

        client.end().then(function () {
            done();
        });
    });

ヘッドレス環境で動かすときの問題

テストが書けたので動作を確認をしようと、vagrant 環境に入り、次のような感じでテストを動かしてみました。

# ローカル環境から Vagrant 環境に入る
vagrant ssh

# Vagrant 環境でテストを実行する
cd /vagrant
yarn install
mocha --no-timeouts test.js

しかし、テストは途中で落ちてしまいました。
画面の表示はこうです。

Nightmare test
    Protoractor test sample
      Should complete test

it で落ちているのは想像がつきますが、エラーメッセージはなく、echo $? の結果も 0 でした。


xvfb をインストールするだけでは動かなかった

これだけでは何が悪いかわからないので、同じような問題が報告されていないか GitHub の issue を確認してみました。
すると「Running Nightmare headlessly on Linux #224」が見つかりました。

ヘッドレスな環境で動かすためには xvfb を使えとあります。
どうやら Nightmare を使っても npm だけで簡単に環境を構築するということはできないようです。

xvfb をインストールするのであれば Chromium をインストールするのと手間は、それほど変わりません。
しかし、ここまでやったのだからと xvfb をインストールして、動かしてみました。

xvfb-run --server-args='-screen 0 1024x768x24' mocha --no-timeouts ./test.js

結果は、このとおりで、何も変わりませんでした。
終了コードも 0 のままです。

Nightmare test
    Protoractor test sample
      Should complete test

xvfb 以外に必要なライブラリ

xvfb を使えばテストが動くと思っていたのですが、そうでは無かったようです。

では、何が悪いのでしょうか。
さきほどの issue を参考に DEBUG 環境変数を指定して、もう 1 度コマンドを実行してみました。

DEBUG=nightmare* xvfb-run --server-args='-screen 0 1024x768x24' mocha --no-timeouts ./test.js

結果はこうです。

Nightmare test
    Protoractor test sample
      Should complete test
nightmare queuing process start +0ms
nightmare queueing action "goto" for https://angularjs.org/ +2ms
nightmare queueing action "type" +1ms
nightmare queueing action "click" +0ms
nightmare queueing action "evaluate" +0ms
nightmare running +0ms
nightmare electron child process exited with code 127: command not found - you may not have electron installed correctly +14ms
nightmare electron child process not started yet, skipping kill. +1ms

エラーメッセージから Electron が起動できていないことが判ります。
Electron のエラーを見るには DEBUG=electron* とすると良さそうなことは issue から判ります。

そこで、さきほどのコマンドを、こう変更して実行してみました。

DEBUG=electron* xvfb-run --server-args='-screen 0 1024x768x24' mocha --no-timeouts ./test.js

結果は、次のとおりです。
原因と思われるエラーメッセージが表示されています。

Nightmare test
    Protoractor test sample
      Should complete test
electron:stderr /vagrant/node_modules/electron/dist/electron: error while loading shared libraries: libgtk-x11-2.0.so.0: cannot open shared object file: No such file or directory +0ms

Electron が起動できていなかった原因は libgtk-x11-2.0.so.0 が無かったことでした。
xvfb をインストールするだけでテストが動くようになると思っていたのですが、他にもライブラリが必要だったようです。

そこで apt コマンドで libgtk2.0-0 をインストールして、再度テストを動かしてみました。
しかし、まだエラーになってしまっています。

electron:stderr /vagrant/node_modules/electron/dist/electron: error while loading shared libraries: libXtst.so.6: cannot open shared object file: No such file or directory +0ms

ここからは、テストが動くようになるまで、必要なライブラリをインストールして DEBUG=electron* を付けてテストを実行することを繰り返しました。
最終的にテストを動作させるのに xvfb 以外に必要だったライブラリは、次の 6 つです。

  • libgtk2.0-0
  • libxtst6
  • libxss1
  • libgconf-2-4
  • libnss3
  • libasound2

まとめ

npm でインストールができる Nightmare を使えば環境構築が楽になるかと思ったのですが、Electron を動かすのには、思ったよりも色々なライブラリが必要でした。
簡単に環境が構築できることを重視するなら Headless Chrome + chrome-remote-interface の方が良さそうです。

ただしテストの書きやすさでいえば Nightmare の方が、大分、書きやすい印象です。
環境構築の手間も含め Nightmare は Selenium に近い印象を受けました。
Headless Chrome のときもそうでしたが、すでに Selenium を使ったテストがあれば、そこから乗り換えるメリットは無さそうです。

とはいえ、手元のマシンにテストの環境を作りたいのであれば、Selenium や Headless Chrome よりもお手軽です。
WebDriver や Headless Chrome は利用する前に自分で立ち上げる必要がありますが Nightmare では、その必要がありません。
goto メソッドを呼べば自動で Electron が立ち上がりますし、end メソッドで終了します。

他にも Headless Chrome は Headless ではない Chrome が、すでに立ち上がっていると起動ができません。
Chrome をメインのブラウザとして使っている場合、これが問題になることもあると思います。

結局のところ、どれも一長一短で、これがベストと、いえるものはありませんでした。
色々なブラウザで試す必要があれば Selenium を使うなど、要件に合わせて使い分ける必要がありそうです。

後は、好みの問題でしょうか。
個人的には、ローカル環境の構築がしやすく、テストも書きやすい Nightmare が良さそうに思いました。

UI Automatorでスクロール可能なViewGroup内の特定の子要素をクリックなどする場合についてです。

実はこれが結構面倒でした。
UI Automatorでclickなどの操作を行うには、基本的にその操作対象のViewが画面に表示されている必要があります。
なので、ViewGroupまたはAdapterViewには存在しているけど、画面に未だ表示されていないものはclickなどの選択ができません(※)。そのため、特定のViewを画面に表示させてから実施する必要があります。
※ UiObject.clickなどをコールするとUiObjectNotFoundExceptionがスローされます。

そうなると、ViewGroupやAdapterViewをスクロールさせる必要が出てきますが、スクロールさせるためのメソッドとして、以下のものが用意されています。

メソッド名称概要
UiDevice.swipe座標を指定したスクロール(スワイプ)が可能
UiObject.swipeUp/Down/Left/Rightsteps指定によりスクロール(スワイプ)が可能
UiScrollable.scrollToBegining/Endsteps指定によりスクロール(スワイプ)が可能
UiScrollable.scrollForward/Backwardsteps指定によりスクロール(スワイプ)が可能
UiScrollable.scrollIntoView見つけたいViewへのUiObjectまたはUiSelectorを指定し、それを探してくれる
UiScrollable.scrollTextIntoView見つけたいテキストが記載されたViewを探してくれる

使ってみて分かったこととしては、以下の点でした。
  1. UiDevice.swipeはスクロール対象のViewに対するUiObjectが特定できていないときでも利用できる
  2. UiDevice.swipeはスクロールしたい量が分かっているときは、直観的で使いやすい
  3. UiObject.swipeUp/Down/Left/RightやUiScrollable.scrollToBegining/EndやUiScrollable.scrollForward/Backwardは約1ページ分スクロールし、stepsによってスクロール速度が変わる
  4. UiObject.swipeDownをandroid.support.v4.widget.DrawerLayoutに対して実施するとステータス領域が表示される(詳細はこちらを参照)
  5. UiScrollable.scrollIntoViewやUiScrollable.scrollTextIntoViewは探したいViewの特徴(表示している文字列や設定されている一意のリソースID)が明確なら使いやすい
  6. UiScrollable.scrollIntoViewやUiScrollable.scrollTextIntoViewは一旦リストの先頭に移動してから探し始めるので、少し重たい

上記のメソッドにて、スクロールさせて対象のViewが表示された状態にした後に、UiObject.findObjectなどで子要素のUiObjectを取得すると操作可能なUiObjectを取得することができます。
UiScrollableの取得方法についてはこちらを参照してください。
先日公開したブログ"UnityでGoogle Tangoと戯れる - Motion Tracking"を公開する前にちょっとしたトラブルがありました。
トラブルの内容としては、
  • カメラで撮影した映像が表示されない
  • GameObject多重に表示される
となります。そのため、前のシーンが下に表示されつつ、該当シーンで表示しているGameObjectが多重に表示されるといったものとなります。
Hopak時
意図した表示
Ikariotikos時

原因_その1

カメラで撮影した映像が表示されない
こちらの原因は"Ikariotikos"から、Prefab"Tango Camera"にインポートされているComponentの内、"Tango AR Screen"が非アクティブになっているため、カメラで撮影中の画像が表示されなくなっていました。なので、Prefab"Tango Camera"で"Tango AR Screen"を有効にしてしまい、一括で変更するか使用するシーン毎に有効にすることで修正可能です。

原因_その2

GameObject多重に表示される
こちらの原因は"Ikariotikos"から、Prefab"Tango Camera"の以下の設定が変更されたことによる影響でした。
設定名称Ikariotikosでの設定値Hopak時の設定値
Clear FlagsDepth onlySolid Color
Culling MaskEverythingMixed(Default | TransparentFX | Ignore Raycast | Water | UI | Occlusion)
元に戻すと、多重表示はなくなりましたが、Googleが公開している"Unity Example"(https://github.com/eq-inc/eq-unity-google-tango.git)は設定を戻さなくても問題が発生しなかったので、別の修正方法が存在する可能性が残されています。なので、こちらの修正については慎重に行った方がよいかと思いました。
Google Tangoの機能の1つである、Motion Trackingを試してみました。

Motion Trackingとは

3次元空間において、デバイス自体の動きや向きをトラッキングする機能。
GPSは地球上のどこにいるかを認識することに用いられますが、Motion Trackingはカメラで撮影されている世界(?)において、デバイスがどこでどのような傾きになっていたかを認識しています。
カメラで撮影されている世界の座標系は、特定のタイミング(例: Motion Tracking開始時)に居た場所を基準としています。

使用するAPI

クラス名称メソッド名称概要
Tango.PoseProviderGetPoseAtTime Poseデータの取得を実施。タイムスタンプを指定しない場合は最新、指定している場合はそれに最も近い時刻のPoseデータが取得できます。

Motion Trackingにおいて設定可能なCoordinate Pairは以下のものとなります。
No.Base frameTarget frame概要
1COORDINATE_FRAME_START_OF_SERVICECOORDINATE_FRAME_DEVICETangoサービスが初期化されたときか、ResetMotionTracking()により初期化されたときを起点としたデバイスの位置などを返却します。
2COORDINATE_FRAME_AREA_DESCRIPTIONCOORDINATE_FRAME_DEVICE保存されている領域学習の結果から起点を抽出し、それに合った位置などを返却します。領域学習の結果が存在しないときは、No.1と同じ動作になります。
3COORDINATE_FRAME_AREA_DESCRIPTIONCOORDINATE_FRAME_START_OF_SERVICEローカライゼーションイベントまたはドリフト補正が行われた場合にのみ更新が返却される(らしい)。こちらも領域学習との絡みになります。

今回は未だ領域学習はやっていなかったので、No.1のペアを使用しています。

結果

屋内

屋内では場所が狭いことが影響しているのか、Tracking・空間認識に失敗しやすかったです。
以下のスクリーンショットは弊社屋内でMotion Trackingを行い、移動した経路にパンを配置してみたものになります。
※ パンを表示した理由は、「ヘンゼルとグレーテル」からですが、よくよく考えたら、パン(くず)を置いていくと家に帰れなくなりお菓子の家に行ってしまいますね
※ "パン"は"Low Poly RPG Pack"を使用させて頂きました。無料公開有難う御座います。

左側のスクリーンショットの奥の扉から廊下に出て、右側のスクリーンショットの廊下へ移動したところまでを取得しています。
残念ながら左側のスクリーンショットの青枠で括られている部分が、廊下を移動しているときのものなので、表示されてほしくなかったものになります。

屋外

弊社の入っている建物の周りでMotion Trackingを行い、移動した経路にパンを配置してみたものになります。

通ったルートは以下の赤線のルートのようになり、弊社オフィスを半周した軌跡になります。

このときの結果が以下のようになります。
※ 図の⑤~①に歩いた際にMotion Trackingを取得し、帰りに①~⑤の順番で戻る際にTrackingされたものを確認したものとなります。

②は方向的には③~⑤で表示されてるパンも見える方向ではありますが、空間を認識しているために、それらが表示されていないとなります。
また、⑤で画像左側に⑥で表示されているものが表示されていないことから、同様に空間を認識していると思われます。

精度から見ると、屋内での使用だと少し誤判断もありそうですが、屋外においては十分期待できそうでした。
今回作成したサンプルアプリはこちらに置いておきました。Tango SDKや"Low Poly RPG Pack"は除いているので、試してみたい方はそれらをimportとしてください。
ただし、 Tango SDKは最新バージョンの"Ikariotikos"ではカメラで撮影したものが表示されなくなってしまったので、"Hopak"を使用してください。

参考文献:
Frames of Reference
先日「Headless Chrome を使ったフロントエンドのテスト」という記事を公開しました。
この記事の中で公開しているテストコードで require されている fs が使われていないことに気づいた方はいますか?
実はこれ、スクリーンショットを保存しながら動作確認をしていたときのコードを消し忘れていたものです。
Headless Chrome は名前のとおり Headless で動作します。
そのため、動作中の画面を確認は、保存したスクリーンショットで行う必要があります。
もちろん開発中は Chrome を通常起動させるという方法もあります。
しかし、今回のように Vagrant などで環境を構築して開発している場合、そのためだけに GUI の環境を用意するのはちょっと大袈裟です。
そこで、今回は Headless Chrome でスクリーンショットを保存する方法を紹介したいと思います。

画面に表示されている内容を保存する

Headless Chrome の起動時に window-size というオプションをつけることで、画面のサイズを指定することができます。
この例でのウィンドウサイズは横 1024 ピクセル、縦 768 ピクセルになります。
chromium-browser --headless --disable-gpu --remote-debugging-port=9222 --window-size="1024x768"
この領域に表示されている内容のスクリーンショットを保存する方法は、Wiki の「Take page screenshot」で紹介されています。
私はこんな感じでスクリーンショットを保存するメソッドを作って利用していました。
テストコードに残っていた fs はここで利用していたものです。
Page.navigate と組み合わせれば、URL を開いて、そのファーストビューのスクリーンショットを保存することができます。
const screenshot = async (client, filename, options) => {
    const result = await client.Page.captureScreenshot(Object.assign({
        format: 'png'
    }, options));

    fs.writeFileSync(filename, Buffer.from(result.data, 'base64'));
};

画面をスクロールする

スクリーンショットを保存しようと思ったときに、保存したい部分はファーストビューではなくスクロールした先にあることの方が多いのではないでしょうか。
Headless Chrome でスクロールイベントを発生させるには Input.synthesizeScrollGesture を使います。
特定の要素まで画面をスクロールしたい場合は、次のようなコードになります。
const scroll = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getBoxModel({nodeId: 1}),
        element = await client.DOM.getBoxModel(nodeId),
        documentCenter = Math.ceil(document.model.height / 2),
        elementCenter = Math.ceil(element.model.height / 2),
        distance = element.model.content[1] - documentCenter + elementCenter;

    return client.Input.synthesizeScrollGesture({
        x: Math.ceil(document.model.content[0] / 2),
        y: 0,
        xDistance: 0,
        yDistance: distance * -1
    });
};
ただし、このやり方はスクロール量に比例して実行時間が長くなってしまいます。
1 秒間にスクロールされる距離は、オプションにある speed(default: 800pixel/second)で指定可能です。
また、スクロールイベントを発生させる必要が無い場合には、Viewport を変更するという方法もあります。
Viewport の変更には Emulation.forceViewport を使います。
先ほどのコードをこちらのやり方で書き直すと、次ののようになります。
この方法なら指定された位置にすぐに移動しますし、移動前の位置に戻ることも簡単にできるので、こちらの方法がお勧めです。
const setViewport = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getBoxModel({nodeId: 1}),
        element = await client.DOM.getBoxModel(nodeId),
        documentCenter = Math.ceil(document.model.height / 2),
        elementCenter = Math.ceil(element.model.height / 2),
        distance = element.model.content[1] - documentCenter + elementCenter;

    return client.Emulation.forceViewport({
        x: 0,
        y: distance,
        scale: 1
    });
};

要素やページ全体を保存する

特定の要素まで画面を移動させたとしても、要素が大きければ画面からはみ出してしまいます。
また、ページ全体を保存したいというケースもあるでしょう。
ページ全体のスクリーンショットを保存する方法は「Using headless Chrome as an automated screenshot tool」という記事で紹介されています。
このページは GitHub の Wiki のページ からもリンクされています。
なお、上のブログでは Emulation.setDeviceMetricsOverride なども使われていますが、私が試した限りでは Emulation.setVisibleSize を変更するだけで問題なさそうでした。
ここでは body タグを指定してページ全体の高さを取得しています。
任意のセレクタを指定すれば、その要素の高さが取得できるので Viewport の変更などと組み合わせれば、その要素全体を含むスクリーンショットを保存することもできます。
const screenshotFull = async (client, filename, options) => {
    const original = await client.DOM.getBoxModel({nodeId: 1}),
        boxmodel = await client.DOM.getBoxModel(await client.DOM.querySelector({
            nodeId: 1,
            selector: 'body'
        }));

    await client.Emulation.setVisibleSize({
        width: boxmodel.model.width,
        height: boxmodel.model.height
    });

    const result = await client.Page.captureScreenshot(Object.assign({
        format: 'png'
    }, options));
    fs.writeFileSync(filename, Buffer.from(result.data, 'base64'));

    return client.Emulation.setVisibleSize({
        width: original.model.width,
        height: original.model.height
    });
};

注意点

この記事を書くに当たって実際にコードを書いて検証していたところ、いくつか注意の必要な部分がありましたので、最後にまとめておこうと思います。

2 回実行するとスクロール位置がずれる

特定の要素まで画面をスクロールしてスクリーンショットを保存する部分を書いていたときのことです。
同じプログラムを 2 回、実行すると、なぜか 2 回目のスクリーンショットでフッタの部分までスクロールされてしまっていました。
Chrome でページを開いて途中までスクロールした後にリロードしたり、もう一度同じ URL を入力すると、直前に見ていた位置が表示されます。
Headless Chrome はヘッドレスなだけで Chrome に変わりはありませんから、同じような動作をしていると考えられます。
そのため、スクリーンショットを保存した後は再度、ページのトップまでスクロールしておいた方がいいでしょう。
このとき実際に画面をスクロールしていた場合、ページトップまでの長さを計算してスクロールする必要があり少々面倒ですし、スクロールをする時間もかかります。
しかし、Emulation.forceViewport で Viewport を変更していた場合は Emulation.reserViewport を使うことで、すぐに元に戻すことができます。
スクロールの方法を説明している際に Viewport を指定する方法が、お勧めだといったのは、このためです。
画面をスクロールする話で紹介した setViewport メソッドを使ってスクリーンショットを保存したあと、Viewport を元に戻す処理は、こんな感じになります。
const document = await client.DOM.getDocument(),
    element = await client.DOM.querySelector({
        nodeId: document.root.nodeId,
        selector: 'div[app-run="todo.html"]'
    });
await setViewport(client, element);
await screenshot(client, './screenshot.png', element);
await client.Emulation.resetViewport();

DOM.getBoxModel ができない

これは画面をスクロールさせる方法のところで紹介した setViewport というメソッドの一部です。
const setViewport = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getBoxModel({nodeId: 1}),
        element = await client.DOM.getBoxModel(nodeId),
この部分のコードは、もともと、こんな感じのコードでした。
しかしこのコードでは element の内容を取得する部分で「Error: Could not find node with given id」というエラーが発生してしまっていました。
const setViewport = async (client, nodeId, options) => {
    nodeId = (nodeId instanceof Object) ? nodeId : {nodeId: nodeId};
    const document = await client.DOM.getDocument(),
        window = await client.DOM.getBoxModel(document.root),
        element = await client.DOM.getBoxModel(nodeId),
// ...
詳しく調査をしてはいないのですが、メソッドの中で DOM.getDocument を呼んでいるのが原因のようでした。
DOM.getDocument を呼んだ時点ではルートの情報しか持っておらず、DOM.querySelector などが呼ばれる度に対象の情報が追加されていくのではないでしょうか。
そして、それらの情報は DOM.getDocument を呼ぶ度にリセットされているように見えます。
なので setViewport では、すでに DOM.getDocument は呼ばれているという前提で、root の nodeId である 1 を決め打ちしています。
setViewport メソッドは nodeId を渡す仕様としたためなのですが、セレクタの文字列を渡してメソッド内で DOM.querySelector を呼ぶという方法もあると思います。

Emulation.setVisibleSize した内容が残る

ページ全体のスクリーンショット保存するために Emulation.setVisibleSize を指定した後、元に戻さない限り、この設定が残り続けます。
これは、テストコード中で Headless Chrome の起動と終了をしている場合には問題にならりません。
しかし nohup などを使って Headless Chrome を立ち上げっぱなしにしている場合、意図しないサイズのスクリーンショットが保存されてしまうこともありますので、注意してください。
また Emulation.forceViewport とは違い、Emulation.setVisibleSize は簡単に元に戻せる仕組みもないため、次のように、どこかに元のサイズを保存しておく必要がありそうです。
const original = await client.DOM.getBoxModel({nodeId: 1}),
    boxmodel = await client.DOM.getBoxModel(await client.DOM.querySelector({
        nodeId: 1,
        selector: 'body'
    }));
最近のアプリで良く使用されている以下のようなDrawerLayoutをUI Automatorで試験する際の注意点です。

一見、何事もなくスクロールすることが可能のように見えますが、以下の黄色い箇所がDrawerLayoutとステータスバー部分のDrawer部分が重複しているために、UiObject.swipeDownを実行するとステータスバーのDrawerが反応してしまい、通知領域が表示されてしまいます。


回避するには、UiObject.swipeDown以外のメソッドでスクロールしてあげる必要があります。

UiDevice.swipeを使用した回避策: 明示的にswipeする位置を指定して回避
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
UiObject drawerLayoutObject = device.findObject("com.android.settings:id/left_drawer");
Rect drawerLayoutBounds = drawerLayoutObject.getVisibleBounds();
device.swipe(drawerLayoutBounds.centerX(), drawerLayoutBounds.centerY(), drawerLayoutBounds.centerX(), drawerLayoutBounds.centerY() - 100, 50); // 移動量の100とstepsの50は暫定
あと、UiScrollable.scrollIntoViewやscrollTextIntoViewは上記の事象は発生しなかったので、これらを使用するのも良いかと。
UI Automatorを初めて暫くしたら、UiScrollableの存在に気付いたのですが、取得方法が分からなかったので一応纏めておきます。
正直、Androidをやってきたエンジニアからすると、ちょっと戸惑いました。

やり方としては、意外にも以下のような感じになります。

試験対象のレイアウト(アプリIDは"jp.eq_inc.test_application"として記載します):
<ListView
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/lvRoot"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
</ListView>

UiScrollable取得コード:
UiDevice device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation());
UiSelector uiSelector = new UiSelector().resourceId("jp.eq_inc.test_application:id/lvRoot");
UiObject listViewObject = device.findObject(uiSelector);
if((listViewObject != null) && listViewObject.isScrollable()){  // 本当はUiObjectNotFoundExceptionをcatchする必要がありますが、省略
    UiScrollable listViewScrollable = new UiScrollable(uiSelector);  // ★
}
結局のところ、そのままインスタンスを生成して、そのときに対象のUiObjectを一意に判断できるようにUiSelectorで指定すればよいだけでした。
てっきりListViewなどをUiDevice.findObjectで取得するとUiScrollableが返却されているのかと思っていましたが、そうではなく、自分でインスタンス生成が必要なようです。
ちなみに自分で生成したインスタンスを使用して、無事UiScrollable.scrollXXXメソッドが動作しました。