Skip to content

Commit e2101d6

Browse files
qureshizawarZawar Qureshi
andauthored
add python bindings (#54)
* add python bindings * add nanobind LICENSE file --------- Co-authored-by: Zawar Qureshi <[email protected]>
1 parent b22345a commit e2101d6

File tree

10 files changed

+1665
-2
lines changed

10 files changed

+1665
-2
lines changed

.gitignore

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,20 @@
11
.DS_Store
2+
*.pyc
3+
4+
# CMake
5+
CMakeCache.txt
6+
CMakeFiles/
7+
CMakeScripts/
8+
Testing/
9+
*.cmake
10+
11+
# Build directories
12+
build/
13+
Build/
14+
BUILD/
15+
out/
16+
_build/
17+
18+
# CMake temporary files
19+
*.tmp
20+
*.cmake~

CMakeLists.txt

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ project(spz
77

88
include(GNUInstallDirs)
99

10-
option(BUILD_SHARED_LIBS "Build using shared libraries" ON)
11-
1210
# zlib is required to build the project
1311
find_package(ZLIB REQUIRED)
1412

@@ -93,3 +91,54 @@ target_include_directories(spz_info PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/src)
9391
install(TARGETS ply_to_spz spz_to_ply spz_info
9492
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
9593
)
94+
95+
# --- Python Bindings Option ---
96+
option(BUILD_PYTHON_BINDINGS "Build Python bindings using nanobind" OFF)
97+
98+
# --- Python Bindings (nanobind) ---
99+
if(BUILD_PYTHON_BINDINGS)
100+
# Find Python with the Development component, which is required by nanobind.
101+
find_package(Python 3.8
102+
REQUIRED COMPONENTS Interpreter Development.Module
103+
OPTIONAL_COMPONENTS Development.SABIModule)
104+
105+
# Detect the installed nanobind package and import it into CMake
106+
execute_process(
107+
COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
108+
OUTPUT_STRIP_TRAILING_WHITESPACE OUTPUT_VARIABLE nanobind_DIR)
109+
find_package(nanobind CONFIG REQUIRED)
110+
111+
112+
nanobind_add_module(
113+
# Name of the extension
114+
spz_py
115+
116+
# Target the stable ABI for Python 3.12+, which reduces
117+
# the number of binary wheels that must be built. This
118+
# does nothing on older Python versions
119+
STABLE_ABI
120+
121+
# Build libnanobind statically and merge it into the
122+
# extension (which itself remains a shared library)
123+
NB_STATIC
124+
125+
# Source code
126+
src/python/spz/spz.cc
127+
)
128+
129+
# Rename the output file to "spz" (no prefix/lib, correct extension)
130+
# This is necessary for the Python module to be recognized correctly
131+
# by Python's import system.
132+
set_target_properties(spz_py PROPERTIES
133+
OUTPUT_NAME spz
134+
)
135+
136+
# Add the project root to the include path for the python module
137+
target_include_directories(spz_py PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
138+
139+
# Link the python module against the spz library
140+
target_link_libraries(spz_py PRIVATE spz::spz)
141+
142+
# Install directive for scikit-build-core
143+
install(TARGETS spz_py LIBRARY DESTINATION spz)
144+
endif()

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,3 +161,17 @@ Each coefficient is represented as an 8-bit signed integer. Additional quantizat
161161
to attain a higher compression ratio. This library currently uses 5 bits of precision for degree 0
162162
and 4 bits of precision for degrees 1 and 2, but this may be changed in the future without breaking
163163
backwards compatibility.
164+
165+
166+
## Python Bindings
167+
168+
The SPZ library provides Python bindings built with [nanobind](https://nanobind.readthedocs.io/) that offer a convenient interface for loading, manipulating, and saving 3D Gaussian splats from Python.
169+
170+
### Installation
171+
```bash
172+
git clone https://github.com/nianticlabs/spz.git
173+
cd spz
174+
pip install .
175+
```
176+
177+
Please see src/python/README.md for more details and usage examples

pyproject.toml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
[build-system]
2+
requires = ["scikit-build-core >=0.10", "nanobind >=1.3.2"]
3+
build-backend = "scikit_build_core.build"
4+
5+
[project]
6+
name = "spz"
7+
dynamic = ["version"]
8+
description = "Python bindings for SPZ file format library that compiles bindings using nanobind and scikit-build"
9+
readme = "README.md"
10+
requires-python = ">=3.8"
11+
dependencies = ["numpy"]
12+
# ---------------------------------------------------------------------------
13+
# Tell scikit-build-core to obtain the dynamic version from CMakeLists:
14+
# ---------------------------------------------------------------------------
15+
[tool.scikit-build.metadata.version]
16+
provider = "scikit_build_core.metadata.regex"
17+
input = "CMakeLists.txt"
18+
regex = 'VERSION[ \t]+(?P<value>[0-9]+\.[0-9]+\.[0-9]+)'
19+
20+
[project.urls]
21+
Homepage = "https://github.com/nianticlabs/spz"
22+
23+
[tool.scikit-build]
24+
# Protect the configuration against future changes in scikit-build-core
25+
minimum-version = "build-system.requires"
26+
27+
# Setuptools-style build caching in a local directory
28+
build-dir = "build/{wheel_tag}"
29+
30+
# Enable Python bindings for Python package builds
31+
cmake.define.BUILD_PYTHON_BINDINGS = "ON"
32+
33+
# Build stable ABI wheels for CPython 3.12+
34+
[tool.scikit-build.wheel]
35+
py-api = "cp312"
36+
37+
[tool.scikit-build.wheel.packages]
38+
spz = "src/python/spz"
39+
40+
[tool.cibuildwheel]
41+
# Necessary to see build output from the actual compilation
42+
build-verbosity = 1
43+
44+
# Run pytest to ensure that the package was correctly built
45+
test-command = "pytest {project}/tests"
46+
test-requires = "pytest"
47+
48+
# Needed for full C++17 support
49+
[tool.cibuildwheel.macos.environment]
50+
MACOSX_DEPLOYMENT_TARGET = "10.14"

src/cc/splat-types.h

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ struct GaussianCloud {
132132
// Convert between two coordinate systems, for example from RDF (ply format) to RUB (used by spz).
133133
// This is performed in-place.
134134
void convertCoordinates(CoordinateSystem from, CoordinateSystem to) {
135+
if (numPoints == 0) {
136+
// There is nothing to convert.
137+
return;
138+
}
135139
CoordinateConverter c = coordinateConverter(from, to);
136140
for (size_t i = 0; i < positions.size(); i += 3) {
137141
positions[i + 0] *= c.flipP[0];

src/python/README.md

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
## Python Bindings
2+
3+
### API Overview
4+
5+
The Python API closely mirrors the C++ API with additional Python-friendly features:
6+
7+
- **Automatic type conversion**: Accepts numpy arrays with various dtypes (int32, float64, etc.) and converts them to float32
8+
- **Memory safety**: All data is safely copied between Python and C++
9+
10+
### Basic Usage Examples
11+
12+
#### Loading and Saving SPZ Files
13+
14+
```python
15+
import spz
16+
17+
# Load a .spz file
18+
cloud = spz.load_spz("samples/hornedlizard.spz")
19+
print(f"Loaded {cloud.num_points} gaussians with SH degree {cloud.sh_degree}")
20+
21+
# Save to a new file
22+
options = spz.PackOptions()
23+
options.from_coord = spz.CoordinateSystem.RUB
24+
spz.save_spz(cloud, options, "output.spz")
25+
```
26+
27+
#### Converting PLY to SPZ
28+
29+
```python
30+
import spz
31+
32+
# Load a PLY file and convert to compressed SPZ format
33+
# PLY files typically use RDF coordinates (right-handed, Y-down, Z-forward)
34+
unpack_options = spz.UnpackOptions()
35+
unpack_options.to_coord = spz.CoordinateSystem.RUB # Convert to RUB for Three.js compatibility
36+
37+
# Load the PLY file
38+
cloud = spz.load_splat_from_ply("input.ply", unpack_options)
39+
print(f"Loaded {cloud.num_points} gaussians from PLY")
40+
41+
# Save as compressed SPZ format
42+
pack_options = spz.PackOptions()
43+
pack_options.from_coord = spz.CoordinateSystem.RUB # Data is now in RUB coordinates
44+
spz.save_spz(cloud, pack_options, "output.spz")
45+
46+
# Check compression ratio
47+
import os
48+
ply_size = os.path.getsize("input.ply")
49+
spz_size = os.path.getsize("output.spz")
50+
compression_ratio = ply_size / spz_size
51+
print(f"Compression ratio: {compression_ratio:.1f}x smaller ({ply_size}{spz_size} bytes)")
52+
```
53+
54+
#### Creating and Manipulating Gaussian Clouds
55+
56+
```python
57+
import spz
58+
import numpy as np
59+
60+
# Create a new Gaussian cloud
61+
cloud = spz.GaussianCloud()
62+
cloud.sh_degree = 1
63+
cloud.antialiased = True
64+
65+
# Choose point count for data you will assign
66+
num_points = 100
67+
68+
# Set positions first (defines num_points)
69+
positions = np.random.randn(num_points * 3).astype(np.float32)
70+
cloud.positions = positions
71+
assert cloud.num_points == num_points # num_points is derived, read‑only
72+
73+
# Set scales (3 floats per point: log-scale factors)
74+
scales = np.random.randn(num_points * 3).astype(np.float32)
75+
cloud.scales = scales
76+
77+
# Set rotations (4 floats per point: quaternion x, y, z, w)
78+
rotations = np.random.randn(num_points * 4).astype(np.float32)
79+
cloud.rotations = rotations
80+
81+
# Set alphas (1 float per point: opacity before sigmoid)
82+
alphas = np.random.randn(num_points).astype(np.float32)
83+
cloud.alphas = alphas
84+
85+
# Set colors (3 floats per point: RGB)
86+
colors = np.random.rand(num_points * 3).astype(np.float32)
87+
cloud.colors = colors
88+
89+
# Set spherical harmonics (9 floats per point for degree 1)
90+
sh_coeffs = np.random.randn(num_points * 9).astype(np.float32)
91+
cloud.sh = sh_coeffs
92+
93+
# Calculate median volume
94+
median_vol = cloud.median_volume()
95+
print(f"Median volume: {median_vol}")
96+
97+
# Apply coordinate transformation
98+
cloud.rotate_180_deg_about_x() # Converts between RUB and RDF coordinates
99+
```
100+
101+
#### Coordinate System Conversions
102+
103+
```python
104+
import spz
105+
106+
# All available coordinate systems
107+
print("Available coordinate systems:")
108+
for coord_sys in [spz.CoordinateSystem.UNSPECIFIED, spz.CoordinateSystem.LDB,
109+
spz.CoordinateSystem.RDB, spz.CoordinateSystem.LUB,
110+
spz.CoordinateSystem.RUB, spz.CoordinateSystem.LDF,
111+
spz.CoordinateSystem.RDF, spz.CoordinateSystem.LUF,
112+
spz.CoordinateSystem.RUF]:
113+
print(f" {coord_sys}")
114+
115+
# Load PLY (typically RDF) and convert to Unity coordinates (RUF)
116+
unpack_options = spz.UnpackOptions()
117+
unpack_options.to_coord = spz.CoordinateSystem.RUF
118+
cloud = spz.load_splat_from_ply("ply_file.ply", unpack_options)
119+
120+
# Save for Three.js (RUB coordinates)
121+
pack_options = spz.PackOptions()
122+
pack_options.from_coord = spz.CoordinateSystem.RUF # Current data is in RUF
123+
spz.save_spz(cloud, pack_options, "threejs_output.spz") # Will be converted to RUB internally
124+
```
125+
126+
#### In-place conversions on an existing GaussianCloud
127+
128+
```python
129+
import spz
130+
import numpy as np
131+
132+
cloud = spz.GaussianCloud()
133+
cloud.positions = np.array([1.0, 2.0, 3.0], dtype=np.float32)
134+
cloud.rotations = np.array([0.1, 0.2, 0.3, 0.9], dtype=np.float32)
135+
136+
# Convert from RUB to RDF (flips Y and Z axes)
137+
cloud.convert_coordinates(spz.CoordinateSystem.RUB, spz.CoordinateSystem.RDF)
138+
139+
# Convert back to RUB
140+
cloud.convert_coordinates(spz.CoordinateSystem.RDF, spz.CoordinateSystem.RUB)
141+
```
142+
143+
#### Advanced Usage with NumPy Integration
144+
145+
```python
146+
import spz
147+
import numpy as np
148+
149+
# Load existing cloud
150+
cloud = spz.load_spz("samples/hornedlizard.spz")
151+
152+
# Access data as NumPy arrays (always returns float32)
153+
positions = cloud.positions # Shape: (num_points * 3,)
154+
scales = cloud.scales # Shape: (num_points * 3,)
155+
rotations = cloud.rotations # Shape: (num_points * 4,)
156+
alphas = cloud.alphas # Shape: (num_points,)
157+
colors = cloud.colors # Shape: (num_points * 3,)
158+
sh_coeffs = cloud.sh # Shape: (num_points * sh_coeffs_per_point,)
159+
160+
# Reshape for easier manipulation
161+
positions_3d = positions.reshape(-1, 3) # Shape: (num_points, 3)
162+
colors_rgb = colors.reshape(-1, 3) # Shape: (num_points, 3)
163+
164+
# Modify data
165+
positions_3d[:, 2] += 1.0 # Move all points up by 1 unit in Z
166+
colors_rgb[:, 0] *= 0.5 # Reduce red channel by half
167+
168+
# Update the cloud (automatic type conversion)
169+
cloud.positions = positions_3d.flatten()
170+
cloud.colors = colors_rgb.flatten()
171+
172+
# Save modified cloud
173+
spz.save_spz(cloud, spz.PackOptions(), "modified.spz")
174+
```
175+
176+
### Python API invariants and validations
177+
178+
The Python bindings enforce consistency across fields and provide clear errors:
179+
180+
- num_points is read‑only and derived from positions.size() / 3.
181+
- Set positions first to establish the point count.
182+
- positions: length must be a multiple of 3. Setting positions updates num_points.
183+
- scales: length must be a multiple of 3; if num_points > 0, length must equal num_points * 3.
184+
- rotations: length must be a multiple of 4; if num_points > 0, length must equal num_points * 4.
185+
- alphas: if num_points > 0, length must equal num_points.
186+
- colors: length must be a multiple of 3; if num_points > 0, length must equal num_points * 3.
187+
- sh_degree: must be in [0, 3]. Set sh_degree before assigning sh.
188+
- sh:
189+
- If sh_degree == 0, sh must be empty.
190+
- Otherwise, length must be a multiple of (((sh_degree + 1)^2 − 1) * 3).
191+
- If num_points > 0, length must equal num_points * (((sh_degree + 1)^2 − 1) * 3).
192+
- Dtypes: numeric arrays are accepted and converted to float32; non‑numeric arrays raise TypeError.
193+
- All arrays must be C‑contiguous; non‑contiguous inputs will be copied by nanobind.
194+
195+
### Data Layout
196+
197+
The Python bindings maintain the same data layout as the C++ library:
198+
199+
- **Positions**: `[x1, y1, z1, x2, y2, z2, ...]`
200+
- **Scales**: `[sx1, sy1, sz1, sx2, sy2, sz2, ...]` (log-scale)
201+
- **Rotations**: `[x1, y1, z1, w1, x2, y2, z2, w2, ...]` (quaternions)
202+
- **Alphas**: `[a1, a2, a3, ...]` (before sigmoid activation)
203+
- **Colors**: `[r1, g1, b1, r2, g2, b2, ...]` (base RGB)
204+
- **Spherical Harmonics**: Coefficient-major order, e.g., for degree 1:
205+
`[sh1n1_r, sh1n1_g, sh1n1_b, sh10_r, sh10_g, sh10_b, sh1p1_r, sh1p1_g, sh1p1_b, ...]`
206+
207+
### Type Safety
208+
209+
The Python bindings provide automatic type conversion while maintaining safety:
210+
211+
-**Accepts**: `int32`, `float64`, `uint8`, etc. → automatically converts to `float32`
212+
-**Rejects**: `string`, `complex`, `object` arrays → raises `TypeError`
213+
- 🔄 **Preserves**: `float32` arrays → no conversion needed
214+
215+
### Testing and Development
216+
217+
The Python bindings include a comprehensive test suite that covers all API functionality:
218+
219+
```bash
220+
221+
# Install pytest and scipy
222+
pip install pytest scipy
223+
224+
# Run the test suite
225+
python -m pytest tests/python/
226+
227+
### Requirements
228+
229+
- Python 3.8+
230+
- NumPy (automatically installed)
231+
- For development: pytest, scipy (for testing)

0 commit comments

Comments
 (0)