VR Media Connection BETA Documentation - JAPANESE

Oculus Go x THETA V連携で、かんたん360度閲覧

はじめに

こんにちは、リコーの@roohii_3です。

RICOH THETAプラグイン機能で、 Oculus Goから直接THETAのファイルを見られるプラグイン を作ってみました。
以前、Oculusブラウザから見る方法を紹介しましたが、今回のプラグインでは Oculusギャラリーアプリ から見られるようになります。

DLNAに対応しているなら、 Oculus Go以外の機器からでも 見られる場合もあります。
たとえば、 PSVR+PS4 Pro の"Media Player"からTHETAの中身を見れることも確認しています。

THETAとOculus GoをWi-Fiで繋ぎ、THETAのModeボタンを長押しするだけで、
こんな感じで OculusギャラリーRICOH THETA が表示されます。
oculus_gallery_devices.png

サムネイルから見たい静止画・動画を選択すると、360度表示されます。
oculus_gallery_photos.png

もし360度表示になっていなかったら、 プロジェクション設定 から360度表示にできます。
oculus_gallery_projection.png

本記事で紹介しているプラグイン(ベータ版)は、下記リンク先からインストールできます。
RICOH THETA VとOculus Goをお持ちの方はぜひ試してみてください。
なお、あくまでもベータ版なので完全な動作保証はできません。

プラグインのインストール方法・変更方法についてはこちら

使い方

使い方には2種類あります。
※ 手順に出てくるTHETAの「スマートフォン基本アプリ」はここからダウンロードできます。

[1] THETAとOculus Goを1対1接続して使うとき

THETAを アクセスポイント(AP)モード にして、THETAにOculus Goを直接接続させて見る場合です。

[手順]

  1. THETAの無線ボタンを押して、 APモード にする(無線ランプが 青色 で点滅します)
  2. Oculusの無線LAN設定から、THETAに接続する
  3. THETAの Modeボタン を長押しし、プラグインモードにする
  4. Oculusのギャラリーを選択し、” RICOH THETA ”を選ぶ

動画をみるとき、途切れる場合があります。あらかじめ、スマートフォン基本アプリからTHETAのWi-Fiの周波数帯域を 5GHz に設定しておく(→詳細)ことをおすすめします。
※ ただし周波数帯域を5GHzにする場合は、屋内のみでご使用ください。

[2] THETAとOculus Goを同じ無線LAN下に置いて使うとき

THETAを クライアント(CL)モード にして、THETAとOculus Goを同じ無線LANに接続する場合の手順です。
複数のOculusから同時にTHETAの中身を見ることもできます。

[事前準備]

  1. スマートフォン基本アプリより、THETAを接続する無線LANのSSIDとパスワード設定を行う
    (→[YouTube]設定方法マニュアル

[手順]

  1. THETAの無線ボタンを押して、 CLモード にする(無線ランプが 緑色 で点滅します)
  2. 無線ランプが 点滅 から 点灯 に変わり、THETAが無線LANに接続されたことを確認する
  3. Oculusの無線LAN設定から、THETAと同じ無線LANに接続する
  4. THETAの Modeボタン を長押しし、プラグインモードにする
  5. Oculusのギャラリーを選択し、” RICOH THETA ”を選ぶ

注意点

  • あくまでもβ版です。完全な動作保証はできません。
  • 動画転送のパフォーマンスは無線LAN環境に依存するので、途切れることがあります。
    THETAの動画撮影設定で、ビットレートを「Low」に設定することや、5GHz無線LANを使うことで状況が改善されることがあります。
  • Oculus Goでは[1]、[2]の方法どちらでもできますが、機種によっては[1]の方法で使えない場合があります。PSVRでは[1]の方法は使えないため、[2]の方法でお試しください。
  • デフォルトでは、動画は繋がれていない状態(Dual-Fisheye形式)で保存するように設定されています。
    スマートフォン基本アプリから動画の「撮影時スティッチ」を ON に設定しておくと、Oculus上で360度表示できる形式(Equirectangular形式)で保存されます。
  • 閲覧時、天頂補正されません。
    撮影するときに、THETAをまっすぐ立てて撮影することをおすすめします。
  • Oculusギャラリーに ダウンロード メニューがありますが、APモードではダウンロードできません。

RICOH THETAプラグインについて

RICOH THETAって何?という方にご説明すると、RICOH THETAとは、弊社で出している全周囲360度の映像を撮れるカメラのことです。THETAシリーズの一つである RICOH THETA VAndroid で動いています。「RICOH THETAプラグイン」とはTHETAをカスタマイズできる機能のことであり、Androidアプリを作る感覚で作ることができます。

RICOH THETAプラグイン開発者コミュニティでは、他にもプラグインについての記事を書いています。興味を持たれた方がいれば、ぜひ読んでみてください。プラグイン詳細についてはこちら。興味を持たれた方はtwitterのフォローとTHETAプラグイン開発コミュニティ(slack)への参加もぜひどうぞ。

実現方法

ここからは、本プラグインの実現方法について書きます。

本プラグインは、DLNAのDMS機能にならって実装しました。DMSは、映像や音楽などのコンテンツを他のDLNA対応機器に配信する機能のことです。THETAがコンテンツを配信するためのサーバーとなり、Oculus Go側はコンテンツを見るためのクライアントになります。
※ 本プラグインでは、DMSのすべての機能を実装したわけではありません。そのため、本プラグインでDLNA・DMS機能すべてを行える保証はないことにご留意ください。

私自身、勉強中なので具体的には説明できませんが、 DLNAUPnP という機器同士を接続するプロトコルを基盤にしているようです。機器間の情報のやりとりには XML が用いられていますが、実際のコンテンツの配信は HTTP で実現しているようです。

Cling

UPnP・DLNA周りのややこしい処理は、 Cling というライブラリに任せました。
ネットワーク内の機器の認識や、機器間の情報やりとりなど、ほとんどClingがやってくれました。XMLの生成もClingがまかなってくれて、実際のところXMLに直接触れることはありませんでした。

実装の際には、Cling Coreマニュアルに「 5. Cling on Android 」という項目があり、それを参考にしました。本プラグインのようなメディアサーバーを作る場合は、Cling Supportマニュアルの「 3. Accessing and providing MediaServers 」の項目が参考になります。

デバイス情報

Cling Coreマニュアルの「5.3. Creating a UPnP device」には、UPnPサービスの実装例が載っています。本プラグイン用にアレンジすると、以下のようなコードになります。
また、このコードの記述に従ってデバイス情報のXMLが自動生成されます。他のDLNAデバイスに生成されたXMLが送られ、THETAの基本情報・サービス情報が認識されます。

コードの中身を簡単に説明すると、
コードのはじめの方では、メディアサーバーに相当する デバイス(今回の例はTHETA)の基本情報 を設定しています。
コードの中ほどでは、デバイスに紐づけるための サービス の定義をしています。1つのデバイスに対し、機能に応じて1つ以上のサービスを設定します。メディアサーバーの場合は「 コネクションマネージャ 」や「 コンテンツディレクトリ 」というサービスが必要です。
コードの終わりの方で、定義した基本情報やサービスなどをデバイスに紐づけています。

protected LocalDevice createDevice()
        throws ValidationException, LocalServiceBindingException {

    // デバイスの基本情報を定義
    DeviceType type = new UDADeviceType("MediaServer", 1);
    DeviceDetails details = new DeviceDetails(
            "RICOH THETA",
            new ManufacturerDetails("RICOH"),
            new ModelDetails(
                    "VR Media Connection BETA",
                    "VR Media Connection for RICOH THETA",
                    "0.1.0"
            )
    );

    // サービス(機能)を定義
    LocalService contentDirectory = new AnnotationLocalServiceBinder()
            .read(ContentDirectoryService.class);
    contentDirectory.setManager(
            new DefaultServiceManager<ContentDirectoryService>(
                    contentDirectory, ContentDirectoryService.class));

    LocalService connectionManager = new AnnotationLocalServiceBinder()
            .read(ConnectionManagerService.class);
    connectionManager.setManager(
            new DefaultServiceManager<ConnectionManagerService>(
                    connectionManager, null) {
                @Override
                protected ConnectionManagerService createServiceInstance() throws Exception {
                    return new ConnectionManagerService(sourceProtocols, null);
                }
            }
    );

    // アイコンの読み込み
    Bitmap icon = BitmapFactory.decodeResource(getResources(), R.drawable.theta_image_01);
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    icon.compress(Bitmap.CompressFormat.PNG, 100, baos);

    // 上で定義したものをLocalDeviceに紐づける
    return new LocalDevice(
            new DeviceIdentity(udn),
            type,
            details,
            new Icon("image/png",
                    icon.getWidth(), icon.getHeight(), 8,
                    "icon.png", baos.toByteArray()),
            new LocalService[]{
                    connectionManager, contentDirectory});
}

コンテンツ情報の生成

クライアント側にコンテンツの情報を伝える際もXMLが使われます。

THETAで扱うコンテンツは動画と静止画ですが、静止画の情報は下記のような形式になっています。「DIDL-Lite」形式というもので、UPnPで定義されているようです。

(ファイルタイトル) (作成者) object.item.imageItem (サムネイルのURL) (コンテンツのURL)

上記のxmlは、下記のようにClingの Item クラスにコンテンツ情報を格納すると自動生成されます。

String MIMETYPE_JPEG = "image/jpeg";

Res res = new Res(new MimeType(
        MIMETYPE_JPEG.substring(0, MIMETYPE_JPEG.indexOf('/')),
        MIMETYPE_JPEG.substring(MIMETYPE_JPEG.indexOf('/') + 1)),
        filesize,
        fileUrl);
res.setResolution(width, height);

ImageItem imageItem = new ImageItem("id", "parentId", "title", "creatorName", res);
imageItem.addProperty(new DIDLObject.Property.UPNP.ALBUM_ART_URI(new URI(thumbnailUrl)));
imageItem.setRestricted(true);

ディレクトリの情報は Container クラス、動画や静止画などの情報は Item クラスで管理します。Itemには、 ImageItemVideoItem など、コンテンツ内容に特化した派生クラスがいくつかあるようです。

上記のXMLとコード中で設定している コンテンツID(id)親コンテンツID(parentId) へは任意の文字列を設定します。
「親コンテンツ」と呼んでいるのはここでは「ディレクトリ」のことですが、ディレクトリとその下のコンテンツは、これら2つのIDをセットとして紐づいています。これにより、コンテンツの階層構造が作られます。なお、ルートとなるContainerの親コンテンツIDへは、"-1"を設定する必要があるようです(1)。

Clingを使いこなせれば不要かもしれませんが、私は「ContainerとItemの構造を『コンテンツID』で管理するようなクラス」を別途用意しました。

コンテンツ配信

マニュアルにあるように、メディアサーバー側には ContentDirectory を実装する必要があるようです。
マニュアルの例にならって、本プラグインでも AbstractContentDirectoryService を継承したクラスを作りました。このクラスの browse() は、ざっくりと書くと下記コードのような感じです。

    @Override
    public BrowseResult browse(String objectID, BrowseFlag browseFlag,
                            String filter,
                            long firstResult, long maxResults,
                            SortCriterion[] orderby) throws ContentDirectoryException {

    try {
        DIDLContent didl = new DIDLContent();

        DIDLObject content = ContentTree.getNode(objectID).getContent();

        if (content instanceof Item) {
                didl.addItem((Item) content);
                return new BrowseResult(new DIDLParser().generate(didl), 1, 1);

        } else {
                for (Container container : ((Container) content).getContainers()) {
                didl.addContainer(container);
                }
                for (Item item : ((Container) content).getItems()) {
                didl.addItem(item);
                }

                String xml = new DIDLParser().generate(didl);
                return new BrowseResult(xml,
                        ((Container) content).getChildCount(),
                        ((Container) content).getChildCount());
        }

    } catch (Exception ex) {
        throw new ContentDirectoryException(
                ContentDirectoryErrorCode.CANNOT_PROCESS,
                ex.toString()
        );
    }
}

コード中に出てくる ContentTree は、「コンテンツ情報の生成」項で触れた「ContainerとItemの構造を『コンテンツID』で管理するクラス」です。コンテンツIDをキーにして、それに対応した DIDLObjectContainerItem もDIDLObjectの派生クラスです)を取り出すようにしています。
browse() の引数の objectID は「コンテンツID」と同等の値となっており、これにより適当な DIDLObjectContentTree から取り出します。
取り出した DIDLObjectDIDLContentaddContainer() あるいは addItem() し、 DIDLParser().generate() によってDIDL-Lite形式のXMLに変換されます。

この browse() は、「クライアントがサーバーへ最初にアクセスしたとき」と、そのあと「クライアントが下層ディレクトリへアクセスするたび」に呼ばれるようです。
クライアントの選択に応じたコンテンツIDが引数 objectID として送られますが、最初にアクセスするときはルートContainerのコンテンツIDが指定されるようです。(おそらく、親コンテンツIDに”-1”を設定したものがルートであると判断しているのだと思います(1)。ルートContainerを取得できれば、コンテンツID・親コンテンツIDを使って数珠つなぎ的に下層ディレクトリが分かります。)
クライアント側はbrowse()経由でDIDL-Lite形式のコンテンツ情報を受け取り、受け取った情報をプレーヤー等に反映します。

個別の静止画や動画を配信する際には HTTP が使われます。
「コンテンツ情報の生成」項のコードにある ResfileUrl という値が設定されていますが、クライアントからコンテンツ配信の要求があったときには、このURLを通じて配信します。
本プラグイン内ではNanoHTTPDを使用しています。コンテンツ配信の要求があると、指定されたURLに対応したファイルを読み込み、結果として返すような実装をしています。

おわりに

まだベータ版なので、使い勝手が悪かったり、不具合があることもあるかもしれません。
正式版リリースに向けて、日々改善させていこうと思います!

  1. UPnP仕様の”ContentDirectory:4” > ”5.2.13 Hierarchical location”項(p.41)より