[Astro] #109 Point Cloud Viewer — Intensity/Classification表示&LAS/PCDエクスポート
概要
前回[#108]でLAZ圧縮対応とXYZ/CSVの入出力を追加した。今回はIntensity/Classificationカラーモード、LAS/PCDエクスポート、スクリーンショット保存を追加して、Point Cloud Viewerの機能を完成させる。
[Astro] #108 Point Cloud Viewer — LAZ圧縮対応&XYZ/CSVインポート/エクスポート // PROTOCOL.LAIN
Astro + React Three Fiber製の点群ビューアにLAZ圧縮フォーマット対応を追加。laz-perf WASMのESMパッチ適用、Emscriptenモジュールの静的配信、解凍ループのチャンク分割による進捗表示、XYZ/CSVテキストフォーマットの入出力対応までの全工程を解説。
lain-lab.comこれで入力6フォーマット(PLY/LAS/LAZ/PCD/XYZ/CSV)、出力6フォーマット(PLY/GLB/LAS/PCD/XYZ/CSV)+スクリーンショット保存に対応。
スクリーンショット
Intensity
Classification
動画(GIF)
スクリーンショット保存
CanvasのWebGLレンダリング結果をPNG画像として保存する機能。
const handleScreenshot = useCallback(() => {
const canvas = document.querySelector('canvas');
if (!canvas) return;
const url = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = url;
a.download = 'screenshot.png';
a.click();
}, []);
ポイントは、Canvasの gl オプションに preserveDrawingBuffer: true を追加すること。これがないと toDataURL が真っ黒な画像を返す。WebGLはデフォルトで描画後にバッファをクリアするため、バッファの保持を明示的に指定する必要がある。
<Canvas
gl={{ antialias: true, preserveDrawingBuffer: true }}
...
/>
Intensity/Classification カラーモード
parseLAS.ts の拡張
LASのPoint Dataには座標とRGBの他にIntensity(反射強度)とClassification(分類コード)が含まれている。すべてのPoint Format(0〜3)で共通の位置にある。
Offset 12-13: Intensity (uint16)
Offset 15: Classification (uint8)
LASResultにこれらの配列を追加して返す。
export interface LASResult {
positions: Float32Array;
colors: Float32Array | null;
intensity: Float32Array; // 0–1 normalized
classification: Uint8Array; // ASPRS classification code
count: number;
}
Intensity の自動範囲検出
Intensityはuint16(0〜65535)で格納されるが、実際のデータは0〜255や0〜数千の範囲に収まることが多い。RGBの8bit/16bit自動判定と同様に、サンプリングで最大値を検出して正規化する。
let intensityMax = 0;
const sampleStep = Math.max(1, Math.floor(numPoints / 1000));
for (let i = 0; i < numPoints; i += sampleStep) {
const intVal = view.getUint16(base + 12, true);
intensityMax = Math.max(intensityMax, intVal);
}
const intensityDivisor = intensityMax || 1;
// 各点で:
intensity[i] = rawIntensity / intensityDivisor;
65535で固定割りすると大半のデータで真っ暗になるため、この自動検出は必須。
Classification の色テーブル
ASPRSが定めたLiDAR分類コードに基づいて色分けする。
function classificationColor(code: number): [number, number, number] {
switch (code) {
case 0: return [0.6, 0.6, 0.6]; // Never classified
case 1: return [0.8, 0.8, 0.8]; // Unclassified
case 2: return [0.6, 0.4, 0.2]; // Ground (brown)
case 3: return [0.5, 0.8, 0.3]; // Low Vegetation
case 4: return [0.2, 0.7, 0.2]; // Medium Vegetation
case 5: return [0.0, 0.5, 0.0]; // High Vegetation
case 6: return [1.0, 0.2, 0.2]; // Building (red)
case 7: return [0.3, 0.3, 0.3]; // Low Point (noise)
case 9: return [0.0, 0.4, 1.0]; // Water (blue)
case 17: return [0.2, 0.6, 0.8]; // Bridge Deck
// ... 他のコードも定義
}
}
分類済みのLiDARデータ(建物・植生・地面が分離されたもの)であれば、地面が茶色、植生が緑、建物が赤で表示される。未分類データでは全点同色になるが、これはデータ側の問題で正常な挙動。
カスタム属性としてジオメトリに格納
IntensityとClassificationはThree.jsのBufferGeometryにカスタム属性として設定し、カラーモード切替時に参照する。
mergedGeo.setAttribute(
'intensity',
new THREE.Float32BufferAttribute(finalIntensity, 1)
);
mergedGeo.setAttribute(
'classification',
new THREE.BufferAttribute(finalClassification, 1)
);
サンプリング時にもこれらの配列を正しく間引く必要がある。当初 push(...sampledArray) でスプレッド展開していたが、200万要素のスプレッドでコールスタックオーバーフローが発生。TypedArrayの直接代入に修正した。
LAS エクスポート
ヘッダー構築
LAS 1.2のヘッダーは227バイト。主要なフィールドをバイナリで構築する。
[0-3] Signature: "LASF"
[24-25] Version: 1.2
[94-95] Header Size: 227
[96-99] Offset to Point Data: 227
[104] Point Data Format: 0 (no RGB) or 2 (with RGB)
[105-106] Point Record Length: 20 or 26
[107-110] Number of Points
[131-178] Scale X/Y/Z, Offset X/Y/Z
[179-226] Bounding Box (Max/Min for each axis)
スケールは 0.000001(6桁精度)に固定し、オフセットはバウンディングボックスの中心を使う。
座標系の逆変換
ここが最大のハマりポイント。LASを読み込む時に座標系を変換している。
読み込み時: LAS (X, Y, Z=up) → Three.js (X, Y=up, -Z)
エクスポート時にこの逆変換をしないと、再読み込み時に二重変換されて形状が壊れる。
// Three.js → LAS 逆変換
const lasX = positions.getX(i); // Three.js X → LAS X
const lasY = -positions.getZ(i); // Three.js -Z → LAS Y
const lasZ = positions.getY(i); // Three.js Y → LAS Z (up)
バウンディングボックスの計算もLAS座標系で行う必要がある。
Intensity / Classification の保持
エクスポート時にジオメトリのカスタム属性から読み出して、LASの対応フィールドに書き戻す。
const intensity = hasIntensity
? Math.round(geo.attributes.intensity.getX(i) * 65535) : 0;
view.setUint16(base + 12, intensity, true);
const cls = hasClassification
? geo.attributes.classification.getX(i) : 0;
view.setUint8(base + 15, cls);
PCD エクスポート
ヘッダー構築
PCD v0.7のテキストヘッダーを構築し、Binary形式でデータを書き出す。
# .PCD v0.7 - Point Cloud Data file format
VERSION 0.7
FIELDS x y z rgb
SIZE 4 4 4 4
TYPE F F F F
COUNT 1 1 1 1
WIDTH <点数>
HEIGHT 1
VIEWPOINT 0 0 0 1 0 0 0
POINTS <点数>
DATA binary
Packed RGB Float
PCLのRGB格納方式に従い、RGBの各バイトをuint32に詰めてfloat32のビットパターンとして書き込む。
const rgb = (r << 16) | (g << 8) | b;
const tmpBuf = new ArrayBuffer(4);
new Uint32Array(tmpBuf)[0] = rgb;
const rgbFloat = new Float32Array(tmpBuf)[0];
dataView.setFloat32(base + 12, rgbFloat, true);
座標系の逆変換
LAS同様、PCD読み込み時の変換(Z→Y, Y→-Z)を逆変換する。
dataView.setFloat32(base, positions.getX(i), true); // X → X
dataView.setFloat32(base + 4, -positions.getZ(i), true); // -Z → Y
dataView.setFloat32(base + 8, positions.getY(i), true); // Y → Z
対応フォーマット一覧(最終版)
入力(6種類)
| 形式 | 実装 | 特徴 |
|---|---|---|
| PLY | Three.js PLYLoader | ASCII / Binary |
| LAS | 自前パーサー | 1.0–1.4 / Format 0–3 / RGB / Intensity / Classification |
| LAZ | laz-perf WASM → parseLAS | 圧縮LAS / 進捗表示 |
| PCD | 自前パーサー | ASCII / Binary / LZF圧縮 |
| XYZ | 自前パーサー | スペース区切りテキスト |
| CSV | 自前パーサー | カンマ区切り / ヘッダー自動判定 |
出力(6種類 + スクリーンショット)
| 形式 | 特徴 |
|---|---|
| PLY (ASCII) | 頂点座標+頂点カラー |
| GLB | Three.js GLTFExporter |
| LAS 1.2 | Format 0/2 / Intensity・Classification保持 / 座標逆変換 |
| PCD v0.7 | Binary / packed RGB float / 座標逆変換 |
| XYZ | スペース区切りテキスト |
| CSV | カンマ区切り / ヘッダー付き |
| PNG | Canvas スクリーンショット |
カラーモード(4種類)
| モード | 表示 |
|---|---|
| Original | ファイルのRGBカラー |
| Height Heatmap | Y座標に基づく青→赤グラデーション |
| Intensity | LAS反射強度のグレースケール |
| Classification | ASPRS分類コードの色テーブル |
まとめ
Point Cloud Viewerの機能が一通り完成した。
3日間の開発で実装した機能:
- v1: PLY読み込み、ヒートマップ、スライス、計測、SOR、PLY/GLBエクスポート
- v2: LAS/PCD読み込み、200万点サンプリング、測地座標の正規化
- v3: LAZ読み込み、XYZ/CSVインポート/エクスポート、ローディングインジケーター
- v4: Intensity/Classification表示、LAS/PCDエクスポート、スクリーンショット保存
実装のポイント:
- LASのIntensityは固定値で割ると真っ暗になるため、サンプリングで最大値を検出して正規化する
- エクスポート時の座標系逆変換を忘れると、再読み込みで二重変換されて形状が壊れる
- 200万要素の配列をspread展開するとコールスタックオーバーフロー。TypedArrayの直接代入で回避