SplatTools (SplatTools v0.1.0)

Copy Markdown View Source

Server-side toolkit for 3D Gaussian splat assets.

Takes what a splat trainer produces — a large PLY — and turns it into something a browser can load, plus the metadata an application needs to store alongside it.

{:ok, asset} = SplatTools.prepare("capture.ply", "priv/static/scans")

asset.sog                 #=> "priv/static/scans/capture.sog"
asset.source_splat_count  #=> 1_284_331
asset.camera              #=> %{position: {…}, target: {…}, fov: 50.0}

What runs where

Elixir owns decisions and records; the bytes are somebody else's job. Validation, argument construction, metadata extraction, camera framing and error reporting are here. Compression is splat-transform, the MIT-licensed converter from PlayCanvas — a gigabyte PLY is 4.2 million rows and 260 million float extractions, and its encoder needs k-means clustering and a spatial sort that no library on Hex or crates.io provides.

This is the same shape as every other media type: shell out for the codec, own the pipeline.

Install the converter

npm install -g @playcanvas/splat-transform

SplatTools.Transform.available?/0 says whether it is there; SplatTools.Transform.version/0 says which version.

Store the camera, not just the file

prepare/3 derives a framing camera and returns it. Persist it next to the file. No splat format carries a viewpoint — PlayCanvas's own publish flow stores a camera pose rather than a preview image — and without one a viewer opens pointing at nothing.

Non-finite values are normal

Real training output contains NaN, which is why splat-transform ships a --filter-nan flag and why it is on by default here. Bounds computation drops the whole splat when any of its coordinates is non-finite, matching what the converter does, so the camera frames the geometry that actually ends up in the file.

Summary

Types

A prepared asset and everything worth recording about it.

Functions

How much smaller the converted file is, as a ratio.

Inspects a splat file from its header alone.

Computes bounds and a framing camera without converting anything.

Prepares a splat for web delivery.

Whether a file is a Gaussian-splat point cloud.

Types

asset()

@type asset() :: %{
  source: Path.t(),
  sog: Path.t(),
  preview: Path.t() | nil,
  preview_error: SplatTools.Error.t() | nil,
  source_splat_count: non_neg_integer(),
  nan_filtered: boolean(),
  sh_bands: 0..3,
  sh_coefficients: non_neg_integer(),
  bytes_per_splat: pos_integer() | :variable,
  source_bytes: non_neg_integer(),
  sog_bytes: non_neg_integer(),
  bounds: SplatTools.Camera.bounds(),
  camera: SplatTools.Camera.t()
}

A prepared asset and everything worth recording about it.

source_splat_count, sh_coefficients and bytes_per_splat describe the source file, which is the only thing that can be known without decoding the output again. With nan_filtered true — the default — the delivered file holds no more splats than that, and usually fewer.

bounds and camera describe the source geometry too, so passing a transforming flag through :extra_args will move the output out from under them.

Functions

compression_ratio(map)

@spec compression_ratio(%{
  source_bytes: non_neg_integer(),
  sog_bytes: non_neg_integer()
}) :: float()

How much smaller the converted file is, as a ratio.

iex> SplatTools.compression_ratio(%{source_bytes: 1000, sog_bytes: 100})
10.0

inspect_file(source)

@spec inspect_file(Ply.source()) ::
  {:ok, SplatTools.Inspect.t()} | {:error, SplatTools.Error.t()}

Inspects a splat file from its header alone.

Cheap on any file, because it reads only the header — worth doing before anything that costs minutes.

See SplatTools.Inspect.run/1.

measure(source, opts \\ [])

@spec measure(
  Ply.source(),
  keyword()
) ::
  {:ok, %{bounds: SplatTools.Camera.bounds(), camera: SplatTools.Camera.t()}}
  | {:error, SplatTools.Error.t()}

Computes bounds and a framing camera without converting anything.

Reads only the position columns, packed — a capture carries around sixty properties and this needs three, so the other fifty-seven are never decoded. The three that are still become a list of floats per axis, which is the cost of sorting for a percentile: budget roughly 140 bytes per splat, so a 4-million-splat capture peaks near 600 MB.

Options

Passed to SplatTools.Camera; :percentile (default 0.01) is the one that matters, since stray splats otherwise inflate the bounds.

prepare(source, output_dir, opts \\ [])

@spec prepare(Path.t(), Path.t(), keyword()) ::
  {:ok, asset()} | {:error, SplatTools.Error.t()}

Prepares a splat for web delivery.

Validates the file, converts it to SOG, computes bounds and a framing camera, and reports everything worth persisting.

Options

  • :name — base name for outputs, default the source's basename. A plain filename: anything with a path separator is refused, since it would place the output outside output_dir.
  • :sh_bands — see SplatTools.Transform.convert/3
  • :preview — also render a .webp preview, from the converted file. Off by default: it needs a GPU or a software Vulkan driver. A preview that fails does not fail the conversion — that would be the wrong trade, and it would mean discarding a finished .sog over a thumbnail. The asset comes back with preview: nil and the reason in preview_error.
  • :timeout — milliseconds for the conversion
  • :percentile, :fov, :margin, :elevation — camera framing, see SplatTools.Camera

Examples

{:ok, asset} = SplatTools.prepare("room.ply", "priv/static/scans")
{:ok, asset} = SplatTools.prepare("room.ply", "out", sh_bands: 0, preview: true)

splat?(source)

@spec splat?(Ply.source()) :: boolean()

Whether a file is a Gaussian-splat point cloud.