crossbind
GitHub
← All libraries

LERC

Limited-error raster compression.

Install

Runs in browsers; Node.js and edge runtimes use the same package. Built for wasm32, single-threaded and multi-threaded.

shell
npm install @crossbind/port-lerc-wasm@beta
crossbind.config.js
import lercWasm from '@crossbind/port-lerc-wasm/crossbind.config.js';
 
export default {
dependencies: [lercWasm],
paths: { config: import.meta.url },
};
main.js
import { initNative, LERC_VERSION_MAJOR } from '@crossbind/port-lerc/Lerc_c_api.h';
 
await initNative();

crossbind itself comes with your bundler plugin; Browser has Vite, Webpack, Rspack and Rollup.

LERC in your browser: rasters stored to the error you choose

LERC, from Esri, stores rasters such as elevation so that no value moves by more than the error you pick; ArcGIS elevation services send their tiles in it, and GDAL's GeoTIFF and MRF drivers read and write it. Esri's own lerc package on npm and loaders.gl only decode it. These apps run Esri's C++ library, compiled by crossbind: they encode a terrain at six error budgets and check every height, open LERC tiles, and compare LERC with the browser's gzip. The first run downloads 0.9 MB of WebAssembly once; every app on this page shares it, and nothing is uploaded.

APP 01

Shrink an elevation model to the error you can accept

LERC stores a raster so that no value moves by more than the error you choose. Here a 512 × 512 elevation model is encoded at six budgets, from lossless to 5 m, then every height is decoded and compared with the original. Esri's own lerc package on npm and loaders.gl only decode it; this is Esri's C++ library, compiled by crossbind, encoding in your tab.

The terrain is generated in the module from integer noise, so the sizes are the same on every machine.

Compress the terrain to see how size and error trade off, budget by budget.
SHOW THE CODE
src/support/grid.h
// src/support/grid.h (excerpt)
// LERC quantizes in double precision and rounds back to float32, which can
// overshoot maxError by half a float32 step: ask for one step less.
const double step = double(std::nextafter(largest, INFINITY)) - largest;
const double bound = maxError > step ? maxError - step : 0;
unsigned int size = 0;
check(lerc_computeCompressedSize(values.data(), kFloat, 1, width, height, 1,
masks, valid, bound, &size), "sizing");
std::string blob(size, '\0');
unsigned int written = 0;
check(lerc_encode(values.data(), kFloat, 1, width, height, 1, masks, valid, bound,
reinterpret_cast<unsigned char*>(&blob[0]), size, &written), "encoding");
main.js
const m = await initNative();
const lab = await new m.TerrainLab(0); // 512 x 512 float32 heights, 30 m cells
await m.FS.mkdirTree('/memfs/lercapps');
 
const packed = JSON.parse(await lab.compress(0.01, '/memfs/lercapps/t.lerc'));
// packed.bytes 464,085: 2.26x smaller than the 1,048,576 B of float32
const back = JSON.parse(await lab.decompress('/memfs/lercapps/t.lerc', '/memfs/lercapps/t.f32'));
// back.maxError 0.009521484375: every height within 1 cm
APP 02

Look inside a LERC tile before you decode it

ArcGIS elevation services send each tile as a LERC blob. Open one to read its header without decoding: size, data type, bands, how many pixels hold a value, the value range and the error it was stored with. Then decode a band and draw it. Legacy Lerc1 tiles, the kind ArcGIS Terrain 3D serves, open too.

The sample is written by the module: the lowland from the first app as an island, the sea below 56 m left out through LERC's validity mask. Your own file stays in this tab: it is mounted into the module's in-memory filesystem, never uploaded.

Open the sample, or a .lerc tile of your own, to read its header and draw a band.
SHOW THE CODE
src/native/lerc_inspector.h
// src/native/lerc_inspector.h (excerpt)
unsigned int info[11] = {}; // version, type, depth, width, height, bands, valid pixels, ...
double range[3] = {}; // zMin, zMax, the largest error the encoder allowed
check(lerc_getBlobInfo(blob, size, info, range, 11, 3), "reading the header");
// [min, max] for every band and value per pixel, still without decoding
lerc_getDataRanges(blob, size, depth, bands, mins.data(), maxs.data());
// then any data type decodes to double, with the validity mask
lerc_decodeToDouble_4D(blob, size, masks, valid.data(), depth, width, height, bands,
values.data(), usesNoData.data(), noData.data());
main.js
const m = await initNative();
const [path] = await m.autoMountFiles([file], await m.getRandomPath('/memfs')); // a .lerc from <input type=file>
 
const info = JSON.parse(await m.LercInspector.inspect(path));
// { codec: 'Lerc2 v6', type: 'float32', width: 512, height: 512,
// bands: 1, validPixels: 171406, maxZErrorUsed: 0.00999..., ... }
const band = JSON.parse(await m.LercInspector.decodeBand(path, 0, '/memfs/b.f32', '/memfs/b.mask'));
const values = new Float32Array((await m.getFileBytes('/memfs/b.f32')).buffer);
APP 03

Where LERC beats gzip, and where it does not

gzip sees a float32 raster as bytes; LERC sees the values. Four 512 × 512 rasters go through this browser's own gzip and through LERC, losslessly and within a stated tolerance. Terrain and measurements come out smaller with LERC; a map of whole-number classes is where gzip wins.

Each raster is 1,048,576 bytes of float32, generated in the module. The gzip bars come from this browser's CompressionStream and can differ a little between browsers; the LERC numbers are the same everywhere.

Compress the four rasters to compare gzip and LERC on each.
SHOW THE CODE
src/native/dataset_lab.h
// src/native/dataset_lab.h (excerpt): LERC within maxError, decoded back and compared
const std::string blob = grid::encode(values, 512, 512, nullptr, maxError);
const std::vector<float> back = grid::decode(blob);
const bool identical = std::memcmp(back.data(), values.data(), values.size() * 4) == 0;
main.js
const m = await initNative();
const lab = await new m.DatasetLab();
await lab.write(1, '/memfs/temps.f32'); // air temperature, read to 0.01
const raw = await m.getFileBytes('/memfs/temps.f32');
const gzip = new CompressionStream('gzip');
const gzipped = await new Response(new Blob([raw]).stream().pipeThrough(gzip)).arrayBuffer();
 
JSON.parse(await lab.compress(1, 0)); // lossless: 252,904 B
JSON.parse(await lab.compress(1, 0.005)); // 235,281 B, largest error 0.0000019

Examples

Each one is a small C++ header and the JavaScript that calls it, with what it printed when the site was built. The JavaScript only tab does the same with LERC's own headers and no C++ file. The last is a whole C++ program, built for WASI.

Compress heights to within 1 cm

LERC's main job: lerc_encode stores a float raster so that no value moves by more than the error you allow, and lerc_decode reads it back. The example checks the bound on every one of the 65,536 heights.

src/native/lerc_codec.h
#pragma once
 
#include <Lerc_c_api.h>
 
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <string>
#include <vector>
 
// One band of float32 heights, row by row from the top left, in and out of LERC. Bytes cross the
// binding as a byte string: one UTF-16 code unit (0-255) per byte.
class LercCodec {
public:
static std::string version() {
return std::to_string(LERC_VERSION_MAJOR) + "." + std::to_string(LERC_VERSION_MINOR) + "." + std::to_string(LERC_VERSION_PATCH);
}
 
// Every decoded height stays within maxError of the original; 0 keeps every bit.
static std::u16string encode(const std::u16string& heights, int width, int height, double maxError) {
const std::vector<float> values = toFloats(heights, width, height);
const double bound = boundFor(values, maxError);
unsigned int size = 0;
check(lerc_computeCompressedSize(values.data(), kFloat, 1, width, height, 1, 0, nullptr, bound, &size), "sizing");
std::vector<unsigned char> blob(size);
unsigned int written = 0;
check(lerc_encode(values.data(), kFloat, 1, width, height, 1, 0, nullptr, bound, blob.data(), size, &written), "encoding");
return toUnits(blob.data(), written);
}
 
static std::u16string decode(const std::u16string& blob) {
const std::vector<unsigned char> bytes = fromUnits(blob);
const auto size = static_cast<unsigned int>(bytes.size());
unsigned int info[11] = {};
double range[3] = {};
check(lerc_getBlobInfo(bytes.data(), size, info, range, 11, 3), "reading the header");
const int width = static_cast<int>(info[3]);
const int height = static_cast<int>(info[4]);
if (info[1] != kFloat || info[2] != 1 || info[5] != 1) throw std::invalid_argument("this codec reads one band of float32");
if (info[6] != info[3] * info[4]) throw std::invalid_argument("this blob has missing pixels: decode it with its mask");
std::vector<float> values(static_cast<size_t>(width) * height);
check(lerc_decode(bytes.data(), size, 0, nullptr, 1, width, height, 1, kFloat, values.data()), "decoding");
return toUnits(reinterpret_cast<const unsigned char*>(values.data()), values.size() * sizeof(float));
}
 
private:
static constexpr unsigned int kFloat = 6; // dt_float in Lerc_types.h
 
// LERC quantizes in double precision and rounds back to float32, which can overshoot maxError by
// half a float32 step (31 µm at 1,000 m). Like Esri's own sample, ask for a little less: one
// float32 step at the largest height.
static double boundFor(const std::vector<float>& values, double maxError) {
if (maxError <= 0) return 0;
float largest = 0;
for (const float value : values) largest = std::max(largest, std::fabs(value));
const double step = static_cast<double>(std::nextafter(largest, INFINITY)) - largest;
return maxError > step ? maxError - step : 0;
}
 
static void check(lerc_status status, const char* step) {
static const char* const names[] = {"ok", "failed", "wrong parameter", "buffer too small", "NaN", "uses noData", "dimensions too large"};
if (status != 0) throw std::runtime_error(std::string("LERC failed ") + step + ": " + (status < 7 ? names[status] : "unknown error"));
}
 
static std::vector<float> toFloats(const std::u16string& units, int width, int height) {
if (width <= 0 || height <= 0 || units.size() != static_cast<size_t>(width) * height * sizeof(float)) {
throw std::invalid_argument("expected width * height float32 values");
}
std::vector<float> values(static_cast<size_t>(width) * height);
auto* bytes = reinterpret_cast<unsigned char*>(values.data());
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<unsigned char>(units[i]);
}
return values;
}
 
static std::vector<unsigned char> fromUnits(const std::u16string& units) {
std::vector<unsigned char> bytes(units.size());
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<unsigned char>(units[i]);
}
return bytes;
}
 
static std::u16string toUnits(const unsigned char* bytes, size_t size) {
std::u16string units(size, u'\0');
for (size_t i = 0; i < size; ++i) units[i] = bytes[i];
return units;
}
};
main.js
import { initNative, LercCodec } from './native/lerc_codec.h';
 
await initNative();
let seed = 42;
const random = (n) => (seed = (seed * 48271) % 2147483647) % n;
const width = 256;
const height = 256;
const heights = new Float32Array(width * height); // metres, row by row from the top left
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const dx = x - 128;
const dy = y - 128;
heights[y * width + x] = 1500 - (dx * dx + dy * dy) / 70 + random(1000) / 1000; // a hill, rough to 1 m
}
}
const toText = (bytes) => Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
const toBytes = (text) => Uint8Array.from(text, (unit) => unit.charCodeAt(0));
 
const blob = await LercCodec.encode(toText(new Uint8Array(heights.buffer)), width, height, 0.01);
const decoded = new Float32Array(toBytes(await LercCodec.decode(blob)).buffer);
let worst = 0;
for (let i = 0; i < heights.length; i += 1) worst = Math.max(worst, Math.abs(decoded[i] - heights[i]));
console.log(`LERC ${await LercCodec.version()}: ${heights.byteLength} B of float32 heights -> ${blob.length} B`);
console.log(`largest error ${worst.toFixed(4)} m, within 1 cm: ${worst <= 0.01}`);
PRINTSfirst run downloads 0.9 MB
LERC 4.2.0: 262144 B of float32 heights -> 94775 B
largest error 0.0099 m, within 1 cm: true

Read a blob's header without decoding it

lerc_getBlobInfo answers from the header alone: size, data type, bands, valid pixels, the value range and the largest error the encoder allowed. A tile viewer uses it to size its buffers, or to skip an empty tile, before decoding. The blob comes from the codec in the first example, which asks for one float32 step less than 10 cm.

src/native/lerc_blob_info.h
#pragma once
 
#include <Lerc_c_api.h>
 
#include <cstdio>
#include <stdexcept>
#include <string>
 
// What a LERC blob holds, read from its header without decoding a pixel. Bytes cross the binding as
// a byte string: one UTF-16 code unit (0-255) per byte.
class LercBlobInfo {
public:
// {"codec","width","height","depth","bands","type","validPixels","masks","blobSize","zMin","zMax","maxZErrorUsed"}
static std::string read(const std::u16string& blob) {
std::string bytes(blob.size(), '\0');
for (size_t i = 0; i < blob.size(); ++i) {
if (blob[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<char>(blob[i]);
}
unsigned int info[11] = {}; // version, type, depth, width, height, bands, valid pixels, blob size, masks, depth, noData bands
double range[3] = {}; // zMin, zMax, and the largest error the encoder allowed
const lerc_status status = lerc_getBlobInfo(reinterpret_cast<const unsigned char*>(bytes.data()), static_cast<unsigned int>(bytes.size()), info, range, 11, 3);
if (status != 0) throw std::runtime_error("not a LERC blob (status " + std::to_string(status) + ")");
static const char* const types[] = {"int8", "uint8", "int16", "uint16", "int32", "uint32", "float32", "float64"};
const std::string codec = info[0] == 0 ? "Lerc1" : "Lerc2 v" + std::to_string(info[0]);
return "{\"codec\":\"" + codec + "\",\"width\":" + std::to_string(info[3]) + ",\"height\":" + std::to_string(info[4]) + ",\"depth\":" + std::to_string(info[2]) +
",\"bands\":" + std::to_string(info[5]) + ",\"type\":\"" + (info[1] < 8 ? types[info[1]] : "unknown") + "\",\"validPixels\":" + std::to_string(info[6]) +
",\"masks\":" + std::to_string(info[8]) + ",\"blobSize\":" + std::to_string(info[7]) + ",\"zMin\":" + number(range[0]) + ",\"zMax\":" + number(range[1]) +
",\"maxZErrorUsed\":" + number(range[2]) + "}";
}
 
private:
// 17 significant digits read back as the same double.
static std::string number(double value) {
char text[40];
std::snprintf(text, sizeof text, "%.17g", value);
return text;
}
};
main.js
import { initNative, LercBlobInfo } from './native/lerc_blob_info.h';
import { LercCodec } from './native/lerc_codec.h';
 
await initNative();
let seed = 42;
const random = (n) => (seed = (seed * 48271) % 2147483647) % n;
const width = 256;
const height = 256;
const heights = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const dx = x - 128;
const dy = y - 128;
heights[y * width + x] = 1500 - (dx * dx + dy * dy) / 70 + random(1000) / 1000;
}
}
const toText = (bytes) => Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
const blob = await LercCodec.encode(toText(new Uint8Array(heights.buffer)), width, height, 0.1);
 
const info = JSON.parse(await LercBlobInfo.read(blob));
console.log(`${info.codec}: ${info.width}x${info.height}, ${info.bands} band of ${info.type}, ${info.validPixels} valid pixels, ${info.blobSize} B`);
console.log(`heights ${info.zMin.toFixed(3)} to ${info.zMax.toFixed(3)} m, stored within ${info.maxZErrorUsed.toFixed(6)} m`);
PRINTSfirst run downloads 0.9 MB
Lerc2 v6: 256x256, 1 band of float32, 65536 valid pixels, 67591 B
heights 1032.268 to 1500.945 m, stored within 0.099878 m

Keep integer data exact

Class maps and sensor counts must not change at all. With a maxZError of 0, LERC stores integers exactly (it raises 0 to 0.5, which rounds back to the same whole number) and float32 bit for bit. Here, a 12-bit sensor band in 16-bit pixels.

src/native/lerc_lossless.h
#pragma once
 
#include <Lerc_c_api.h>
 
#include <stdexcept>
#include <string>
#include <vector>
 
// Lossless LERC for any of its eight data types, bands one after another, each row by row. A
// maxZError of 0 keeps every value: LERC raises it to 0.5 for integers, which rounds back to the
// same whole number, and keeps float32 and float64 bit for bit. Bytes cross the binding as a byte
// string: one UTF-16 code unit (0-255) per byte.
class LercLossless {
public:
// type: "int8", "uint8", "int16", "uint16", "int32", "uint32", "float32" or "float64".
static std::u16string encode(const std::u16string& values, const std::string& type, int width, int height, int bands) {
const unsigned int dataType = typeCode(type);
if (width <= 0 || height <= 0 || bands <= 0 || values.size() != static_cast<size_t>(width) * height * bands * kSizes[dataType]) {
throw std::invalid_argument("expected width * height * bands values of " + type);
}
const std::vector<unsigned char> data = fromUnits(values);
unsigned int size = 0;
check(lerc_computeCompressedSize(data.data(), dataType, 1, width, height, bands, 0, nullptr, 0.0, &size), "sizing");
std::vector<unsigned char> blob(size);
unsigned int written = 0;
check(lerc_encode(data.data(), dataType, 1, width, height, bands, 0, nullptr, 0.0, blob.data(), size, &written), "encoding");
return toUnits(blob.data(), written);
}
 
// The values in the blob's own type, band after band.
static std::u16string decode(const std::u16string& blob) {
const std::vector<unsigned char> bytes = fromUnits(blob);
const auto size = static_cast<unsigned int>(bytes.size());
unsigned int info[11] = {};
double range[3] = {};
check(lerc_getBlobInfo(bytes.data(), size, info, range, 11, 3), "reading the header");
const unsigned int dataType = info[1];
const int depth = static_cast<int>(info[2]);
const int width = static_cast<int>(info[3]);
const int height = static_cast<int>(info[4]);
const int bands = static_cast<int>(info[5]);
if (dataType > 7) throw std::invalid_argument("unknown LERC data type");
if (info[6] != info[3] * info[4]) throw std::invalid_argument("this blob has missing pixels: decode it with its mask");
std::vector<unsigned char> values(static_cast<size_t>(depth) * width * height * bands * kSizes[dataType]);
check(lerc_decode(bytes.data(), size, 0, nullptr, depth, width, height, bands, dataType, values.data()), "decoding");
return toUnits(values.data(), values.size());
}
 
private:
static constexpr size_t kSizes[] = {1, 1, 2, 2, 4, 4, 4, 8}; // bytes per value, in Lerc_types.h order
 
static unsigned int typeCode(const std::string& type) {
static const char* const names[] = {"int8", "uint8", "int16", "uint16", "int32", "uint32", "float32", "float64"};
for (unsigned int code = 0; code < 8; ++code) {
if (type == names[code]) return code;
}
throw std::invalid_argument("unknown type " + type);
}
 
static void check(lerc_status status, const char* step) {
static const char* const names[] = {"ok", "failed", "wrong parameter", "buffer too small", "NaN", "uses noData", "dimensions too large"};
if (status != 0) throw std::runtime_error(std::string("LERC failed ") + step + ": " + (status < 7 ? names[status] : "unknown error"));
}
 
static std::vector<unsigned char> fromUnits(const std::u16string& units) {
std::vector<unsigned char> bytes(units.size());
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<unsigned char>(units[i]);
}
return bytes;
}
 
static std::u16string toUnits(const unsigned char* bytes, size_t size) {
std::u16string units(size, u'\0');
for (size_t i = 0; i < size; ++i) units[i] = bytes[i];
return units;
}
};
main.js
import { initNative, LercLossless } from './native/lerc_lossless.h';
 
await initNative();
let seed = 7;
const random = (n) => (seed = (seed * 48271) % 2147483647) % n;
const width = 256;
const height = 256;
const band = new Uint16Array(width * height); // a gradient plus sensor noise, 12-bit values
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) band[y * width + x] = 1800 + ((x + y) >> 1) + random(64);
}
const toText = (bytes) => Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
const toBytes = (text) => Uint8Array.from(text, (unit) => unit.charCodeAt(0));
 
const blob = await LercLossless.encode(toText(new Uint8Array(band.buffer)), 'uint16', width, height, 1);
const back = new Uint16Array(toBytes(await LercLossless.decode(blob)).buffer);
console.log(`uint16, ${width}x${height}: ${band.byteLength} B -> ${blob.length} B`);
console.log(`identical: ${back.length === band.length && back.every((value, i) => value === band[i])}`);
PRINTSfirst run downloads 0.9 MB
uint16, 256x256: 131072 B -> 59091 B
identical: true

Leave out pixels that have no data

Rasters mark gaps with a NoData value such as -9999. Stored as a height, it stretches the value range of every block it lands in. Passed as the validity mask (pValidBytes in lerc_encode and lerc_decode), it costs at most a bit per pixel, and decoding hands the mask back.

src/native/lerc_nodata.h
#pragma once
 
#include <Lerc_c_api.h>
 
#include <algorithm>
#include <cmath>
#include <stdexcept>
#include <string>
#include <vector>
 
// One band of float32 heights with gaps. Pixels equal to noData (NaN too, when noData is NaN) go into
// LERC's validity mask, a bit per pixel, instead of into the values; decoding puts noData back. Bytes
// cross the binding as a byte string: one UTF-16 code unit (0-255) per byte.
class LercNoData {
public:
static std::u16string encode(const std::u16string& heights, int width, int height, double noData, double maxError) {
const std::vector<float> values = toFloats(heights, width, height);
std::vector<unsigned char> valid(values.size());
float largest = 0;
for (size_t i = 0; i < values.size(); ++i) {
valid[i] = missing(values[i], noData) ? 0 : 1;
if (valid[i]) largest = std::max(largest, std::fabs(values[i]));
}
// LERC rounds back to float32, which can overshoot maxError by half a float32 step: ask for one step less.
const double step = static_cast<double>(std::nextafter(largest, INFINITY)) - largest;
const double bound = maxError > step ? maxError - step : 0;
unsigned int size = 0;
check(lerc_computeCompressedSize(values.data(), kFloat, 1, width, height, 1, 1, valid.data(), bound, &size), "sizing");
std::vector<unsigned char> blob(size);
unsigned int written = 0;
check(lerc_encode(values.data(), kFloat, 1, width, height, 1, 1, valid.data(), bound, blob.data(), size, &written), "encoding");
return toUnits(blob.data(), written);
}
 
static std::u16string decode(const std::u16string& blob, double noData) {
const std::vector<unsigned char> bytes = fromUnits(blob);
const auto size = static_cast<unsigned int>(bytes.size());
unsigned int info[11] = {};
double range[3] = {};
check(lerc_getBlobInfo(bytes.data(), size, info, range, 11, 3), "reading the header");
const int width = static_cast<int>(info[3]);
const int height = static_cast<int>(info[4]);
if (info[1] != kFloat || info[2] != 1 || info[5] != 1) throw std::invalid_argument("this reads one band of float32");
std::vector<float> values(static_cast<size_t>(width) * height);
std::vector<unsigned char> valid(values.size());
check(lerc_decode(bytes.data(), size, 1, valid.data(), 1, width, height, 1, kFloat, values.data()), "decoding");
for (size_t i = 0; i < values.size(); ++i) {
if (!valid[i]) values[i] = static_cast<float>(noData);
}
return toUnits(reinterpret_cast<const unsigned char*>(values.data()), values.size() * sizeof(float));
}
 
private:
static constexpr unsigned int kFloat = 6; // dt_float in Lerc_types.h
 
static bool missing(float value, double noData) { return std::isnan(noData) ? std::isnan(value) : value == static_cast<float>(noData); }
 
static void check(lerc_status status, const char* step) {
static const char* const names[] = {"ok", "failed", "wrong parameter", "buffer too small", "NaN", "uses noData", "dimensions too large"};
if (status != 0) throw std::runtime_error(std::string("LERC failed ") + step + ": " + (status < 7 ? names[status] : "unknown error"));
}
 
static std::vector<float> toFloats(const std::u16string& units, int width, int height) {
if (width <= 0 || height <= 0 || units.size() != static_cast<size_t>(width) * height * sizeof(float)) {
throw std::invalid_argument("expected width * height float32 values");
}
std::vector<float> values(static_cast<size_t>(width) * height);
auto* bytes = reinterpret_cast<unsigned char*>(values.data());
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<unsigned char>(units[i]);
}
return values;
}
 
static std::vector<unsigned char> fromUnits(const std::u16string& units) {
std::vector<unsigned char> bytes(units.size());
for (size_t i = 0; i < units.size(); ++i) {
if (units[i] > 0xFF) throw std::invalid_argument("not a byte string");
bytes[i] = static_cast<unsigned char>(units[i]);
}
return bytes;
}
 
static std::u16string toUnits(const unsigned char* bytes, size_t size) {
std::u16string units(size, u'\0');
for (size_t i = 0; i < size; ++i) units[i] = bytes[i];
return units;
}
};
main.js
import { initNative, LercNoData } from './native/lerc_nodata.h';
import { LercCodec } from './native/lerc_codec.h';
 
await initNative();
let seed = 42;
const random = (n) => (seed = (seed * 48271) % 2147483647) % n;
const width = 256;
const height = 256;
const heights = new Float32Array(width * height);
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
const dx = x - 128;
const dy = y - 128;
heights[y * width + x] = 1500 - (dx * dx + dy * dy) / 70 + random(1000) / 1000;
}
}
for (let i = 0; i < heights.length; i += 1) if (random(50) === 0) heights[i] = -9999; // the survey missed 2% of its points
const toText = (bytes) => Array.from(bytes, (byte) => String.fromCharCode(byte)).join('');
const toBytes = (text) => Uint8Array.from(text, (unit) => unit.charCodeAt(0));
const text = toText(new Uint8Array(heights.buffer));
 
const asHeight = await LercCodec.encode(text, width, height, 0.01); // the codec from the first example
const blob = await LercNoData.encode(text, width, height, -9999, 0.01);
const back = new Float32Array(toBytes(await LercNoData.decode(blob, -9999)).buffer);
let gaps = 0;
let worst = 0;
for (let i = 0; i < heights.length; i += 1) {
if (heights[i] === -9999) gaps += back[i] === -9999 ? 1 : 0;
else worst = Math.max(worst, Math.abs(back[i] - heights[i]));
}
console.log(`-9999 stored as a height: ${asHeight.length} B`);
console.log(`-9999 as missing pixels: ${blob.length} B`);
console.log(`${gaps} gaps come back as -9999, largest error elsewhere ${worst.toFixed(4)} m`);
PRINTSfirst run downloads 0.9 MB
-9999 stored as a height: 147759 B
-9999 as missing pixels: 98308 B
1273 gaps come back as -9999, largest error elsewhere 0.0099 m

A LERC command-line tool

WASI has no JavaScript bindings: the program is a main() that links the library, built into one .wasm and run with wasmtime. This one compresses a float32 height grid within an error bound, reads the header back, then decodes and measures the largest difference.

crossbind.config.js
import lercWasi from '@crossbind/port-lerc-wasi/crossbind.config.js';
 
export default {
general: { name: 'lerc-tool' },
dependencies: [lercWasi],
paths: { config: import.meta.url },
};
src/native/main.cpp
// A command-line LERC for WASI:
// lerc-tool encode <heights.f32> <width> <height> <maxError> <out.lerc>
// lerc-tool info <in.lerc>
// lerc-tool decode <in.lerc> <out.f32> [original.f32]
#include <Lerc_c_api.h>
 
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <memory>
#include <vector>
 
namespace {
 
constexpr unsigned int kFloat = 6; // dt_float in Lerc_types.h
 
using File = std::unique_ptr<FILE, int (*)(FILE*)>;
 
bool readFile(const char* path, std::vector<unsigned char>& data) {
File file(std::fopen(path, "rb"), std::fclose);
if (!file) {
std::fprintf(stderr, "cannot open %s\n", path);
return false;
}
data.clear();
unsigned char chunk[4096];
size_t count = 0;
while ((count = std::fread(chunk, 1, sizeof chunk, file.get())) > 0) data.insert(data.end(), chunk, chunk + count);
return true;
}
 
bool writeFile(const char* path, const void* data, size_t size) {
File file(std::fopen(path, "wb"), std::fclose);
if (file && std::fwrite(data, 1, size, file.get()) == size) return true;
std::fprintf(stderr, "cannot write %s\n", path);
return false;
}
 
bool failed(lerc_status status, const char* step) {
if (status == 0) return false;
std::fprintf(stderr, "LERC failed %s with status %u\n", step, status);
return true;
}
 
int encode(const char* input, int width, int height, double maxError, const char* output) {
std::vector<unsigned char> raw;
if (!readFile(input, raw)) return 1;
if (width <= 0 || height <= 0 || raw.size() != static_cast<size_t>(width) * height * sizeof(float)) {
std::fprintf(stderr, "%s does not hold %d x %d float32 values\n", input, width, height);
return 1;
}
std::vector<float> values(static_cast<size_t>(width) * height);
std::memcpy(values.data(), raw.data(), raw.size());
// LERC rounds back to float32, which can overshoot maxError by half a float32 step: ask for one step less.
float largest = 0;
for (const float value : values) largest = std::max(largest, std::fabs(value));
const double step = static_cast<double>(std::nextafter(largest, INFINITY)) - largest;
const double bound = maxError > step ? maxError - step : 0;
unsigned int size = 0;
if (failed(lerc_computeCompressedSize(values.data(), kFloat, 1, width, height, 1, 0, nullptr, bound, &size), "sizing")) return 1;
std::vector<unsigned char> blob(size);
unsigned int written = 0;
if (failed(lerc_encode(values.data(), kFloat, 1, width, height, 1, 0, nullptr, bound, blob.data(), size, &written), "encoding")) return 1;
if (!writeFile(output, blob.data(), written)) return 1;
std::printf("LERC %d.%d.%d encode: %s -> %s, %zu B -> %u B within %g\n", LERC_VERSION_MAJOR, LERC_VERSION_MINOR, LERC_VERSION_PATCH, input, output, raw.size(),
written, maxError);
return 0;
}
 
int info(const char* input) {
std::vector<unsigned char> blob;
if (!readFile(input, blob)) return 1;
unsigned int header[11] = {}; // version, type, depth, width, height, bands, valid pixels, blob size, masks, depth, noData bands
double range[3] = {}; // zMin, zMax, the largest error the encoder allowed
if (failed(lerc_getBlobInfo(blob.data(), static_cast<unsigned int>(blob.size()), header, range, 11, 3), "reading the header")) return 1;
static const char* const types[] = {"int8", "uint8", "int16", "uint16", "int32", "uint32", "float32", "float64"};
char codec[16] = "Lerc1";
if (header[0] > 0) std::snprintf(codec, sizeof codec, "Lerc2 v%u", header[0]);
std::printf("%s: %s, %ux%u, %u band%s of %s, %u valid pixels, %.3f to %.3f, max error %.7f\n", input, codec, header[3], header[4], header[5],
header[5] == 1 ? "" : "s", header[1] < 8 ? types[header[1]] : "unknown", header[6], range[0], range[1], range[2]);
return 0;
}
 
int decode(const char* input, const char* output, const char* original) {
std::vector<unsigned char> blob;
if (!readFile(input, blob)) return 1;
const auto size = static_cast<unsigned int>(blob.size());
unsigned int header[11] = {};
double range[3] = {};
if (failed(lerc_getBlobInfo(blob.data(), size, header, range, 11, 3), "reading the header")) return 1;
if (header[1] != kFloat || header[2] != 1 || header[5] != 1) {
std::fprintf(stderr, "%s is not one band of float32\n", input);
return 1;
}
const int width = static_cast<int>(header[3]);
const int height = static_cast<int>(header[4]);
std::vector<float> values(static_cast<size_t>(width) * height);
if (failed(lerc_decode(blob.data(), size, 0, nullptr, 1, width, height, 1, kFloat, values.data()), "decoding")) return 1;
const size_t bytes = values.size() * sizeof(float);
if (!writeFile(output, values.data(), bytes)) return 1;
if (!original) {
std::printf("%s -> %s, %zu B\n", input, output, bytes);
return 0;
}
std::vector<unsigned char> raw;
if (!readFile(original, raw)) return 1;
if (raw.size() != bytes) {
std::fprintf(stderr, "%s is not the same size as %s\n", original, output);
return 1;
}
std::vector<float> before(values.size());
std::memcpy(before.data(), raw.data(), raw.size());
double worst = 0;
for (size_t i = 0; i < values.size(); ++i) worst = std::max(worst, std::fabs(static_cast<double>(values[i]) - before[i]));
std::printf("%s -> %s, %zu B, largest difference from %s %.7f\n", input, output, bytes, original, worst);
return 0;
}
 
} // namespace
 
int main(int argc, char** argv) {
if (argc == 7 && std::strcmp(argv[1], "encode") == 0) return encode(argv[2], std::atoi(argv[3]), std::atoi(argv[4]), std::atof(argv[5]), argv[6]);
if (argc == 3 && std::strcmp(argv[1], "info") == 0) return info(argv[2]);
if ((argc == 4 || argc == 5) && std::strcmp(argv[1], "decode") == 0) return decode(argv[2], argv[3], argc == 5 ? argv[4] : nullptr);
std::fprintf(stderr,
"usage: lerc-tool encode <heights.f32> <width> <height> <maxError> <out.lerc>\n"
" lerc-tool info <in.lerc>\n"
" lerc-tool decode <in.lerc> <out.f32> [original.f32]\n");
return 2;
}
shell
npx crossbind build -p wasi -e wasi -b release
wasmtime run --dir=. .crossbind/build/lerc-tool-wasi-wasm32-st-release.wasm encode dem.f32 256 256 0.01 dem.lerc
wasmtime run --dir=. .crossbind/build/lerc-tool-wasi-wasm32-st-release.wasm info dem.lerc
wasmtime run --dir=. .crossbind/build/lerc-tool-wasi-wasm32-st-release.wasm decode dem.lerc dem.copy.f32 dem.f32
output
LERC 4.2.0 encode: dem.f32 -> dem.lerc, 262144 B -> 94775 B within 0.01
dem.lerc: Lerc2 v6, 256x256, 1 band of float32, 65536 valid pixels, 1032.268 to 1500.945, max error 0.0098779
dem.lerc -> dem.copy.f32, 262144 B, largest difference from dem.f32 0.0098877
Type to search every guide page and section.
↑↓ navigate↵ openesc close