[Astro] #108 Point Cloud Viewer — LAZ圧縮対応&XYZ/CSVインポート/エクスポート

[Astro] #108 Point Cloud Viewer — LAZ圧縮対応&XYZ/CSVインポート/エクスポート

概要

前回、[#107]でLASとPCDに対応した。今回はLAZ(LASzip圧縮)、XYZ/CSVの入出力、ローディングインジケーターを追加して、対応フォーマットを6種類に拡張する。

LAZはLASの圧縮版で、測量データの配布ではLAZが主流。圧縮率は7〜15分の1で、386MBのLASが30〜50MBのLAZになる。ただし独自の算術符号化を使っているため、自前パーサーでは対応できず、laz-perfのWASMライブラリを使う。

スクリーンショット

Point Cloud Viewer — LAZ圧縮対応 Point Cloud Viewer — LAZ圧縮対応

動画(GIF) LAZ

Point Cloud Viewer — LAZ圧縮対応

コンポーネント構成

src/components/PointCloudViewer/
├── PointCloudApp.tsx      # メインアプリ(state管理)
├── PointCloudScene.tsx    # 3D描画・ファイル読み込み振り分け
├── ControlPanel.tsx       # GUI操作パネル
├── parseLAS.ts            # LASバイナリパーサー
├── parsePCD.ts            # PCDパーサー + LZF解凍
├── parseXYZCSV.ts         # XYZ/CSVテキストパーサー [NEW]
├── decompressLAZ.ts       # LAZ→LAS解凍ラッパー [NEW]
├── laz-perf.js            # laz-perf WASMラッパー (ESMパッチ済み) [NEW]
└── laz-perf.d.ts          # 型定義 [NEW]
public/
└── PointCloudViewer/
    └── laz-perf.wasm      # LAZ解凍WASMバイナリ (210KB) [NEW]

LAZ対応の実装

WASMライブラリの選定

LAZの圧縮アルゴリズムは算術符号化ベースで、自前のJavaScript実装は現実的ではない。laz-perfはC++のLAZ実装をEmscriptenでWASMにコンパイルしたもので、ブラウザで使える唯一の選択肢。

npmパッケージとして公開されているが、Astro(Vite)のプロジェクトにnpm installすると @vite-pwa/astro のpeer dependency conflictが発生したため、npmを使わず必要なファイルだけを直接配置した。

Emscripten UMDモジュールのESMパッチ

laz-perf.jsはEmscriptenが生成するUMD(Universal Module Definition)形式で、末尾が以下のようになっている。

if (typeof exports === 'object' && typeof module === 'object')
  module.exports = createLazPerf;
else if (typeof define === 'function' && define['amd'])
  define([], function() { return createLazPerf; });
else if (typeof exports === 'object')
  exports["createLazPerf"] = createLazPerf;

ViteはESMを期待するため、この部分を以下に置き換える。

export default createLazPerf;

これだけで import createLazPerf from './laz-perf.js' が動くようになる。

JSとWASMの配置

Emscriptenモジュールは初期化時にWASMファイルを fetch で取得する。JSファイルはViteがバンドルするが、WASMファイルはバンドルされずに別ファイルとしてブラウザに配信する必要がある。

src/components/PointCloudViewer/laz-perf.js  ← Viteがバンドル
public/PointCloudViewer/laz-perf.wasm         ← そのまま静的配信

locateFile オプションでWASMのパスを指定する。

const module = await createLazPerf({
  locateFile: () => '/PointCloudViewer/laz-perf.wasm',
});

解凍の仕組み

laz-perfのAPIは1点ずつ解凍するインターフェースになっている。

const laszip = new module.LASZip();
laszip.open(inputPtr, lazData.length);

const pointCount = laszip.getCount();       // 点の数
const pointLength = laszip.getPointLength(); // 1点のバイト数

for (let i = 0; i < pointCount; i++) {
  laszip.getPoint(pointPtr);  // 1点をWASMメモリに解凍
  // pointPtrから読み取ってoutputに書き込む
}

解凍結果は元のLASヘッダー+解凍済みの点データで構成されるLAS互換のArrayBufferとして構築し、既存の parseLAS() にそのまま渡す。パーサーの二重実装を避けられる。

チャンク分割による進捗表示

2180万点を1点ずつ解凍するループはブラウザのメインスレッドをブロックする。50万点ごとに setTimeout でUIスレッドに制御を返し、進捗を更新する。

const CHUNK_SIZE = 500_000;

for (let i = 0; i < pointCount; i++) {
  laszip.getPoint(pointPtr);
  outputBytes.set(pointData, headerSize + i * pointLength);

  if (i > 0 && i % CHUNK_SIZE === 0) {
    const percent = Math.round((i / pointCount) * 100);
    onProgress?.(percent);
    await new Promise((r) => setTimeout(r, 0));
  }
}

UIには「Decompressing LAZ: 23%」のように表示される。2180万点で約43回の進捗更新が入る。

LAZファイルの判別

LAZファイルはLASと同じ LASF シグネチャを持つため、マジックバイトでは区別できない。拡張子 .laz で判別し、解凍後にマジックバイト判定でLASパーサーに振り分ける。

const isLAZ = file.name.toLowerCase().endsWith('.laz');
if (isLAZ) {
  buffer = await decompressLAZ(buffer, (percent) => {
    onLoadingStart(`Decompressing LAZ: ${percent}%`);
  });
}
// この後、通常のLASF判定 → parseLAS() に流れる

ローディングインジケーター

ファイル読み込み・解凍・パース・サンプリングの各段階でステータスメッセージを表示する。

Loading 1 file(s)...
→ Decompressing LAZ: filename (105.1 MB)...
→ Decompressing LAZ: 23%
→ Parsing: filename...
→ Sampling: 21,812,790 → 2,000,000 points...
→ (完了 → オーバーレイ非表示)

CSSアニメーションのスピナーとステータステキストを半透明オーバーレイで表示する。z-index: 1000 で全面を覆い、処理完了時に setLoading(null) で非表示にする。

XYZ/CSVインポート/エクスポート

フォーマット

XYZはスペース区切り、CSVはカンマ区切りのテキスト形式。

# XYZ(ヘッダーなし、スペース区切り)
0.123456 0.234567 0.345678
0.123456 0.234567 0.345678 128 64 32    ← RGB付き

# CSV(ヘッダーあり、カンマ区切り)
x,y,z,r,g,b
0.123456,0.234567,0.345678,128,64,32

インポート

拡張子(.xyz / .csv / .txt)で判別し、テキストパーサーに振り分ける。CSVの場合は1行目がヘッダーかどうかを自動判定する(非数値を含む場合はヘッダーとみなしてスキップ)。

RGBの値域も自動判定する。最大値が1を超えたら0–255として扱い、1以下なら0–1として扱う。

エクスポート

4種類のエクスポート(PLY / GLB / XYZ / CSV)に対応。XYZとCSVはエクスポートした結果をそのままインポートし直せる(ラウンドトリップ対応)。

テスト結果

データフォーマット点数結果
USGS Ouachita LiDARLAZ21,812,790 → 2,000,000✅ 約2分で解凍+表示
Export→Import (XYZ)XYZ2,000,000✅ ラウンドトリップ
Export→Import (CSV)CSV2,000,000✅ ラウンドトリップ

対応フォーマット一覧

入力(6種類)

形式実装特徴
PLYThree.js PLYLoaderASCII / Binary
LAS自前パーサー1.0–1.4 / Format 0–3 / RGB
LAZlaz-perf WASM → parseLAS圧縮LAS / 進捗表示
PCD自前パーサーASCII / Binary / LZF圧縮
XYZ自前パーサースペース区切りテキスト
CSV自前パーサーカンマ区切り / ヘッダー自動判定

出力(4種類)

形式説明
PLY (ASCII)頂点座標+頂点カラー
GLBThree.js GLTFExporter
XYZスペース区切りテキスト
CSVカンマ区切り / ヘッダー付き

まとめ

LAZ対応でLiDAR測量データの標準的な配布形式を読めるようになった。

実装のポイント:

  • Emscripten UMDモジュールは末尾を export default に書き換えるだけでViteで使える
  • WASMファイルはpublicフォルダに配置して locateFile でパスを指定
  • 大量点の解凍ループはチャンク分割+setTimeoutでUI応答性を確保
  • LAZ/LASはマジックバイトが同じ(LASF)なので拡張子で判別
  • XYZ/CSVはテキストベースなのでラウンドトリップ確認が容易