ARKitの水平面検出を試してみた

本記事は [Sansan Advent Calendar 2017](https://adventar.org/calendars/2530) の17日目となります。17日目はiOS関連の技術記事(と後半はAR技術を応用したUIについての考察)となります。

最初に一言だけ自分の人となりを話しておくと、普段はiOSアプリの開発に従事しておりますが、趣味で自作OS関連のことをやったりと、割と多方面に興味のアンテナがある人間だと自分自身では思っています(今年も[自作OS Advent Calendar](https://adventar.org/calendars/2282) の方でも[記事を書いてる](http://kotetuco.hatenablog.com/entry/2017/12/14/235019)ので、良かったらそちらもどうぞ)。

# はじめに

ARKitをテーマに選んだのは、ARKitの発表(とWWDCのデモの映像)を見てからARKitを使ってみたいなと思っていたのと、ARKitでは水平面の検出ができるというので、何かに応用できないかと気になりはじめていたためです。

ちなみに、自分はARKitはおろかAR自体ほとんどやったことない(数年前に雑誌の記事の特集に載っていたことから、Android上で簡単なアプリを作って遊んだことはあります)ので、様々な文献を参考に、水平面検出できるところまで試してみました。

また、それだけだと何ら目新しさがないので、水平面検出やAR(拡張現実)の使いみちについて、少し自分なりに考えてみました。

# 水平面検出とは

ARKitの空間認識の方式は `Visual Inertial Odometry` と呼ばれるものです。これはカメラの映像とCore Motionのセンサデータから自分の位置を認識しようという手法です。[^1] [^2] また、画像内の特徴点を見つけ、フレーム間の差異を比較することで、デバイスの動きも認識しているようです。

で、これらの技術のおかげで実現できたことの一つが、`カメラで撮影している領域上の平面を検出する`です。なぜ平面の認識が必要なのかというと、3Dオブジェクトを配置する際にそこに配置してよいかどうかを判別するために必要な情報だからだと思われます。[^3] WWDCのKeynoteでテーブルの上でゲームの戦闘が繰り広げられるような映像のデモがありましたが、あのようなことをするためには必須の機能といえますね。

# iOSで水平面検出を使ってみる

## ARKitが使えるiOSのバージョン、端末について

ARKitは最新の技術のため、もちろん全ての端末で使えるというわけではなく、iOSのバージョンや端末を限定する技術となります。古い端末やOSのバージョンをサポートしないといけないケースでは注意が必要となります。

iOSのバージョンとしては `iOS11以降` が必要となります。

また、ハードウェア要件的には `A9以降のプロセッサを搭載したiOSデバイスが必要`とされており、機種でいえば `iPhone 6s/6s Plus以降の端末(iPhone SEも可)` が必要となります。 [^4]

## ARKitを使ったXcodeプロジェクトを作成

さて、実際にアプリを作って試してみましょう・・・とはいえ、ARKitの使い方については既にたくさんの方が書かれていて、自分が書いてもそれらの記事と全く同じ内容になってしまうので、プロジェクトテンプレートから水平面検出用に追加した実装を中心に最低限のことだけ書いておきます。 [^5]

自分の手元で試した際のXcodeのバージョンは9.2です。以後、Xcode9.2の使用を前提とした解説となります。

ARKitを使ったプロジェクトを作りたい場合は、　`Augumented Reality App` というプロジェクトテンプレートがあるので、これでお手軽にプロジェクトを作成できます。

<img width="430" alt="screen_20171217_01.png" src="https://qiita-image-store.s3.amazonaws.com/0/122081/c3ce8bf8-8f8d-18b0-482c-942c3a6ea651.png">

テンプレートそのままの状態で実行すると、飛行機の3Dオブジェクトが表示されます。

## 平面検出に必要な実装を追加する

さて、ここまでできたところで、平面検出に必要な機能を追加していきましょう。

なお、以降に記述したサンプルコードの全体は下記Gistに置いておきました。プロジェクトテンプレートからプロジェクト作成後、Gistの内容をそのままViewController.swiftへコピペすればすぐに動かせます。

https://gist.github.com/kotetuco/f40663207097645aa2169433a9dcb6c7

まず、前節で表示した3Dオブジェクトは不要なので消します。

```swift
override func viewDidLoad() {
    ...
//    let scene = SCNScene(named: "art.scnassets/ship.scn")!
    let scene = SCNScene()
    ...
}
```

次に、平面検出を行うために、`ARWorldTrackingConfiguration` オブジェクトに対し、下記設定を追加しましょう。

```swift
override func viewWillAppear(_ animated: Bool) {
    ...
    let configuration = ARWorldTrackingConfiguration()
    configuration.planeDetection = .horizontal
    ...
}
```

検知した平面の情報を取得するためにはARSCNViewDelegateのメソッドを実装する必要があります。delegateの設定については、使用したテンプレートでは既に設定済みですので、あとは使用するdelegateメソッドを実装していきましょう。まず、ノード(SceneKitのノード)がアンカーに新たに追加された際に呼ばれるメソッド `renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor)` を実装しましょう。

```swift
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    print("didAdd")
}
```

次に、ノードの位置が変更された際に呼ばれる `renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor)` メソッドも実装しましょう。

```swift
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
    print("didUpdate")
}
```

なおアンカー自体の検出時に呼ばれるDelegateメソッドもありますが、平面の描画だけであれば上記メソッドだけでひとまずは大丈夫です。

これで必要な情報を取得することができるようになったので、`renderer(_:didAdd:for:)`に実装を追加して画面上に平面を描画していきましょう。

まずおさえておかなければならないことがあります。それは、 `renderer(_:didAdd:for:)` で取得できるSCNNodeは、アンカーに対応付けられたノードですが、このノードはグラフィカルな情報(ジオメトリ情報)を持ちません。なので、実際に平面を画面上に表示したい場合はジオメトリ情報を持ったノードを新たに自分で作って追加してあげないといけません。

というわけで、 `renderer(_:didAdd:for:)` に次のような処理を追加してあげましょう。[^6]

```swift
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    guard let planeAnchor = anchor as? ARPlaneAnchor else {
        print("Error: This anchor is not ARPlaneAnchor. [\(#function)]")
        return
    }

    let planeGeometory = SCNPlane(width: CGFloat(planeAnchor.extent.x),
                                  height: CGFloat(planeAnchor.extent.z))

    planeGeometory.materials.first?.diffuse.contents = UIColor.blue

    let geometryPlaneNode = SCNNode(geometry: planeGeometory)

    geometryPlaneNode.simdPosition = float3(planeAnchor.center.x, 0, planeAnchor.center.z)

    geometryPlaneNode.eulerAngles.x = -.pi / 2

    geometryPlaneNode.opacity = 0.8

    node.addChildNode(geometryPlaneNode)
}
```

上記の中で、下記1行があります。

```swift
geometryPlaneNode.eulerAngles.x = -.pi / 2
```

これは、そのままだとSCNPlaneの座標系とARKitの座標系が異なってしまうため、x軸方向に90度(π/2)回転させることでARKitの座標系と一致させるようにしています。

最後に、　`renderer(_:didUpdate:for:)`　に平面情報時に表示する図形のサイズや座標情報を更新する処理を記述します。

```swift
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
    guard let planeAnchor = anchor as? ARPlaneAnchor else {
        print("Error: This anchor is not ARPlaneAnchor. [\(#function)]")
        return
    }
        
    guard let geometryPlaneNode = node.childNodes.first,
           let planeGeometory = geometryPlaneNode.geometry as? SCNPlane else {
            print("Error: SCNPlane node is not found. [\(#function)]")
            return
    }

    geometryPlaneNode.simdPosition = float3(planeAnchor.center.x, 0, planeAnchor.center.z)

    planeGeometory.width = CGFloat(planeAnchor.extent.x)
    planeGeometory.height = CGFloat(planeAnchor.extent.z)
}
```

## 補足：アンカー(ARAnchor)とノード(SCNNode)

水平面検出処理を実装する上でも大きく関わる概念なので、少し補足しておきます。

アンカー(ARAnchor)は、AR空間上に物体を配置するために必要な空間内の位置と方向を持ったデータのことです。[^7] 平面検知モードをオンにした場合に検出されるARAnchorは `ARPlaneAnchor` というサブクラスとして取得されます。ARPlaneAnchorは、平面の中心点や幅・長さ等の平面に関する情報が追加されています。

アンカーは実際の物体を表しているわけではないので、実際の物体にあたる概念がノード(SCNNode)という概念となります。ノードはアンカーに紐付いている概念ですので、「空間内の位置がアンカー、その位置に存在する物体がノード」という役割分担をイメージすると良いと思います。このノードはSCNNodeというクラス名からもわかるとおり、SceneKit内の物体の定義と同じです。

## 実際に動かしてみた結果

実装したアプリを実際に動かしてみました。結果は次のスクリーンショットのとおりです。

<img width="340" alt="screen_20171217_02.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/122081/fbe36531-be9a-9451-2ea0-5229ed2cdb08.jpeg">

<img width="340" alt="screen_20171217_03.jpg" src="https://qiita-image-store.s3.amazonaws.com/0/122081/052ea7b0-9ad5-db1d-04cb-2a77df28d2c5.jpeg">

完璧とは言わないまでも、机の領域は割と精度良く認識できていたと思います。

認識結果以外で気になった点は次のとおりです。

- 最初に認識されるまでに時間がかかる(だいたい6〜7秒くらい)。なので、UI/UXについてはこの時間を考慮して設計する必要がある。
- ああ机の上に物が乗っていた場合の凹凸までは認識できなかった(ただし、背の高い物だったら認識するかも)。
- 端末を動かしても、追従してくれる(しかも高速)。
- (カメラ制御設定の問題かもしれないが)一度ピンぼけしてしまうとなかなか再度オートフォーカスが走ってしてくれなかった印象。

さらに色んなシチュエーションで試してみたいところです。

# まとめ

もう少しテーブル上の凹凸を認識してくれるものと思っていたので、少し意外な結果とも言えますが、検証の仕方に問題があったかもしれないですし、仕様的に細かい凹凸については考慮していないのかもしれません。そもそもこのくらいの精度が出ていれば、ゲームや3Dオブジェクトを表示する分には問題ないと思います。

良い意味で意外だったのは、何よりも一度認識した後の追従性ですね。激しく動かしてみてもちゃんと追従してくれるのは見てて面白かったです。

# おまけ：業務用アプリのARの使いみちについて考えてみた

現時点だとARはゲームなどのマルチメディア系アプリで使われはじめた、といった感じですが(ARを使った最も有名なゲームはポケモンGOですね)、Sansanは非マルチメディアアプリを作る会社なので、Sansanで作っているような割と業務アプリに近いジャンルにおけるARの使いみちについて考えてみました。

## ケース１：ナビゲーションUIとして

これはゲーム等の延長線上にあるような使い方ですね。何らかの機能のナビゲーションにARを使うというものです。

私が一つ考えているのは`アプリのカメラ機能(名刺を撮影する機能)のチュートリアルとして使えないかどうか`です。

名刺を撮影する際には必ずカメラを起動して物体にかざす必要があるため、ARにありがち(だと自分は思う)な`ARするためにわざわざ端末を持って物体に対してかざさないといけない`という手間はほぼ起こらないのではないかと考えます。

その上で仮想的な名刺オブジェクトを配置することで`上手く撮影するためのコツ`や`極端に端末を傾けて撮ると上手く撮れないよ`などのアンチパターンをユーザに直に操作してもらうことで理解してもらえるのではないかと。

また、チュートリアルとは少し異なりますが、水平面検出の機能を使って、 `そもそも平面が検知できない場合は「おやおや、名刺を撮影できる環境ではないようですね」ということを検知してユーザに伝える` ことができないかなと。
※ 上記の考え方の応用で`水平面を検出できなかったら「お前が今いる部屋は散らかりすぎている」と教えてあげる`アプリとかできないかな・・・。

ただ、チュートリアル一つのためにARKit導入するのも、ちょっとオーバースペックな気がしますね・・・。

## ケース２：画面の小ささを補う

スマートフォンのUI設計にあたり、最大の考慮点(だと思われる)は「(PCやタブレットと比較して)画面サイズが小さい」ということだと思います。ゆえに、PCのブラウザでのプラクティスをそのままスマホアプリに持ち込んでしまうと大変なことになります。

画面を平面に使うと確かに情報量が少ないですが、画面内を立体的に使うことができれば、(物理的には平面情報であることに変わりありませんが)画面内の情報量をもう少し増やすことができるのではないか、という気がしています。

可能性として考えられるのは、ARを使って3D空間内に情報を表示することで、これまで画面上に収めるのが難しいと考えられていたような情報を表示するような用途で使うことができないだろうかと考えています。

難点としては、情報を閲覧するためだけに端末を持って壁などにかざし続けないといけないという点ですね(割と致命的な欠点ですね・・・)。

## おまけ(その2)：Human Interface Guideline

iOSの話に限定されますが、iOSでARアプリを作る際に考慮すべきことについて少し。

iOSアプリに関わったことのある人間なら一度は耳にしたことがあると思われる `Human Interface Guideline` ですが、ARKit登場に合わせてARについての項目が追加されていました。

https://developer.apple.com/ios/human-interface-guidelines/technologies/augmented-reality/

上記ガイドラインでは、ARアプリを作る上で気を付けるべき点が記載されています。

いくつか抜粋すると次のとおりです(抜粋した以外の項目や詳細は上記リンク先をご覧ください)。

- ディスプレイ全体を使用する
    - 極力表示する領域を大きくするべき
- ユーザを不快にさせない
    - 長時間端末を持たせるようなことはしない
- ユーザの安全に配慮する
    - 端末を激しく動かすようなアプリだと、近くに人がいた場合に危険
- ヒントを文脈で提供する
    - テキストベースのUIでナビゲーションするよりは、インジケータや操作対象に関係するグラフィカルなUIでナビゲーションする方が良い(ただし場合によってはテキストベースのUIが良い場合もある)。

# 参考文献

本記事の内容は、Appleの開発者向けドキュメントや [iOS 11 Programming](https://peaks.cc/iOS11) の一部を参考に執筆しました。

この記事を書くためにiOS 11 Programmingを買いましたが、ARや機械学習関連の機能の解説が充実しており、日本語で読める文献としてはなかなか良いのではないかと思います。ARKitに興味を持たれている方はARKitの入口として読んでみると良いのではないでしょうか！

# 脚注

[^1]: https://developer.apple.com/documentation/arkit/about_augmented_reality_and_arkit
[^2]: https://developer.apple.com/arkit/
[^3]: ARでなぜ平面認識を使うのかについて、自分が調べた範囲で明確に答えている文献がなかなか見つからなかったため、この理由については自分の単なる推測です。
[^4]: https://developer.apple.com/documentation/arkit
[^5]: 私が主に参考にしたのは、参考文献の節でも紹介している`iOS 11 Programming`の @shu223 さんのARKitの解説記事です。
[^6]: 実装の参考にしたのは `iOS 11 Programming`の内容、および https://developer.apple.com/documentation/arkit/building_your_first_ar_experience です。
[^7]: https://developer.apple.com/documentation/arkit/aranchor