Closed32

@lumaai/luma-webのBabylon.js版を作る

にー兄さんにー兄さん

残念ながらこのパッケージ自体はOSSではないらしく、中身は見れないんだけど
実際に使って見てネットワーク通信部分とかを見て見ると、どこと通信して何を取ってきているのかが分かったので
いったん必要なデータの取得はできそうだなぁとなった

にー兄さんにー兄さん

3DGSデータの表示に必要なのは.plyとか.splatになるわけで
現状Babylon.jsはply読み込みに対応したので、どうにかしてuuidからplyを取得出来ればOKということになる

https://webapp.engineeringlumalabs.com/api/v3/captures/{slug}/publicというエンドポイントにアクセスすればそれっぽいデータが取得出来るっぽい

最後のpublicがカギで、これが無いとCORSでエラーになる

にー兄さんにー兄さん

現状docsなどがあるLumaAI APIはv2なんだけど、これはv3なんですね
あとcaptureではなくcapturesに変わってる

全面的に使ってよいのか微妙なのが微妙ですが、いったんこれでやってみましょう

にー兄さんにー兄さん

レスポンスはこんな感じ

にー兄さんにー兄さん

見て見ると、response.artifacts[13]に香ばしい情報が

拡張子は.plyなのでこれっぽい

実は他にもplyがあるので、こっちはなんだろう

にー兄さんにー兄さん

ここまでをいったん整理

LumaAI API v3で返ってくるplyは2種類

  1. type: gaussian_splatting_point_cloud.ply
  2. type: gs_point_cloud
にー兄さんにー兄さん

1に関していうと、以下の特徴がある

  • .plyではなく.ply.zipが返ってくる
  • おそらく3DGSの元データっぽい
  • サイズが巨大
    • おそらくzipになってる理由がこれ
  • zipを解凍すればBabylon.jsのSPLATFileLoaderを使って読み込み可能
    • 手元で試したときはfflateを使って解凍した
  • 巨大なファイルのダウンロードと解凍により、表示されるまで数十秒かかる
にー兄さんにー兄さん

2に関していうと以下の特徴がある

  • サイズが小さい
  • .plyが返ってくる
  • おそらくGSのデータであるが、SPLATFileLoaderでは読み込めない
    • ヘッダ情報を見て見ると、通常の.plyがfloatなのに対してushortやucharになっている
    • このせいでSPLATFileLoaderではエラーになる
にー兄さんにー兄さん

1に関して、頑張ってzip解凍まで実装すれば
動作はめっちゃ襲おいけどGSの表示はできている

めっちゃおそいけど

にー兄さんにー兄さん

gaussian_splatting_point_cloud.ply(以下 形式①)とgs_point_cloud(以下 形式②)の違いは
フィールドデータの種類とデータ型

とくにfloatをushortやucharに、どのようにマッピングしているのか気になる
これは数値を見ながら推測しなくちゃなので、バイナリ形式をascii形式に変換して
数値を比べてみるかな

にー兄さんにー兄さん

数値のマッピングについて調べていたら、meta情報を含んだJSONファイルを発見した
.ply同様にAPIレスポンスで取得できる

JSONの例(一部抜粋)

_gs_point_cloud_meta.json
{
    ...,
    "bounds": {
      "xyz_min": [
        -99.99710083007812,
        -99.99878692626953,
        -99.99800109863281
      ],
      "xyz_max": [
        99.99812316894531,
        99.9970474243164,
        99.99800109863281
      ],
      "f_dc_min": [
        -11.302934646606445,
        -11.037412643432617,
        -12.602523803710938
      ],
      "f_dc_max": [
        7.605276107788086,
        7.917906284332275,
        7.743527412414551
      ],
      "opacity_min": [
        -5.537333965301514
      ],
      "opacity_max": [
        11.465744018554688
      ],
      "scale_min": [
        -13.708109855651855,
        -21.529224395751953,
        -16.2803955078125
      ],
      "scale_max": [
        0.8483610153198242,
        0.6941477060317993,
        0.6941477060317993
      ],
      "rotation_min": [
        -0.6299273371696472,
        -1.2513082027435303,
        -1.230669379234314,
        -1.2749708890914917
      ],
      "rotation_max": [
        2.2383224964141846,
        1.5769015550613403,
        1.4272433519363403,
        1.4079049825668335
      ]
    }
  },
  ...
}
にー兄さんにー兄さん

みつけた上記の方法を使ってLumaAIのplyファイルをsplat形式に変換してBabylonのGaussianSplattingに読み込ませたところ、無事に読み込みに成功した

にー兄さんにー兄さん

最終的に出来上がったLuma ply -> splatに変換するメソッドはこちら

convertLumaPlyToSplatData関数
/**
 * Code from https://github.com/dylanebert/gsplat.js/blob/main/src/loaders/PLYLoader.ts Under MIT license
 * Loads a .ply from data array buffer
 * if data array buffer is not ply, returns the original buffer
 */
export const convertLumaPlyToSplatData = (
  data: ArrayBufferLike,
  metadata: IGSPointCloudMeta
): ArrayBuffer => {
  const ubuf = new Uint8Array(data);
  const header = new TextDecoder().decode(ubuf.slice(0, 1024 * 10));
  const headerEnd = "end_header\n";
  const headerEndIndex = header.indexOf(headerEnd);
  if (headerEndIndex < 0 || !header) {
    return data;
  }
  const vertexCount = parseInt(/element vertex (\d+)\n/.exec(header)![1]);

  let rowOffset = 0;
  const offsets: Record<string, number> = {
    double: 8,
    int: 4,
    uint: 4,
    float: 4,
    short: 2,
    ushort: 2,
    uchar: 1,
  };

  type PlyProperty = {
    name: string;
    type: string;
    offset: number;
  };
  const properties: PlyProperty[] = [];
  const filtered = header
    .slice(0, headerEndIndex)
    .split("\n")
    .filter((k) => k.startsWith("property "));
  for (const prop of filtered) {
    const [_p, type, name] = prop.split(" ");
    properties.push({ name, type, offset: rowOffset });
    if (!offsets[type]) throw new Error(`Unsupported property type: ${type}`);
    rowOffset += offsets[type];
  }

  const rowLength = 3 * 4 + 3 * 4 + 4 + 4;
  const SH_C0 = 0.28209479177387814;

  const dataView = new DataView(data, headerEndIndex + headerEnd.length);
  const buffer = new ArrayBuffer(rowLength * vertexCount);
  const q = new Quaternion();

  for (let i = 0; i < vertexCount; i++) {
    const position = new Float32Array(buffer, i * rowLength, 3);
    const scale = new Float32Array(buffer, i * rowLength + 12, 3);
    const rgba = new Uint8ClampedArray(buffer, i * rowLength + 24, 4);
    const rot = new Uint8ClampedArray(buffer, i * rowLength + 28, 4);

    let r0: number = 255;
    let r1: number = 0;
    let r2: number = 0;
    let r3: number = 0;

    for (
      let propertyIndex = 0;
      propertyIndex < properties.length;
      propertyIndex++
    ) {
      const property = properties[propertyIndex];
      let value;
      let remapFunc: (x: number, y: number) => number;
      switch (property.type) {
        // case "float":
        //   value = dataView.getFloat32(property.offset + i * rowOffset, true);
        //   break;
        // case "int":
        //   value = dataView.getInt32(property.offset + i * rowOffset, true);
        //   break;
        case "ushort":
          value = dataView.getUint16(property.offset + i * rowOffset, true);
          remapFunc = remapValue(value)(0, 65535);
          break;
        case "uchar":
          value = dataView.getUint8(property.offset + i * rowOffset);
          remapFunc = remapValue(value)(0, 255);
          break;
        default:
          throw new Error(`Unsupported property type: ${property.type}`);
      }

      const {
        f_dc_max,
        f_dc_min,
        opacity_max,
        opacity_min,
        rotation_max,
        rotation_min,
        scale_max,
        scale_min,
        xyz_max,
        xyz_min,
      } = metadata.gaussians.bounds;

      switch (property.name) {
        case "x":
          position[0] = remapFunc(xyz_min[0], xyz_max[0]);
          break;
        case "y":
          position[1] = remapFunc(xyz_min[1], xyz_max[1]);
          break;
        case "z":
          position[2] = remapFunc(xyz_min[2], xyz_max[2]);
          break;
        case "scale_0":
          scale[0] = Math.exp(remapFunc(scale_min[0], scale_max[0]));
          break;
        case "scale_1":
          scale[1] = Math.exp(remapFunc(scale_min[1], scale_max[1]));
          break;
        case "scale_2":
          scale[2] = Math.exp(remapFunc(scale_min[2], scale_max[2]));
          break;
        case "red":
          rgba[0] = value;
          break;
        case "green":
          rgba[1] = value;
          break;
        case "blue":
          rgba[2] = value;
          break;
        case "f_dc_0":
          rgba[0] = (0.5 + SH_C0 * remapFunc(f_dc_min[0], f_dc_max[0])) * 255;
          break;
        case "f_dc_1":
          rgba[1] = (0.5 + SH_C0 * remapFunc(f_dc_min[1], f_dc_max[1])) * 255;
          break;
        case "f_dc_2":
          rgba[2] = (0.5 + SH_C0 * remapFunc(f_dc_min[2], f_dc_max[2])) * 255;
          break;
        case "f_dc_3":
          rgba[3] = (0.5 + SH_C0 * value) * 255;
          break;
        case "opacity":
          rgba[3] =
            (1 / (1 + Math.exp(-remapFunc(opacity_min[0], opacity_max[0])))) *
            255;
          break;
        case "rot_0":
          r0 = remapFunc(rotation_min[0], rotation_max[0]);
          break;
        case "rot_1":
          r1 = remapFunc(rotation_min[1], rotation_max[1]);
          break;
        case "rot_2":
          r2 = remapFunc(rotation_min[2], rotation_max[2]);
          break;
        case "rot_3":
          r3 = remapFunc(rotation_min[3], rotation_max[3]);
          break;
      }
    }

    q.set(r1, r2, r3, r0);
    q.normalize();
    rot[0] = q.w * 128 + 128;
    rot[1] = q.x * 128 + 128;
    rot[2] = q.y * 128 + 128;
    rot[3] = q.z * 128 + 128;
  }

  return buffer;
};
にー兄さんにー兄さん

今回はViteテンプレートのlibraryを使ってやっていたのだけど、vite-plugin-dtsを入れるだけではうまく出力されなかった

そこでvite.config.ts, tsconfig.jsonを変更することに

にー兄さんにー兄さん

tsconfig.tsは以下の変更をした

  • noEmitsをコメントアウト
  • declarationをtrueに
  • emitDeclarationOnlyをtrueに
  • outDirを./dist

vite.config.tsは以下のように変更

import { resolve } from "path";
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";

export default defineConfig({
  plugins: [dts()],
  build: {
    lib: {
      entry: resolve(__dirname, "lib/index.ts"),
      name: "luma-splatting-for-babylonjs",
      fileName: "index",
      formats: ["es", "umd"],
    },
    rollupOptions: {
      external: ["@babylonjs/core"],
      output: {
        globals: {
          "@babylonjs/core": "BABYLON",
        },
      },
    },
  },
});
にー兄さんにー兄さん

残タスクはこちら

  • pnpm linkでローカルからパッケージインストール試してみる
  • READMEの追加
  • npm publish

いったん公開までしておいて、そのあとにやりたいこと一覧はこちら

  • lernaによるリリースフロー確立
  • GitHub Actions整備
  • eslintとprettier整える
  • docコメントつける
にー兄さんにー兄さん

懸念事項として、今回はBabylon.jsをdevDependencyにしているので
インストールする側がBabylonを別途インストールしていれば使える想定だけど

それがちゃんと動くのか

にー兄さんにー兄さん
# lumas-splatting-for-babylonjs内で
pnpm link --global

# テストプロジェクト内で
pnpm add -D @babylonjs/core
pnpm link --global luma-splatting-for-babylonjs

これで普通に使えた

このスクラップは2024/01/10にクローズされました