Houdini + LiDARを使った3Dスキャンを試す

# はじめに
この記事は[Houdini Apprenticeアドベントカレンダー2020](https://qiita.com/advent-calendar/2020/happrentice) 14日目の記事です。

iPhone12 Proを買ったので、ゲームとSNSするだけの高い端末にならないようにARKitのLiDARスキャナーと

点群、メッシュ、FBX/Pixar USD/GLTF とほぼなんでも読み込み可能なHoudiniを組み合わせて検証してみました。

3Dスキャンやフォトグラメトリ的にはn番宣じ感が強いのと、建築関係のトピックが多く門外漢からするとテーマ設定的に

後悔しているのですが、頑張って内容を差別化して書きます。

はじめに断っておきますと、最終的ないい成果を得ることができなかったので、そういう失敗込みで許容できる方に読んでもらえると嬉しいです。&#x1f647;&#x1f647;&#x1f647;



## LiDARとARKit4のおさらい
ドキュメントやGoogle先生で頑張って情報を漁るよりも[WWDC2020のセッション](https://developer.apple.com/videos/play/wwdc2020-10611/?time=943) をみるのが一番わかりやすいです。

要約するとARKitの`ARFrame`から取得できる`ARDepthData`(LiDAR搭載デバイスで`smoothedSceneDepth`プロパティから取得)、`ARPointCloud` はそれぞれ

ピクセルバッファとして取り出せば深度マップとして使うことができ、pointcloud の Array データとして取り出せば、点群データとして書き出すことができます。
  
Swiftコードはこんな感じになります。

```swift:arkitサンプルより
import ARKit
  
...

let session = ARSession()
let configuration = ARWorldTrackingConfiguration()

if type(of: configuration).supportsFrameSemantics(.sceneDepth) {
    configuration.frameSemantics = .sceneDepth
}                                             
session.run(configuration)
                         
...
     
func session(_ session: ARSession, didUpdate frame: ARFrame) {
    guard let depthData = frame.sceneDepth else { return }
    
    var pixelBuffer: CVPixelBuffer!
    pixelBuffer = depthData.depthMap

    var pointcloud: ARPointCloud!
    pointcloud = frame.rawFeaturePoints
}
```
LiDARの有効距離が最大で5mなので、歩き回れば細かくスキャンできるものの、広域スキャンという感じではないです。

点群の認識精度はそこまで高くない(精度何ミリまでは調べるだけでは分からなかった)ので、処理結果の正確さは`CVPixelBuffer`の画像処理に

依存する形になります。`ARConfidenceLevel`は信頼度マップをアプリから切り替えられるプロパティですが、アプリ内で調整や加工ができるかどうかは割と重要です。

オフラインでのキャプチャになってしまうので、デスクトップのアプリケーションに移してから点群を頑張って修正する作業は避けたいですよね。

収録データやアセット製作工程を後に回してしまって良いとなると、だいたい素材製作工程が適当になってポスプロ工程にしわ寄せが来るようになる懸念があるので...


## HoudiniのLiDARのおさらい
LiDARスキャンデータを取り扱うにあたり、Houdiniにはそのものずばり [LiDAR Import SOP](https://www.sidefx.com/ja/docs/houdini/nodes/sop/lidarimport.html) というノードがあります。

詳しくは@reisyuさんの [記事](https://qiita.com/reisyu/items/1d57a7430139e97f2568) にとてもよくまとまっています。読み込みサポート形式はlasとe57の2種類です。

lasは静岡県伊豆の点群データが有名ですが [3次元点群](https://www.geospatial.jp/ckan/dataset?tags=3次元点群データ) でよく見るフォーマットです。

e57は記事作成時点で見かけることがあまりなかったのですが、[Leica BLK360](https://shop.leica-geosystems.com/jp/ja-JP/buy/blk360/blk360) でiOSアプリと連携して出力できるらしいです。(新車が買える値段)

[スキャンされたサンプル](https://shop.leica-geosystems.com/jp/blk2go-dataset-downloads) も公開されています。データサイズもでかいです。
                       
Apprentice的に検証にそこまでお金をかけられないので、手元にあるiPhoneと連携する部分で中身を掘り下げて書きたいと思います。

それぞれの点群フォーマットについて掘り下げたい方は、下記のリンク先を参照ください。
- las ライブラリ[^1] と LAS data format[^2]
- e57 ライブラリ[^3] と e57 data format[^4]
- las と e57 の違い [^5]



### 対応フォーマットについて
最初に断っておくと標準フレームワーク内で前述の**lasやe57といったデータを書き出すことはできません**。

MetalやSceneKitから3Dファイルを出力する際は`ModelIO`フレームワークを使います。 出力対応フォーマット内[^6] にある ply で出力しておけば、点群データとして使えます。

それ以外のデータフォーマットに対応する場合は`ARPointCloud`をasciiで直書きするか、それぞれの対応フォーマットに沿ったシリアライザを自分で書きます。

(ARKitからのメッシュ出力関連は独自パーサーというより assimp で対応していると思います。FBXがないのが判断基準の一つです。)



### LiDAR 対応アプリと連携させる
本当は以上の検証を基にして、自分でARKitを使ったSwiftでアプリを作るところまで実践するのが一番なのですが、

時間(と気持ち)に余裕がなかったので、無償有償問わずLiDAR対応のアプリをダウンロードして使ってみる事にしました。

|　| 1. pronoPointsScan | 2. 3d Scanner App™ | 3. Record3D | 4. ScandyPro | 5. Polycam | 
|:-----------------|:------------------:|:------------------:|:------------------:|:------------------:|:------------------:|
| アプリ(リンク先詳細) | [![pronoPointsScan](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48607/fa30cd42-60c8-2f0f-a348-a98d523c3c0c.png)](https://apps.apple.com/jp/app/pronopointsscan/id1528284625) | [![3d Scanner App™](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48607/2eb6c205-b107-ce5f-bb5c-78ba8c0843ad.png)](https://apps.apple.com/jp/app/3d-scanner-app/id1419913995) | [![Record3D](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48607/c7094ec4-0751-7ee5-a330-23029ce13c02.png)](https://apps.apple.com/jp/app/record3d-3d-videos/id1477716895) | [![ScandyPro](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48607/cd92f81c-3d8f-6ad7-1b1a-9e9aae7bc572.png)](https://apps.apple.com/jp/app/scandy-pro/id1388028223) | [![Polycam](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48607/c81159f3-6893-46de-ca3d-2442580b5cb1.png)](https://apps.apple.com/jp/app/polycam-lidar-3d-scanner/id1532482376) | 
| 対応フォーマット | xyz | usdz, obj, gltf, stl, dae, ply, pts, xyz | ply, gltf | ply, obj, stl, usdz, glb | obj, gltf, dae, stl, dxf, ply, xyz, pts |
| 機能 | ノイズ除去 | crop, align, transform | 動画 | depth カメラが使える | crop、追加スキャン |
| 使いやすさ(主観) | ★★ | ★★★ | ★★ | ★ | ★★★ |


1. pronoPointsScanはこの中だと唯一の国内アプリでかつ精度が一番高いです。usb又はwifiで繋いだデスクトップのファイル共有からコピーして使います。Unityアプリですね。
2. 3d Scanner App™は無料なのが信じられないレベルで出力対応フォーマットも多いです。最初に使ってみるARアプリとしてもおすすめです。
3. Record3Dは対応フォーマットは少ないものの、USBで繋ぐとストリームデータとしてRGBD(DはDepth)で取り出す事ができる面白いアプリです。C++/Python ライブラリがGitHubで公開されています。
4. ScandyProはよく検索にかかる機能豊富なアプリです。サブスクリプションです。機能豊富なんですが肝心のスキャンがいまいちなので、継続的にお金払う気にはならないです...(使い込んでいないというのもあります)
5. Polycamはスキャン後の後加工がアプリ内で完結するので、非常に使いやすいです。サブスクでも払う価値あります。

いろいろ試した結果、汎用的に使えるplyを読み込むのもいいのですが、画像で取り出すことのできるRecord3Dが割と面白そうなのでHoudiniと組み合わせて使ってみる事にします。



### HoudiniにLiDARデータを読み込む準備
record3D のPython パッケージは`python -m pip install record3d` でインストールできます。`inlinecpp`モジュールやその派生元のHDKを通じてC++に切り替えもできます。
         
USBでPCと接続してください。WiFi化は [#issue](https://github.com/marek-simonik/record3d/issues/1) が立っているので近日対応ですかね？

demoアプリを試す際は`python -m pip install opencv-python==4.1.2.30 opencv-contrib-python==4.1.2.30`で先にパッケージをインストールしてください。numpyは依存関係上、勝手に入るはず。

<details><summary><b>(注意)ここからは検証がうまく行かなかった内容を含みます!</b></summary><div> 

##### COP ジェネレーターの定義
まずは[PythonでCOP Generatorオペレータ](https://www.sidefx.com/ja/docs/houdini/hom/pythoncop.html) を定義して、record3D から RGBD ストリームデータを取り出します。雰囲気でやっていきます。

RGB ストリームデータあるいは Depth ストリームデータを取得できますが、デバイスが繋がっていない時も考慮してColorパラメータを返すようにします。

```python
from record3d import Record3DStream
import numpy as np
from threading import Event


class App:
    def __init__(self):
        self.event = Event()
        self.session = None

    def on_new_frame(self):
        self.event.set()

    def on_stream_stopped(self):
        pass

    def connect_to_device(self, dev_idx):
        devs = Record3DStream.get_connected_devices()
        self.session = Record3DStream()
        self.session.on_new_frame = self.on_new_frame
        self.session.on_stream_stopped = self.on_stream_stopped

        if len(devs) > 0:
            return self.session.connect(devs[dev_idx])  # Initiate connection and start capturing
        return False

    def start_processing_stream(self, cop_node, plane):
        while True:
            self.event.wait()  # Wait for new frame to arrive

            if plane == "A":
                pixels = self.session.get_depth_frame() # > numpy.ndarray[float32]
            else:
                pixels = self.session.get_rgb_frame().astype(np.float32) / 255  # > numpy.ndarray[uint8]

            x, y, d = pixels.shape[:3]
            c_pixels = pixels.reshape(y, x, d).copy()

            try:
                cop_node.setPixelsOfCookingPlaneFromString(c_pixels, component=None, interleaved=False, depth=hou.imageDepth.Float32)
            except hou.OperationFailed as e:
                raise hou.NodeWarning(e.instanceMessage() + "\n" + str(c_pixels))
            finally:
                self.event.clear()

def resolution(cop_node):
    return cop_node.parmTuple("res").eval()

def output_planes_to_cook(cop_node):
    return ("0", "A")

def cook(cop_node, plane, resolution):
    app = App()

    if app.connect_to_device(0):
        app.start_processing_stream(cop_node, plane)
    else:
        num_pixels = resolution[0] * resolution[1]
        rgba = cop_node.parmTuple("color").eval()
        pixel = (rgba[3:] if plane == "A" else rgba[:3])
        cop_node.setPixelsOfCookingPlane(pixel * num_pixels)
```  

record3d は `numpy.ndarray` 型で上記のストリームデータを返すので、`hou.CopNode.setPixelsOfCookingPlaneFromString` に直接渡すことができるらしいです。
  
`numpy`を用いたサンプルでうまく動作したものが見つからなかったので、情報あれば下さい(必死)。forumも探してみましたが良い回答はなかったです。

普通に渡してAuto Updateでcookするとエラー時に Houdini が巻き込まれて落ちることがあり、printデバッグもできないので難しいです。

Pythonオペレータをテストする際は`raise hou.NodeWarning(STR)`で例外を投げて、ノードエディタに警告を出すのが安全です。
  
<img width="400" alt="スクリーンショット 0002-12-10 20.16.42.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48607/ce8e482f-886b-1aeb-2047-2d01ae011f0e.png">

##### COP → SOP へ
COP ジェネレーターで取り出した各画像をSOPでAttributeに流し込んでいきます。  

これを`Attribute from Map SOP`に繋いで、アトリビュートに取り出します。

<img width="400" alt="スクリーンショット 0002-12-11 21.55.32.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/48607/08b69066-d4e6-6702-cfc7-4700caf8c81b.png">

</div></details>

...

ここまで、LiDARの深度データをCOPで取り出してSOPにつなげるまでの一連の流れを検証してみました。


#### おわりに
途中脱線しまくりでApprenticeの内容にまとめきれなくてすみません。

(良い結果が得られるかはともかく)周辺デバイスと組み合わせてビジュアライズするまでのハードルがかなり低く、

プロトタイプにも使いやすいHoudiniを積極的に使っていきたいと改めて思いました。プロセスをあれこれ試行錯誤するのが何より楽しい。

[^1]: https://github.com/libLAS/libLAS
[^2]: http://www.asprs.org/wp-content/uploads/2019/03/LAS_1_4_r14.pdf
[^3]: http://www.libe57.org/
[^4]: https://www.ri.cmu.edu/pub_files/2011/1/2011-huber-e57-v3.pdf
[^5]: https://www.laserscanningforum.com/forum/viewtopic.php?t=13519
[^6]: https://developer.apple.com/documentation/modelio/mdlasset/asset_file_types