catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

hololensの空間マッピングに触れる

久しぶりのブログ記事です。かたりぃなです。 Hololensの空間マッピングC++からいじっていて、とりあえずだいたいどんなものか掴めたので整理します。

実際に動いているものはまだないので、インパクトは小さいですが、こういう小さな積み重ねが大事だと思ってます。

目的

まず空間マッピングを使う目的について。これは2つあって、

  1. カードゲームARでカードを検出しやすくなりそう
  2. ARの可能性の一つとしてHololens特有の機能(=空間マッピング)を試したい

少々話が長くなりますが、整理してみます。

平面マーカー(カード)を検出しやすくするアイデア

まず私がやりたいことは既存カードゲームのAR化で、技術的には画像からの特定物体検出+コンテンツのレンダリングです。

マーカー型ARで有名なvuforiaでは、たとえば二次元マーカーとして「特定の絵柄をもったカード」を検出できます。 しかし、カードゲームは絵柄が多いので特定の絵柄をマーカーとして検出しても意味がありません。 「カードらしきもの」を検出して、絵柄の分類は別タスクとして処理したいというわけです。

で、絵柄を除いた「カードらしきもの」とは何かというと、画像データでいうとカードの外枠だったり、輪郭だったりしか残らないわけです。 これを画像データから直接検出するには少々難易度が高いので、次のようなフローを考えています。

  1. 空間マッピングデータを解析して、ゲームが行われているテーブルを見つける
  2. 三次元空間上のテーブルの平面を、二次元平面に変換する方法fを求める
  3. 方法fをカメラの二次元画像に適用して、テーブル平面を画面に投影した画像を生成する
  4. 画像からカードを検出する

こうすると何がうれしいかというと、4の段階では「机を真上から撮影した画像」であるという前提が得られるので、処理が簡単かつ高速になると考えます。

Holensの空間マッピングを試す

Microsoftがサンプルコードを提供してくれているのでこれを使います。

https://github.com/Microsoft/Windows-universal-samples/tree/master/Samples/HolographicSpatialMapping

ライセンスはMITのようです。 https://github.com/Microsoft/Windows-universal-samples/blob/master/LICENSE

以下の文章はこのサンプルコードに関する説明になります。

そもそも空間マッピングとは?

Hololensでは装着者の周囲の環境を読み取ることができます。

乱暴に解釈するなら3Dスキャン的なもんです。装着して部屋の中歩き回れば、どんな空間なのかがわかります。

ここで注意しなくてはいけないのは、Hololensの空間マッピングで識別できるのは「特定の位置に何かがある」ということだけです。

それがテーブルなのか、本棚なのか、そこまではHololensのAPIは関与しません。理由は以下に説明します。

Hololensはどうやって空間マッピングをしているの?

Hololensの空間マッピングではおそらくKinnectと同じく赤外線照射による空間認識だろうといわれています。

赤外線を使った距離測定器みたいなものだといえば伝わりやすいかと思います、

ただ、別の手段を使えばHololens以外でも空間マッピングは(ある程度は)可能と考えています。

たとえば有名なアルゴリズムでPTAMとかDTAMなどがありますが、あれらはカメラの映像を解析して三次元推定をします。

最近ではDNNとかでやってる例もありますね。

実際にHololensの空間マッピングを使ってみる

実際にやってみました。 とはいえ、ほとんどMSが提供しているサンプルコードのままなので、あれをそのまま読んで意味を理解できる人なら、以降の情報は役に立たないかと思います。

Hololensで動かすアプリはUWPアプリとなるので、この形式にあわせて作ります。

開発用の言語は、C++でいきます。私はC++が好きなので。いわゆるC++/cxですね。

VisualStudio2017のテンプレートにあるHolographicのDirectX11プロジェクトをベースに作業していきます。

答えだけ欲しい人は、UWPのHolographicサンプルコード中のSpatialMapping周りのクラスとレンダリング用のシェーダーをコピペすれば動きます。

以下はサンプルコードを調べた内容のメモです。

アプリのパッケージマニフェストの設定

アプリがどんな機能を利用するかをマニフェストファイルに記載していきます。

VisualStudioが生成したプロジェクトにPackage.appmanifestというファイルが含まれているので、これを編集します。

注意点としては、このファイルを普通にVisualStudioで開くとGUIからの設定画面になってしまうので、右クリックとかで適当なテキストエディタで開きます。

Holographic Academyのチュートリアルどおり次のように編集します。

  • uap2の追加
  • spatialPerceptionの追加
<Package
  xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
  xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest"
  xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
  xmlns:uap2="http://schemas.microsoft.com/appx/manifest/uap/windows10/2"
  IgnorableNamespaces="uap uap2 mp">
  <!--- 中略 --->
  <Capabilities>
    <uap2:Capability Name="spatialPerception"/>
  </Capabilities>
</Package>

これで空間マッピング系のAPIを呼び出せるようになりました。

空間マッピングが利用可能かどうか調べる

やっとコードの編集まできました。

まずは空間マッピングの機能を使えるかどうか、調べる必要があります。 こんな感じです。

    auto initSurfaceObserverTask = create_task(SpatialSurfaceObserver::RequestAccessAsync());
    initSurfaceObserverTask.then(
        [this, currentCoordinateSystem]
        (Windows::Perception::Spatial::SpatialPerceptionAccessStatus status)
    {
        switch (status)
        {
        case SpatialPerceptionAccessStatus::Allowed:
            return true;
            break;
        default:
            return false;
            break;
        }
    });

空間マッピングのフォーマットを決める

空間マッピングで得られる情報は、PointCloud形式ではなくアプリケーションレイヤではポリゴンデータとなっています。 ここではその設定をしています。

PointCloudとポリゴンの違いは、乱暴に言うと点と面の違いです。 ポリゴンになっているとそのままDirectXレンダリングに渡せるので楽ですね。

さて、ここでのフォーマットですが、

  • VertexPositionFormatに頂点の表現方法を設定する
  • VertexNormalFormatに法線の表現形式を設定する

だけです。 浮動小数点形式はHololensでは受け付けてくれませんでした。

ここで注意が必要なのは次のフォーマットです。

DirectXPixelFormat::R16G16B16A16IntNormalized

一般的なフォーマットならfloat要素3つ(もしくはアクセス効率を考慮してfloat要素4つ)で一つの頂点が表現されますが、このフォーマットは「正規化された符号付き16bit整数」だと言っています。

念のため、、、3次元空間x,y,zの要素なのに4要素使っているのは、よくあるアクセス効率のためと考えます。 1つの頂点表現が32bitもしくは64bit境界を跨ぐとアクセス効率が極端に悪くなるので。

頂点表現の話に戻ります。

細かい部分は置いといて、概要だけいうと符号付き16bit値(C言語でいうsigned short型)を-1.0 ~ 1.0の範囲にマッピングする符号付き固定小数点です。

つまり整数として読んだときに32768が1.0, -32767が-1.0です。

ただし、このマッピングだけだと誤差があるので、正確な情報は上記フォーマットのマニュアルページを参照してください。

    m_surfaceMeshOptions = ref new SpatialSurfaceMeshOptions();
    IVectorView<DirectXPixelFormat>^ supportedVertexPositionFormats = m_surfaceMeshOptions->SupportedVertexPositionFormats;
    unsigned int formatIndex = 0;
    if (supportedVertexPositionFormats->IndexOf(DirectXPixelFormat::R16G16B16A16IntNormalized, &formatIndex))
    {
        m_surfaceMeshOptions->VertexPositionFormat = DirectXPixelFormat::R16G16B16A16IntNormalized;
    }
    IVectorView<DirectXPixelFormat>^ supportedVertexNormalFormats = m_surfaceMeshOptions->SupportedVertexNormalFormats;
    if (supportedVertexNormalFormats->IndexOf(DirectXPixelFormat::R8G8B8A8IntNormalized, &formatIndex))
    {
        m_surfaceMeshOptions->VertexNormalFormat = DirectXPixelFormat::R8G8B8A8IntNormalized;
    }

空間マッピングのデータを受け取るためのイベントハンドラを登録する

空間マッピングのデータをアプリケーションが受け取るためのイベントハンドラを登録し、ハンドラ内で受け取ったデータを好きなように料理しましょうという流れです。

複数回空間マッピングのデータを採取すると同じものが取れる(空間内のオブジェクトに変化がないということ)ので、サンプルコードでは上手に弾くように実装されてます。

    m_surfaceObserver = ref new SpatialSurfaceObserver();
    if (m_surfaceObserver)
    {
        m_surfaceObserver->SetBoundingVolume(bounds);

        // If the surface observer was successfully created, we can initialize our
        // collection by pulling the current data set.
        auto mapContainingSurfaceCollection = m_surfaceObserver->GetObservedSurfaces();
        for (auto const& pair : mapContainingSurfaceCollection)
        {
            // Store the ID and metadata for each surface.
            auto const& id = pair->Key;
            auto const& surfaceInfo = pair->Value;
            m_meshRenderer->AddSurface(id, surfaceInfo);
        }

        // We then subcribe to an event to receive up-to-date data.
        m_surfacesChangedToken = m_surfaceObserver->ObservedSurfacesChanged +=
            ref new TypedEventHandler<SpatialSurfaceObserver^, Platform::Object^>(
                bind(&spatial_plane_detectionMain::OnSurfacesChanged, this, _1, _2)
                );
    }

空間マッピングデータを受け取るイベントハンドラ本体

上記で登録したイベントハンドラはこうなっていました。 AddOrUpdateという名前のとおり、同一のメッシュに対する処理がしっかりされています。

void spatial_plane_detectionMain::OnSurfacesChanged(
    SpatialSurfaceObserver^ sender,
    Object^ args)
{
    IMapView<Platform::Guid, SpatialSurfaceInfo^>^ const& surfaceCollection = sender->GetObservedSurfaces();

    // Process surface adds and updates.
    for (const auto& pair : surfaceCollection)
    {
        auto id = pair->Key;
        auto surfaceInfo = pair->Value;

        if (m_meshRenderer->HasSurface(id))
        {
            if (m_meshRenderer->GetLastUpdateTime(id).UniversalTime < surfaceInfo->UpdateTime.UniversalTime)
            {
                // Update existing surface.
                m_meshRenderer->UpdateSurface(id, surfaceInfo);
            }
        }
        else
        {
            // New surface.
            m_meshRenderer->AddSurface(id, surfaceInfo);
        }
    }

    m_meshRenderer->HideInactiveMeshes(surfaceCollection);
}

メッシュデータを取り出す

ここでは頂点、法線、インデックスなど、レンダリングするために必要な情報を取り出して、surfaceMeshクラスに放り込んでいます。 このとき重要になるのが、メッシュの変換行列です。

上述のとおり、メッシュ表現に使われる頂点座標は上限が1.0で下限が-1.0です。Hololensの座標系はメートル単位なので、このままでは1メートル四方のものしか表現できないことになってしまいます。

んで、そんな不便なわけなくて、ただ単純に行列のスケール要素で処理すれば元の大きさになりますよという話です。

サンプルコードではレンダリングするだけなのでConstantBufferに入れて終わりです。

Concurrency::task<void> RealtimeSurfaceMeshRenderer::AddOrUpdateSurfaceAsync(Guid id, SpatialSurfaceInfo^ newSurface)
{
    auto options = ref new SpatialSurfaceMeshOptions();
    options->IncludeVertexNormals = true;

    // The level of detail setting is used to limit mesh complexity, by limiting the number
    // of triangles per cubic meter.
    auto createMeshTask = create_task(newSurface->TryComputeLatestMeshAsync(m_maxTrianglesPerCubicMeter, options));
    auto processMeshTask = createMeshTask.then([this, id](SpatialSurfaceMesh^ mesh)
    {
        if (mesh != nullptr)
        {
            std::lock_guard<std::mutex> guard(m_meshCollectionLock);

            auto& surfaceMesh = m_meshCollection[id];
            surfaceMesh.UpdateSurface(mesh);
            surfaceMesh.SetIsActive(true);
        }
    }, task_continuation_context::use_current());

    return processMeshTask;
}

メッシュデータをもとにDirectXレンダリングするためのリソースを作る

このあたりのコードはもはやお約束ですね。 サンプルコードを引用するの疲れたので省略します。

重要なポイントだけ列挙すると

  • メッシュデータのスケールは、シェーダー内でやっている
  • 頂点フォーマットの形式は、空間マッピングで取得したものをそのまま受け渡せるように設定する

といったところでしょうか。

スケールの設定は、モデルの大きさをプログラムから変更するようなことをしている場合には注意ですね。行列の乗算順序を意識しておかないと、おかしくなります。

頂点フォーマットについては、普通のDirectXアプリでは頂点データは32bit浮動小数点形式で受け渡すことが多いと思いますが、今回は符号付き16bit形式なので注意が必要です。