Closed92

WebXR Raw Camera Access APIのbabylon.jsでの利用について考える

にー兄さんにー兄さん

Platform Supportに関しては以下

This will be supported on platforms where Chrome supports AR. Currently, this is only Android. WebView support is planned. There are no technical restrictions specific to this API preventing it from being implemented on other platforms.

ARをサポートしているChromeで、現在はAndroidのみ

にー兄さんにー兄さん

モチベとしては、
従来のAPIではプライバシー保護の理由から生のカメラアクセスを許可isていなかったが
他のWebXR APIに影響が出そうとのこと

にー兄さんにー兄さん

まずはBabylon.jsで'camera-access'feature付きでimmersive WebARモードに入ってみて
XRSessionなどから直接APIをたたけるか試したほうが良さそう

もしそのあと余力があったらbabylon.jsの規格に基づいて
WebXRFeatureを作ることもできそうだ

にー兄さんにー兄さん

自分の持っているChrome for Androidで対応しているのか
TypeScriptで開発できるのか、などいろいろ心配事はある

にー兄さんにー兄さん

Babylon.jsでWebXR APIを使うときには、WebXR APIの型定義がある
https://github.com/BabylonJS/Babylon.js/blob/master/src/LibDeclarations/webxr.d.ts

いまちょっと気になるのが、
XRViewerPoseにあるViewからcameraを取り出しているけど
cameraプロパティ定義されてなくない?ってところかな

// ... in rAFcb ...
let viewerPose = xrFrame.getViewerPose(xrRefSpace);
for (const view of viewerPose.views) {
  if (view.camera) {
    // ... handle a view that has a camera image ...
  }
}
にー兄さんにー兄さん

現行のBabylon.jsの型定義では、WebGLBindingからカメラのWebGLTextureを取得するメソッドがなさそう

にー兄さんにー兄さん

とりあえず躊躇していても始まらんのでvite-ts+babylonjsで作ってみる

> yarn create vite webxr-raw-camera-access-babylon --template vanilla-ts
にー兄さんにー兄さん
yarn add @babylonjs/core@5.0.0-alpha.60

babylonは5.0じゃないとWebGLBindingとかWebXR系の機能が欠けている可能性がある

にー兄さんにー兄さん

今日時点の成果は、babylonで豆腐を出して
viteでhttpsの設定をして
immersive-arが実行できたところまでかな

にー兄さんにー兄さん

余力があれば、

  • GitHub ActionsでLinter走らせる
  • GitHub PagesもしくはAzure SWAへのデプロイの自動化
  • vscodeで保存時にeslintやprettierのfixを走らせる
にー兄さんにー兄さん

adb のリモート接続とchromeのdevice inspectを使ったリモートデバッグができたことを確認した

にー兄さんにー兄さん

Babylon.jsのcreateDefaultXRExperienceAsyncでcamera-accessをfeatureとして渡してみたところ、
不明のfeatureとして警告された
これはFeatureManager当たりの実装を見たほうがいいな

WebXR APIでFeatureがenableかどうかを返してくれるメソッドとかあると嬉しいんだけどな

にー兄さんにー兄さん

エイヤッと実装してみたら、特にcamera-access APIを使うことなくカメラの内部パラメータを取得できた
え、どゆこと
img

にー兄さんにー兄さん

Webでカメラ画像ってどう取得しようかなーってずっとやってたけど
やっぱりgetUserMediaを試しておこう

ここらへんAPIが少なくて困っていたイメージがあるんだけど
今見たら意外と色々あったのでその知見を整理していく

にー兄さんにー兄さん

まずお決まりのごとく、

const stream = await navigator.mediaDevices.getUserMedia(constraints);

でMediaStreamを取得する。
constraintsはvideoなのかとか解像度とかカメラの向きとか指定できる奴
https://developer.mozilla.org/ja/docs/Web/API/MediaDevices/getUserMedia

そしてMediaStream.getVideoTracks()でビデオトラックとなるMediaStreamTrackオブジェクトを取得する。
そしたらvideoTrackを引数にしてImageCaptureオブジェクトを作成することができる。
ドキュメントのサンプルを見たほうが速い

const input = document.querySelector('input[type="range"]');

var imageCapture;

navigator.mediaDevices.getUserMedia({video: true})
.then(mediaStream => {
  document.querySelector('video').srcObject = mediaStream;

  const track = mediaStream.getVideoTracks()[0];
  imageCapture = new ImageCapture(track);

  return imageCapture.getPhotoCapabilities();
})

https://developer.mozilla.org/ja/docs/Web/API/MediaStream/getVideoTracks

にー兄さんにー兄さん

ImageCaptureのドキュメントはこちら
https://developer.mozilla.org/en-US/docs/Web/API/ImageCapture

写真をキャプチャするだけでいい、みたいな用途には良さそう
ImageCapture.grabFrameもしくはtakePhotoでキャプチャ画像を取得できる

grabFrameではBitmapImageオブジェクトが返される
BitmapImageはcanvasのCanvasBitMapImageContextを使って表示出来たりする
つまりgrabFrameでcanvasに画像を表示してcanvas.toDataURLでbase64エンコできる

takePhotoはblobオブジェクトが返される
<img>タブに表示させるようなんだとか
tこれはbase64エンコできるのかな

var takePhotoButton = document.querySelector('button#takePhoto');
var canvas = document.querySelector('canvas');

takePhotoButton.onclick = takePhoto;

function takePhoto() {
  imageCapture.takePhoto().then(function(blob) {
    console.log('Took photo:', blob);
    img.classList.remove('hidden');
    img.src = URL.createObjectURL(blob);
  }).catch(function(error) {
    console.log('takePhoto() error: ', error);
  });
}
にー兄さんにー兄さん

canvas要素をいちいち作るのめんどくさいのでoffscreenCanvas使いたいんだけど
こいつには肝心のtoDataURLメソッドがない、悲しい

だからめんどくさいけどcanvasを作るか
blobからbase64エンコすることになるけど
blobの場合pngになってるか心配

にー兄さんにー兄さん

カメラのキャプチャはできた
だがしかしカメラのストリームをbabylonと奪い合ってしまって共存するのが無理みたい......ん~~~~

にー兄さんにー兄さん

まさかと思って試してみたら、NEXUS 5XのChromeは
camera-accessに対応していたという....マジ??

にー兄さんにー兄さん

camera-accessを使って生のCPUイメージの取得に成功
OpenARCloudさんのサンプル通りに変換したらいい感じに動いたけどちょっと重い

にー兄さんにー兄さん

Immersalとの連携について考える
現状はApp.tsにほぼすべてのロジックを詰め込んでいるのでリファクタもしたいと思いつつ
とりあえず先に動くのを確認したいとも思う

にー兄さんにー兄さん

Immersal REST API用のインターフェースは一通り定義できた
そこからBabylonで使えるように相互変換するロジックが必要になる

Requestはbase64文字列とか内部パラメータなのであまり考える必要はないけど
ResponseはMatrixに変換して姿勢データを取得しなくちゃいけな

にー兄さんにー兄さん

Matrix.decompose()が使えそう
ここからダイレクトで位置と回転が取得できるっぽい

const matrix = ~~;
matrix.decompose(map.position, map.rotation);

みたいな感じかな

にー兄さんにー兄さん

リクエスト送信時の姿勢にレスポンスで返ってきた姿勢の逆行列をかける必要がある
Babylonのmatrix.multiplyの実装を見たところ、
m1.multiply(m2)はM1M2って意味らしい

にー兄さんにー兄さん

処理のフローを整理する

  1. 事前にシーンにはマップ原点に対応するオブジェクトを配置
  2. EnterWebXR
  3. 起動直後?もしくはオフセット時間を置いてRawCameraからテクスチャを取得
  4. 取得時の姿勢を保存
  5. Immersal REST APIへリクエスト
  6. apiからレスポンスを受け取る
  7. レスポンスを変換
  8. マップの姿勢を計算
にー兄さんにー兄さん

ワークフローが基本babylonに依存しているから
ライフサイクルを司るクラスにステートとロジックを詰め込んでしまってる肥大化してる
XR系だけでもWebXR Managerとして分離したり
ロジックも静的にしてユーティリティとして分離したい

にー兄さんにー兄さん

ここら辺の実装が割と終わった
あとはいい感じのタイミングでキャプチャ画像の処理とImmersalへのリクエストを送る実装をして
どんなレスが返ってくるかを検証する

にー兄さんにー兄さん

なんとかオレオレアーキテクチャのバグを取り除き、リクエストに必要なパラメータの取得と変換を突破
そしてImmersal /localizeb64 apiにリクエストが通った

にー兄さんにー兄さん

Immersal REST APIはCORSでContent-Typeヘッダを許可していないので
指定するとCORSでブロックされる

にー兄さんにー兄さん

次のタスクは部屋のglbもしくは.babylonファイルにしてもいいけど
とりあえず部屋のメッシュモデルを読み込む

そして座標変換をして位置合わせがあっているか確認する

にー兄さんにー兄さん

部屋のモデルのいらない部分を削って軽くしたモデルをbabylon.js sandboxで表示してみた
多分うまくいってる

にー兄さんにー兄さん

glbインポート、そういえば前に失敗していたような......
そんなことを思っているとやまゆさんが反応してくださった

にー兄さんにー兄さん

Viteの静的アセットのURLハンドリングについての内容
https://vitejs.dev/guide/assets.html#importing-asset-as-url

例だと/img.png/assets/img.2d8efhg.pngになってる
この仕様のせいでディレクトリとファイル名を分けて扱わなくちゃいけないSceneLoaderと相性が悪い
結局?urlで取得したパスを処理してディレクトリ名とファイル名に分けるしかないかなぁ

にー兄さんにー兄さん

何とか苦戦しながらもglbインポートに成功
Immersalのモデルは重いのでblenderでメッシュを削りに削ったらオンボロスマホでもなんとか読み込めた......

にー兄さんにー兄さん

あとは座標変換ができれば完成、というところまで来たけど
案の定詰まってる

にー兄さんにー兄さん

Babylonjs Sandboxとか活用しながらもちょっと使い勝手悪いし
BabylonjsのMatrixの挙動が想定していたのと違ったりしてう~~~~nって感じ

やっぱりビジュアルで確認しながらトライアンドエラーしたい感じなのだが
ここでBabylonjs Editorが使えないか、と思った

部屋のglbモデル読み込んで
Immersalのレスポンスをもとに変換行列を作って試してみたい

にー兄さんにー兄さん

既出の問題を2つ解決した

一つ目はモデル読み込みで固まる件
これは.glbをsandboxを使って.babylonに変換しておくことによって解決
Immersalのマップを無加工でも読み込めそうだな?

2つ目はcameraのattachControllerが使えなかった件
base64変換用のcnavasが前面に来ていたせいでした()

にー兄さんにー兄さん

camera-accessでカメラ画像からImageDataを取得する処理で
WebGLRenderingContext周りで問題が起きているゆえに、
OnXRFrameでglをいじらないといけなくなってコードが煩雑になっている。

しかしgl contextが必要なのはカメラ画像を取得する部分だけなので
ここをまるっとメソッドで囲ってしまって、コールバックにすればよいのではというアイデア
かつここの処理がかなり重いのでWebWorkerに委託できないかと思った

にー兄さんにー兄さん

今リポジトリの名前が
WebXR Raw Camera Access Babylonになってるけど
これは後々わからなくなるのでImmersalって単語を入れたほうがいい(戒め)

にー兄さんにー兄さん

chrome://flagsにアクセスしてWebXR Incubationをenableにすると
WebXR Device APIの実験的機能にアクセスできるみたい
Nexus5Xはもともとこれをonにしていたんだなぁ

Pixel 4a 5Gで試したところ、無事camera-accessを使用できたので:超嬉しい:

にー兄さんにー兄さん

連続で位置合わせが成功することが分かった
Pixelでデバッグできるからかなり快適

にー兄さんにー兄さん

babylon.js editorにマップメッシュモデルを読み込んでみる
(リダクション済み)

にー兄さんにー兄さん

これ自体は、まず__root__のy軸回転が180度、zのscaleが-1になってて
それをidentityなEmptyの子にして整合性が保たれている

にー兄さんにー兄さん
  public onStart(): void {
    const pos = new Vector3(0.8086, 0.2053, 0.2098);
    const rotMatrix = new Matrix();
    rotMatrix.setRow(0, new Vector4(0.352, 0.1208, 0.9281, 0));
    rotMatrix.setRow(1, new Vector4(0.9345, 0.0102, -0.3557, 0));
    rotMatrix.setRow(2, new Vector4(-0.0524, 0.9926, -0.1093, 0));
    rotMatrix.setRow(3, new Vector4(0.0, 0.0, 0.0, 1.0));

    const rot = Quaternion.FromRotationMatrix(rotMatrix.transpose());

    console.log(pos, rot.toEulerAngles());
  }

で出てきた姿勢をcameraに指定してみると
以下のようになった

それっぽいけどカメラのz回転が90度かかってる、おしい

にー兄さんにー兄さん

つまりこれ

    const rot = Quaternion.FromRotationMatrix(rotMatrix.transpose()).multiply(
      Quaternion.RotationAxis(Vector3.Forward(), -Math.PI / 2)
    );

にー兄さんにー兄さん
Quaternion.FromRotationMatrix(
      rotMatrix.invert().transpose()
    )

つまり逆行列からの天地という順番でやってみたところ、ニアピンって感じの表示になった

カメラの姿勢はidentity、マップの回転を↑の式から得た

にー兄さんにー兄さん

マップ原点でカメラの姿勢を変える時は行列→転置→Quaternion→z軸90度回転Quaternionを掛ける
で対処できた
行列→inverse→転置→Quaternionでマップの逆姿勢っぽいものを得られたが
ここでz軸回転のQuaternionを掛けても何も変わらなかった
ということは、逆行列を得る前もしくは逆行列に対してz軸回転の行列を掛けてから計算する必要がある?

にー兄さんにー兄さん

Inspectorを導入してインポートされたモデルを見てみた
やはり階層構造がリセットされていてモデルの見た目も変

__root__というTransformationNodeのtransformationがいい感じに補正されていたやつ
(つまりrotatinoのyが180度、scaleのzが-1になってる)

にー兄さんにー兄さん
    const node = new BABYLON.TransformNode('MapRoot', this._scene);
    BABYLON.SceneLoader.ImportMeshAsync('', dirName, fileName, this._scene).then((result) => {
      console.log(result.meshes);
      for (const mesh of result.meshes) {
        mesh.rotation.y = Math.PI;
        mesh.scaling.z = -1.0;
        mesh.setParent(node);
      }
    });

これでMapRootの中に良い感じに調整されたメッシュが子供になった

にー兄さんにー兄さん

無理やりだけどカメラ座標系に対するマップ姿勢を計算できた?

        const pos = new BABYLON.Vector3(0.8086, 0.2053, 0.2098).negate();

        const rotMatrix = new BABYLON.Matrix();
        rotMatrix.setRow(0, new BABYLON.Vector4(0.352, 0.1208, 0.9281, 0));
        rotMatrix.setRow(1, new BABYLON.Vector4(0.9345, 0.0102, -0.3557, 0));
        rotMatrix.setRow(2, new BABYLON.Vector4(-0.0524, 0.9926, -0.1093, 0));
        rotMatrix.setRow(3, new BABYLON.Vector4(0.0, 0.0, 0.0, 1.0));

        const enhancementMatrix = BABYLON.Matrix.RotationAxis(BABYLON.Vector3.Up(), Math.PI / 2);

        const rot = BABYLON.Quaternion.FromRotationMatrix(
          rotMatrix.multiply(enhancementMatrix).invert().transpose()
        );

        this.mapRootNode.position = pos;
        this.mapRootNode.rotateAround(
          BABYLON.Vector3.Zero(),
          BABYLON.Vector3.Right(),
          rot.toEulerAngles().x
        );
        this.mapRootNode.rotateAround(
          BABYLON.Vector3.Zero(),
          BABYLON.Vector3.Up(),
          rot.toEulerAngles().y
        );
        this.mapRootNode.rotateAround(
          BABYLON.Vector3.Zero(),
          BABYLON.Vector3.Forward(),
          rot.toEulerAngles().z
        );

カメラの姿勢はIdentityでマップだけが動いている

にー兄さんにー兄さん

ちゃんとXRSessionがInitされてから5秒後にローカライズという風に指定した
もう位置合わせのリクエストは当たり前のように通るようになったな

ただなぜかリクエスト2回読んじゃってる気がする......

にー兄さんにー兄さん

前に出てきたレスポンスだとうまくいくのに
なぜか直近のレスポンスだとy軸で反対になっちゃうというバグがある
何回かリクエストを飛ばしてみて実験しなくてはいけない

にー兄さんにー兄さん

あと今のところ一回のキャプチャにつき2回のリクエストが飛んでしまっている
これはXRFrameが頻繁に飛んでくることによるもので
キャプチャの必要があるかのフラグがfalseになる前に違うOnXRFrameコールバックが呼ばれてしまうからだと思われる

にー兄さんにー兄さん

最終フェーズに入って、Immersalのレスポンスから直接座標変換までをつなぎこんだ
実際に座標変換してみたところ、なんか微妙に曲がってる
これ前に見たやつだ~~~~~


にー兄さんにー兄さん

座標変換のメソッド、ちゃんと確立しなきゃダメだな
毎回こんな感じに傾くので、Immersalのレスポンスは結構一定値を返してるんだけど

にー兄さんにー兄さん

ふと、canvasの表示のされ方がおかしいことに気づいた
気づいたというか前から気になっていたんだけど、
これ普通に送信データがこのままになってるので大丈夫なのか心配になった

にー兄さんにー兄さん

なぜか1080x2340の範囲のなかで
889x1920の領域だけ画像が表示されていて、左下に寄っている現象
どこで間違えているんだろう

にー兄さんにー兄さん

左下に寄っているのはflipの処理によるもので
本来は左上に寄っているものがImageDataに入って
それを上下flipすることによって左下に来ている

にー兄さんにー兄さん

XRCameraCaptureUtil.createImageFromTextureの時点で生成されているImageDataが変になっている
このImageDataはUint8Array生成のときから同じ大きさのはず

にー兄さんにー兄さん

出てきた
どうやらViewportの解像度を使うのではなくViewのカメラから解像度を取得するのが正確なのかもしれない

にー兄さんにー兄さん

実際のviewのサイズはどうやら886x1920だったらしい
(view as any).camera.witthなどによってわかったし
手動でviewPortを作成して噛ませることにより正確な解像度のCamera Imageを取得することが可能になった

にー兄さんにー兄さん

なんか位置合わせが改善された気がする
だけどなんかあと少しなんだよなぁ......
調整しなきゃいけないのだろうか

にー兄さんにー兄さん

あと、なぜかかなり計算リソースを食うようになってる
スクショをとろうとしてもエラーが出たり、fpsがかなり低下したりしている

にー兄さんにー兄さん

もしかしたら、画像の向きに問題があるかもしれない
ARFoundationでやった時に、なぜか画像が左に90度回転した画像を送信していたのを思い出した

にー兄さんにー兄さん

直近で試してみたいこと

  • カメラ座標系での変換が合っているか
  • 画像の向きに問題があるのか
にー兄さんにー兄さん

記事を書いていて知ったのが、
explanationのGitHubではXRViewやXRWebGLBindingsに対してpartialで型宣言をしていた

partial interface XRView {
  // Non-null iff there exists an associated camera that perfectly aligns with the view:
  [SameObject] readonly attribute XRCamera? camera;
};

interface XRCamera {
  // Dimensions of the camera image:
  readonly attribute long width;
  readonly attribute long height;
};

partial interface XRWebGLBinding {
  // Access to the camera texture itself:
  WebGLTexture? getCameraImage(XRCamera camera);
};

これすればXRViewとかいちいちanyにしなくても型補完が効くかもな

このスクラップは2022/07/30にクローズされました