Powered by AppSignal & Oban Pro

Spatial Codecs: Point Clouds and Gaussian Splats

livebooks/06_spatial_codecs.livemd

Spatial Codecs: Point Clouds and Gaussian Splats

local_path = Path.join(__DIR__, "../mix.exs")

ex_codecs_dep =
  if File.exists?(local_path) do
    [{:ex_codecs, path: Path.join(__DIR__, "..")}, {:rustler, "~> 0.36"}]
  else
    [{:ex_codecs, "~> 0.2.0"}]
  end

config =
  if File.exists?(local_path) do
    [rustler_precompiled: [force_build: [ex_codecs: true]]]
  else
    []
  end

Mix.install(ex_codecs_dep, config: config)

One Framework, Specialized Category APIs

Compression codecs and spatial formats share discovery metadata and %ExCodecs.Error{} results. Their operation APIs differ because compression maps binary to binary, while spatial codecs map domain structs to formats.

ExCodecs.available_codecs(:spatial)
for format <- ExCodecs.Spatial.available_formats() do
  {:ok, info} = ExCodecs.codec_info(format)

  %{
    format: info.name,
    interface: info.interface,
    configurable?: info.configurable?,
    version: info.version
  }
end

Build a Point Cloud

Point.new/4 stores coordinates as floats and normalizes attribute keys to strings. PointCloud.new/2 computes axis-aligned bounds by default.

alias ExCodecs.Spatial.{Gaussian, GaussianCloud, Point, PointCloud}

points = [
  Point.new(0, 0, 0,
    color: {255, 32, 32},
    normal: {0.0, 0.0, 1.0},
    attributes: %{intensity: 0.25}
  ),
  Point.new(1, 0, 0,
    color: {32, 255, 32},
    normal: {0.0, 0.0, 1.0},
    attributes: %{intensity: 0.5}
  ),
  Point.new(0, 1, 0,
    color: {32, 32, 255},
    normal: {0.0, 0.0, 1.0},
    attributes: %{intensity: 0.75}
  )
]

cloud = PointCloud.new(points)

%{
  points: PointCloud.size(cloud),
  bounds: cloud.bounds,
  attribute_keys: cloud.points |> hd() |> Map.fetch!(:attributes) |> Map.keys()
}

PLY Interchange

PLY supports ASCII and binary little-/big-endian representations. The default is binary little-endian.

{:ok, ply_ascii} =
  ExCodecs.Spatial.encode(cloud,
    format: :ply,
    ply_format: :ascii,
    comments: ["created by the ExCodecs spatial Livebook"]
  )

ply_ascii
|> String.split("\n")
|> Enum.take(14)
|> Enum.join("\n")
{:ok, ply_binary} = ExCodecs.Spatial.encode(cloud, format: :ply)
{:ok, decoded_ply} = ExCodecs.Spatial.decode(ply_binary, format: :ply)

%{
  encoded_bytes: byte_size(ply_binary),
  decoded_points: PointCloud.size(decoded_ply),
  first_point: hd(decoded_ply.points)
}

Compact Point-Cloud Binary

:spatial_binary uses the versioned ExCodecs point format (EXCP). It is intended for compact ExCodecs-to-ExCodecs interchange.

{:ok, excp} = ExCodecs.Spatial.encode(cloud, format: :spatial_binary)
{:ok, decoded_excp} = ExCodecs.Spatial.decode(excp, format: :spatial_binary)

%{
  magic: binary_part(excp, 0, 4),
  encoded_bytes: byte_size(excp),
  decoded_points: PointCloud.size(decoded_excp)
}

Spatial Encoding Plus Compression

Spatial encoding chooses a geometry format. Compression is a separate, composable binary step.

{:ok, compressed_excp} = ExCodecs.encode(:zstd, excp, level: 7)
{:ok, restored_excp} = ExCodecs.decode(:zstd, compressed_excp)
{:ok, restored_cloud} =
  ExCodecs.Spatial.decode(restored_excp, format: :spatial_binary)

%{
  excp_bytes: byte_size(excp),
  compressed_bytes: byte_size(compressed_excp),
  round_trip_points: PointCloud.size(restored_cloud)
}

Gaussian Splats

Gaussian uses scalar-first quaternions ({w, x, y, z}). :gsplat is the versioned ExCodecs Gaussian format (GSPL).

gaussian_cloud =
  GaussianCloud.new([
    Gaussian.new({0, 0, 0},
      scale: {0.1, 0.2, 0.1},
      opacity: 0.9,
      color: {1.0, 0.2, 0.1}
    ),
    Gaussian.new({1, 0.5, -0.25},
      rotation: {0.9239, 0.0, 0.3827, 0.0},
      scale: {0.25, 0.1, 0.15},
      opacity: 0.65,
      color: {0.1, 0.4, 1.0}
    )
  ])

{:ok, gspl} = ExCodecs.Spatial.encode(gaussian_cloud, format: :gsplat)
{:ok, decoded_gaussians} = ExCodecs.Spatial.decode(gspl, format: :gsplat)

%{
  magic: binary_part(gspl, 0, 4),
  encoded_bytes: byte_size(gspl),
  decoded_gaussians: GaussianCloud.size(decoded_gaussians)
}

Enumerable Helpers

The current stream_encode/2 and stream_decode/2 names describe an enumerable-facing API. In v0.2.0 they still collect the complete enumerable or payload in memory; they are not incremental file I/O.

{:ok, streamed_payload} =
  ExCodecs.Spatial.stream_encode(points, format: :spatial_binary)

streamed_points =
  streamed_payload
  |> ExCodecs.Spatial.stream_decode(format: :spatial_binary, source: :binary)
  |> Enum.to_list()

length(streamed_points)

Category-Safe Dispatch

The shared catalog does not overload the binary API with struct inputs. Choosing a spatial catalog entry through ExCodecs.encode/3 returns guidance to use the spatial category API.

{:error, error} = ExCodecs.encode(:ply, ply_binary)

%{reason: error.reason, message: error.message}

Next Steps

  • Read Understanding Spatial Codecs for schema and format trade-offs.
  • Read Spatial Wire Formats for the frozen EXCP and GSPL layouts.
  • Add Zstd after spatial encoding when storage or transfer size matters.