[Astro] #106 PLY点群データビューアを自作 — React Three Fiber + SOR高速ノイズ除去

[Astro] #106 PLY点群データビューアを自作 — React Three Fiber + SOR高速ノイズ除去

概要

PLY形式の点群データをブラウザで閲覧・編集・エクスポートできるWebツール「Point Cloud Viewer」を Astro + React Three Fiber で実装した。

業務用の点群処理ソフトは100万円を超えるものもあるが、中身を分解すると個々の機能は数学的にそこまで複雑ではない。必要な機能だけを自作して、ブラウザ完結のツールとして公開する。

点群データのサンプルは「スタンフォード大学3Dスキャンリポジトリ」からお借りしています。

スクリーンショット

[Astro] #106 PLY点群データビューアを自作 — React Three Fiber + SOR高速ノイズ除去

動画(gif)

[Astro] #106 PLY点群データビューアを自作 — React Three Fiber + SOR高速ノイズ除去

技術スタック

  • Astro — 静的サイトフレームワーク
  • React Three Fiber — Three.jsのReactバインディング
  • Three.js — PLYLoader、Points、Raycaster
  • TypeScript

コンポーネント構成

src/
├── pages/
│   └── PointCloudViewer.astro    # エントリポイント
└── components/
    └── PointCloudViewer/
        ├── PointCloudApp.tsx      # メインアプリ(state管理)
        ├── PointCloudScene.tsx    # 3D描画・PLY読み込み
        └── ControlPanel.tsx      # GUI操作パネル

Astroページは client:only="react" でReactコンポーネントをマウントするだけ。3D描画とUI操作はすべてReact側で完結する。

<Layout title="Point Cloud Viewer">
  <div class="app">
    <PointCloudApp client:only="react" />
  </div>
</Layout>

PLYファイルの読み込み

PLY(Polygon File Format)は点群データの標準フォーマットの一つ。ヘッダーにプロパティ定義、その後に座標データが並ぶシンプルな構造。

ply
format ascii 1.0
element vertex 40256
property float x
property float y
property float z
end_header
-0.06325 0.0359793 0.0420873
-0.06275 0.0360343 0.0425949
...

Three.jsのPLYLoaderでパースし、THREE.Pointsで描画する。

const loader = new PLYLoader();
const reader = new FileReader();

reader.onload = (e) => {
  const buffer = e.target?.result as ArrayBuffer;
  const geometry = loader.parse(buffer);
  // geometryにposition属性が入る
};

reader.readAsArrayBuffer(file);

複数PLYの結合

ファイル選択でmultiple属性を有効にし、複数PLYを同時に読み込んで頂点データを結合する。

files.forEach((file) => {
  const reader = new FileReader();
  reader.onload = (e) => {
    const geo = loader.parse(buffer);
    // allPositions配列に追加していく
    for (let i = 0; i < geo.attributes.position.count; i++) {
      allPositions.push(
        positions.getX(i), positions.getY(i), positions.getZ(i)
      );
    }
  };
});

ただし、異なる座標系で撮影されたデータ(Stanford Bunnyの各角度スキャンなど)は、変換行列を適用しないと位置がずれる。同一座標系のデータ(Scaniverseの出力など)であればそのまま結合できる。

ヒートマップ表示

各頂点のY座標をバウンディングボックスの最小〜最大で0〜1に正規化し、青→シアン→緑→黄→赤のグラデーションに変換する。

function heightToColor(t: number): [number, number, number] {
  if (t < 0.25) {
    const s = t / 0.25;
    return [0, s, 1];       // 青→シアン
  } else if (t < 0.5) {
    const s = (t - 0.25) / 0.25;
    return [0, 1, 1 - s];   // シアン→緑
  } else if (t < 0.75) {
    const s = (t - 0.5) / 0.25;
    return [s, 1, 0];       // 緑→黄
  } else {
    const s = (t - 0.75) / 0.25;
    return [1, 1 - s, 0];   // 黄→赤
  }
}

BufferGeometrycolor属性に値を設定し、vertexColors: trueのPointsMaterialで描画する。

スライス断面

指定した軸(X/Y/Z)のMin〜Max範囲内の点だけを抽出して表示する。CTスキャンのように内部構造を確認できる。

if (settings.sliceEnabled) {
  const axisIndex = { x: 0, y: 1, z: 2 }[settings.sliceAxis];
  for (let i = 0; i < count; i++) {
    const val = positions.array[i * 3 + axisIndex];
    if (val >= settings.sliceMin && val <= settings.sliceMax) {
      newPositions.push(/* ... */);
    }
  }
}

計測ツール

レイキャストの最適化

R3FのonClickプロパティを<points>に付けると、毎フレームレイキャストの準備処理が走って重くなる。解決策として、R3Fのイベントシステムを使わず、Measureモード時のみcanvasのclickイベントで自前レイキャストを実行する。

useEffect(() => {
  if (settings.toolMode !== 'measure') return;

  const canvas = gl.domElement;

  const handleCanvasClick = (e: MouseEvent) => {
    const rect = canvas.getBoundingClientRect();
    const mouse = new THREE.Vector2(
      ((e.clientX - rect.left) / rect.width) * 2 - 1,
      -((e.clientY - rect.top) / rect.height) * 2 + 1
    );

    const rc = new THREE.Raycaster();
    rc.setFromCamera(mouse, camera);
    rc.params.Points = { threshold: settings.pointSize * 0.002 };

    const intersects = rc.intersectObject(pointsRef.current);
    if (intersects.length > 0) {
      onPointClick({
        position: intersects[0].point.clone(),
        index: intersects[0].index ?? 0,
      });
    }
  };

  canvas.addEventListener('click', handleCanvasClick);
  return () => canvas.removeEventListener('click', handleCanvasClick);
}, [settings.toolMode, settings.pointSize, camera, gl, onPointClick]);

これにより、Orbitモード時はレイキャスト処理がゼロになり、Measureモードでもクリックした瞬間だけ走る。

ヘロンの公式による面積計算

3点を選択すると、ヘロンの公式で三角形の面積を算出する。

function calcArea(a: Vector3, b: Vector3, c: Vector3): number {
  const ab = a.distanceTo(b);
  const bc = b.distanceTo(c);
  const ca = c.distanceTo(a);
  const s = (ab + bc + ca) / 2;
  return Math.sqrt(s * (s - ab) * (s - bc) * (s - ca));
}

選択点にはピンクのSphereマーカーを配置し、2点以上でラインを描画、3点で半透明の三角形面を表示する。

SORノイズ除去

アルゴリズム

Statistical Outlier Removal (SOR) は、各点のk近傍平均距離を計算し、全体の平均+標準偏差×倍率を閾値として、それを超える点をノイズとして除去する。

O(n²)問題

素朴な実装では各点について全点との距離を計算するため O(n²) になる。40,256点で約16億回の距離計算が必要で、ブラウザがフリーズする。

空間ハッシュグリッドによる高速化

空間を均等なセルに分割し、各点の近傍27セル内の点だけを探索する。

// グリッド構築
const cellSize = Math.max(size.x, size.y, size.z) / 50;
const grid = new Map<string, number[]>();

for (let i = 0; i < count; i++) {
  const cx = Math.floor(x / cellSize);
  const cy = Math.floor(y / cellSize);
  const cz = Math.floor(z / cellSize);
  const key = `${cx},${cy},${cz}`;
  if (!grid.has(key)) grid.set(key, []);
  grid.get(key)!.push(i);
}

// 近傍探索(27セルのみ)
for (let dx = -1; dx <= 1; dx++) {
  for (let dy = -1; dy <= 1; dy++) {
    for (let dz = -1; dz <= 1; dz++) {
      const key = `${cx + dx},${cy + dy},${cz + dz}`;
      const cell = grid.get(key);
      if (!cell) continue;
      for (const j of cell) {
        // 距離計算
      }
    }
  }
}

O(n²) → O(n × セル内点数) で、40,256点が3-4分かかっていた処理が一瞬で終わるようになった。

エクスポート

PLY出力

ASCII形式のPLYファイルを生成してダウンロードする。ヘッダーにプロパティ定義を書き、頂点座標(と色情報)を出力する。

GLB出力

Three.jsのGLTFExporterを使い、THREE.PointsオブジェクトをGLB形式でエクスポートする。Babylon.js SandboxやBlenderで読み込み可能。

const { GLTFExporter } = await import(
  'three/examples/jsm/exporters/GLTFExporter.js'
);

const exporter = new GLTFExporter();
exporter.parse(scene, (result) => {
  const blob = new Blob([result as ArrayBuffer], {
    type: 'application/octet-stream',
  });
  // ダウンロード処理
}, (error) => {}, { binary: true });

SOR後の再描画

SORでgeometryを差し替えた後、Reactのstateが更新されないため再描画されない問題があった。updateTriggerカウンターをstateに追加し、SOR後にインクリメントすることでPointCloudSceneのuseEffectをトリガーして解決。

// PointCloudApp.tsx
const [updateTrigger, setUpdateTrigger] = useState(0);

// SOR後
setUpdateTrigger((prev) => prev + 1);

// PointCloudScene.tsx
useEffect(() => {
  if (updateTrigger === 0) return;
  const geo = geometryRef.current;
  if (!geo) return;
  setBaseGeometry(geo);
}, [updateTrigger]);

テストデータ

Stanford Bunny(Stanford 3D Scanning Repository)を使用。1996年にスタンフォード大学がLiDARスキャナで撮影した点群データで、CG業界では定番のテストモデル。bun000.ply(40,256点、約1.9MB)で全機能の動作を確認した。

まとめ

PLYの読み込みから、ヒートマップ・スライス・計測・ノイズ除去・エクスポートまで、点群処理ソフトの基本機能をブラウザで実現できた。

実装のポイント:

  • R3FのonClickは毎フレームレイキャストが走るため、点群では自前のclickイベントを使う
  • SORノイズ除去はO(n²)のまま実装するとブラウザがフリーズする。空間ハッシュグリッドで高速化が必須
  • SOR後の再描画にはReactのstateトリガーが必要(refだけでは再描画されない)