はじめに


今回Magic Leap2でImmersalを動かしてみたので紹介してきたいと思います。
Immersalについてはサンプルからひも解くImmersalの記事で紹介しているので、Immersalって何?という方は是非どうぞ。

この記事では主に、Immersal REST APIについてと、Magic Leap2のカメラ周りについて取り扱っていきます。
あわせて、実際に半屋外でMagic Leap2を使ってみた所感等をお伝えできたらと思います。

※Magic Leap2の開発環境セットアップについては、Magic Leap2開発者ポータルに詳しく記載されているのでここでは割愛させて頂きます。

目次


  1. 開発環境
  2. Immersal REST APIについて
  3. カメラデータの取得
  4. Immersal REST API通
  5. 実際の運用
  6. おわりに

開発環境


  • Unity2022.2.0b4
    • XR Plugin Management 4.2.0
    • AR Foundation 5.0.0-pre.12
    • UniTask 2.3.1
    • UniRx 7.1.0
    • Magic Leap SDK 0.53.3
    • Magic Leap XR Plugin 7.0.0-exp.3
  • Magic Leap2 OS B3E.220818.12-R.085 (user)

Immersal REST APIについて


記事を書いている時点でMagic Leap2はImmersal SDKに対応していません。
そこで、Immersal SDKを使わなくともサーバー通信でVPS処理が可能なImmersal REST APIを使用します。
Immersal REST APIはSDKを必要としない代わりに、カメラ情報の取得や座標変換を自前で行う必要があるので、この記事で説明していきます。

カメラデータの取得


Immersal REST APIはデバイスのカメラデータを必要とするので、それらの取得を行います。
今回はビデオキャプチャーを起動させて、そこから画像データを取得するようにします。

Manifestの設定

デフォルトでは全ての項目にチェックが入っていますが、以下の設定を確認しましょう。
Project Settings -> Magic Leap -> Manifest Settings

  • API Level20
  • android.permission.CAMERA
  • android.permission.RECORD_AUDIO

カメラの有効化

スマートフォン等と同様に、アクセス権限を要求してカメラを有効化させます。
権限確認時のコールバックや権限要求の関数が用意されているので、これらを使用します。

/// <summary>
/// 準備完了フラグ
/// </summary>
public bool IsReady => _isReady;

/// <summary>
/// 権限コールバック
/// </summary>
private readonly MLPermissions.Callbacks _permissionCallbacks = new MLPermissions.Callbacks();

/// <summary>
/// Awake
/// </summary>
private void Awake()
{
    // 権限確認
    _permissionCallbacks.OnPermissionGranted += onPermissionGranted;
    _permissionCallbacks.OnPermissionDenied += onPermissionDenied;
    _permissionCallbacks.OnPermissionDeniedAndDontAskAgain += onPermissionDenied;
}

/// <summary>
/// Start
/// </summary>
private void Start()
{
    // 権限要求
    MLPermissions.RequestPermission(MLPermission.Camera, _permissionCallbacks);
    MLPermissions.RequestPermission(MLPermission.RecordAudio, _permissionCallbacks);

    // カメラ有効化
    tryEnableCamera();
}

/// <summary>
/// 権限否認時処理
/// ここではErrorLogを出しているだけです
/// </summary>
/// <param name="permission"></param>
private void onPermissionDenied(string permission)
{
    if (permission == MLPermission.Camera)
    {
        Debug.LogError($"{permission} denied, example won't function.");
    }
    else if (permission == MLPermission.RecordAudio)
    {
           Debug.LogError($"{permission} denied, audio wont be recorded in the file.");
    }
}

/// <summary>
/// 権限承認時処理
/// </summary>
/// <param name="permission"></param>
private void onPermissionGranted(string permission)
{
    Debug.LogError($"Granted {permission}.");
    tryEnableCamera();
}

/// <summary>
/// カメラ有効化
/// </summary>
private void tryEnableCamera()
{
    if (!MLPermissions.CheckPermission(MLPermission.Camera).IsOk)
    return;
    
    enableMLCamera().Forget();
}

/// <summary>
/// MLカメラ有効化
/// </summary>
private async UniTaskVoid enableMLCamera()
{
    var cameraDeviceAvailable = false;
    while (!cameraDeviceAvailable)
    {
        MLResult result =
        MLCamera.GetDeviceAvailabilityStatus(MLCamera.Identifier.CV, out cameraDeviceAvailable);
        if (!(result.IsOk && cameraDeviceAvailable))
        {
            await UniTask.Delay(TimeSpan.FromSeconds(1));
        }
    }
    // カメラ有効化が確認できた時点で準備完了とする
    _isReady = true;
}

ビデオキャプチャー開始

カメラの有効化ができたら、カメラ接続をしてビデオキャプチャーを開始します。
カメラ映像取得時のコールバック処理を登録できるので、後述の画像取得処理を登録します。

/// <summary>
/// MLカメラ
/// </summary>
private MLCamera _captureCamera;

/// <summary>
/// StreamCapability保持用の変数
/// </summary>
private List<MLCamera.StreamCapability> _streamCapabilities;

/// <summary>
/// カメラ接続
/// </summary>
private void connectCamera()
{
    MLCamera.ConnectContext context = MLCamera.ConnectContext.Create();
    context.CamId = MLCamera.Identifier.CV;
    context.Flags = MLCamera.ConnectFlag.CamOnly;
    context.EnableVideoStabilization = true;

    _captureCamera = MLCamera.CreateAndConnect(context);

    if (_captureCamera != null)
    {
        if (registerStreamCapabilities())
        {
            // 映像取得時処理の登録
            _captureCamera.OnRawVideoFrameAvailable += onCaptureRawVideoFrameAvailable;
        }
    }
}

/// <summary>
/// StreamCapabilityの登録
/// </summary>
/// <returns></returns>
private bool registerStreamCapabilities()
{
    // StreamCapabilitiesInfoの取得
    var result =
    _captureCamera.GetStreamCapabilities(out MLCamera.StreamCapabilitiesInfo[] streamCapabilitiesInfo);

    if (!result.IsOk)
    {
        return false;
    }

    _streamCapabilities = new List<MLCamera.StreamCapability>();
    for (int i = 0; i < streamCapabilitiesInfo.Length; i++)
    {
        foreach (var streamCap in streamCapabilitiesInfo[i].StreamCapabilities)
        {
            _streamCapabilities.Add(streamCap);
        }
    }

    return _streamCapabilities.Count > 0;
}

/// <summary>
/// カメラ切断
/// </summary>
private void disconnectCamera()
{
    if (_captureCamera == null || !_captureCamera.ConnectionEstablished)
    {
        MLCamera.Uninitialize();
        return;
    }

    _streamCapabilities = null;
    _captureCamera.OnRawVideoFrameAvailable -= onCaptureRawVideoFrameAvailable;
    _captureCamera.Disconnect();
}
        
/// <summary>
/// ビデオキャプチャー開始
/// </summary>
private void startVideoCapture()
{
    // キャプチャーストリームのコンフィグを生成
    var captureConfig = new MLCamera.CaptureConfig();
    captureConfig.CaptureFrameRate = MLCamera.CaptureFrameRate._30FPS;
    captureConfig.StreamConfigs = new MLCamera.CaptureStreamConfig[1];
    captureConfig.StreamConfigs[0] = MLCamera.CaptureStreamConfig.Create(getStreamCapability(), MLCamera.OutputFormat.YUV_420_888);

    MLResult result = _captureCamera.PrepareCapture(captureConfig, out MLCamera.Metadata _);

    if (MLResult.DidNativeCallSucceed(result.Result, nameof(_captureCamera.PrepareCapture)))
    {
        _captureCamera.PreCaptureAEAWB();

        result = _captureCamera.CaptureVideoStart();
        MLResult.DidNativeCallSucceed(result.Result, nameof(_captureCamera.CaptureVideoStart));
    }
}

/// <summary>
/// StreamCapabilityの取得
/// </summary>
/// <returns></returns>
private MLCamera.StreamCapability getStreamCapability()
{
    foreach (var streamCapability in _streamCapabilities.Where(s => s.CaptureType == MLCamera.CaptureType.Video))
    {
        if (streamCapability.Width == 1920 && streamCapability.Height == 1080)
        {
            return streamCapability;
        }
    }
    return _streamCapabilities[0];
}

/// <summary>
/// ビデオキャプチャー停止
/// </summary>
private void stopVideoCapture()
{
    _captureCamera?.CaptureVideoStop();
}

ビデオキャプチャーコンフィグの設定

  • キャプチャーフレームレート:30FPS
  • 解像度:1920×1080
  • 出力形式:YUV_420_888

出力形式をYUVにしているのは、画像照合をかける際に最終的には輝度による白黒になるため、この形式を選択しました。
キャプチャーフレームレートと解像度に関しては、60FPSを指定するためには解像度とFPSの組み合わせに制限があり、他の数値での組み合わせではまだ試せていません。

必要データの取得

Immersal REST APIに使用するためのデータを取得します。
カメラ映像取得時のコールバックであるOnRawVideoFrameAvailableの引数から、キャプチャーした画像データ、カメラの固有パラメーターが取得できます。

/// <summary>
/// カメラ固有パラメーター
/// </summary>
private MLCamera.IntrinsicCalibrationParameters? _calibrationParameters;

/// <summary>
/// YUV画像保持変数
/// </summary>
private MLCamera.PlaneInfo _latestYUVPlane;

/// <summary>
/// グレイスケール画像用テクスチャー
/// </summary>
private Texture2D _grayTexture;

/// <summary>
/// 画像取得時のカメラのPose保持変数
/// </summary>
private Pose _cameraPoseAtLatestCapture;

/// <summary>
/// 映像取得時のコールバック
/// </summary>
/// <param name="capturedImage"></param>
/// <param name="resultExtras"></param>
private void onCaptureRawVideoFrameAvailable(MLCamera.CameraOutput capturedFrame, MLCamera.ResultExtras resultExtras)
{
    // 光学中心・焦点距離
    _calibrationParameters = resultExtras.Intrinsics;
    if (_calibrationParameters == null)
    {
        Debug.LogError("カメラ固有パラメーターが取得できませんでした。");
        return;
    }

    // 画像データ
    // 輝度データ取得
    _latestYUVPlane = capturedFrame.Planes[0];
    if (_latestYUVPlane.Data is null || _latestYUVPlane.Data.Length <= 0)
    {
        Debug.LogError("YUVデータが取得できませんでした。");
    }
        
    // 輝度データからピクセルデータ取得
    getUnpaddedBytes(_latestYUVPlane, true, out byte[] pixelBuffer);

    // グレイデータテクスチャ変換
    if (!_grayTexture)
    {
        _grayTexture = new Texture2D(1920, 1080, TextureFormat.R8, false);
    }
    _grayTexture.SetPixelData(pixelBuffer, 0);
    _grayTexture.filterMode = FilterMode.Point;
    _grayTexture.Apply();

    // 撮影時のカメラPose保持
    _cameraPoseAtLatestCapture = new Pose(_camera.transform.position, _camera.transform.rotation);
}
        
/// <summary>
/// YUVデータからピクセルデータを抽出する
/// </summary>
/// <param name="yBuffer"></param>
/// <param name="invertVertically"></param>
/// <param name="pixelBuffer"></param>
private void getUnpaddedBytes(MLCamera.PlaneInfo yBuffer, bool invertVertically, out byte[] pixelBuffer)
{
    byte[] data = yBuffer.Data;
    int width = (int) yBuffer.Width, height = (int) yBuffer.Height;
    int stride = invertVertically ? -(int) yBuffer.Stride : (int) yBuffer.Stride;
    int invertStartOffset = ((int) yBuffer.Stride * height) - (int) yBuffer.Stride;
    pixelBuffer = new byte[width * height];

    unsafe
    {
        fixed (byte* pinnedData = data)
        {
            ulong handle;
            byte* srcPtr = invertVertically ? pinnedData + invertStartOffset : pinnedData;
            byte* dstPtr = (byte*) UnsafeUtility.PinGCArrayAndGetDataAddress(pixelBuffer, out handle);
            if (width > 0 && height > 0)
            {
                UnsafeUtility.MemCpyStride(dstPtr, width, srcPtr, stride, width, height);
            }

            UnsafeUtility.ReleaseGCObject(handle);
        }
    }
}
        
/// <summary>
/// PNG画像取得
/// </summary>
/// <param name="pngBytes"></param>
/// <param name="cameraTransform"></param>
/// <returns></returns>
private bool tryAcquirePngBytes(out byte[] pngBytes, out Pose cameraPose)
{
    pngBytes = null;
    cameraPose= _cameraPoseAtLatestCapture;

    // YUVデータが取得できていない(_grayTexture変換ができていない)場合は失敗
    if (_latestYUVPlane.Data is null || _latestYUVPlane.Data.Length <= 0) return false;
    // PNGデータ取得
    pngBytes = _grayTexture.EncodeToPNG();
            
    return !(pngBytes is null || pngBytes.Length <= 0);
}
画像データ

キャプチャーした画像データはYUV形式で、3チャンネル分のデータが配列で格納されています。
必要となる輝度のYチャンネルデータは0番目に入っているので、これを最終的にPNGデータまで変換して使用します。

光学中心・焦点距離

カメラの固有パラメーターである光学中心焦点距離を保持しておきます。
これらの値は変化しないので、一度取得して保持しておけば大丈夫です。

カメラPose

Immersal REST API通信自体には直接必要ないのですが、通信後の座標変換に必要となるので画像取得時に保持しておきます。
Unityシーン上にあるカメラのTransformからの取得で大丈夫です。

Immersal REST API通信


前項で取得したデータでImmersal REST APIを使用すると、通信結果として以下が得られます。
API:https://api.immersal.com/localizeb64
※APIの詳細ドキュメントはこちら

  • 合致したマップデータID
  • マップ原点から見たカメラの位置と向きの行列要素

このときマップ原点から見たカメラの位置というのが曲者で、移動させる対象がカメラとなっています。
カメラはユーザーの動きと同期しているため、カメラを動かす訳にはいきません。
そこで行列を使ってカメラから見たマップの原点位置へ変換します。

座標変換

ここで前項で取得保持していたカメラ画像取得時のカメラPoseが活きてきます。
この変換処理の結果として、カメラから見たマップの原点位置を得ることができるので、合致したマップデータIDのマップオブジェクトを移動させることで位置合わせが可能となります。
座標変換のコードはImmersalのこちらの部分を参考にしました。

/// <summary>
/// リクエスト結果(マップデータ基準座標)をカメラ基準座標へ変換する
/// </summary>
/// <param name="result"></param>
/// <param name="cameraPose"></param>
/// <param name="mapT"></param>
/// <returns></returns>
private Pose localizeARSpace(ApiResponse result, Pose cameraPose, Transform mapT)
{
    // 通信結果を4x4行列にする
    Matrix4x4 responseMatrix = Matrix4x4.identity;
    responseMatrix.m00 = result.r00; responseMatrix.m01 = result.r01; responseMatrix.m02 = result.r02; responseMatrix.m03 = result.px;
    responseMatrix.m10 = result.r10; responseMatrix.m11 = result.r11; responseMatrix.m12 = result.r12; responseMatrix.m13 = result.py;
    responseMatrix.m20 = result.r20; responseMatrix.m21 = result.r21; responseMatrix.m22 = result.r22; responseMatrix.m23 = result.pz;
        
    // 行列から座標と回転を取得し、Unityの座標系に変換した座標と回転を取得
    Vector3 pos = responseMatrix.GetColumn(3);
    Quaternion rot = responseMatrix.rotation;
    rot *= Quaternion.Euler(0f,0f,180f); 
    pos = switchHandednessPosition(pos);
    rot = switchHandednessRotation(rot);

    // 動かす対象マップのスケールを考慮した座標を計算
    Vector3 scaledPos = Vector3.Scale(pos, mapT.localScale);
    // レスポンスの座標行列を改めて行列に変換
    Matrix4x4 cloudSpace = Matrix4x4.TRS(scaledPos, rot, Vector3.one);
    // カメラの座標行列
    Matrix4x4 trackerSpace = Matrix4x4.TRS(cameraPose.position, cameraPose.rotation, Vector3.one);
    // カメラの座標行列 x レスポンスの逆行列 = カメラ座標はそのままで対象マップオブジェクトをどこに動かしたらよいかの座標行列が得られる
    Matrix4x4 m = trackerSpace * (cloudSpace.inverse);

    var finalPos = m.GetColumn(3);
    var finalRotation = m.rotation;

    return new Pose(finalPos, finalRotation);
            
    //=======================================================
    //ローカル関数
    //=======================================================
    Matrix4x4 switchHandedness(Matrix4x4 b)
    {
        Matrix4x4 D = Matrix4x4.identity;
        D.m00 = -1;
        return D * b * D;
    }
    Quaternion switchHandednessRotation(Quaternion b)
    {
        Matrix4x4 m = switchHandedness(Matrix4x4.Rotate(b));
        return m.rotation;
    }
    Vector3 switchHandednessPosition(Vector3 b)
    {
        Matrix4x4 m = switchHandedness(Matrix4x4.TRS(b, Quaternion.identity, Vector3.one));
        return m.GetColumn(3);
    }
}

実際の運用


今回は半屋外で運用したのですが、実施環境が特殊なため少し変わった運用となりました。

実施環境

  • 半屋外(天井あり)
  • ガラス張り、連続した特徴パターン(床・天井)あり

運用方法

今回の環境で作成される特徴点群マップデータは、連続した特徴パターン箇所に拠るものが多くなりがちで、位置合わせの誤認が非常に発生しやすかったです。
そのため、常時Immersalを動かしていると位置の誤認が起きやすくなってしまうため、Immersalを動かすのは誤認しにくい特徴点がある箇所のみに限定し、位置合わせ成功後はImmersalを切ってデバイスの自己位置推定に任せるという運用にしました。

加えて、位置合わせの精度を上げるためにVPSを行うホットスポット一か所につき複数回位置合わせが成功させるまで待機させるようにしています。

おわりに


今回はMagic Leap2でImmersalを半屋外という環境で動かしてみました。

Magic Leap2は発色がかなり良く、スマートグラスにありがちな背景にバーチャル表示部分が透けてしまうというのをほとんど感じませんでした。
また、Magic Leap2独自の調光機能(Dimming)を使用することで、背景の輝度が高い場所でもよりはっきりと見ることもできました。

加えて、半屋外の開けた空間での運用だったにも関わらず自己位置推定はかなり優秀で、100mの移動で現実空間とバーチャル空間のズレは1m程といった具合でした。
参考までに他のスマートグラスでの例を挙げると、15mの移動で1m程のズレです。
この自己位置推定でさらに驚いたことで、エスカレーターを挟んだ階層移動をしても上下のズレは1m程だったというのがあります。
これはもちろんエスカレーターの周辺環境によってズレの大小はあるかと思いますが、上下方向に関してもここまで強い自己位置推定ができるのはすごいと感じました。

Magic Leap2とImmersalの組み合わせ、いかがだったでしょうか。
まだ画像取得タイミングや変換方法などで改良の余地はあるかと思いますが、Immersal SDK未対応のデバイスであってもカメラデータが取得できれば動かせるので、スマートグラスと組み合わせるとより未来っぽい体験ができるようになります。
みなさんもスマートグラスをお持ちでしたら試してみてはいかがでしょうか。



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる