gsply 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gsply/__init__.py +55 -0
- gsply/formats.py +226 -0
- gsply/py.typed +1 -0
- gsply/reader.py +894 -0
- gsply/writer.py +1019 -0
- gsply-0.1.0.dist-info/METADATA +520 -0
- gsply-0.1.0.dist-info/RECORD +11 -0
- gsply-0.1.0.dist-info/WHEEL +5 -0
- gsply-0.1.0.dist-info/licenses/LICENSE +21 -0
- gsply-0.1.0.dist-info/top_level.txt +1 -0
- gsply-0.1.0.dist-info/zip-safe +1 -0
gsply/__init__.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""gsply - Fast Gaussian Splatting PLY I/O Library
|
|
2
|
+
|
|
3
|
+
A pure Python library for ultra-fast reading and writing of Gaussian splatting
|
|
4
|
+
PLY files in both uncompressed and compressed formats.
|
|
5
|
+
|
|
6
|
+
Basic Usage:
|
|
7
|
+
>>> import gsply
|
|
8
|
+
>>>
|
|
9
|
+
>>> # Read PLY file (auto-detect format) - returns GSData
|
|
10
|
+
>>> data = gsply.plyread("model.ply")
|
|
11
|
+
>>> print(f"Loaded {data.means.shape[0]} Gaussians")
|
|
12
|
+
>>> positions = data.means
|
|
13
|
+
>>> colors = data.sh0
|
|
14
|
+
>>>
|
|
15
|
+
>>> # Or unpack if needed
|
|
16
|
+
>>> means, scales, quats, opacities, sh0, shN = data[:6]
|
|
17
|
+
>>>
|
|
18
|
+
>>> # Zero-copy reading (1.65x faster, default behavior)
|
|
19
|
+
>>> data = gsply.plyread("model.ply", fast=True)
|
|
20
|
+
>>>
|
|
21
|
+
>>> # Safe copy reading (if you need independent arrays)
|
|
22
|
+
>>> data = gsply.plyread("model.ply", fast=False)
|
|
23
|
+
>>>
|
|
24
|
+
>>> # Write uncompressed PLY file
|
|
25
|
+
>>> gsply.plywrite("output.ply", data.means, data.scales, data.quats,
|
|
26
|
+
... data.opacities, data.sh0, data.shN)
|
|
27
|
+
>>>
|
|
28
|
+
>>> # Write compressed PLY file (saves as "output.compressed.ply")
|
|
29
|
+
>>> gsply.plywrite("output.ply", data.means, data.scales, data.quats,
|
|
30
|
+
... data.opacities, data.sh0, data.shN, compressed=True)
|
|
31
|
+
>>>
|
|
32
|
+
>>> # Detect format
|
|
33
|
+
>>> is_compressed, sh_degree = gsply.detect_format("model.ply")
|
|
34
|
+
|
|
35
|
+
Features:
|
|
36
|
+
- Zero dependencies (pure Python + numpy)
|
|
37
|
+
- SH degrees 0-3 support (14, 23, 38, 59 properties)
|
|
38
|
+
- Compressed format (PlayCanvas compatible)
|
|
39
|
+
- Ultra-fast (~3-5ms read, ~5-10ms write)
|
|
40
|
+
- Zero-copy optimization (1.65x faster reads)
|
|
41
|
+
- Auto-format detection
|
|
42
|
+
|
|
43
|
+
Performance (50K Gaussians):
|
|
44
|
+
- Read uncompressed (fast=True): ~3ms (SH degree 3, zero-copy)
|
|
45
|
+
- Read uncompressed (fast=False): ~5ms (SH degree 3, safe copies)
|
|
46
|
+
- Read compressed: ~30-50ms (with decompression)
|
|
47
|
+
- Write uncompressed: ~5-10ms
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from gsply.reader import plyread, GSData
|
|
51
|
+
from gsply.writer import plywrite
|
|
52
|
+
from gsply.formats import detect_format
|
|
53
|
+
|
|
54
|
+
__version__ = "0.1.0"
|
|
55
|
+
__all__ = ["plyread", "GSData", "plywrite", "detect_format", "__version__"]
|
gsply/formats.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Format detection and constants for Gaussian splatting PLY files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Tuple, Dict, List, Optional
|
|
5
|
+
|
|
6
|
+
# Property counts by SH degree
|
|
7
|
+
PROPERTY_COUNTS_BY_SH_DEGREE = {
|
|
8
|
+
0: 14, # xyz(3) + f_dc(3) + opacity(1) + scales(3) + quats(4)
|
|
9
|
+
1: 23, # +9 f_rest
|
|
10
|
+
2: 38, # +24 f_rest
|
|
11
|
+
3: 59, # +45 f_rest
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
# Expected property names in order for each SH degree
|
|
15
|
+
EXPECTED_PROPERTIES_BY_SH_DEGREE: Dict[int, List[str]] = {
|
|
16
|
+
0: [
|
|
17
|
+
"x", "y", "z",
|
|
18
|
+
"f_dc_0", "f_dc_1", "f_dc_2",
|
|
19
|
+
"opacity",
|
|
20
|
+
"scale_0", "scale_1", "scale_2",
|
|
21
|
+
"rot_0", "rot_1", "rot_2", "rot_3"
|
|
22
|
+
],
|
|
23
|
+
1: [
|
|
24
|
+
"x", "y", "z",
|
|
25
|
+
"f_dc_0", "f_dc_1", "f_dc_2",
|
|
26
|
+
*[f"f_rest_{i}" for i in range(9)],
|
|
27
|
+
"opacity",
|
|
28
|
+
"scale_0", "scale_1", "scale_2",
|
|
29
|
+
"rot_0", "rot_1", "rot_2", "rot_3"
|
|
30
|
+
],
|
|
31
|
+
2: [
|
|
32
|
+
"x", "y", "z",
|
|
33
|
+
"f_dc_0", "f_dc_1", "f_dc_2",
|
|
34
|
+
*[f"f_rest_{i}" for i in range(24)],
|
|
35
|
+
"opacity",
|
|
36
|
+
"scale_0", "scale_1", "scale_2",
|
|
37
|
+
"rot_0", "rot_1", "rot_2", "rot_3"
|
|
38
|
+
],
|
|
39
|
+
3: [
|
|
40
|
+
"x", "y", "z",
|
|
41
|
+
"f_dc_0", "f_dc_1", "f_dc_2",
|
|
42
|
+
*[f"f_rest_{i}" for i in range(45)],
|
|
43
|
+
"opacity",
|
|
44
|
+
"scale_0", "scale_1", "scale_2",
|
|
45
|
+
"rot_0", "rot_1", "rot_2", "rot_3"
|
|
46
|
+
],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
# Compressed format constants
|
|
50
|
+
CHUNK_SIZE = 256
|
|
51
|
+
COMPRESSED_CHUNK_PROPERTIES = 18 # min/max bounds (6*3)
|
|
52
|
+
COMPRESSED_VERTEX_PROPERTIES = 4 # packed position, rotation, scale, color
|
|
53
|
+
|
|
54
|
+
# SH coefficient for color conversion
|
|
55
|
+
SH_C0 = 0.28209479177387814
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse_ply_header(file_path: Path) -> Tuple[Dict, int]:
|
|
59
|
+
"""Parse PLY header to extract element definitions.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
file_path: Path to PLY file
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (elements_dict, header_size_bytes)
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If PLY format is invalid
|
|
69
|
+
"""
|
|
70
|
+
elements = {}
|
|
71
|
+
current_element = None
|
|
72
|
+
header_size = 0
|
|
73
|
+
|
|
74
|
+
with open(file_path, 'rb') as f:
|
|
75
|
+
line = f.readline()
|
|
76
|
+
header_size += len(line)
|
|
77
|
+
|
|
78
|
+
if line.strip() != b'ply':
|
|
79
|
+
raise ValueError("Not a valid PLY file")
|
|
80
|
+
|
|
81
|
+
while True:
|
|
82
|
+
line = f.readline()
|
|
83
|
+
header_size += len(line)
|
|
84
|
+
line_str = line.decode('ascii').strip()
|
|
85
|
+
|
|
86
|
+
if line_str == 'end_header':
|
|
87
|
+
break
|
|
88
|
+
|
|
89
|
+
parts = line_str.split()
|
|
90
|
+
if not parts:
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
if parts[0] == 'element':
|
|
94
|
+
element_name = parts[1]
|
|
95
|
+
element_count = int(parts[2])
|
|
96
|
+
elements[element_name] = {
|
|
97
|
+
'count': element_count,
|
|
98
|
+
'properties': []
|
|
99
|
+
}
|
|
100
|
+
current_element = element_name
|
|
101
|
+
|
|
102
|
+
elif parts[0] == 'property' and current_element:
|
|
103
|
+
prop_type = parts[1]
|
|
104
|
+
prop_name = parts[2]
|
|
105
|
+
elements[current_element]['properties'].append((prop_type, prop_name))
|
|
106
|
+
|
|
107
|
+
return elements, header_size
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def detect_format(file_path: str | Path) -> Tuple[bool, Optional[int]]:
|
|
111
|
+
"""Detect PLY format type and SH degree.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
file_path: Path to PLY file
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Tuple of (is_compressed, sh_degree)
|
|
118
|
+
- is_compressed: True if compressed format, False if uncompressed
|
|
119
|
+
- sh_degree: 0-3 for uncompressed, None for compressed or unknown
|
|
120
|
+
|
|
121
|
+
Example:
|
|
122
|
+
>>> is_compressed, sh_degree = detect_format("model.ply")
|
|
123
|
+
>>> if is_compressed:
|
|
124
|
+
... print("Compressed format")
|
|
125
|
+
... else:
|
|
126
|
+
... print(f"Uncompressed SH degree {sh_degree}")
|
|
127
|
+
"""
|
|
128
|
+
file_path = Path(file_path)
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
elements, _ = _parse_ply_header(file_path)
|
|
132
|
+
except Exception:
|
|
133
|
+
return False, None
|
|
134
|
+
|
|
135
|
+
# Check for compressed format
|
|
136
|
+
if _is_compressed_format(elements):
|
|
137
|
+
return True, None
|
|
138
|
+
|
|
139
|
+
# Check for uncompressed format
|
|
140
|
+
if 'vertex' in elements:
|
|
141
|
+
vertex_props = elements['vertex']['properties']
|
|
142
|
+
property_count = len(vertex_props)
|
|
143
|
+
|
|
144
|
+
# Try to match against known SH degrees
|
|
145
|
+
for sh_degree, expected_count in PROPERTY_COUNTS_BY_SH_DEGREE.items():
|
|
146
|
+
if property_count == expected_count:
|
|
147
|
+
# Verify property names match
|
|
148
|
+
prop_names = [p[1] for p in vertex_props]
|
|
149
|
+
expected_names = EXPECTED_PROPERTIES_BY_SH_DEGREE[sh_degree]
|
|
150
|
+
if prop_names == expected_names:
|
|
151
|
+
return False, sh_degree
|
|
152
|
+
|
|
153
|
+
# Unknown format
|
|
154
|
+
return False, None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _is_compressed_format(elements: Dict) -> bool:
|
|
158
|
+
"""Check if elements dict represents compressed format.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
elements: Parsed PLY header elements
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
True if compressed format detected
|
|
165
|
+
"""
|
|
166
|
+
# Must have chunk and vertex elements
|
|
167
|
+
if 'chunk' not in elements or 'vertex' not in elements:
|
|
168
|
+
return False
|
|
169
|
+
|
|
170
|
+
chunk_elem = elements['chunk']
|
|
171
|
+
vertex_elem = elements['vertex']
|
|
172
|
+
|
|
173
|
+
# Check chunk element (18 float properties)
|
|
174
|
+
chunk_props = chunk_elem['properties']
|
|
175
|
+
if len(chunk_props) != COMPRESSED_CHUNK_PROPERTIES:
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
expected_chunk_names = [
|
|
179
|
+
"min_x", "min_y", "min_z", "max_x", "max_y", "max_z",
|
|
180
|
+
"min_scale_x", "min_scale_y", "min_scale_z",
|
|
181
|
+
"max_scale_x", "max_scale_y", "max_scale_z",
|
|
182
|
+
"min_r", "min_g", "min_b", "max_r", "max_g", "max_b"
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
for i, (prop_type, prop_name) in enumerate(chunk_props):
|
|
186
|
+
if prop_type != 'float' or prop_name != expected_chunk_names[i]:
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# Check vertex element (4 uint properties)
|
|
190
|
+
vertex_props = vertex_elem['properties']
|
|
191
|
+
if len(vertex_props) < COMPRESSED_VERTEX_PROPERTIES:
|
|
192
|
+
return False
|
|
193
|
+
|
|
194
|
+
expected_vertex_names = [
|
|
195
|
+
"packed_position", "packed_rotation", "packed_scale", "packed_color"
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
for i in range(COMPRESSED_VERTEX_PROPERTIES):
|
|
199
|
+
prop_type, prop_name = vertex_props[i]
|
|
200
|
+
if prop_type != 'uint' or prop_name != expected_vertex_names[i]:
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
# Check chunk count matches splat count
|
|
204
|
+
num_chunks = chunk_elem['count']
|
|
205
|
+
num_vertices = vertex_elem['count']
|
|
206
|
+
expected_chunks = (num_vertices + CHUNK_SIZE - 1) // CHUNK_SIZE
|
|
207
|
+
|
|
208
|
+
if num_chunks != expected_chunks:
|
|
209
|
+
return False
|
|
210
|
+
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def get_sh_degree_from_property_count(property_count: int) -> Optional[int]:
|
|
215
|
+
"""Get SH degree from property count.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
property_count: Number of properties in vertex element
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
SH degree (0-3) or None if unknown
|
|
222
|
+
"""
|
|
223
|
+
for sh_degree, expected_count in PROPERTY_COUNTS_BY_SH_DEGREE.items():
|
|
224
|
+
if property_count == expected_count:
|
|
225
|
+
return sh_degree
|
|
226
|
+
return None
|
gsply/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Marker file for PEP 561 - indicates this package supports type checking
|