[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をビルドすることにしました。
[JavaScript] 極小2.4MBのOpenCV Wasmを自作してブラウザで画像処理(Inpainting)を動かす // PROTOCOL.LAIN
OpenCVをEmscriptenで軽量ビルドし、WebAssemblyとしてブラウザで動かす方法を解説。cv.inpaintを用いた修復処理の実装や、cv.grabCut除外によるエラーなどのトラブル情報もまとめました。
lain-lab.com1. 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 Wasm | laz-perf Wasm | |
|---|---|---|
| 元ライブラリのサイズ | 巨大(数十万行) | 軽量(ソース数十ファイル) |
| 依存関係 | 多数 | ゼロ |
| 削ぎ落とし作業 | opencv_js.config.py のホワイトリスト編集+CMake除外が必須 | 不要(そのまま全部入れて250KB) |
| バインディング | 既存のものを絞る | 既存になかった圧縮側を新規追加 |
| 最終WASMサイズ | 2.4 MB | 250 KB |
| 難しかったポイント | 機能の取捨選択 | C++ protected/public の継承設計 |
OpenCVは「巨大なものを削る」作業、laz-perfは「足りないものを足す」作業。方向性は真逆ですが、Emscriptenビルドの基本的な流れ(CMake → 静的ライブラリ → em++でバインディング付きWASM生成)は共通しています。
C++ライブラリをWasm化する際、npmに公開されているビルド済みパッケージが自分の用途に合わないケースは珍しくありません。そんなときは、ソースコードに直接バインディングを追加してカスタムビルドする選択肢があることを、今回の実装で改めて実感しました。