e.blog

主にUnity/UE周りのことについてまとめていきます

XRCameraSubSystemから直接カメラの映像を取得する

概要

今回はUnityのARFoundationが扱うシステムからカメラ映像を抜き出す処理についてまとめたいと思います。
これを利用する目的は、カメラの映像をDeep Learningなどに応用してなにかしらの出力を得たいためです。

今回の画像データの取得に関してはドキュメントに書かれているものを参考にしました。

docs.unity3d.com

今回のサンプルを録画したのが以下の動画です。

今回のサンプルはGitHubにアップしてあるので、詳細が気になる方はそちらをご覧ください。

github.com

全体の流れ

画像を取得する全体のフローを以下に示します。

  1. XRCameraImageを取得する
  2. XRCameraImage#Convertを利用してデータを取り出す
  3. 取り出したデータをTexture2Dに読み込ませる
  4. Texture2Dの画像を適切に回転しRenderTextureに書き出す

という流れになります。

ということでひとつずつ見ていきましょう。

XRCameraImageを取得し変換する

ここではXRCameraImageからデータを取得し、Texture2Dに書き込むまでを解説します。

まずはARのシステムからカメラの生データを取り出します。
ある意味でこの工程が今回の記事のほぼすべてです。

取り出したらDeep Learningなどで扱えるフォーマットに変換します。

今回実装したサンプルのコードは以下の記事を参考にさせていただきました。

qiita.com

ドキュメントの方法でも同様の結果を得ることができますが、テクスチャの生成を制限するなど最適化が入っているのでこちらを採用しました。

以下に取り出し・変換する際のコード断片を示します。

private void RefreshCameraFeedTexture()
{
    // TryGetLatestImageで最新のイメージを取得します。
    // ただし、失敗の可能性があるため、falseが返された場合は無視します。
    if (!_cameraManager.TryGetLatestImage(out XRCameraImage cameraImage)) return;

    // 中略

    // デバイスの回転に応じてカメラの情報を変換するための情報を定義します。
    CameraImageTransformation imageTransformation = (Input.deviceOrientation == DeviceOrientation.LandscapeRight)
        ? CameraImageTransformation.MirrorY
        : CameraImageTransformation.MirrorX;

    // カメライメージを取得するためのパラメータを設定します。
    XRCameraImageConversionParams conversionParams =
        new XRCameraImageConversionParams(cameraImage, TextureFormat.RGBA32, imageTransformation);

    // 生成済みのTexture2D(_texture)のネイティブのデータ配列の参照を得ます。
    NativeArray<byte> rawTextureData = _texture.GetRawTextureData<byte>();

    try
    {
        unsafe
        {
            // 前段で得たNativeArrayのポインタを渡し、直接データを流し込みます。
            cameraImage.Convert(conversionParams, new IntPtr(rawTextureData.GetUnsafePtr()), rawTextureData.Length);
        }
    }
    finally
    {
        cameraImage.Dispose();
    }

    // 取得したデータを適用します。
    _texture.Apply();

    // 後略
}

Texture2Dの画像を適切に回転しRenderTextureに書き出す

前段でXRCameraImageからデータを取り出しTexture2Dへ書き出すことができました。
ただ今回は最終的にTensorFlow Liteで扱うことを想定しているのでRenderTextureに情報を格納するのがゴールです。

ぱっと思いつくのはGraphics.Blitを利用してRenderTextureにコピーすることでしょう。
しかし、取り出した画像は生のデータ配列のため回転を考慮していません。(つまりカメラからの映像そのままということです)

以下の質問にUnityの中の人からの返信があります。

forum.unity.com

TryGetLatestImage exposes the raw, unrotated camera data. It will always be in the same orientation (landscape right, I believe). The purpose of this API is to allow for additional CPU-based image processing, such as with OpenCV or other computer vision library. These libraries usually have a means to rotate images, or accept images in various orientations, so we there is no built-in functionality to rotate the image.

要は、だいたいの場合において利用する対象(OpenCVなど)に回転の仕組みやあるいは回転を考慮しないでそのまま扱える機構があるからいらないよね、ってことだと思います。

そのため、人が見て適切に見えるようにするためには画像を回転してコピーする必要があります。
ですが心配いりません。処理自体はとてもシンプルです。

基本的には時計回りに90度回転させるだけでOKです。

なにも処理しない画像をQuadに貼り付けると以下のような感じで90度回転したものが出力されます。
(ちょっと分かりづらいですが、赤枠で囲ったところはAR空間に置かれたQuadで、そこにカメラの映像を貼り付けています)

f:id:edo_m18:20200823113553p:plain

これを90度回転させるためにはUVの値を少し変更するだけで達成することができます。

まずはシェーダコードを見てみましょう。

シェーダで画像を回転させる

見てもらうと分かりますが、基本はシンプルなImage EffectシェーダでUVの値をちょっと工夫しているだけです。

Shader "Hidden/RotateCameraImage"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Cull Off ZWrite Off ZTest Always

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = v.uv;
                return o;
            }

            sampler2D _MainTex;

            fixed4 frag (v2f i) : SV_Target
            {
                float x = 1.0 - i.uv.y;
                float y = i.uv.x;
                float2 uv = float2(x, y);
                fixed4 col = tex2D(_MainTex, uv);
                return col;
            }
            ENDCG
        }
    }
}

x, yを反転して、さらにyの値を1.0から引いているだけです。簡単ですね。

そしてこのシェーダを適用したマテリアルを用いてGraphics.Blitを実行してやればOKです。

Graphics.Blit(texture, _previewTexture, _transposeMaterial);

分かりやすいように、グリッドの画像で適用したものを載せます。

f:id:edo_m18:20200823112925p:plain

90度右に回転しているのが分かるかと思います。

これで無事、画像が回転しました。

バイスの回転を考慮する

実は上のコードだけでは少し問題があります。
バイスの回転によって取得される画像データの見栄えが変わってしまうのです。

というのは、Portraitモードでは回転しているように見える画像でも、Landscapeモードだとカメラからの映像と見た目が一致して問題なくなるのです。
以下の動画を見てもらうと分かりますが、Portraitモードでは90度回転しているように見える画像が、Landscapeモードでは適切に見えます。

f:id:edo_m18:20200823114155g:plain

結論としてはPortraitモードのときだけ処理すればいいことになります。

private void PreviewTexture(Texture2D texture)
{
    if (_needsRotate)
    {
        Graphics.Blit(texture, _previewTexture, _transposeMaterial);
    }
    else
    {
        Graphics.Blit(texture, _previewTexture);
    }

    _renderer.material.mainTexture = _previewTexture;
}

バイスが回転した際のイベントが実はUnityには用意されていないようで、以下の記事を参考に回転の検知を実装しました。
(まぁゲームにおいて回転を検知してなにかをする、っていうケースが稀だからでしょうかね・・・)

forum.unity.com

カメライメージを取得するタイミング

最後にカメライメージの取得タイミングについて書いておきます。
ドキュメントにも書かれていますが、ARCameraManagerのframeReceivedというイベントのタイミングでカメライメージを取得するのが適切なようです。

ARCameraManager#frameReceivedイベント

ARCameraManagerにはframeReceivedというイベントがドキュメントでは以下のように説明されています。

An event which fires each time a new camera frame is received.

カメラフレームを受信したタイミングで発火するようですね。
なのでこのタイミングで最新のカメラデータを取得することで対象の映像を取得することができるというわけです。

ということで、以下のようにコールバックを設定してその中で今回の画像取得の処理を行います。

[SerializeField] private ARCameraManager _cameraManager = null;

// ---------------------------

_cameraManager.frameReceived += OnCameraFrameReceived;

// ---------------------------

private void OnCameraFrameReceived(ARCameraFrameEventArgs eventArgs)
{
    RefreshCameraFeedTexture();
}

最後に

無事、カメライメージを取得して扱える状態に変換することができました。
Texture2DとしてもRenderTextureとしても扱えるので用途に応じて使うといいでしょう。

気になる点としてはパフォーマンスでしょうか。
一度CPUを経由しているのでそのあたりが気になるところです。(まだ計測はしていませんが・・・)

が、シンプルな今回のデモシーンでは特に重さは感じなかったので、コンテンツが重すぎない限りは問題ないかなとも思います。