[Astro] #107 Point Cloud Viewer — LAS/PCD対応&大規模点群サンプリング
概要
前回(#106)でPLY形式の点群ビューアを作った。今回はLASとPCDに対応させて、3フォーマット対応の汎用点群ビューアに拡張。
[Astro] #106 PLY点群データビューアを自作 — React Three Fiber + SOR高速ノイズ除去 // PROTOCOL.LAIN
iPhone LiDARや写真測量で取得した点群データ(PLY)をブラウザ上で閲覧・編集できるWebツールの実装記録。Three.jsのPointsとPLYLoaderによる描画、高さベースヒートマップ、軸別スライス断面、レイキャストによる計測ツール、空間ハッシュグリッドで高速化したSORノイズ除去、PLY/GLBエクスポートまでの全工程。
lain-lab.comLASは測量・LiDAR業界の標準フォーマット、PCDはPoint Cloud Library(PCL)の標準フォーマット。どちらもパーサーを自前で書いてブラウザ完結で動くように変更
テストデータとして、静岡県が公開しているVIRTUAL SHIZUOKAの富士山火口LiDARデータ(約1200万点)と、PCLプロジェクトのoffice.pcdを使用させてもらってます。
VIRTUAL SHIZUOKA 静岡県 富士山および静岡東部 点群データ
CC BY 4.0 / ODbLデュアルライセンスで公開されている3次元点群データ。
www.geospatial.jp
Point Cloud Library
Standalone, large scale, open project for 3D point cloud processing. Brought to you by: aichim, holzers, jspricke, pointclouds, sgedikli
sourceforge.netスクリーンショット
LAS
PCD
動画(GIF)LAS (VIRTUAL SHIZUOKAの富士山火口LiDARデータ)
動画(GIF)PCD (PCLプロジェクト office.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] ← 点の座標・色・分類情報の配列
ヘッダーから読む重要なフィールド:
| オフセット | サイズ | 内容 |
|---|---|---|
| 0 | 4 | シグネチャ “LASF” |
| 24-25 | 2 | バージョン (1.2, 1.4 など) |
| 96 | 4 | 点データの開始位置 |
| 104 | 1 | Point Data Format ID (0–3) |
| 105 | 2 | 1点あたりのバイト数 |
| 107 | 4 | 点の数 (LAS 1.4は247に64bit) |
| 131–178 | 48 | Scale X/Y/Z, Offset X/Y/Z |
| 179–226 | 48 | Bounding Box (Min/Max) |
座標の変換
LASの座標はint32で格納されており、実数座標への変換が必要。
実座標 = 整数値 × Scale + Offset
const xi = view.getInt32(base, true);
const x = xi * scaleX + offsetX;
座標系の変換とセンタリング+正規化
LASは測地座標系を使うため、座標値が数十万(メートル単位)になることがある。そのままThree.jsに渡すと、ポイントサイズやカメラ設定が破綻する。
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 |
|---|---|---|
| 0 | XYZ + Intensity + 分類情報 | なし |
| 1 | Format 0 + GPS時刻 | なし |
| 2 | Format 0 + RGB | あり (offset 20) |
| 3 | Format 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_compressed | LZF圧縮+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 Bunny | PLY | 40,256 | なし | ✅ |
| DepthViz iPhone LiDARスキャン | LAS 1.2 Format 2 | 58,576 | なし | ✅ |
| 富士山火口 (VIRTUAL SHIZUOKA) | LAS | 11,918,448 | → 2,000,000 | ✅ |
| PCL office.pcd | PCD binary_compressed | 254,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万点に間引く
- フォーマット判別は拡張子ではなくマジックバイトで行う