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/reader.py
ADDED
|
@@ -0,0 +1,894 @@
|
|
|
1
|
+
"""Reading functions for Gaussian splatting PLY files.
|
|
2
|
+
|
|
3
|
+
This module provides ultra-fast reading of Gaussian splatting PLY files
|
|
4
|
+
in both uncompressed and compressed formats.
|
|
5
|
+
|
|
6
|
+
API Examples:
|
|
7
|
+
>>> from gsply import plyread
|
|
8
|
+
>>> data = plyread("scene.ply")
|
|
9
|
+
>>> print(f"Loaded {data.means.shape[0]} Gaussians with SH degree {data.shN.shape[1]}")
|
|
10
|
+
|
|
11
|
+
>>> # Or use format-specific readers
|
|
12
|
+
>>> from gsply.reader import read_uncompressed
|
|
13
|
+
>>> data = read_uncompressed("scene.ply")
|
|
14
|
+
>>> if data is not None:
|
|
15
|
+
... print(f"Loaded {data.means.shape[0]} Gaussians")
|
|
16
|
+
|
|
17
|
+
Performance:
|
|
18
|
+
- Read uncompressed: 8-12ms for 50K Gaussians
|
|
19
|
+
- Read compressed: 30-50ms for 50K Gaussians
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
import struct
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Optional, Union, Tuple, NamedTuple
|
|
26
|
+
import logging
|
|
27
|
+
|
|
28
|
+
from gsply.formats import (
|
|
29
|
+
detect_format,
|
|
30
|
+
get_sh_degree_from_property_count,
|
|
31
|
+
EXPECTED_PROPERTIES_BY_SH_DEGREE,
|
|
32
|
+
CHUNK_SIZE,
|
|
33
|
+
SH_C0,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# Try to import numba for JIT optimization (optional)
|
|
37
|
+
try:
|
|
38
|
+
from numba import jit
|
|
39
|
+
import numba
|
|
40
|
+
HAS_NUMBA = True
|
|
41
|
+
except ImportError:
|
|
42
|
+
HAS_NUMBA = False
|
|
43
|
+
# Fallback: no-op decorator
|
|
44
|
+
def jit(*args, **kwargs):
|
|
45
|
+
def decorator(func):
|
|
46
|
+
return func
|
|
47
|
+
return decorator
|
|
48
|
+
# Mock numba module for prange fallback
|
|
49
|
+
class _MockNumba:
|
|
50
|
+
@staticmethod
|
|
51
|
+
def prange(n):
|
|
52
|
+
return range(n)
|
|
53
|
+
numba = _MockNumba()
|
|
54
|
+
|
|
55
|
+
logger = logging.getLogger(__name__)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ======================================================================================
|
|
59
|
+
# ZERO-COPY DATA CONTAINER
|
|
60
|
+
# ======================================================================================
|
|
61
|
+
|
|
62
|
+
class GSData(NamedTuple):
|
|
63
|
+
"""Gaussian Splatting data container.
|
|
64
|
+
|
|
65
|
+
This container holds Gaussian parameters, either as separate arrays
|
|
66
|
+
or as zero-copy views into a single base array for maximum performance.
|
|
67
|
+
|
|
68
|
+
Attributes:
|
|
69
|
+
means: (N, 3) - xyz positions
|
|
70
|
+
scales: (N, 3) - scale parameters
|
|
71
|
+
quats: (N, 4) - rotation quaternions
|
|
72
|
+
opacities: (N,) - opacity values
|
|
73
|
+
sh0: (N, 3) - DC spherical harmonics
|
|
74
|
+
shN: (N, K, 3) - Higher-order SH coefficients (K bands)
|
|
75
|
+
base: (N, P) - Base array (keeps memory alive for views, None otherwise)
|
|
76
|
+
|
|
77
|
+
Performance:
|
|
78
|
+
- Zero-copy reads via plyfastread() are 1.65x faster
|
|
79
|
+
- No memory overhead (views share memory with base)
|
|
80
|
+
|
|
81
|
+
Example:
|
|
82
|
+
>>> data = plyfastread("scene.ply")
|
|
83
|
+
>>> print(f"Loaded {data.means.shape[0]} Gaussians")
|
|
84
|
+
>>> # Access via attributes
|
|
85
|
+
>>> positions = data.means
|
|
86
|
+
>>> colors = data.sh0
|
|
87
|
+
"""
|
|
88
|
+
means: np.ndarray
|
|
89
|
+
scales: np.ndarray
|
|
90
|
+
quats: np.ndarray
|
|
91
|
+
opacities: np.ndarray
|
|
92
|
+
sh0: np.ndarray
|
|
93
|
+
shN: np.ndarray
|
|
94
|
+
base: np.ndarray # Keeps base array alive for zero-copy views
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ======================================================================================
|
|
98
|
+
# JIT-COMPILED DECOMPRESSION FUNCTIONS
|
|
99
|
+
# ======================================================================================
|
|
100
|
+
|
|
101
|
+
@jit(nopython=True, parallel=True, fastmath=True, cache=True)
|
|
102
|
+
def _unpack_positions_jit(packed_position, chunk_indices, min_x, min_y, min_z, max_x, max_y, max_z, chunk_size=256):
|
|
103
|
+
"""JIT-compiled position unpacking and dequantization (11-10-11 bits) with parallel processing.
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
packed_position: uint32 array of packed position data
|
|
107
|
+
chunk_indices: int32 array of chunk indices for each vertex
|
|
108
|
+
min_x, min_y, min_z: chunk minimum bounds
|
|
109
|
+
max_x, max_y, max_z: chunk maximum bounds
|
|
110
|
+
chunk_size: chunk size (default 256)
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
means: (N, 3) float32 array of dequantized positions
|
|
114
|
+
"""
|
|
115
|
+
n = len(packed_position)
|
|
116
|
+
means = np.zeros((n, 3), dtype=np.float32)
|
|
117
|
+
|
|
118
|
+
for i in numba.prange(n):
|
|
119
|
+
packed = packed_position[i]
|
|
120
|
+
chunk_idx = chunk_indices[i]
|
|
121
|
+
|
|
122
|
+
# Unpack 11-10-11 bits
|
|
123
|
+
px = float((packed >> 21) & 0x7FF) / 2047.0
|
|
124
|
+
py = float((packed >> 11) & 0x3FF) / 1023.0
|
|
125
|
+
pz = float(packed & 0x7FF) / 2047.0
|
|
126
|
+
|
|
127
|
+
# Dequantize
|
|
128
|
+
means[i, 0] = min_x[chunk_idx] + px * (max_x[chunk_idx] - min_x[chunk_idx])
|
|
129
|
+
means[i, 1] = min_y[chunk_idx] + py * (max_y[chunk_idx] - min_y[chunk_idx])
|
|
130
|
+
means[i, 2] = min_z[chunk_idx] + pz * (max_z[chunk_idx] - min_z[chunk_idx])
|
|
131
|
+
|
|
132
|
+
return means
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@jit(nopython=True, parallel=True, fastmath=True, cache=True)
|
|
136
|
+
def _unpack_scales_jit(packed_scale, chunk_indices, min_sx, min_sy, min_sz, max_sx, max_sy, max_sz, chunk_size=256):
|
|
137
|
+
"""JIT-compiled scale unpacking and dequantization (11-10-11 bits) with parallel processing.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
packed_scale: uint32 array of packed scale data
|
|
141
|
+
chunk_indices: int32 array of chunk indices for each vertex
|
|
142
|
+
min_sx, min_sy, min_sz: chunk minimum scale bounds
|
|
143
|
+
max_sx, max_sy, max_sz: chunk maximum scale bounds
|
|
144
|
+
chunk_size: chunk size (default 256)
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
scales: (N, 3) float32 array of dequantized scales
|
|
148
|
+
"""
|
|
149
|
+
n = len(packed_scale)
|
|
150
|
+
scales = np.zeros((n, 3), dtype=np.float32)
|
|
151
|
+
|
|
152
|
+
for i in numba.prange(n):
|
|
153
|
+
packed = packed_scale[i]
|
|
154
|
+
chunk_idx = chunk_indices[i]
|
|
155
|
+
|
|
156
|
+
# Unpack 11-10-11 bits
|
|
157
|
+
sx = float((packed >> 21) & 0x7FF) / 2047.0
|
|
158
|
+
sy = float((packed >> 11) & 0x3FF) / 1023.0
|
|
159
|
+
sz = float(packed & 0x7FF) / 2047.0
|
|
160
|
+
|
|
161
|
+
# Dequantize
|
|
162
|
+
scales[i, 0] = min_sx[chunk_idx] + sx * (max_sx[chunk_idx] - min_sx[chunk_idx])
|
|
163
|
+
scales[i, 1] = min_sy[chunk_idx] + sy * (max_sy[chunk_idx] - min_sy[chunk_idx])
|
|
164
|
+
scales[i, 2] = min_sz[chunk_idx] + sz * (max_sz[chunk_idx] - min_sz[chunk_idx])
|
|
165
|
+
|
|
166
|
+
return scales
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@jit(nopython=True, parallel=True, fastmath=True, cache=True)
|
|
170
|
+
def _unpack_colors_jit(packed_color, chunk_indices, min_r, min_g, min_b, max_r, max_g, max_b, sh_c0, chunk_size=256):
|
|
171
|
+
"""JIT-compiled color unpacking, dequantization, and SH0 conversion (8-8-8-8 bits) with parallel processing.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
packed_color: uint32 array of packed color data
|
|
175
|
+
chunk_indices: int32 array of chunk indices for each vertex
|
|
176
|
+
min_r, min_g, min_b: chunk minimum color bounds
|
|
177
|
+
max_r, max_g, max_b: chunk maximum color bounds
|
|
178
|
+
sh_c0: SH constant for conversion
|
|
179
|
+
chunk_size: chunk size (default 256)
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
sh0: (N, 3) float32 array of SH0 coefficients
|
|
183
|
+
opacities: (N,) float32 array of opacities in logit space
|
|
184
|
+
"""
|
|
185
|
+
n = len(packed_color)
|
|
186
|
+
sh0 = np.zeros((n, 3), dtype=np.float32)
|
|
187
|
+
opacities = np.zeros(n, dtype=np.float32)
|
|
188
|
+
|
|
189
|
+
for i in numba.prange(n):
|
|
190
|
+
packed = packed_color[i]
|
|
191
|
+
chunk_idx = chunk_indices[i]
|
|
192
|
+
|
|
193
|
+
# Unpack 8-8-8-8 bits
|
|
194
|
+
cr = float((packed >> 24) & 0xFF) / 255.0
|
|
195
|
+
cg = float((packed >> 16) & 0xFF) / 255.0
|
|
196
|
+
cb = float((packed >> 8) & 0xFF) / 255.0
|
|
197
|
+
co = float(packed & 0xFF) / 255.0
|
|
198
|
+
|
|
199
|
+
# Dequantize colors
|
|
200
|
+
color_r = min_r[chunk_idx] + cr * (max_r[chunk_idx] - min_r[chunk_idx])
|
|
201
|
+
color_g = min_g[chunk_idx] + cg * (max_g[chunk_idx] - min_g[chunk_idx])
|
|
202
|
+
color_b = min_b[chunk_idx] + cb * (max_b[chunk_idx] - min_b[chunk_idx])
|
|
203
|
+
|
|
204
|
+
# Convert to SH0
|
|
205
|
+
sh0[i, 0] = (color_r - 0.5) / sh_c0
|
|
206
|
+
sh0[i, 1] = (color_g - 0.5) / sh_c0
|
|
207
|
+
sh0[i, 2] = (color_b - 0.5) / sh_c0
|
|
208
|
+
|
|
209
|
+
# Convert opacity to logit space
|
|
210
|
+
if co > 0.0 and co < 1.0:
|
|
211
|
+
opacities[i] = -np.log(1.0 / co - 1.0)
|
|
212
|
+
elif co >= 1.0:
|
|
213
|
+
opacities[i] = 10.0
|
|
214
|
+
else:
|
|
215
|
+
opacities[i] = -10.0
|
|
216
|
+
|
|
217
|
+
return sh0, opacities
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@jit(nopython=True, parallel=True, fastmath=True, cache=True)
|
|
221
|
+
def _unpack_quaternions_jit(packed_rotation, chunk_size=256):
|
|
222
|
+
"""JIT-compiled quaternion unpacking (smallest-three encoding, 2+10-10-10 bits).
|
|
223
|
+
|
|
224
|
+
Args:
|
|
225
|
+
packed_rotation: uint32 array of packed rotation data
|
|
226
|
+
chunk_size: chunk size (default 256)
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
quats: (N, 4) float32 array of quaternions
|
|
230
|
+
"""
|
|
231
|
+
n = len(packed_rotation)
|
|
232
|
+
quats = np.zeros((n, 4), dtype=np.float32)
|
|
233
|
+
norm = 1.0 / (np.sqrt(2) * 0.5)
|
|
234
|
+
|
|
235
|
+
for i in numba.prange(n):
|
|
236
|
+
packed = packed_rotation[i]
|
|
237
|
+
|
|
238
|
+
# Unpack three components (10 bits each)
|
|
239
|
+
a = (float((packed >> 20) & 0x3FF) / 1023.0 - 0.5) * norm
|
|
240
|
+
b = (float((packed >> 10) & 0x3FF) / 1023.0 - 0.5) * norm
|
|
241
|
+
c = (float(packed & 0x3FF) / 1023.0 - 0.5) * norm
|
|
242
|
+
|
|
243
|
+
# Compute fourth component from unit constraint
|
|
244
|
+
m_squared = 1.0 - (a * a + b * b + c * c)
|
|
245
|
+
m = np.sqrt(max(0.0, m_squared))
|
|
246
|
+
|
|
247
|
+
# Which component is the fourth? (2 bits)
|
|
248
|
+
which = (packed >> 30)
|
|
249
|
+
|
|
250
|
+
# Reconstruct quaternion based on 'which' flag
|
|
251
|
+
if which == 0:
|
|
252
|
+
quats[i, 0] = m
|
|
253
|
+
quats[i, 1] = a
|
|
254
|
+
quats[i, 2] = b
|
|
255
|
+
quats[i, 3] = c
|
|
256
|
+
elif which == 1:
|
|
257
|
+
quats[i, 0] = a
|
|
258
|
+
quats[i, 1] = m
|
|
259
|
+
quats[i, 2] = b
|
|
260
|
+
quats[i, 3] = c
|
|
261
|
+
elif which == 2:
|
|
262
|
+
quats[i, 0] = a
|
|
263
|
+
quats[i, 1] = b
|
|
264
|
+
quats[i, 2] = m
|
|
265
|
+
quats[i, 3] = c
|
|
266
|
+
else: # which == 3
|
|
267
|
+
quats[i, 0] = a
|
|
268
|
+
quats[i, 1] = b
|
|
269
|
+
quats[i, 2] = c
|
|
270
|
+
quats[i, 3] = m
|
|
271
|
+
|
|
272
|
+
return quats
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ======================================================================================
|
|
276
|
+
# UNCOMPRESSED PLY READER
|
|
277
|
+
# ======================================================================================
|
|
278
|
+
|
|
279
|
+
def read_uncompressed(file_path: Union[str, Path]) -> Optional[GSData]:
|
|
280
|
+
"""Read uncompressed Gaussian splatting PLY file.
|
|
281
|
+
|
|
282
|
+
Supports all standard Gaussian PLY formats (SH degrees 0-3).
|
|
283
|
+
Uses zero-copy numpy operations for maximum performance.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
file_path: Path to PLY file
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
GSData container with Gaussian parameters, or None if format
|
|
290
|
+
is incompatible. The base field is None for this function (copies are made).
|
|
291
|
+
|
|
292
|
+
Performance:
|
|
293
|
+
- SH degree 0 (14 props): ~17ms for 388K Gaussians
|
|
294
|
+
- SH degree 3 (59 props): ~8ms for 50K Gaussians
|
|
295
|
+
|
|
296
|
+
Example:
|
|
297
|
+
>>> result = read_uncompressed("scene.ply")
|
|
298
|
+
>>> if result is not None:
|
|
299
|
+
... print(f"Loaded {result.means.shape[0]} Gaussians")
|
|
300
|
+
... positions = result.means
|
|
301
|
+
"""
|
|
302
|
+
file_path = Path(file_path)
|
|
303
|
+
|
|
304
|
+
try:
|
|
305
|
+
with open(file_path, 'rb') as f:
|
|
306
|
+
# Read header
|
|
307
|
+
header_lines = []
|
|
308
|
+
while True:
|
|
309
|
+
line = f.readline().decode('ascii').strip()
|
|
310
|
+
header_lines.append(line)
|
|
311
|
+
if line == "end_header":
|
|
312
|
+
break
|
|
313
|
+
if len(header_lines) > 200:
|
|
314
|
+
return None
|
|
315
|
+
|
|
316
|
+
# Parse header inline (while file is still open)
|
|
317
|
+
vertex_count = None
|
|
318
|
+
is_binary_le = False
|
|
319
|
+
property_names = []
|
|
320
|
+
|
|
321
|
+
for line in header_lines:
|
|
322
|
+
if line.startswith("format "):
|
|
323
|
+
format_type = line.split()[1]
|
|
324
|
+
is_binary_le = (format_type == "binary_little_endian")
|
|
325
|
+
elif line.startswith("element vertex "):
|
|
326
|
+
vertex_count = int(line.split()[2])
|
|
327
|
+
elif line.startswith("property float "):
|
|
328
|
+
prop_name = line.split()[2]
|
|
329
|
+
property_names.append(prop_name)
|
|
330
|
+
|
|
331
|
+
# Validate format
|
|
332
|
+
if not is_binary_le or vertex_count is None:
|
|
333
|
+
return None
|
|
334
|
+
|
|
335
|
+
# Detect SH degree from property count
|
|
336
|
+
property_count = len(property_names)
|
|
337
|
+
sh_degree = get_sh_degree_from_property_count(property_count)
|
|
338
|
+
|
|
339
|
+
if sh_degree is None:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
# Validate property names and order (batch comparison is faster)
|
|
343
|
+
expected_properties = EXPECTED_PROPERTIES_BY_SH_DEGREE[sh_degree]
|
|
344
|
+
if property_names != expected_properties:
|
|
345
|
+
return None
|
|
346
|
+
|
|
347
|
+
# Read binary data in single operation (file already positioned after header)
|
|
348
|
+
data = np.fromfile(f, dtype=np.float32, count=vertex_count * property_count)
|
|
349
|
+
|
|
350
|
+
if data.size != vertex_count * property_count:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
data = data.reshape(vertex_count, property_count)
|
|
354
|
+
|
|
355
|
+
# Extract arrays based on SH degree (zero-copy slicing)
|
|
356
|
+
# No .copy() needed - arrays are returned immediately and parent data goes out of scope
|
|
357
|
+
means = data[:, 0:3]
|
|
358
|
+
sh0 = data[:, 3:6]
|
|
359
|
+
|
|
360
|
+
if sh_degree == 0:
|
|
361
|
+
shN = np.zeros((vertex_count, 0, 3), dtype=np.float32)
|
|
362
|
+
opacities = data[:, 6]
|
|
363
|
+
scales = data[:, 7:10]
|
|
364
|
+
quats = data[:, 10:14]
|
|
365
|
+
elif sh_degree == 1:
|
|
366
|
+
shN = data[:, 6:15]
|
|
367
|
+
opacities = data[:, 15]
|
|
368
|
+
scales = data[:, 16:19]
|
|
369
|
+
quats = data[:, 19:23]
|
|
370
|
+
elif sh_degree == 2:
|
|
371
|
+
shN = data[:, 6:30]
|
|
372
|
+
opacities = data[:, 30]
|
|
373
|
+
scales = data[:, 31:34]
|
|
374
|
+
quats = data[:, 34:38]
|
|
375
|
+
else: # sh_degree == 3
|
|
376
|
+
shN = data[:, 6:51]
|
|
377
|
+
opacities = data[:, 51]
|
|
378
|
+
scales = data[:, 52:55]
|
|
379
|
+
quats = data[:, 55:59]
|
|
380
|
+
|
|
381
|
+
# Reshape shN to (N, K, 3) format - need copy here since we're reshaping
|
|
382
|
+
if sh_degree > 0:
|
|
383
|
+
num_sh_coeffs = shN.shape[1]
|
|
384
|
+
shN = shN.copy().reshape(vertex_count, num_sh_coeffs // 3, 3)
|
|
385
|
+
|
|
386
|
+
logger.debug(f"[Gaussian PLY] Read uncompressed: {vertex_count} Gaussians, SH degree {sh_degree}")
|
|
387
|
+
|
|
388
|
+
# Return GSData container (base=None since we made copies)
|
|
389
|
+
return GSData(
|
|
390
|
+
means=means,
|
|
391
|
+
scales=scales,
|
|
392
|
+
quats=quats,
|
|
393
|
+
opacities=opacities,
|
|
394
|
+
sh0=sh0,
|
|
395
|
+
shN=shN,
|
|
396
|
+
base=None # No shared base array for standard read
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
except (OSError, ValueError, IOError):
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def read_uncompressed_fast(file_path: Union[str, Path]) -> Optional[GSData]:
|
|
404
|
+
"""Read uncompressed Gaussian splatting PLY file with zero-copy optimization.
|
|
405
|
+
|
|
406
|
+
This is a high-performance variant of read_uncompressed() that avoids expensive
|
|
407
|
+
memory copies by returning views into a single base array. Approximately 1.86x
|
|
408
|
+
faster than read_uncompressed() for files with higher-order SH coefficients.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
file_path: Path to PLY file
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
GSData namedtuple with zero-copy array views, or None if format
|
|
415
|
+
is incompatible. The base array is kept alive to ensure views remain valid.
|
|
416
|
+
|
|
417
|
+
Performance:
|
|
418
|
+
- SH degree 3 (59 props): ~3.2ms for 50K Gaussians (1.86x faster)
|
|
419
|
+
- Zero memory overhead (views share memory with base array)
|
|
420
|
+
|
|
421
|
+
Example:
|
|
422
|
+
>>> data = read_uncompressed_fast("scene.ply")
|
|
423
|
+
>>> if data is not None:
|
|
424
|
+
... print(f"Loaded {data.means.shape[0]} Gaussians")
|
|
425
|
+
... positions = data.means
|
|
426
|
+
... colors = data.sh0
|
|
427
|
+
|
|
428
|
+
Note:
|
|
429
|
+
The returned arrays are views into a shared base array. This is safe
|
|
430
|
+
because the GSData container keeps the base array alive via
|
|
431
|
+
Python's reference counting mechanism.
|
|
432
|
+
"""
|
|
433
|
+
file_path = Path(file_path)
|
|
434
|
+
|
|
435
|
+
try:
|
|
436
|
+
with open(file_path, 'rb') as f:
|
|
437
|
+
# Read header
|
|
438
|
+
header_lines = []
|
|
439
|
+
while True:
|
|
440
|
+
line = f.readline().decode('ascii').strip()
|
|
441
|
+
header_lines.append(line)
|
|
442
|
+
if line == "end_header":
|
|
443
|
+
break
|
|
444
|
+
if len(header_lines) > 200:
|
|
445
|
+
return None
|
|
446
|
+
|
|
447
|
+
data_offset = f.tell()
|
|
448
|
+
|
|
449
|
+
# Parse header
|
|
450
|
+
vertex_count = None
|
|
451
|
+
is_binary_le = False
|
|
452
|
+
property_names = []
|
|
453
|
+
|
|
454
|
+
for line in header_lines:
|
|
455
|
+
if line.startswith("format "):
|
|
456
|
+
format_type = line.split()[1]
|
|
457
|
+
is_binary_le = (format_type == "binary_little_endian")
|
|
458
|
+
elif line.startswith("element vertex "):
|
|
459
|
+
vertex_count = int(line.split()[2])
|
|
460
|
+
elif line.startswith("property float "):
|
|
461
|
+
prop_name = line.split()[2]
|
|
462
|
+
property_names.append(prop_name)
|
|
463
|
+
|
|
464
|
+
# Validate format
|
|
465
|
+
if not is_binary_le or vertex_count is None:
|
|
466
|
+
return None
|
|
467
|
+
|
|
468
|
+
# Detect SH degree from property count
|
|
469
|
+
property_count = len(property_names)
|
|
470
|
+
sh_degree = get_sh_degree_from_property_count(property_count)
|
|
471
|
+
|
|
472
|
+
if sh_degree is None:
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
# Validate property names and order
|
|
476
|
+
expected_properties = EXPECTED_PROPERTIES_BY_SH_DEGREE[sh_degree]
|
|
477
|
+
if property_names != expected_properties:
|
|
478
|
+
return None
|
|
479
|
+
|
|
480
|
+
# Read binary data in single operation
|
|
481
|
+
with open(file_path, 'rb') as f:
|
|
482
|
+
f.seek(data_offset)
|
|
483
|
+
data = np.fromfile(f, dtype=np.float32, count=vertex_count * property_count)
|
|
484
|
+
|
|
485
|
+
if data.size != vertex_count * property_count:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
data = data.reshape(vertex_count, property_count)
|
|
489
|
+
|
|
490
|
+
# Extract arrays as zero-copy views
|
|
491
|
+
means = data[:, 0:3]
|
|
492
|
+
sh0 = data[:, 3:6]
|
|
493
|
+
|
|
494
|
+
if sh_degree == 0:
|
|
495
|
+
shN = np.zeros((vertex_count, 0, 3), dtype=np.float32)
|
|
496
|
+
opacities = data[:, 6]
|
|
497
|
+
scales = data[:, 7:10]
|
|
498
|
+
quats = data[:, 10:14]
|
|
499
|
+
elif sh_degree == 1:
|
|
500
|
+
shN_flat = data[:, 6:15]
|
|
501
|
+
opacities = data[:, 15]
|
|
502
|
+
scales = data[:, 16:19]
|
|
503
|
+
quats = data[:, 19:23]
|
|
504
|
+
num_sh_coeffs = shN_flat.shape[1]
|
|
505
|
+
shN = shN_flat.reshape(vertex_count, num_sh_coeffs // 3, 3)
|
|
506
|
+
elif sh_degree == 2:
|
|
507
|
+
shN_flat = data[:, 6:30]
|
|
508
|
+
opacities = data[:, 30]
|
|
509
|
+
scales = data[:, 31:34]
|
|
510
|
+
quats = data[:, 34:38]
|
|
511
|
+
num_sh_coeffs = shN_flat.shape[1]
|
|
512
|
+
shN = shN_flat.reshape(vertex_count, num_sh_coeffs // 3, 3)
|
|
513
|
+
else: # sh_degree == 3
|
|
514
|
+
shN_flat = data[:, 6:51]
|
|
515
|
+
opacities = data[:, 51]
|
|
516
|
+
scales = data[:, 52:55]
|
|
517
|
+
quats = data[:, 55:59]
|
|
518
|
+
num_sh_coeffs = shN_flat.shape[1]
|
|
519
|
+
shN = shN_flat.reshape(vertex_count, num_sh_coeffs // 3, 3)
|
|
520
|
+
|
|
521
|
+
logger.debug(f"[Gaussian PLY] Read uncompressed (fast): {vertex_count} Gaussians, SH degree {sh_degree}")
|
|
522
|
+
|
|
523
|
+
# Return GSData with base array to keep views alive
|
|
524
|
+
return GSData(
|
|
525
|
+
means=means,
|
|
526
|
+
scales=scales,
|
|
527
|
+
quats=quats,
|
|
528
|
+
opacities=opacities,
|
|
529
|
+
sh0=sh0,
|
|
530
|
+
shN=shN,
|
|
531
|
+
base=data # Keep alive for zero-copy views
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
except (OSError, ValueError, IOError):
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ======================================================================================
|
|
539
|
+
# COMPRESSED PLY READER
|
|
540
|
+
# ======================================================================================
|
|
541
|
+
|
|
542
|
+
def _unpack_unorm(value: int, bits: int) -> float:
|
|
543
|
+
"""Extract normalized value [0,1] from packed bits."""
|
|
544
|
+
mask = (1 << bits) - 1
|
|
545
|
+
return (value & mask) / mask
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _unpack_111011(value: int) -> Tuple[float, float, float]:
|
|
549
|
+
"""Unpack 3D vector from 32-bit value (11-10-11 bits)."""
|
|
550
|
+
x = _unpack_unorm(value >> 21, 11)
|
|
551
|
+
y = _unpack_unorm(value >> 11, 10)
|
|
552
|
+
z = _unpack_unorm(value, 11)
|
|
553
|
+
return x, y, z
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _unpack_8888(value: int) -> Tuple[float, float, float, float]:
|
|
557
|
+
"""Unpack 4 channels from 32-bit value (8 bits each)."""
|
|
558
|
+
x = _unpack_unorm(value >> 24, 8)
|
|
559
|
+
y = _unpack_unorm(value >> 16, 8)
|
|
560
|
+
z = _unpack_unorm(value >> 8, 8)
|
|
561
|
+
w = _unpack_unorm(value, 8)
|
|
562
|
+
return x, y, z, w
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _unpack_rotation(value: int) -> Tuple[float, float, float, float]:
|
|
566
|
+
"""Unpack quaternion using smallest-three encoding."""
|
|
567
|
+
norm = 1.0 / (np.sqrt(2) * 0.5)
|
|
568
|
+
|
|
569
|
+
a = (_unpack_unorm(value >> 20, 10) - 0.5) * norm
|
|
570
|
+
b = (_unpack_unorm(value >> 10, 10) - 0.5) * norm
|
|
571
|
+
c = (_unpack_unorm(value, 10) - 0.5) * norm
|
|
572
|
+
|
|
573
|
+
m = np.sqrt(max(0.0, 1.0 - (a * a + b * b + c * c)))
|
|
574
|
+
which = value >> 30
|
|
575
|
+
|
|
576
|
+
if which == 0:
|
|
577
|
+
return m, a, b, c
|
|
578
|
+
elif which == 1:
|
|
579
|
+
return a, m, b, c
|
|
580
|
+
elif which == 2:
|
|
581
|
+
return a, b, m, c
|
|
582
|
+
else:
|
|
583
|
+
return a, b, c, m
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _is_compressed_format(header_lines: list) -> bool:
|
|
587
|
+
"""Check if PLY header indicates compressed format."""
|
|
588
|
+
elements = {}
|
|
589
|
+
current_element = None
|
|
590
|
+
|
|
591
|
+
for line in header_lines:
|
|
592
|
+
if line.startswith("element "):
|
|
593
|
+
parts = line.split()
|
|
594
|
+
name = parts[1]
|
|
595
|
+
count = int(parts[2])
|
|
596
|
+
elements[name] = {"count": count, "properties": []}
|
|
597
|
+
current_element = name
|
|
598
|
+
elif line.startswith("property ") and current_element:
|
|
599
|
+
parts = line.split()
|
|
600
|
+
prop_type = parts[1]
|
|
601
|
+
prop_name = parts[2]
|
|
602
|
+
elements[current_element]["properties"].append((prop_type, prop_name))
|
|
603
|
+
|
|
604
|
+
# Compressed format has "chunk" and "vertex" elements with specific properties
|
|
605
|
+
if "chunk" not in elements or "vertex" not in elements:
|
|
606
|
+
return False
|
|
607
|
+
|
|
608
|
+
chunk_props = elements["chunk"]["properties"]
|
|
609
|
+
if len(chunk_props) != 18:
|
|
610
|
+
return False
|
|
611
|
+
|
|
612
|
+
vertex_props = elements["vertex"]["properties"]
|
|
613
|
+
if len(vertex_props) != 4:
|
|
614
|
+
return False
|
|
615
|
+
|
|
616
|
+
expected_vertex = ["packed_position", "packed_rotation", "packed_scale", "packed_color"]
|
|
617
|
+
for (_, prop_name), expected_name in zip(vertex_props, expected_vertex):
|
|
618
|
+
if prop_name != expected_name:
|
|
619
|
+
return False
|
|
620
|
+
|
|
621
|
+
return True
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def read_compressed(file_path: Union[str, Path]) -> Optional[GSData]:
|
|
625
|
+
"""Read compressed Gaussian splatting PLY file (PlayCanvas format).
|
|
626
|
+
|
|
627
|
+
Format uses chunk-based quantization with 256 Gaussians per chunk.
|
|
628
|
+
Achieves 14.5x compression (16 bytes/splat vs 232 bytes/splat).
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
file_path: Path to compressed PLY file
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
GSData container with decompressed Gaussian parameters, or None
|
|
635
|
+
if format is incompatible. The base field is None (no shared array).
|
|
636
|
+
|
|
637
|
+
Performance:
|
|
638
|
+
~30-50ms for 50K Gaussians (decompression overhead)
|
|
639
|
+
|
|
640
|
+
Example:
|
|
641
|
+
>>> result = read_compressed("scene.ply_compressed")
|
|
642
|
+
>>> if result is not None:
|
|
643
|
+
... print(f"Loaded {result.means.shape[0]} compressed Gaussians")
|
|
644
|
+
... positions = result.means
|
|
645
|
+
"""
|
|
646
|
+
file_path = Path(file_path)
|
|
647
|
+
|
|
648
|
+
try:
|
|
649
|
+
with open(file_path, 'rb') as f:
|
|
650
|
+
# Read header
|
|
651
|
+
header_lines = []
|
|
652
|
+
while True:
|
|
653
|
+
line = f.readline().decode('ascii').strip()
|
|
654
|
+
header_lines.append(line)
|
|
655
|
+
if line == "end_header":
|
|
656
|
+
break
|
|
657
|
+
if len(header_lines) > 200:
|
|
658
|
+
return None
|
|
659
|
+
|
|
660
|
+
data_offset = f.tell()
|
|
661
|
+
|
|
662
|
+
# Check if compressed format
|
|
663
|
+
if not _is_compressed_format(header_lines):
|
|
664
|
+
return None
|
|
665
|
+
|
|
666
|
+
# Parse element info
|
|
667
|
+
elements = {}
|
|
668
|
+
current_element = None
|
|
669
|
+
|
|
670
|
+
for line in header_lines:
|
|
671
|
+
if line.startswith("element "):
|
|
672
|
+
parts = line.split()
|
|
673
|
+
name = parts[1]
|
|
674
|
+
count = int(parts[2])
|
|
675
|
+
elements[name] = {"count": count, "properties": []}
|
|
676
|
+
current_element = name
|
|
677
|
+
elif line.startswith("property ") and current_element:
|
|
678
|
+
parts = line.split()
|
|
679
|
+
prop_type = parts[1]
|
|
680
|
+
prop_name = parts[2]
|
|
681
|
+
elements[current_element]["properties"].append((prop_type, prop_name))
|
|
682
|
+
|
|
683
|
+
# Read chunk data (18 float32 per chunk)
|
|
684
|
+
with open(file_path, 'rb') as f:
|
|
685
|
+
f.seek(data_offset)
|
|
686
|
+
|
|
687
|
+
num_chunks = elements["chunk"]["count"]
|
|
688
|
+
chunk_data = np.fromfile(f, dtype=np.float32, count=num_chunks * 18)
|
|
689
|
+
chunk_data = chunk_data.reshape(num_chunks, 18)
|
|
690
|
+
|
|
691
|
+
# Read vertex data (4 uint32 per vertex)
|
|
692
|
+
num_vertices = elements["vertex"]["count"]
|
|
693
|
+
vertex_data = np.fromfile(f, dtype=np.uint32, count=num_vertices * 4)
|
|
694
|
+
vertex_data = vertex_data.reshape(num_vertices, 4)
|
|
695
|
+
|
|
696
|
+
# Read optional SH data (uint8 per coefficient)
|
|
697
|
+
shN_data = None
|
|
698
|
+
if "sh" in elements:
|
|
699
|
+
num_sh_coeffs = len(elements["sh"]["properties"])
|
|
700
|
+
shN_data = np.fromfile(f, dtype=np.uint8, count=num_vertices * num_sh_coeffs)
|
|
701
|
+
shN_data = shN_data.reshape(num_vertices, num_sh_coeffs)
|
|
702
|
+
|
|
703
|
+
# Extract chunk bounds
|
|
704
|
+
min_x, min_y, min_z = chunk_data[:, 0], chunk_data[:, 1], chunk_data[:, 2]
|
|
705
|
+
max_x, max_y, max_z = chunk_data[:, 3], chunk_data[:, 4], chunk_data[:, 5]
|
|
706
|
+
min_scale_x, min_scale_y, min_scale_z = chunk_data[:, 6], chunk_data[:, 7], chunk_data[:, 8]
|
|
707
|
+
max_scale_x, max_scale_y, max_scale_z = chunk_data[:, 9], chunk_data[:, 10], chunk_data[:, 11]
|
|
708
|
+
min_r, min_g, min_b = chunk_data[:, 12], chunk_data[:, 13], chunk_data[:, 14]
|
|
709
|
+
max_r, max_g, max_b = chunk_data[:, 15], chunk_data[:, 16], chunk_data[:, 17]
|
|
710
|
+
|
|
711
|
+
# Allocate output arrays
|
|
712
|
+
means = np.zeros((num_vertices, 3), dtype=np.float32)
|
|
713
|
+
scales = np.zeros((num_vertices, 3), dtype=np.float32)
|
|
714
|
+
quats = np.zeros((num_vertices, 4), dtype=np.float32)
|
|
715
|
+
opacities = np.zeros(num_vertices, dtype=np.float32)
|
|
716
|
+
sh0 = np.zeros((num_vertices, 3), dtype=np.float32)
|
|
717
|
+
|
|
718
|
+
# Decompress vertices (vectorized for 5-10x speedup)
|
|
719
|
+
packed_position = vertex_data[:, 0]
|
|
720
|
+
packed_rotation = vertex_data[:, 1]
|
|
721
|
+
packed_scale = vertex_data[:, 2]
|
|
722
|
+
packed_color = vertex_data[:, 3]
|
|
723
|
+
|
|
724
|
+
# Pre-compute chunk indices for all vertices
|
|
725
|
+
chunk_indices = np.arange(num_vertices, dtype=np.int32) // CHUNK_SIZE
|
|
726
|
+
|
|
727
|
+
# Use JIT-compiled functions if available (2-3x faster)
|
|
728
|
+
if HAS_NUMBA:
|
|
729
|
+
# JIT-compiled decompression (parallel, fastmath)
|
|
730
|
+
means = _unpack_positions_jit(packed_position, chunk_indices, min_x, min_y, min_z, max_x, max_y, max_z)
|
|
731
|
+
scales = _unpack_scales_jit(packed_scale, chunk_indices, min_scale_x, min_scale_y, min_scale_z, max_scale_x, max_scale_y, max_scale_z)
|
|
732
|
+
sh0, opacities = _unpack_colors_jit(packed_color, chunk_indices, min_r, min_g, min_b, max_r, max_g, max_b, SH_C0)
|
|
733
|
+
quats = _unpack_quaternions_jit(packed_rotation)
|
|
734
|
+
else:
|
|
735
|
+
# Fallback: Vectorized NumPy operations
|
|
736
|
+
# Position unpacking (11-10-11 bits)
|
|
737
|
+
px = ((packed_position >> 21) & 0x7FF).astype(np.float32) / 2047.0
|
|
738
|
+
py = ((packed_position >> 11) & 0x3FF).astype(np.float32) / 1023.0
|
|
739
|
+
pz = (packed_position & 0x7FF).astype(np.float32) / 2047.0
|
|
740
|
+
|
|
741
|
+
means[:, 0] = min_x[chunk_indices] + px * (max_x[chunk_indices] - min_x[chunk_indices])
|
|
742
|
+
means[:, 1] = min_y[chunk_indices] + py * (max_y[chunk_indices] - min_y[chunk_indices])
|
|
743
|
+
means[:, 2] = min_z[chunk_indices] + pz * (max_z[chunk_indices] - min_z[chunk_indices])
|
|
744
|
+
|
|
745
|
+
# Scale unpacking (11-10-11 bits)
|
|
746
|
+
sx = ((packed_scale >> 21) & 0x7FF).astype(np.float32) / 2047.0
|
|
747
|
+
sy = ((packed_scale >> 11) & 0x3FF).astype(np.float32) / 1023.0
|
|
748
|
+
sz = (packed_scale & 0x7FF).astype(np.float32) / 2047.0
|
|
749
|
+
|
|
750
|
+
scales[:, 0] = min_scale_x[chunk_indices] + sx * (max_scale_x[chunk_indices] - min_scale_x[chunk_indices])
|
|
751
|
+
scales[:, 1] = min_scale_y[chunk_indices] + sy * (max_scale_y[chunk_indices] - min_scale_y[chunk_indices])
|
|
752
|
+
scales[:, 2] = min_scale_z[chunk_indices] + sz * (max_scale_z[chunk_indices] - min_scale_z[chunk_indices])
|
|
753
|
+
|
|
754
|
+
# Color unpacking (8-8-8-8 bits)
|
|
755
|
+
cr = ((packed_color >> 24) & 0xFF).astype(np.float32) / 255.0
|
|
756
|
+
cg = ((packed_color >> 16) & 0xFF).astype(np.float32) / 255.0
|
|
757
|
+
cb = ((packed_color >> 8) & 0xFF).astype(np.float32) / 255.0
|
|
758
|
+
co = (packed_color & 0xFF).astype(np.float32) / 255.0
|
|
759
|
+
|
|
760
|
+
color_r = min_r[chunk_indices] + cr * (max_r[chunk_indices] - min_r[chunk_indices])
|
|
761
|
+
color_g = min_g[chunk_indices] + cg * (max_g[chunk_indices] - min_g[chunk_indices])
|
|
762
|
+
color_b = min_b[chunk_indices] + cb * (max_b[chunk_indices] - min_b[chunk_indices])
|
|
763
|
+
|
|
764
|
+
# Convert to SH0
|
|
765
|
+
sh0[:, 0] = (color_r - 0.5) / SH_C0
|
|
766
|
+
sh0[:, 1] = (color_g - 0.5) / SH_C0
|
|
767
|
+
sh0[:, 2] = (color_b - 0.5) / SH_C0
|
|
768
|
+
|
|
769
|
+
# Opacity conversion (logit space)
|
|
770
|
+
opacities = np.where(
|
|
771
|
+
(co > 0.0) & (co < 1.0),
|
|
772
|
+
-np.log(1.0 / co - 1.0),
|
|
773
|
+
np.where(co >= 1.0, 10.0, -10.0)
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
# Quaternion unpacking (smallest-three encoding)
|
|
777
|
+
norm = 1.0 / (np.sqrt(2) * 0.5)
|
|
778
|
+
a = (((packed_rotation >> 20) & 0x3FF).astype(np.float32) / 1023.0 - 0.5) * norm
|
|
779
|
+
b = (((packed_rotation >> 10) & 0x3FF).astype(np.float32) / 1023.0 - 0.5) * norm
|
|
780
|
+
c = ((packed_rotation & 0x3FF).astype(np.float32) / 1023.0 - 0.5) * norm
|
|
781
|
+
m = np.sqrt(np.maximum(0.0, 1.0 - (a * a + b * b + c * c)))
|
|
782
|
+
which = (packed_rotation >> 30).astype(np.int32)
|
|
783
|
+
|
|
784
|
+
quats[:, 0] = np.where(which == 0, m, a)
|
|
785
|
+
quats[:, 1] = np.where(which == 1, m, np.where(which == 0, a, b))
|
|
786
|
+
quats[:, 2] = np.where(which == 2, m, np.where(which <= 1, b, c))
|
|
787
|
+
quats[:, 3] = np.where(which == 3, m, c)
|
|
788
|
+
|
|
789
|
+
# Decompress SH coefficients (vectorized)
|
|
790
|
+
if shN_data is not None:
|
|
791
|
+
num_sh_coeffs = shN_data.shape[1]
|
|
792
|
+
num_sh_bands = num_sh_coeffs // 3
|
|
793
|
+
|
|
794
|
+
# Vectorized normalization
|
|
795
|
+
# Handle three cases: val==0, val==255, else
|
|
796
|
+
normalized = np.where(
|
|
797
|
+
shN_data == 0,
|
|
798
|
+
0.0,
|
|
799
|
+
np.where(
|
|
800
|
+
shN_data == 255,
|
|
801
|
+
1.0,
|
|
802
|
+
(shN_data.astype(np.float32) + 0.5) / 256.0
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
# Vectorized conversion to SH values
|
|
807
|
+
sh_flat = (normalized - 0.5) * 8.0
|
|
808
|
+
|
|
809
|
+
# Reshape to (N, num_bands, 3)
|
|
810
|
+
shN = sh_flat.reshape(num_vertices, num_sh_bands, 3)
|
|
811
|
+
else:
|
|
812
|
+
shN = np.zeros((num_vertices, 0, 3), dtype=np.float32)
|
|
813
|
+
|
|
814
|
+
logger.debug(f"[Gaussian PLY] Read compressed: {num_vertices} Gaussians, SH bands {shN.shape[1]}")
|
|
815
|
+
|
|
816
|
+
# Return GSData container (base=None since decompressed data is separate)
|
|
817
|
+
return GSData(
|
|
818
|
+
means=means,
|
|
819
|
+
scales=scales,
|
|
820
|
+
quats=quats,
|
|
821
|
+
opacities=opacities,
|
|
822
|
+
sh0=sh0,
|
|
823
|
+
shN=shN,
|
|
824
|
+
base=None # No shared base array for compressed format
|
|
825
|
+
)
|
|
826
|
+
|
|
827
|
+
except (OSError, ValueError, struct.error):
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
# ======================================================================================
|
|
832
|
+
# UNIFIED READING API
|
|
833
|
+
# ======================================================================================
|
|
834
|
+
|
|
835
|
+
def plyread(file_path: Union[str, Path], fast: bool = True) -> GSData:
|
|
836
|
+
"""Read Gaussian splatting PLY file (auto-detect format).
|
|
837
|
+
|
|
838
|
+
Automatically detects and reads both compressed and uncompressed formats.
|
|
839
|
+
Uses formats.detect_format() for fast format detection.
|
|
840
|
+
|
|
841
|
+
Args:
|
|
842
|
+
file_path: Path to PLY file
|
|
843
|
+
fast: If True, use zero-copy optimization for uncompressed files (1.65x faster).
|
|
844
|
+
If False, make safe copies of all arrays. Default: True.
|
|
845
|
+
|
|
846
|
+
Returns:
|
|
847
|
+
GSData container with Gaussian parameters
|
|
848
|
+
|
|
849
|
+
Raises:
|
|
850
|
+
ValueError: If file format is not recognized or invalid
|
|
851
|
+
|
|
852
|
+
Performance:
|
|
853
|
+
- fast=True: 2.89ms for 50K Gaussians (zero-copy views, 1.65x faster)
|
|
854
|
+
- fast=False: 4.75ms for 50K Gaussians (safe independent copies)
|
|
855
|
+
|
|
856
|
+
Example:
|
|
857
|
+
>>> # Fast zero-copy reading (default, recommended)
|
|
858
|
+
>>> data = plyread("scene.ply")
|
|
859
|
+
>>> print(f"Loaded {data.means.shape[0]} Gaussians")
|
|
860
|
+
>>> positions = data.means
|
|
861
|
+
>>>
|
|
862
|
+
>>> # Safe reading with independent copies
|
|
863
|
+
>>> data = plyread("scene.ply", fast=False)
|
|
864
|
+
>>>
|
|
865
|
+
>>> # Can still unpack if needed
|
|
866
|
+
>>> means, scales, quats, opacities, sh0, shN = data[:6]
|
|
867
|
+
"""
|
|
868
|
+
file_path = Path(file_path)
|
|
869
|
+
|
|
870
|
+
# Detect format first
|
|
871
|
+
is_compressed, sh_degree = detect_format(file_path)
|
|
872
|
+
|
|
873
|
+
# Try appropriate reader based on format and fast parameter
|
|
874
|
+
if is_compressed:
|
|
875
|
+
result = read_compressed(file_path)
|
|
876
|
+
else:
|
|
877
|
+
if fast:
|
|
878
|
+
result = read_uncompressed_fast(file_path)
|
|
879
|
+
else:
|
|
880
|
+
result = read_uncompressed(file_path)
|
|
881
|
+
|
|
882
|
+
if result is not None:
|
|
883
|
+
return result
|
|
884
|
+
|
|
885
|
+
raise ValueError(f"Unsupported PLY format or invalid file: {file_path}")
|
|
886
|
+
|
|
887
|
+
|
|
888
|
+
__all__ = [
|
|
889
|
+
'plyread',
|
|
890
|
+
'GSData',
|
|
891
|
+
'read_uncompressed',
|
|
892
|
+
'read_uncompressed_fast',
|
|
893
|
+
'read_compressed',
|
|
894
|
+
]
|