depthpack
Compact codec for 16-bit raster fields with nodata, as produced by
projecting LiDAR point clouds into panoramic (equirectangular) camera
frames. Pure Rust, wasm32 compatible, dependency-light.
A blob stores an integer lattice of u16 counts (0 = nodata) plus a
physical mapping value = count·scale + offset and an opaque unit
label — so decode_scaled hands you metres (or any unit) as f32
directly. The encoder predicts each valid count from its causal
neighbours with the LOCO-I / JPEG-LS median edge detector (MED) and
compresses the residuals plus a 1-bpp validity mask with zstd. Depth maps
built from triangulated point clouds are mostly smooth surfaces, so the
residuals are near zero — which is why this outperforms both generic
image codecs and raster codecs like LERC on this data, on size and
speed.
Numbers
Measured on a real 7200×3600 street-level depth pano (26 MP, 61% nodata,
Apple M-series, best of 3). All rows roundtrip-verified. The lattice here
is a millimetre grid (our pipeline's choice — the format is unit-neutral);
a coarser lattice is a smaller scale, and "err" is the reconstruction
error it implies. "Lossless" means the lattice roundtrips bit-exact.
| encoding | encode | decode | size | err |
|---|---|---|---|---|
| depthpack, 1 mm lattice | 0.09 s | 123 ms | 5.38 MB | exact |
| depthpack, 2 mm lattice | 0.11 s | 120 ms | 4.59 MB | 1 mm |
| depthpack, 10 mm lattice | 0.10 s | 102 ms | 2.94 MB | 5 mm |
| WebP lossless (libwebp q75) | 8.71 s | — | 5.42 MB | exact |
| LERC (lerc-rs, tol 5 mm) | 0.13 s | 63 ms | 5.86 MB | 5 mm |
| LERC (lerc-rs, lossless) | 0.13 s | 63 ms | 9.81 MB | exact |
Lossless, depthpack matches WebP's size at ~90× the encode speed. At a
5 mm error bound it is half the size of LERC. Encode rows use the
zstd-c feature; the default pure-Rust build encodes ~2.6× slower and
~27% larger (ruzstd's encoder compresses less densely than C zstd), and
decodes identically.
Quick start
[]
= "0.1"
use ;
// Row-major u16 lattice counts, 0 = nodata. Here: millimetres.
let counts: = vec!;
// scale/offset/unit describe the physical meaning: value = count*scale + offset.
// A millimetre lattice read as metres is scale = 0.001.
let opts = EncodeOptions ;
let blob = encode?;
// Raw lattice roundtrips bit-exact…
assert_eq!;
// …or get physical f32 straight out (NaN at nodata) — no conversion needed.
let scaled = decode_scaled?; // scaled.values in metres, scaled.unit == "m"
# Ok::
The codec stores the lattice bit-exact and never quantizes — you pick
the precision by choosing scale and quantizing before encoding
(a 1 mm lattice read as metres is scale = 0.001; a 5 mm lattice is
scale = 0.005, error ≤ 2.5 mm). depthpack applies scale/offset on
decode but never interprets unit: a US survey-foot producer stores
foot counts with unit = "ftUS", and no unit conversion ever happens
inside this crate.
Reading a blob
Two representations, each with an allocating and a decode-into form. The scaled forms are what a consumer usually wants — physical values, ready to use, with no post-decode arithmetic:
| function | returns | nodata |
|---|---|---|
decode / decode_into |
raw u16 lattice counts |
0 |
decode_scaled / decode_scaled_into |
physical f32 = count·scale + offset |
NaN |
decode_header |
dimensions + scale, offset, unit (no pixels) |
— |
unit is returned verbatim so the caller knows what the values are
("m", "ftUS", …); depthpack never converts between units. The
*_into forms write into a caller-provided buffer of exactly
width × height, avoiding the output allocation — handy for streaming a
decoded field straight into a GPU texture:
let header = decode_header?;
let mut depth_m = vec!;
decode_scaled_into?; // physical f32, NaN = nodata
# Ok::
Builds
The crate is pure Rust end to end by default — encode and decode
compile for wasm32-unknown-unknown unchanged, with no C toolchain and
no JS interop, so a browser viewer decodes with the same code path.
For native bulk encoding, enable the zstd-c feature to swap the
encoder's entropy stage to the C zstd bindings (~2.6× faster encode,
~27% smaller output, honours zstd_level). Decoding stays pure Rust
regardless:
[]
= { = "0.1", = ["zstd-c"] }
Format (v1)
Little-endian, 46-byte header, two zstd frames:
offset size field
0 4 magic "DPCK"
4 1 version (1)
5 1 codec (0 = MED)
6 4 width (u32)
10 4 height (u32)
14 4 n_valid (u32) — number of valid pixels
18 8 scale (f64) — physical = count*scale + offset
26 8 offset (f64)
34 8 unit (ASCII, null-padded; opaque, never interpreted)
42 4 mask_len (u32) — compressed length of the mask frame
46 * zstd frame: validity mask, 1 bpp, row-major, LSB-first
46+* * zstd frame: MED residuals, zigzag, byte-plane split
(all high bytes, then all low bytes)
Residuals are computed in wrapping u16 arithmetic and zigzag-mapped as
i16, so reconstruction is exact for any value/prediction pair, not
just the small residuals smooth data produces. The decoder validates
every section against the header (dimension cap, mask popcount vs
n_valid, exact decompressed lengths) and never panics on malformed
input — see tests/robustness.rs and the fuzz/ harness.
Non-goals
- Not a general image codec: it assumes
u16+0-as-nodata and wins by exploiting smoothness. For photos, use an image codec. - No ecosystem tooling (GDAL etc. cannot read it). The format is ~120 lines to decode; keep the debug tools next to it.
Related
- 360-geo/copc — streaming COPC reader used by the pipelines that produce these depth maps.
- LERC — the raster codec we benchmarked against; excellent general-purpose choice when you don't control both ends of the pipe.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE)
- MIT license (LICENSE-MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.