Packages

Server-side toolkit for 3D Gaussian splat assets: validate a PLY, convert to compressed SOG, extract metadata, and derive a framing camera. Orchestration in Elixir, heavy lifting shelled out.

Current section

Files

Jump to
splat_tools README.md
Raw

README.md

# SplatTools
[![Hex.pm](https://img.shields.io/hexpm/v/splat_tools.svg)](https://hex.pm/packages/splat_tools)
[![Docs](https://img.shields.io/badge/hex-docs-blue.svg)](https://hexdocs.pm/splat_tools)
Server-side toolkit for **3D Gaussian splat** assets in Elixir. Validate a
capture, convert it to a web-ready format, and get back the metadata your
application needs to store alongside it.
```elixir
def deps do
[{:splat_tools, "~> 0.1"}]
end
```
## Usage
```elixir
{:ok, asset} = SplatTools.prepare("capture.ply", "priv/static/scans")
asset.sog #=> "priv/static/scans/capture.sog"
asset.source_splat_count #=> 1_284_331
asset.sh_bands #=> 3
asset.camera #=> %{position: {…}, target: {…}, fov: 50.0}
asset.source_bytes #=> 318_513_920
asset.sog_bytes #=> 7_071_744
```
Check a file before spending anything on it — this reads only the header, so
it is cheap on a gigabyte capture:
```elixir
{:ok, info} = SplatTools.inspect_file("maybe.ply")
info.kind #=> :splat | :mesh | :point_cloud
SplatTools.splat?("maybe.ply") #=> true
```
Frame a scene without converting it:
```elixir
{:ok, %{bounds: bounds, camera: camera}} = SplatTools.measure("capture.ply")
```
## Install the converter
Compression is done by [`splat-transform`](https://github.com/playcanvas/splat-transform),
the MIT-licensed converter from PlayCanvas:
```bash
npm install -g @playcanvas/splat-transform # needs Node 22+
```
```elixir
SplatTools.Transform.available?() #=> true
SplatTools.Transform.version() #=> {:ok, "3.3.0"}
```
## What runs where
**Elixir owns decisions and records; the bytes are somebody else's job.**
Validation, argument construction, metadata, camera framing and error
reporting live here. Compression does not.
That is not squeamishness — a gigabyte PLY is 4.2 million rows and 260 million
float extractions, which is not a BEAM workload under any strategy, and the
SOG encoder needs k-means clustering plus a spatial sort that no library on
Hex or crates.io provides. It is also the same shape every other media type
already has: shell out for the codec, own the pipeline.
## Things worth knowing
**Store the camera, not just the file.** No splat format carries a viewpoint —
PlayCanvas's own publish flow stores a camera pose rather than a preview
image — so a viewer opening your file points at nothing unless you saved one.
`prepare/3` derives one; persist it next to the asset.
**Bounds use percentiles, not extremes.** A real capture has stray splats flung
far from the scene by reconstruction noise, and a single one inflates the
bounding box enough to push the camera so far back the room becomes a dot.
The outer 1% is trimmed at each end by default (`:percentile`).
**Non-finite values are normal.** Real training output contains NaN — that is
why `splat-transform` ships `--filter-nan`, and why it is on by default here.
Bounds computation skips non-finite values rather than letting one poison
every derived number.
**Dropping spherical harmonics buys time, not bytes.** `sh_bands: 0` makes
encoding roughly **25× faster** while the file shrinks only about a quarter,
because SOG's palette already compresses SH to about 2 bytes per splat. Reach
for it when conversion time hurts, not when file size does.
**Compression ratios below 1 are normal on toy files.** SOG has fixed
container overhead — `meta.json`, WebP images, a ZIP wrapper — so a four-splat
test file comes out *larger*. Real captures land around 45×.
**Previews need a GPU.** `splat-transform` has no CPU rasterisation path;
without a WebGPU device it reports *"writeImage requires a createDevice
function"*. On a headless server that means `mesa-vulkan-drivers` and
`libvulkan1` for llvmpipe. Preview rendering is therefore **off by default**
failing an entire conversion for a thumbnail is the wrong trade. Output is
lossless WebP only; a `.png` path is rejected at the call site.
**Long conversions are expected.** A 2M-splat scene with full SH spends most
of its time in k-means; the default timeout is 45 minutes, and the subprocess
runs in a task that can actually be killed (`System.cmd/3` has no timeout, so
a hung converter would otherwise pin a worker forever).
## Development
```bash
mix test # integration tests skip if splat-transform is absent
PLY_PATH=../ply mix test # against a local ply checkout
mix precommit
```
## License
MIT