Unityのカメラ映像をPythonで受け取る方法

はじめに

Unityのカメラ(プレイヤー視点の映像とか)の映像をPythonで受け取る方法を調べた。方法としては以下のようなものがあった。

  • UnityCam, UnityCaptuerを使用する
  • RTSPで飛ばす
  • 共有メモリで受け渡す

UnityCam, UnityCaptuerを使用する

調べた中では一番一般的な手法っぽかった。ざっくりとした使い方としては、インストールして、Unity内のカメラにUnity Camを追加すればOK。これで仮想Webカメラとして扱うことができる。

GitHub - mrayy/UnityCam: Unity3D Virtual webcam plugin, streams unity viewport contents to other applications as virtual camera

注意点としては、この仮想WebカメラPythonOpenCVで読む際、下記のように幅、高さ、FPSをきっちり指定しないと映像を読み取れなかった。

cap = cv2.VideoCapture(0)
W = 1280
H = 720
cap.set(3, W)
cap.set(4, H)
cap.set(cv2.CAP_PROP_FPS, 60)

また、複数のカメラを設定する方法がわからなかったのと、カメラから映像を読み出す際、CPU負荷が高くなってしまうという問題があった。

RTSPで飛ばす

RTSPとは映像をネットワーク経由で送信するプロトコル。Unityにおいてソケット通信は簡単にできるため、これもできないかと思いいろいろ探してみたが見つからなかった。

共有メモリで受け渡す

ここでいう共有メモリとはプロセス間通信で使用する共有メモリのこと。受け渡しの際にメモリコピーが発生しないため、上記2つと比較して負荷が軽く、速度も速い方法となる。ただし、以下のような注意点がある。

  • カメラ映像はレンダリング後(3D空間を画像に変換する処理)にメモリに書き込む必要がある。
  • 書き込み側(ここではUnity)が書き込み権限を持っている必要があるので、書き込み側が先にファイルを開く必要がある。
  • 共有メモリの扱い方はOSによって変わる。

これらに注意して実装を行う。今回はWindows10で行っている。

Unity側(書き込み)

適当なオブジェクトにカメラを付けて、スクリプトを設定する。

まずはStart()の部分は以下の通り。

    private Texture2D tex = null;
    public MemoryMappedFile mmf;
    public MemoryMappedViewAccessor accessor;
    [SerializeField] private Camera _captureCamera;

    // Start is called before the first frame update
    void Start()
    {
        mmf = MemoryMappedFile.CreateNew("test", 1024 * 1024 * 50);
        tex = new Texture2D(_captureCamera.pixelWidth, _captureCamera.pixelHeight, TextureFormat.ARGB32, false);
        _captureCamera.targetTexture = new RenderTexture(_captureCamera.pixelWidth, _captureCamera.pixelHeight, 24);
    }

mmfは上記の共有メモリにあたる。第一引数の"test"は共有メモリのキーであり、任意のものを指定できるが後ほど使用するので覚えておく。第二引数はメモリサイズ(バイト単位)。使用するサイズよりも十分に大きい必要がある。今回は50MBとした。texはバッファをコピーするために作成。targetTextureはカメラ画像のレンダリング先を変更する。これが設定されていないとゲーム画面にレンダリングされる。

次にメモリコピー部分。先述した通りカメラ画像はレンダリング後で読みだす必要がある。OnPostRender()はレンダリング後に呼び出されるメソッドであり、メモリコピーはこの中で行う。

    private void OnPostRender()
    {
        accessor = mmf.CreateViewAccessor();
        RenderTexture.active = _captureCamera.targetTexture;
        tex.ReadPixels(new Rect(0, 0, _captureCamera.targetTexture.width, _captureCamera.targetTexture.height), 0, 0);
        tex.Apply();

        byte[] bytes = tex.EncodeToPNG();
        accessor.WriteArray<byte>(0, bytes, 0, bytes.Length);
        accessor.Dispose();
    }

まずは共有メモリのアクセッサを入手する。次にRenderTexture.activeでアクティブなレンダラーテクスチャを指定する。詳しくは以下を参考。

RenderTexture-active - Unity スクリプトリファレンス

次にReadPixelsでRenderTexture.activeで指定したレンダラーテクスチャをtexにコピーする。その後はPING形式にエンコードし、メモリに書き込む。

以上でUnity側の作業は完了。追記として、以下のようにOnDestroy()で取得したものを開放しておく。

    private void OnDestroy()
    {
        mmf.Dispose();
        accessor.Dispose();
        Destroy(tex);
        Destroy(_captureCamera.targetTexture);
    }

Python側(読み込み)

Python側のコードは以下の通り。mmapで読み込む。この時、メモリサイズとキーはUnity側と同じものを使用する。たまに書き出し中にアクセスしてしまっているのか、画像への変換で失敗する場合があるためtry, exceptでくくっておく。

import mmap, time, cv2, io
from PIL import Image
import numpy as np

windowName = "video"
cv2.imshow(windowName, np.zeros((1,1,3)))
frame_num = 0

while cv2.getWindowProperty(windowName,cv2.WND_PROP_VISIBLE) > 0:
    mm = mmap.mmap(-1, 1024 * 1024 * 50, tagname="test")
    mm.seek(0)    
    mode = mm.read()
    ByteToImg = Image.open(io.BytesIO(mode))
    try:
        img = np.array(ByteToImg, dtype=np.uint8)
        img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGRA)
    except:
        print("failed")
        pass
    cv2.imshow(windowName, img)
del mm

動かす

動かす場合はUnity→Pythonの順で行うこと。