[JavaScript] 極小250KBのLAZ圧縮Wasmを自作してブラウザでLAS→LAZ変換を動かす

[JavaScript] 極小250KBのLAZ圧縮Wasmを自作してブラウザでLAS→LAZ変換を動かす

はじめに: なぜLAZ圧縮をWasm化するのか

LiDARの点群データで広く使われるLASファイルは、数百万〜数千万のポイントを格納するためファイルサイズが数百MBに達することも珍しくありません。LAZはLASの完全ロスレス圧縮フォーマットで、元のサイズの7〜20%まで縮小できます。

当サイトの点群ビューアツール(Point Cloud Viewer)ではlaz-perfのWASMを使ったLAZ読み込み(解凍)は実装済みでしたが、LAZ書き出し(圧縮)は未対応でした。圧縮用のWASMライブラリが大きすぎてビューアに同梱するのは重いと判断し、単体ツールとして切り出すことにしました。

しかし、laz-perfのnpmパッケージ(v0.0.7)にはデコンプレッサー(解凍)のバインディングしか含まれておらず、コンプレッサー(圧縮)のEmscriptenバインディングは存在しません。そこで、前回のOpenCV Wasm記事と同じアプローチで、C++ソースにバインディングを自作してカスタムWASMをビルドすることにしました。

1. laz-perfの構造を理解する

laz-perfとは

laz-perfは、hobuinc(PDAL開発元)が開発するLAZ圧縮の代替実装です。オリジナルのLASzip(rapidlasso社)と異なり、依存ゼロの軽量C++ライブラリとして設計されており、Emscriptenによるブラウザ向けWASMコンパイルを公式にサポートしています。

既存のEmscriptenバインディング

リポジトリの cpp/emscripten/laz-perf.cpp を確認すると、JSに公開されているのは以下の2クラスのみです。

  • LASZip — LAZ/LASファイル全体のメモリ読み込みとポイント取得
  • ChunkDecoder — LAZチャンク単位のデコンプレッション

つまり「読む」ことしかできません。一方、C++側のヘッダー writers.hpp には圧縮に必要なクラスが揃っています。

  • basic_file — ostreamバックエンドのLAZ書き込み基底クラス
  • chunk_compressor — チャンク単位のポイント圧縮

これらのバインディングを追加すれば、ブラウザでLAZ圧縮が可能になります。

2. カスタムバインディングの作成

追加する2つのクラス

既存の解凍バインディング(LASZip, ChunkDecoder)に加え、以下の2クラスを新たにEmscriptenのEmbindで公開します。

ChunkCompressor — ポイントチャンク単位の低レベル圧縮API:

class ChunkCompressor
{
public:
    ChunkCompressor(int format, int ebCount)
        : comp_(format, ebCount) {}

    void addPoint(unsigned int inBuf)
    {
        const char *buf = reinterpret_cast<const char *>(inBuf);
        comp_.compress(buf);
    }

    unsigned int done()
    {
        result_ = comp_.done();
        return static_cast<unsigned int>(result_.size());
    }

    unsigned int getDataPointer() const
    {
        return reinterpret_cast<unsigned int>(result_.data());
    }

private:
    lazperf::writer::chunk_compressor comp_;
    std::vector<unsigned char> result_;
};

MemFileWriter — LASヘッダーからポイントデータまで、完全なLAZファイルをメモリ上に生成する高レベルAPI:

class MemFileWriter : public lazperf::writer::basic_file
{
public:
    MemFileWriter() {}

    bool open(int pointFormat, int pointRecordLength,
              double scaleX, double scaleY, double scaleZ,
              double offsetX, double offsetY, double offsetZ,
              int minorVersion, unsigned int chunkSize)
    {
        stream_.str("");
        stream_.clear();

        lazperf::header12 h;
        h.version.major = 1;
        h.version.minor = minorVersion;
        h.point_format_id = pointFormat;
        h.point_record_length = pointRecordLength;
        h.scale.x = scaleX; h.scale.y = scaleY; h.scale.z = scaleZ;
        h.offset.x = offsetX; h.offset.y = offsetY; h.offset.z = offsetZ;

        h.minx = h.miny = h.minz = std::numeric_limits<double>::max();
        h.maxx = h.maxy = h.maxz = std::numeric_limits<double>::lowest();

        return basic_file::open(stream_, h, chunkSize);
    }

    void writePoint(unsigned int inBuf)
    {
        const char *buf = reinterpret_cast<const char *>(inBuf);
        basic_file::writePoint(buf);
    }

    void finish()
    {
        basic_file::close();
        std::string s = stream_.str();
        buffer_.assign(s.begin(), s.end());
    }

    unsigned int getDataSize() const
    { return static_cast<unsigned int>(buffer_.size()); }

    unsigned int getDataPointer() const
    { return reinterpret_cast<unsigned int>(buffer_.data()); }

private:
    std::stringstream stream_;
    std::vector<unsigned char> buffer_;
};

Embindの登録

EMSCRIPTEN_BINDINGS(lazperf_module)
{
    // 既存(解凍)
    class_<LASZip>("LASZip") /* ... */;
    class_<ChunkDecoder>("ChunkDecoder") /* ... */;

    // 新規(圧縮)
    class_<ChunkCompressor>("ChunkCompressor")
        .constructor<int, int>()
        .function("addPoint", &ChunkCompressor::addPoint)
        .function("done", &ChunkCompressor::done)
        .function("getDataPointer", &ChunkCompressor::getDataPointer);

    class_<MemFileWriter>("MemFileWriter")
        .constructor()
        .function("open", &MemFileWriter::open)
        .function("writePoint", &MemFileWriter::writePoint)
        .function("finish", &MemFileWriter::finish)
        .function("getDataSize", &MemFileWriter::getDataSize)
        .function("getDataPointer", &MemFileWriter::getDataPointer);
}

3. Wasmビルド手順

Emscripten SDKとlaz-perfソースの準備

# Emscriptenのセットアップ
git clone https://github.com/emscripten-core/emsdk.git
cd emsdk
./emsdk install latest
./emsdk activate latest
source ./emsdk_env.sh

# laz-perfのクローン
git clone https://github.com/hobuinc/laz-perf.git

Step 1: lazperfライブラリの静的ビルド

emcmakeでCMakeを実行し、Emscriptenツールチェーンで静的ライブラリ liblazperf.a をビルドします。

mkdir build && cd build

emcmake cmake ../laz-perf \
  -DCMAKE_BUILD_TYPE=Release \
  -DWITH_TESTS=OFF \
  -DBUILD_SHARED_LIBS=OFF \
  -DCMAKE_CXX_FLAGS="-O3"

emmake make lazperf -j$(nproc)

ここで -DBUILD_SHARED_LIBS=OFFが重要です。指定しないと .so(共有ライブラリ)が生成され、em++のリンクに使えません。

Step 2: バインディング付きWASMのビルド

カスタムバインディングファイルを静的ライブラリとリンクしてWASM + グルーコードJSを生成します。

em++ \
  -O3 \
  -std=c++17 \
  -I./laz-perf/cpp/lazperf \
  ./laz-perf-compress.cpp \
  ./build/cpp/liblazperf.a \
  --bind \
  -s WASM=1 \
  -s MODULARIZE=1 \
  -s EXPORT_ES6=1 \
  -s EXPORT_NAME=createLazPerf \
  -s ALLOW_MEMORY_GROWTH=1 \
  -s INITIAL_MEMORY=16777216 \
  -s TOTAL_STACK=1048576 \
  -s DYNAMIC_EXECUTION=0 \
  -s ASSERTIONS=0 \
  -s ENVIRONMENT=web \
  -s EXPORTED_FUNCTIONS='["_malloc","_free"]' \
  -s EXPORTED_RUNTIME_METHODS='["HEAPU8"]' \
  -o dist/laz-perf.js

ビルド結果

laz-perf.wasm : 250 KB
laz-perf.js   :  40 KB

前回のOpenCV Wasm(2.4MB)の約1/10のサイズです。laz-perfは依存ゼロ・ソースファイル数が少ないため、デッドコード削除を意識した絞り込みすら不要でこのサイズに収まりました。

4. JavaScript側の使い方

ビルドしたWASMモジュールをESMインポートし、LASファイルをメモリ上でLAZに圧縮してダウンロードする流れです。

import createLazPerf from './laz-perf.js';

async function compressLAStoLAZ(lasBuffer: ArrayBuffer): Promise<Uint8Array> {
  const LazPerf = await createLazPerf();
  const view = new DataView(lasBuffer);
  const lasData = new Uint8Array(lasBuffer);

  // LASヘッダーから必要な情報を取得
  const versionMinor = view.getUint8(25);
  const pointFormat = view.getUint8(104) & 0x3f;
  const pointRecordLength = view.getUint16(105, true);
  const pointCount = view.getUint32(107, true);
  const pointDataOffset = view.getUint32(96, true);
  const scaleX = view.getFloat64(131, true);
  const scaleY = view.getFloat64(139, true);
  const scaleZ = view.getFloat64(147, true);
  const offsetX = view.getFloat64(155, true);
  const offsetY = view.getFloat64(163, true);
  const offsetZ = view.getFloat64(171, true);

  // MemFileWriterでLAZを生成
  const writer = new LazPerf.MemFileWriter();
  writer.open(pointFormat, pointRecordLength,
    scaleX, scaleY, scaleZ,
    offsetX, offsetY, offsetZ,
    versionMinor, 50000);

  // WASMヒープにポイントバッファを確保
  const pointBuf = LazPerf._malloc(pointRecordLength);

  for (let i = 0; i < pointCount; i++) {
    const offset = pointDataOffset + i * pointRecordLength;
    LazPerf.HEAPU8.set(
      lasData.subarray(offset, offset + pointRecordLength),
      pointBuf
    );
    writer.writePoint(pointBuf);
  }

  LazPerf._free(pointBuf);
  writer.finish();

  // 結果をJSのUint8Arrayにコピー
  const size = writer.getDataSize();
  const ptr = writer.getDataPointer();
  const lazData = LazPerf.HEAPU8.slice(ptr, ptr + size);

  writer.delete(); // C++側メモリ解放
  return lazData;
}

_malloc / _free でWASMヒープ上にポイントバッファを確保し、1点ずつ writePoint で圧縮しています。finish() を呼ぶとLAZヘッダー・チャンクテーブル・圧縮データが組み立てられ、getDataPointer() + getDataSize() でJS側にコピーできます。

5. トラブルシューティング

liblazperf.a が見つからない

ERROR: liblazperf.a not found in build dir

CMakeのデフォルトで共有ライブラリ(.so)が生成されていました。-DBUILD_SHARED_LIBS=OFF を追加して静的ライブラリを生成するように修正。

basic_file のprotectedコンストラクタ

error: field of type 'lazperf::writer::basic_file' has protected default constructor

basic_file のコンストラクタとデストラクタはprotectedで、メンバー変数として直接持つことができません。継承(public lazperf::writer::basic_file)に変更することで解決しました。また、基底クラスの close() とJS側のAPIが名前衝突するため、圧縮完了メソッドは finish() にリネームしています。

まとめ:OpenCVとlaz-perfのWasmビルド比較

OpenCV Wasmlaz-perf Wasm
元ライブラリのサイズ巨大(数十万行)軽量(ソース数十ファイル)
依存関係多数ゼロ
削ぎ落とし作業opencv_js.config.py のホワイトリスト編集+CMake除外が必須不要(そのまま全部入れて250KB)
バインディング既存のものを絞る既存になかった圧縮側を新規追加
最終WASMサイズ2.4 MB250 KB
難しかったポイント機能の取捨選択C++ protected/public の継承設計

OpenCVは「巨大なものを削る」作業、laz-perfは「足りないものを足す」作業。方向性は真逆ですが、Emscriptenビルドの基本的な流れ(CMake → 静的ライブラリ → em++でバインディング付きWASM生成)は共通しています。

C++ライブラリをWasm化する際、npmに公開されているビルド済みパッケージが自分の用途に合わないケースは珍しくありません。そんなときは、ソースコードに直接バインディングを追加してカスタムビルドする選択肢があることを、今回の実装で改めて実感しました。