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

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

[RICOH THETA](https://theta360.com/ja/)の[プラグイン機能](#ricoh-thetaプラグインについて)で、**Oculus Goから直接THETAのファイルを見られるプラグイン**を作ってみました。
以前、[Oculusブラウザから見る方法](https://qiita.com/3215/items/31339b2850cd052c73e5)を紹介しましたが、今回のプラグインでは**Oculusギャラリーアプリ**から見られるようになります。

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

THETAとOculus GoをWi-Fiで繋ぎ、THETAのModeボタンを長押しするだけで、
こんな感じで**Oculusギャラリー**に**RICOH THETA**が表示されます。
![oculus_gallery_devices.png](https://qiita-image-store.s3.amazonaws.com/0/304709/e8e979c6-a9ad-478d-aaca-b4d7a173c74a.png)

サムネイルから見たい静止画・動画を選択すると、360度表示されます。
![oculus_gallery_photos.png](https://qiita-image-store.s3.amazonaws.com/0/304709/b0ac6233-1daf-7d69-0f1b-a8ca0345b643.png)

もし360度表示になっていなかったら、**プロジェクション設定**から360度表示にできます。
![oculus_gallery_projection.png](https://qiita-image-store.s3.amazonaws.com/0/304709/576cb255-5ed8-cca2-dc79-80171860a879.png)

~~本記事で紹介しているプラグイン（ベータ版）は、下記リンク先からインストールできます。~~
**(2019/05/23 追記) 公式プラグインとしてリリースされました！下記リンク先からインストールできます。**
[https://pluginstore.theta360.com/plugins/com.theta360.vrmediaconnection/](https://pluginstore.theta360.com/plugins/com.theta360.vrmediaconnection/)
**使い方の詳細は[マニュアル](https://support.theta360.com/intl/ricoh_plugins/pdf/vrmediaconnection_ja.pdf)をご覧ください。**

RICOH THETA VとOculus Goをお持ちの方はぜひ試してみてください。
プラグインのインストール方法・変更方法については[こちら](https://support.theta360.com/ja/manual/v/content/menu-plugin.html)。


## 使い方
使い方には2種類あります。
※ 手順に出てくるTHETAの「スマートフォン基本アプリ」は[ここ](https://support.theta360.com/ja/download/)からダウンロードできます。

### [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**に設定しておく（→[詳細](https://support.theta360.com/ja/manual/v/content/settings/settings_02.html)）ことをおすすめします。
※ ただし周波数帯域を5GHzにする場合は、屋内のみでご使用ください。

### [2] THETAとOculus Goを同じ無線LAN下に置いて使うとき
THETAを**クライアント（CL）モード**にして、THETAとOculus Goを同じ無線LANに接続する場合の手順です。
複数のOculusから同時にTHETAの中身を見ることもできます。

[事前準備]
1. スマートフォン基本アプリより、THETAを接続する無線LANのSSIDとパスワード設定を行う<br>（→[[YouTube]設定方法](https://www.youtube.com/watch?v=jE9_VqaVuLM&t=4s)、[マニュアル](https://support.theta360.com/ja/manual/v/content/prepare/prepare_08.html)）

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


### 注意点
* 動画転送のパフォーマンスは無線LAN環境に依存するので、途切れることがあります。<br>
THETAの動画撮影設定で、ビットレートを「Low」に設定することや、5GHz無線LANを使うことで状況が改善されることがあります。
* Oculus Goでは[1]、[2]の方法どちらでもできますが、機種によっては[1]の方法で使えない場合があります。PSVRでは[1]の方法は使えないため、[2]の方法でお試しください。
* Oculusギャラリーに**ダウンロード**メニューがありますが、APモードではダウンロードできません。
* ~~デフォルトでは、動画は繋がれていない状態（Dual-Fisheye形式）で保存するように設定されています。<br>
スマートフォン基本アプリから動画の「[撮影時スティッチ](https://topics.theta360.com/ja/faq/c_00_v/2046/)」を**ON**に設定しておくと、Oculus上で360度表示できる形式（Equirectangular形式）で保存されます。~~
* ~~閲覧時、天頂補正されません。<br>
撮影するときに、THETAをまっすぐ立てて撮影することをおすすめします。~~ <br>
**※ (2019/05/23追記) 公式版では、動画をあとからスティッチング・天頂補正できるようになりました。
詳しくは[マニュアル](https://support.theta360.com/intl/ricoh_plugins/pdf/vrmediaconnection_ja.pdf)をご覧ください。**


## RICOH THETAプラグインについて
[RICOH THETA](https://theta360.com/ja/)って何？という方にご説明すると、RICOH THETAとは、弊社で出している全周囲360度の映像を撮れるカメラのことです。THETAシリーズの一つである**RICOH THETA V**は**Android**で動いています。「RICOH THETAプラグイン」とはTHETAをカスタマイズできる機能のことであり、Androidアプリを作る感覚で作ることができます。

[RICOH THETAプラグイン開発者コミュニティ](https://qiita.com/organizations/theta-plugin)では、他にもプラグインについての記事を書いています。興味を持たれた方がいれば、ぜひ読んでみてください。プラグイン詳細については[こちら](https://api.ricoh/products/theta-plugin/)。興味を持たれた方は[twitter](https://twitter.com/thetaplugin)のフォローと[THETAプラグイン開発コミュニティ(slack)](https://join.slack.com/t/thetadeveloper/shared_invite/enQtNzcxODI2MTk2ODg0LWY5YmU2ZTIxNGYwNzlmNWZjMWMyMWFlMzY3ZmQ3NGU3ZjZiZjI0Njk4MGIxMDU5NDJiMzQ4Y2FkNDEzYmFlNGU)への参加もぜひどうぞ｡


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

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

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

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

実装の際には、[Cling Coreマニュアル](http://4thline.org/projects/cling/core/manual/cling-core-manual.xhtml)に「**5. Cling on Android**」という項目があり、それを参考にしました。本プラグインのようなメディアサーバーを作る場合は、[Cling Supportマニュアル](http://4thline.org/projects/cling/support/manual/cling-support-manual.xhtml)の「**3. Accessing and providing MediaServers**」の項目が参考になります。

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

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

```.java
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で定義されているようです。

```.xml
<item id="(コンテンツID)" parentID="(親コンテンツID)" restricted="1">
    <dc:title>(ファイルタイトル)</dc:title>
    <dc:creator>(作成者)</dc:creator>
    <upnp:class>object.item.imageItem</upnp:class>
    <upnp:albumArtURI>(サムネイルのURL)</upnp:albumArtURI>
    <res protocolInfo="http-get:*:image/jpeg" size="(ファイルサイズ)" resolution="(解像度)">
        (コンテンツのURL)
    </res>
</item>
```

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

```.java
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には、`ImageItem`や`VideoItem`など、コンテンツ内容に特化した派生クラスがいくつかあるようです。

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

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

#### コンテンツ配信
[マニュアル](http://4thline.org/projects/cling/support/manual/cling-support-manual.xhtml#javadoc.example.mediaserver.ContentDirectoryBrowseTest.MP3ContentDirectory)にあるように、メディアサーバー側には**ContentDirectory**を実装する必要があるようです。
マニュアルの例にならって、本プラグインでも`AbstractContentDirectoryService`を継承したクラスを作りました。このクラスの`browse()`は、ざっくりと書くと下記コードのような感じです。

```.java
@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をキーにして、それに対応した`DIDLObject`（`Container`も`Item`もDIDLObjectの派生クラスです）を取り出すようにしています。
`browse()`の引数の**objectID**は「コンテンツID」と同等の値となっており、これにより適当な`DIDLObject`を`ContentTree`から取り出します。
取り出した`DIDLObject`は`DIDLContent`に`addContainer()`あるいは`addItem()`し、`DIDLParser().generate()`によってDIDL-Lite形式のXMLに変換されます。

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

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


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

**(2019/05/23 追記) 公式プラグインとしてリリースされました！是非試してみてください。インストールは[こちら](https://pluginstore.theta360.com/plugins/com.theta360.vrmediaconnection/)。**

[^1]: [UPnP仕様](https://openconnectivity.org/developer/specifications/upnp-resources/upnp/mediaserver4-and-mediarenderer3)の”ContentDirectory:4” > ”5.2.13 Hierarchical location”項（p.41）より