[Astro] #106 PLY点群データビューアを自作 — React Three Fiber + SOR高速ノイズ除去
概要
PLY形式の点群データをブラウザで閲覧・編集・エクスポートできるWebツール「Point Cloud Viewer」を Astro + React Three Fiber で実装した。
業務用の点群処理ソフトは100万円を超えるものもあるが、中身を分解すると個々の機能は数学的にそこまで複雑ではない。必要な機能だけを自作して、ブラウザ完結のツールとして公開する。
点群データのサンプルは「スタンフォード大学3Dスキャンリポジトリ」からお借りしています。
The Stanford 3D Scanning Repository
In recent years, the number of range scanners and surface reconstruction algorithms has been growing rapidly. Many researchers, however, do not have access to scanning facilities or dense polygonal models. The purpose of this repository is to make some range data and detailed reconstructions available to the public. Here's how the models in this repository were created:
graphics.stanford.eduスクリーンショット
動画(gif)
技術スタック
- 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]; // 黄→赤
}
}
BufferGeometryのcolor属性に値を設定し、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だけでは再描画されない)