Help us understand the problem. What is going on with this article?

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

はじめに

この記事はHoudini Apprenticeアドベントカレンダー2020 14日目の記事です。

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

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

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

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

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

LiDARとARKit4のおさらい

ドキュメントやGoogle先生で頑張って情報を漁るよりもWWDC2020のセッション をみるのが一番わかりやすいです。

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

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

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 というノードがあります。

詳しくは@reisyuさんの 記事 にとてもよくまとまっています。読み込みサポート形式はlasとe57の2種類です。

lasは静岡県伊豆の点群データが有名ですが 3次元点群 でよく見るフォーマットです。

e57は記事作成時点で見かけることがあまりなかったのですが、Leica BLK360 でiOSアプリと連携して出力できるらしいです。(新車が買える値段)

スキャンされたサンプル も公開されています。データサイズもでかいです。

Apprentice的に検証にそこまでお金をかけられないので、手元にあるiPhoneと連携する部分で中身を掘り下げて書きたいと思います。

それぞれの点群フォーマットについて掘り下げたい方は、下記のリンク先を参照ください。
- las ライブラリ1 と LAS data format2
- e57 ライブラリ3 と e57 data format4
- 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 3d Scanner App™ Record3D ScandyPro Polycam
対応フォーマット 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 が立っているので近日対応ですかね?

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

(注意)ここからは検証がうまく行かなかった内容を含みます!
COP ジェネレーターの定義

まずはPythonでCOP Generatorオペレータ を定義して、record3D から RGBD ストリームデータを取り出します。雰囲気でやっていきます。

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

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)で例外を投げて、ノードエディタに警告を出すのが安全です。

スクリーンショット 0002-12-10 20.16.42.png

COP → SOP へ

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

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

スクリーンショット 0002-12-11 21.55.32.png

...

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

おわりに

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

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

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

sho7noka
ゲーム業界のPythonista
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away