[Astro] #109 Point Cloud Viewer — Intensity/Classification表示&LAS/PCDエクスポート

[Astro] #109 Point Cloud Viewer — Intensity/Classification表示&LAS/PCDエクスポート

概要

前回[#108]でLAZ圧縮対応とXYZ/CSVの入出力を追加した。今回はIntensity/Classificationカラーモード、LAS/PCDエクスポート、スクリーンショット保存を追加して、Point Cloud Viewerの機能を完成させる。

これで入力6フォーマット(PLY/LAS/LAZ/PCD/XYZ/CSV)、出力6フォーマット(PLY/GLB/LAS/PCD/XYZ/CSV)+スクリーンショット保存に対応。

スクリーンショット

Intensity

Point Cloud Viewer — Intensity/Classification表示&LAS/PCDエクスポート

Classification

Point Cloud Viewer — Intensity/Classification表示&LAS/PCDエクスポート

動画(GIF)

Point Cloud Viewer — Intensity/Classification表示&LAS/PCDエクスポート

スクリーンショット保存

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種類)

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

出力(6種類 + スクリーンショット)

形式特徴
PLY (ASCII)頂点座標+頂点カラー
GLBThree.js GLTFExporter
LAS 1.2Format 0/2 / Intensity・Classification保持 / 座標逆変換
PCD v0.7Binary / packed RGB float / 座標逆変換
XYZスペース区切りテキスト
CSVカンマ区切り / ヘッダー付き
PNGCanvas スクリーンショット

カラーモード(4種類)

モード表示
OriginalファイルのRGBカラー
Height HeatmapY座標に基づく青→赤グラデーション
IntensityLAS反射強度のグレースケール
ClassificationASPRS分類コードの色テーブル

まとめ

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の直接代入で回避