[Astro] #107 Point Cloud Viewer — LAS/PCD対応&大規模点群サンプリング

[Astro] #107 Point Cloud Viewer — LAS/PCD対応&大規模点群サンプリング

概要

前回(#106)でPLY形式の点群ビューアを作った。今回はLASとPCDに対応させて、3フォーマット対応の汎用点群ビューアに拡張。

LASは測量・LiDAR業界の標準フォーマット、PCDはPoint Cloud Library(PCL)の標準フォーマット。どちらもパーサーを自前で書いてブラウザ完結で動くように変更

テストデータとして、静岡県が公開しているVIRTUAL SHIZUOKAの富士山火口LiDARデータ(約1200万点)と、PCLプロジェクトのoffice.pcdを使用させてもらってます。

スクリーンショット

LAS

Point Cloud Viewer — LAS/PCD対応

PCD

Point Cloud Viewer — LAS/PCD対応

動画(GIF)LAS (VIRTUAL SHIZUOKAの富士山火口LiDARデータ)

Point Cloud Viewer — LAS/PCD対応

動画(GIF)PCD (PCLプロジェクト office.pcd)

Point Cloud Viewer — LAS/PCD対応

コンポーネント構成

src/components/PointCloudViewer/
├── PointCloudApp.tsx      # メインアプリ(state管理)
├── PointCloudScene.tsx    # 3D描画・ファイル読み込み振り分け
├── ControlPanel.tsx       # GUI操作パネル
├── parseLAS.ts            # LASバイナリパーサー [NEW]
└── parsePCD.ts            # PCDパーサー + LZF解凍 [NEW]

フォーマット自動判別

拡張子ではなく、ファイル先頭のバイト列で判別する。バイナリファイルの拡張子は信用できない。

const header = new Uint8Array(buffer, 0, Math.min(64, buffer.byteLength));

// LAS: 先頭4バイトが "LASF"
const isLAS =
  header[0] === 0x4c && header[1] === 0x41 &&
  header[2] === 0x53 && header[3] === 0x46;

// PCD: テキストヘッダーが "# .PCD" or "VERSION" で始まる
const headerStr = new TextDecoder().decode(header);
const isPCD = headerStr.startsWith('# .PCD') || headerStr.startsWith('VERSION');

// それ以外はPLYとしてThree.jsのPLYLoaderに渡す

LASパーサーの実装

LASフォーマットの構造

LAS(Log ASCII Standard)はASPRS(米国写真測量学会)が策定したLiDARデータのバイナリフォーマット。名前に反してバイナリ。

[Public Header Block]     ← ファイル情報、点数、座標変換パラメータ
[Variable Length Records]  ← 投影法などのメタデータ
[Point Data Records]       ← 点の座標・色・分類情報の配列

ヘッダーから読む重要なフィールド:

オフセットサイズ内容
04シグネチャ “LASF”
24-252バージョン (1.2, 1.4 など)
964点データの開始位置
1041Point Data Format ID (0–3)
10521点あたりのバイト数
1074点の数 (LAS 1.4は247に64bit)
131–17848Scale X/Y/Z, Offset X/Y/Z
179–22648Bounding Box (Min/Max)

座標の変換

LASの座標はint32で格納されており、実数座標への変換が必要。

実座標 = 整数値 × Scale + Offset
const xi = view.getInt32(base, true);
const x = xi * scaleX + offsetX;

座標系の変換とセンタリング+正規化

LASは測地座標系を使うため、座標値が数十万(メートル単位)になることがある。そのままThree.jsに渡すと、ポイントサイズやカメラ設定が破綻する。

2つの処理で解決する:

  1. センタリング — バウンディングボックスの中心を原点に移動
  2. 正規化 — 最大寸法が1.0になるようスケーリング
const centerX = (xMin + xMax) / 2;
const maxExtent = Math.max(xMax - xMin, yMax - yMin, zMax - zMin) || 1;
const normalizeScale = 1.0 / maxExtent;

const x = (xi * scaleX + offsetX - centerX) * normalizeScale;

さらにLASの座標系(Z=上)をThree.jsの座標系(Y=上)に変換する。

// LAS: X=easting, Y=northing, Z=elevation
// Three.js: X=right, Y=up, Z=toward camera
positions[i * 3]     = (xi * scaleX + offsetX - centerX) * normalizeScale;
positions[i * 3 + 1] = (zi * scaleZ + offsetZ - centerZ) * normalizeScale; // Z→Y
positions[i * 3 + 2] = -(yi * scaleY + offsetY - centerY) * normalizeScale; // Y→-Z

Point Data Format と RGB

LASのPoint Data Formatによってレコードの構造が変わる。

Format内容RGB
0XYZ + Intensity + 分類情報なし
1Format 0 + GPS時刻なし
2Format 0 + RGBあり (offset 20)
3Format 1 + RGBあり (offset 28)

RGBは16bit(0–65535)で格納されるが、一部のソフトは8bit値(0–255)を16bitフィールドに入れる。サンプリングで自動判定する。

let sampleMax = 0;
const sampleStep = Math.max(1, Math.floor(numPoints / 1000));
for (let i = 0; i < numPoints; i += sampleStep) {
  const r = view.getUint16(base + rgbOffset, true);
  sampleMax = Math.max(sampleMax, r, g, b);
}
const rgbDivisor = sampleMax > 255 ? 65535 : 255;

PCDパーサーの実装

PCDフォーマットの構造

PCD(Point Cloud Data)はPCL(Point Cloud Library)の標準フォーマット。テキストヘッダー+データ本体の構造。

# .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 640
HEIGHT 480
VIEWPOINT 0 0 0 0 1 0 0
POINTS 307200
DATA binary_compressed

DATAセクションは3種類:

タイプ内容
asciiテキストベース。1行1点
binary非圧縮バイナリ。row-major(1点ずつ全フィールドが並ぶ)
binary_compressedLZF圧縮+column-major(全x値→全y値→…)

Packed RGB Float

PCLのRGBは独特で、RGBの各バイトを1つのfloat32のビットパターンに詰め込んでいる。

float のビット列: 0x00RRGGBB

デコードにはfloatのビットパターンをint32として再解釈する。

function decodePackedRGB(value: number): [number, number, number] {
  const buf = new ArrayBuffer(4);
  new Float32Array(buf)[0] = value;
  const int = new Uint32Array(buf)[0];
  const r = ((int >> 16) & 0xff) / 255;
  const g = ((int >> 8) & 0xff) / 255;
  const b = (int & 0xff) / 255;
  return [r, g, b];
}

LZF解凍

binary_compressed 形式はLZF圧縮されている。LZFはMarc Lehmannが開発した軽量圧縮アルゴリズムで、解凍が非常に高速。

[compressed_size: uint32] [uncompressed_size: uint32] [LZF圧縮データ]

解凍後のデータはcolumn-majorで、全x値→全y値→全z値→全rgb値の順に並ぶ。

function decompressLZF(input: Uint8Array, expectedSize: number): Uint8Array {
  const output = new Uint8Array(expectedSize);
  let ip = 0, op = 0;

  while (ip < input.length && op < expectedSize) {
    let ctrl = input[ip++];
    if (ctrl < 32) {
      // リテラル: ctrl+1 バイトをそのままコピー
      const len = ctrl + 1;
      for (let i = 0; i < len; i++) output[op++] = input[ip++];
    } else {
      // 後方参照: 過去データからコピー
      let len = (ctrl >> 5) + 2;
      let ref = op - ((ctrl & 0x1f) << 8) - 1;
      if (len === 9) len += input[ip++];
      ref -= input[ip++];
      for (let i = 0; i < len; i++) output[op++] = output[ref++];
    }
  }
  return output;
}

NaNフィルタリング

PCDの organized point cloud(WIDTH×HEIGHTの2D構造を持つ点群)では、デプスカメラの範囲外の点がNaNで格納されている。これをフィルタリングして有効な点だけを抽出する。

307,200点(640×480) → 254,456点(有効点のみ)

大規模点群のランダムサンプリング

問題

富士山火口のLASデータは約1200万点。そのままWebGLで描画しようとすると、BufferGeometryのメモリ確保だけで数百MBになり、描画もフリーズする。

解決: Fisher-Yatesサンプリング

200万点を上限として、ランダムに間引く。Fisher-Yatesシャッフルの前半だけを使って、偏りのないサンプルを高速に取得する。

const MAX_POINTS = 2_000_000;

if (totalCount > MAX_POINTS) {
  const indices = new Uint32Array(totalCount);
  for (let i = 0; i < totalCount; i++) indices[i] = i;

  // Fisher-Yates partial shuffle
  for (let i = 0; i < MAX_POINTS; i++) {
    const j = i + Math.floor(Math.random() * (totalCount - i));
    [indices[i], indices[j]] = [indices[j], indices[i]];
  }

  // indices[0..MAX_POINTS-1] が偏りのないランダムサンプル
  for (let i = 0; i < MAX_POINTS; i++) {
    const src = indices[i] * 3;
    sampledPos[i * 3]     = allPositions[src];
    sampledPos[i * 3 + 1] = allPositions[src + 1];
    sampledPos[i * 3 + 2] = allPositions[src + 2];
  }
}

UIには「Points: 2,000,000 / 11,918,448 (sampled)」のように元の点数とサンプル数を表示する。

テスト結果

データフォーマット点数サンプリング結果
Stanford BunnyPLY40,256なし
DepthViz iPhone LiDARスキャンLAS 1.2 Format 258,576なし
富士山火口 (VIRTUAL SHIZUOKA)LAS11,918,448→ 2,000,000
PCL office.pcdPCD binary_compressed254,456なし

LAZについて

LASの圧縮版であるLAZ(LASzip)は、独自の算術符号化を使っているため自前パーサーでは対応が難しい。laz-perfなどのWASMライブラリが必要になる。測量データの配布はLAZが主流(LASの7〜15分の1のサイズ)なので、需要があれば追加する。

まとめ

PLY/LAS/PCDの3フォーマットに対応し、1200万点規模のLiDARデータもブラウザで表示できるようになった。

実装のポイント:

  • LASの座標は測地座標系で巨大な値になるため、センタリング+正規化が必須
  • PCDのRGBはfloatのビットパターンにRGBを詰め込むPCL独自仕様
  • PCDのbinary_compressedはLZF圧縮+column-majorレイアウト
  • 大規模点群はFisher-Yatesサンプリングで200万点に間引く
  • フォーマット判別は拡張子ではなくマジックバイトで行う