sogpy 1.0.0__tar.gz → 1.0.2__tar.gz
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.
- {sogpy-1.0.0/sogpy.egg-info → sogpy-1.0.2}/PKG-INFO +3 -1
- {sogpy-1.0.0 → sogpy-1.0.2}/README.md +2 -0
- sogpy-1.0.2/SogPython/__init__.py +13 -0
- sogpy-1.0.2/SogPython/__main__.py +6 -0
- sogpy-1.0.2/SogPython/cli.py +58 -0
- sogpy-1.0.2/SogPython/kmeans.py +190 -0
- sogpy-1.0.2/SogPython/morton_order.py +97 -0
- sogpy-1.0.2/SogPython/ply_reader.py +154 -0
- sogpy-1.0.2/SogPython/sog_writer.py +435 -0
- sogpy-1.0.2/SogPython/utils.py +13 -0
- {sogpy-1.0.0 → sogpy-1.0.2}/pyproject.toml +1 -1
- {sogpy-1.0.0 → sogpy-1.0.2/sogpy.egg-info}/PKG-INFO +3 -1
- sogpy-1.0.2/sogpy.egg-info/SOURCES.txt +17 -0
- sogpy-1.0.2/sogpy.egg-info/top_level.txt +1 -0
- sogpy-1.0.0/sogpy.egg-info/SOURCES.txt +0 -9
- sogpy-1.0.0/sogpy.egg-info/top_level.txt +0 -1
- {sogpy-1.0.0 → sogpy-1.0.2}/LICENSE +0 -0
- {sogpy-1.0.0 → sogpy-1.0.2}/setup.cfg +0 -0
- {sogpy-1.0.0 → sogpy-1.0.2}/sogpy.egg-info/dependency_links.txt +0 -0
- {sogpy-1.0.0 → sogpy-1.0.2}/sogpy.egg-info/entry_points.txt +0 -0
- {sogpy-1.0.0 → sogpy-1.0.2}/sogpy.egg-info/requires.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sogpy
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Convert Gaussian Splat PLY files to PlayCanvas SOG format
|
|
5
5
|
Author: Sai Kiran Nunemunthala
|
|
6
6
|
License: MIT
|
|
@@ -35,6 +35,8 @@ Dynamic: license-file
|
|
|
35
35
|
|
|
36
36
|
Convert Gaussian Splat `.ply` files to PlayCanvas `.sog` format.
|
|
37
37
|
|
|
38
|
+
> ⚠️ **Note:** This is a direct Python conversion of [splat-transform](https://github.com/playcanvas/splat-transform)'s PLY to SOG Node.js code. One-shotted with Claude Opus 4.5 — use with caution! Contributions are very welcome.
|
|
39
|
+
|
|
38
40
|
## Installation
|
|
39
41
|
|
|
40
42
|
```bash
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Convert Gaussian Splat `.ply` files to PlayCanvas `.sog` format.
|
|
4
4
|
|
|
5
|
+
> ⚠️ **Note:** This is a direct Python conversion of [splat-transform](https://github.com/playcanvas/splat-transform)'s PLY to SOG Node.js code. One-shotted with Claude Opus 4.5 — use with caution! Contributions are very welcome.
|
|
6
|
+
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
7
9
|
```bash
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SogPython - Convert Gaussian Splat PLY files to PlayCanvas SOG format.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
from SogPython import convert_ply_to_sog
|
|
6
|
+
convert_ply_to_sog("input.ply", "output.sog")
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .sog_writer import convert_ply_to_sog
|
|
10
|
+
from .ply_reader import read_ply
|
|
11
|
+
|
|
12
|
+
__version__ = "1.0.0"
|
|
13
|
+
__all__ = ["convert_ply_to_sog", "read_ply"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SogPython CLI - Convert Gaussian Splat PLY files to PlayCanvas SOG format.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python -m SogPython input.ply output.sog [--iterations N]
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from .sog_writer import convert_ply_to_sog
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main():
|
|
15
|
+
parser = argparse.ArgumentParser(
|
|
16
|
+
description='Convert Gaussian Splat PLY files to PlayCanvas SOG format.',
|
|
17
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
18
|
+
epilog='''
|
|
19
|
+
Examples:
|
|
20
|
+
python -m SogPython input.ply output.sog
|
|
21
|
+
python -m SogPython model.ply model.sog --iterations 20
|
|
22
|
+
python -m SogPython splat.ply splat.sog --no-bundle
|
|
23
|
+
'''
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
parser.add_argument('input', help='Input PLY file path')
|
|
27
|
+
parser.add_argument('output', help='Output SOG file path')
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
'--iterations', '-i',
|
|
30
|
+
type=int,
|
|
31
|
+
default=10,
|
|
32
|
+
help='Number of k-means iterations (default: 10)'
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
'--no-bundle',
|
|
36
|
+
action='store_true',
|
|
37
|
+
help='Write separate files instead of ZIP bundle'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
args = parser.parse_args()
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
convert_ply_to_sog(
|
|
44
|
+
args.input,
|
|
45
|
+
args.output,
|
|
46
|
+
iterations=args.iterations,
|
|
47
|
+
bundle=not args.no_bundle
|
|
48
|
+
)
|
|
49
|
+
except FileNotFoundError as e:
|
|
50
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
51
|
+
sys.exit(1)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == '__main__':
|
|
58
|
+
main()
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""K-means clustering implementation for SOG compression using scipy."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from typing import Tuple
|
|
5
|
+
from scipy.spatial import KDTree
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def kmeans(data: np.ndarray, k: int, iterations: int = 10) -> Tuple[np.ndarray, np.ndarray]:
|
|
9
|
+
"""
|
|
10
|
+
K-means clustering using scipy KDTree for efficient nearest neighbor lookup.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
data: Input data array (n_samples,) or (n_samples, n_features)
|
|
14
|
+
k: Number of clusters
|
|
15
|
+
iterations: Number of iterations
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Tuple of (centroids, labels)
|
|
19
|
+
- centroids: (k,) or (k, n_features) array of cluster centers
|
|
20
|
+
- labels: (n_samples,) array of cluster assignments
|
|
21
|
+
"""
|
|
22
|
+
data = np.asarray(data, dtype=np.float32)
|
|
23
|
+
n = len(data)
|
|
24
|
+
|
|
25
|
+
# Handle case where we have fewer points than clusters
|
|
26
|
+
if n < k:
|
|
27
|
+
return data.copy(), np.arange(n, dtype=np.uint32)
|
|
28
|
+
|
|
29
|
+
# Ensure 2D for consistent handling
|
|
30
|
+
is_1d = data.ndim == 1
|
|
31
|
+
if is_1d:
|
|
32
|
+
data = data.reshape(-1, 1)
|
|
33
|
+
|
|
34
|
+
n_features = data.shape[1]
|
|
35
|
+
|
|
36
|
+
# Initialize centroids using quantile-based method for 1D, k-means++ style for multi-D
|
|
37
|
+
if n_features == 1:
|
|
38
|
+
sorted_data = np.sort(data[:, 0])
|
|
39
|
+
centroids = np.zeros((k, 1), dtype=np.float32)
|
|
40
|
+
for i in range(k):
|
|
41
|
+
quantile = (2 * i + 1) / (2 * k)
|
|
42
|
+
idx = min(int(quantile * n), n - 1)
|
|
43
|
+
centroids[i, 0] = sorted_data[idx]
|
|
44
|
+
else:
|
|
45
|
+
# Random initialization with reproducibility
|
|
46
|
+
np.random.seed(42)
|
|
47
|
+
indices = np.random.choice(n, k, replace=False)
|
|
48
|
+
centroids = data[indices].copy()
|
|
49
|
+
|
|
50
|
+
labels = np.zeros(n, dtype=np.uint32)
|
|
51
|
+
|
|
52
|
+
# For very large datasets, use batch processing
|
|
53
|
+
batch_size = 50000
|
|
54
|
+
|
|
55
|
+
for iteration in range(iterations):
|
|
56
|
+
# Build KDTree for efficient nearest neighbor lookup
|
|
57
|
+
tree = KDTree(centroids)
|
|
58
|
+
|
|
59
|
+
# Assign labels in batches to reduce memory usage
|
|
60
|
+
for batch_start in range(0, n, batch_size):
|
|
61
|
+
batch_end = min(batch_start + batch_size, n)
|
|
62
|
+
batch = data[batch_start:batch_end]
|
|
63
|
+
_, batch_labels = tree.query(batch)
|
|
64
|
+
labels[batch_start:batch_end] = batch_labels
|
|
65
|
+
|
|
66
|
+
# Update centroids using vectorized operations
|
|
67
|
+
new_centroids = np.zeros_like(centroids)
|
|
68
|
+
counts = np.zeros(k, dtype=np.int64)
|
|
69
|
+
|
|
70
|
+
# Use bincount for fast aggregation
|
|
71
|
+
for j in range(n_features):
|
|
72
|
+
sums = np.bincount(labels, weights=data[:, j], minlength=k)
|
|
73
|
+
new_centroids[:, j] = sums
|
|
74
|
+
|
|
75
|
+
counts = np.bincount(labels, minlength=k)
|
|
76
|
+
|
|
77
|
+
# Handle empty clusters
|
|
78
|
+
empty_mask = counts == 0
|
|
79
|
+
if np.any(empty_mask):
|
|
80
|
+
# Reinitialize empty clusters
|
|
81
|
+
empty_indices = np.where(empty_mask)[0]
|
|
82
|
+
random_points = np.random.choice(n, len(empty_indices), replace=False)
|
|
83
|
+
new_centroids[empty_indices] = data[random_points]
|
|
84
|
+
counts[empty_indices] = 1
|
|
85
|
+
|
|
86
|
+
centroids = new_centroids / counts[:, np.newaxis]
|
|
87
|
+
|
|
88
|
+
if is_1d:
|
|
89
|
+
centroids = centroids.flatten()
|
|
90
|
+
|
|
91
|
+
return centroids.astype(np.float32), labels
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def cluster_1d(columns: dict, column_names: list, k: int = 256, iterations: int = 10) -> Tuple[np.ndarray, dict]:
|
|
95
|
+
"""
|
|
96
|
+
Cluster multiple columns as if they were independent 1D datasets.
|
|
97
|
+
|
|
98
|
+
This matches the TypeScript cluster1d function which clusters each column
|
|
99
|
+
independently but using shared centroids.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
columns: Dictionary of column name -> data array
|
|
103
|
+
column_names: Names of columns to cluster
|
|
104
|
+
k: Number of clusters (default 256)
|
|
105
|
+
iterations: Number of k-means iterations
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Tuple of (centroids, labels_dict)
|
|
109
|
+
- centroids: (k,) array of centroid values
|
|
110
|
+
- labels_dict: Dictionary of column name -> labels array (uint8)
|
|
111
|
+
"""
|
|
112
|
+
# Stack all column data into one 1D array
|
|
113
|
+
data_list = [columns[name] for name in column_names]
|
|
114
|
+
n_rows = len(data_list[0])
|
|
115
|
+
|
|
116
|
+
all_data = np.concatenate(data_list)
|
|
117
|
+
|
|
118
|
+
# Run k-means on concatenated data
|
|
119
|
+
centroids, labels = kmeans(all_data, k, iterations)
|
|
120
|
+
|
|
121
|
+
# Order centroids smallest to largest
|
|
122
|
+
order = np.argsort(centroids)
|
|
123
|
+
centroids_sorted = centroids[order]
|
|
124
|
+
|
|
125
|
+
# Create inverse mapping for label reordering
|
|
126
|
+
inv_order = np.zeros(k, dtype=np.uint32)
|
|
127
|
+
inv_order[order] = np.arange(k, dtype=np.uint32)
|
|
128
|
+
|
|
129
|
+
# Reorder labels and split back into columns
|
|
130
|
+
labels_reordered = inv_order[labels].astype(np.uint8)
|
|
131
|
+
|
|
132
|
+
labels_dict = {}
|
|
133
|
+
for i, name in enumerate(column_names):
|
|
134
|
+
start = i * n_rows
|
|
135
|
+
end = (i + 1) * n_rows
|
|
136
|
+
labels_dict[name] = labels_reordered[start:end]
|
|
137
|
+
|
|
138
|
+
return centroids_sorted, labels_dict
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def cluster_sh(columns: dict, sh_names: list, k: int, iterations: int = 10) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
142
|
+
"""
|
|
143
|
+
Cluster spherical harmonics data with a two-level approach.
|
|
144
|
+
|
|
145
|
+
First level: cluster all SH data points into k centroids
|
|
146
|
+
Second level: quantize the centroids using 256 values
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
columns: Dictionary of column name -> data array
|
|
150
|
+
sh_names: List of SH coefficient column names
|
|
151
|
+
k: Number of first-level clusters (palette size)
|
|
152
|
+
iterations: Number of k-means iterations
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
Tuple of (labels, centroids_quantized, codebook)
|
|
156
|
+
- labels: Point -> centroid index (uint16)
|
|
157
|
+
- centroids_quantized: Quantized centroid values (n_centroids, n_coeffs)
|
|
158
|
+
- codebook: 256 values for the quantization
|
|
159
|
+
"""
|
|
160
|
+
n_rows = len(columns[sh_names[0]])
|
|
161
|
+
n_coeffs = len(sh_names)
|
|
162
|
+
|
|
163
|
+
print(f" Clustering {n_rows} points in {n_coeffs} dimensions into {k} clusters...")
|
|
164
|
+
|
|
165
|
+
# Stack SH data: (n_rows, n_coeffs)
|
|
166
|
+
sh_data = np.column_stack([columns[name] for name in sh_names])
|
|
167
|
+
|
|
168
|
+
# First level clustering (reduce iterations for large datasets)
|
|
169
|
+
effective_iterations = min(iterations, 3) if n_rows > 50000 else iterations
|
|
170
|
+
centroids, labels = kmeans(sh_data, k, effective_iterations)
|
|
171
|
+
labels = labels.astype(np.uint16)
|
|
172
|
+
|
|
173
|
+
print(f" Quantizing {k} centroids with 256-value codebook...")
|
|
174
|
+
|
|
175
|
+
# Second level: quantize the centroids channel-wise
|
|
176
|
+
all_centroid_values = centroids.flatten()
|
|
177
|
+
codebook, _ = kmeans(all_centroid_values, 256, iterations)
|
|
178
|
+
|
|
179
|
+
# Order codebook smallest to largest
|
|
180
|
+
order = np.argsort(codebook)
|
|
181
|
+
codebook_sorted = codebook[order].astype(np.float32)
|
|
182
|
+
|
|
183
|
+
# Quantize centroids using broadcasting
|
|
184
|
+
# Find nearest codebook entry for each centroid value
|
|
185
|
+
diffs = np.abs(centroids.flatten()[:, np.newaxis] - codebook_sorted[np.newaxis, :])
|
|
186
|
+
quant_indices = np.argmin(diffs, axis=1)
|
|
187
|
+
|
|
188
|
+
centroids_quantized = quant_indices.reshape(k, n_coeffs).astype(np.uint8)
|
|
189
|
+
|
|
190
|
+
return labels, centroids_quantized, codebook_sorted
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Morton order (Z-order curve) sorting for spatial coherence."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def part1by2(x: np.ndarray) -> np.ndarray:
|
|
8
|
+
"""Spread bits of x by inserting two zeros between each bit."""
|
|
9
|
+
x = x.astype(np.uint32)
|
|
10
|
+
x = x & 0x000003ff
|
|
11
|
+
x = (x ^ (x << 16)) & 0xff0000ff
|
|
12
|
+
x = (x ^ (x << 8)) & 0x0300f00f
|
|
13
|
+
x = (x ^ (x << 4)) & 0x030c30c3
|
|
14
|
+
x = (x ^ (x << 2)) & 0x09249249
|
|
15
|
+
return x
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def encode_morton3(x: np.ndarray, y: np.ndarray, z: np.ndarray) -> np.ndarray:
|
|
19
|
+
"""Encode 3D coordinates into Morton code."""
|
|
20
|
+
return (part1by2(z) << 2) + (part1by2(y) << 1) + part1by2(x)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sort_morton_order(x: np.ndarray, y: np.ndarray, z: np.ndarray, indices: Optional[np.ndarray] = None) -> np.ndarray:
|
|
24
|
+
"""
|
|
25
|
+
Sort points into Morton/Z-order for better spatial coherence.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
x, y, z: Position coordinate arrays
|
|
29
|
+
indices: Optional array of indices to sort (if None, creates 0..n-1)
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
Sorted indices array
|
|
33
|
+
"""
|
|
34
|
+
n = len(x)
|
|
35
|
+
if indices is None:
|
|
36
|
+
indices = np.arange(n, dtype=np.uint32)
|
|
37
|
+
else:
|
|
38
|
+
indices = indices.copy()
|
|
39
|
+
|
|
40
|
+
_sort_morton_recursive(x, y, z, indices)
|
|
41
|
+
return indices
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _sort_morton_recursive(x: np.ndarray, y: np.ndarray, z: np.ndarray, indices: np.ndarray) -> None:
|
|
45
|
+
"""Recursively sort indices by Morton code."""
|
|
46
|
+
if len(indices) == 0:
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
# Get values for current indices
|
|
50
|
+
cx = x[indices]
|
|
51
|
+
cy = y[indices]
|
|
52
|
+
cz = z[indices]
|
|
53
|
+
|
|
54
|
+
# Calculate bounds
|
|
55
|
+
mx, Mx = cx.min(), cx.max()
|
|
56
|
+
my, My = cy.min(), cy.max()
|
|
57
|
+
mz, Mz = cz.min(), cz.max()
|
|
58
|
+
|
|
59
|
+
xlen = Mx - mx
|
|
60
|
+
ylen = My - my
|
|
61
|
+
zlen = Mz - mz
|
|
62
|
+
|
|
63
|
+
# Check for invalid or identical points
|
|
64
|
+
if not (np.isfinite(xlen) and np.isfinite(ylen) and np.isfinite(zlen)):
|
|
65
|
+
return
|
|
66
|
+
if xlen == 0 and ylen == 0 and zlen == 0:
|
|
67
|
+
return
|
|
68
|
+
|
|
69
|
+
# Normalize to 0-1023 range
|
|
70
|
+
xmul = 1024 / xlen if xlen > 0 else 0
|
|
71
|
+
ymul = 1024 / ylen if ylen > 0 else 0
|
|
72
|
+
zmul = 1024 / zlen if zlen > 0 else 0
|
|
73
|
+
|
|
74
|
+
ix = np.minimum(1023, ((cx - mx) * xmul)).astype(np.uint32)
|
|
75
|
+
iy = np.minimum(1023, ((cy - my) * ymul)).astype(np.uint32)
|
|
76
|
+
iz = np.minimum(1023, ((cz - mz) * zmul)).astype(np.uint32)
|
|
77
|
+
|
|
78
|
+
# Compute Morton codes
|
|
79
|
+
morton = encode_morton3(ix, iy, iz)
|
|
80
|
+
|
|
81
|
+
# Sort by Morton code
|
|
82
|
+
order = np.argsort(morton)
|
|
83
|
+
indices[:] = indices[order]
|
|
84
|
+
morton = morton[order]
|
|
85
|
+
|
|
86
|
+
# Recursively sort large buckets (same Morton code)
|
|
87
|
+
start = 0
|
|
88
|
+
while start < len(indices):
|
|
89
|
+
end = start + 1
|
|
90
|
+
while end < len(indices) and morton[end] == morton[start]:
|
|
91
|
+
end += 1
|
|
92
|
+
|
|
93
|
+
# Only recurse for buckets larger than 256
|
|
94
|
+
if end - start > 256:
|
|
95
|
+
_sort_morton_recursive(x, y, z, indices[start:end])
|
|
96
|
+
|
|
97
|
+
start = end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""PLY file reader for Gaussian Splat data."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from typing import Dict, List, Tuple
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def get_numpy_dtype(ply_type: str):
|
|
8
|
+
"""Map PLY type names to numpy dtypes."""
|
|
9
|
+
type_map = {
|
|
10
|
+
'char': np.int8,
|
|
11
|
+
'uchar': np.uint8,
|
|
12
|
+
'short': np.int16,
|
|
13
|
+
'ushort': np.uint16,
|
|
14
|
+
'int': np.int32,
|
|
15
|
+
'uint': np.uint32,
|
|
16
|
+
'float': np.float32,
|
|
17
|
+
'double': np.float64,
|
|
18
|
+
}
|
|
19
|
+
return type_map.get(ply_type)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_ply_header(data: bytes) -> Tuple[Dict, int]:
|
|
23
|
+
"""
|
|
24
|
+
Parse PLY header and return element definitions and header end position.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
Tuple of (header_info, header_end_position)
|
|
28
|
+
header_info = {'comments': [...], 'elements': [{'name': ..., 'count': ..., 'properties': [...]}]}
|
|
29
|
+
"""
|
|
30
|
+
# Find end of header
|
|
31
|
+
header_end = data.find(b'end_header\n')
|
|
32
|
+
if header_end == -1:
|
|
33
|
+
header_end = data.find(b'end_header\r\n')
|
|
34
|
+
end_len = 12
|
|
35
|
+
else:
|
|
36
|
+
end_len = 11
|
|
37
|
+
|
|
38
|
+
if header_end == -1:
|
|
39
|
+
raise ValueError("Invalid PLY file: no end_header found")
|
|
40
|
+
|
|
41
|
+
header_text = data[:header_end].decode('ascii')
|
|
42
|
+
lines = [l.strip() for l in header_text.split('\n') if l.strip()]
|
|
43
|
+
|
|
44
|
+
# Validate PLY magic
|
|
45
|
+
if not lines[0].startswith('ply'):
|
|
46
|
+
raise ValueError("Invalid PLY file: missing 'ply' header")
|
|
47
|
+
|
|
48
|
+
header_info = {
|
|
49
|
+
'comments': [],
|
|
50
|
+
'elements': [],
|
|
51
|
+
'format': 'binary_little_endian'
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
current_element = None
|
|
55
|
+
|
|
56
|
+
for line in lines[1:]:
|
|
57
|
+
words = line.split()
|
|
58
|
+
|
|
59
|
+
if words[0] == 'format':
|
|
60
|
+
header_info['format'] = words[1]
|
|
61
|
+
elif words[0] == 'comment':
|
|
62
|
+
header_info['comments'].append(' '.join(words[1:]))
|
|
63
|
+
elif words[0] == 'element':
|
|
64
|
+
if len(words) != 3:
|
|
65
|
+
raise ValueError(f"Invalid element line: {line}")
|
|
66
|
+
current_element = {
|
|
67
|
+
'name': words[1],
|
|
68
|
+
'count': int(words[2]),
|
|
69
|
+
'properties': []
|
|
70
|
+
}
|
|
71
|
+
header_info['elements'].append(current_element)
|
|
72
|
+
elif words[0] == 'property':
|
|
73
|
+
if current_element is None or len(words) < 3:
|
|
74
|
+
raise ValueError(f"Invalid property line: {line}")
|
|
75
|
+
# Skip list properties (not used in splat data)
|
|
76
|
+
if words[1] == 'list':
|
|
77
|
+
continue
|
|
78
|
+
dtype = get_numpy_dtype(words[1])
|
|
79
|
+
if dtype is None:
|
|
80
|
+
raise ValueError(f"Unknown property type: {words[1]}")
|
|
81
|
+
current_element['properties'].append({
|
|
82
|
+
'name': words[2],
|
|
83
|
+
'type': words[1],
|
|
84
|
+
'dtype': dtype
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return header_info, header_end + end_len
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def read_ply(filepath: str) -> Dict[str, np.ndarray]:
|
|
91
|
+
"""
|
|
92
|
+
Read a PLY file and return a dictionary of property arrays.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
filepath: Path to the PLY file
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Dictionary mapping property names to numpy arrays
|
|
99
|
+
"""
|
|
100
|
+
with open(filepath, 'rb') as f:
|
|
101
|
+
data = f.read()
|
|
102
|
+
|
|
103
|
+
header, data_start = parse_ply_header(data)
|
|
104
|
+
|
|
105
|
+
# Only support binary little endian format
|
|
106
|
+
if 'binary_little_endian' not in header['format']:
|
|
107
|
+
raise ValueError(f"Unsupported PLY format: {header['format']}. Only binary_little_endian is supported.")
|
|
108
|
+
|
|
109
|
+
result = {}
|
|
110
|
+
offset = data_start
|
|
111
|
+
|
|
112
|
+
for element in header['elements']:
|
|
113
|
+
name = element['name']
|
|
114
|
+
count = element['count']
|
|
115
|
+
properties = element['properties']
|
|
116
|
+
|
|
117
|
+
if not properties:
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
# Build structured dtype for this element
|
|
121
|
+
dtype_list = [(p['name'], p['dtype']) for p in properties]
|
|
122
|
+
element_dtype = np.dtype(dtype_list)
|
|
123
|
+
|
|
124
|
+
# Read element data
|
|
125
|
+
element_size = element_dtype.itemsize
|
|
126
|
+
element_data = np.frombuffer(
|
|
127
|
+
data,
|
|
128
|
+
dtype=element_dtype,
|
|
129
|
+
count=count,
|
|
130
|
+
offset=offset
|
|
131
|
+
)
|
|
132
|
+
offset += element_size * count
|
|
133
|
+
|
|
134
|
+
# Store vertex element properties directly
|
|
135
|
+
if name == 'vertex':
|
|
136
|
+
for prop in properties:
|
|
137
|
+
result[prop['name']] = element_data[prop['name']].astype(np.float32)
|
|
138
|
+
|
|
139
|
+
return result
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def get_splat_data(ply_data: Dict[str, np.ndarray]) -> Dict[str, np.ndarray]:
|
|
143
|
+
"""
|
|
144
|
+
Organize PLY data into splat data structure.
|
|
145
|
+
|
|
146
|
+
Returns dictionary with:
|
|
147
|
+
- x, y, z: positions
|
|
148
|
+
- rot_0, rot_1, rot_2, rot_3: quaternion rotation
|
|
149
|
+
- scale_0, scale_1, scale_2: scale (log)
|
|
150
|
+
- f_dc_0, f_dc_1, f_dc_2: DC spherical harmonic (color)
|
|
151
|
+
- opacity: opacity (logit)
|
|
152
|
+
- f_rest_0 to f_rest_44: higher order SH (optional)
|
|
153
|
+
"""
|
|
154
|
+
return ply_data
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""
|
|
2
|
+
SOG (Splat Optimized Graphics) format writer.
|
|
3
|
+
|
|
4
|
+
Converts Gaussian Splat PLY data to PlayCanvas SOG format using:
|
|
5
|
+
- WebP lossless compression for textures
|
|
6
|
+
- K-means clustering for scale and color data
|
|
7
|
+
- Morton ordering for spatial coherence
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import math
|
|
12
|
+
import zipfile
|
|
13
|
+
import io
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Dict, Optional, List, Tuple
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
from PIL import Image
|
|
19
|
+
|
|
20
|
+
from .ply_reader import read_ply
|
|
21
|
+
from .morton_order import sort_morton_order
|
|
22
|
+
from .kmeans import cluster_1d, cluster_sh
|
|
23
|
+
from .utils import sigmoid, log_transform
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# Spherical harmonics coefficient names
|
|
27
|
+
SH_NAMES = [f"f_rest_{i}" for i in range(45)]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def encode_webp_lossless(data: np.ndarray, width: int, height: int) -> bytes:
|
|
31
|
+
"""Encode RGBA data as lossless WebP."""
|
|
32
|
+
# Ensure data is the right shape and type
|
|
33
|
+
rgba = data.reshape(height, width, 4).astype(np.uint8)
|
|
34
|
+
image = Image.fromarray(rgba, mode='RGBA')
|
|
35
|
+
|
|
36
|
+
buffer = io.BytesIO()
|
|
37
|
+
image.save(buffer, format='WEBP', lossless=True)
|
|
38
|
+
return buffer.getvalue()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def calc_min_max(data: Dict[str, np.ndarray], column_names: List[str], indices: np.ndarray) -> List[Tuple[float, float]]:
|
|
42
|
+
"""Calculate min/max for specified columns using only indexed rows."""
|
|
43
|
+
result = []
|
|
44
|
+
for name in column_names:
|
|
45
|
+
values = data[name][indices]
|
|
46
|
+
result.append((float(values.min()), float(values.max())))
|
|
47
|
+
return result
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_means(data: Dict[str, np.ndarray], indices: np.ndarray, width: int, height: int) -> Tuple[bytes, bytes, Dict]:
|
|
51
|
+
"""
|
|
52
|
+
Write position data as two 16-bit WebP textures (low and high bytes).
|
|
53
|
+
|
|
54
|
+
Uses log transform on positions for better precision distribution.
|
|
55
|
+
"""
|
|
56
|
+
n = len(indices)
|
|
57
|
+
channels = 4
|
|
58
|
+
|
|
59
|
+
means_l = np.zeros(width * height * channels, dtype=np.uint8)
|
|
60
|
+
means_u = np.zeros(width * height * channels, dtype=np.uint8)
|
|
61
|
+
|
|
62
|
+
# Calculate min/max with log transform
|
|
63
|
+
column_names = ['x', 'y', 'z']
|
|
64
|
+
min_max_raw = calc_min_max(data, column_names, indices)
|
|
65
|
+
min_max = [(log_transform(np.array([mm[0]]))[0], log_transform(np.array([mm[1]]))[0]) for mm in min_max_raw]
|
|
66
|
+
|
|
67
|
+
x_data = data['x'][indices]
|
|
68
|
+
y_data = data['y'][indices]
|
|
69
|
+
z_data = data['z'][indices]
|
|
70
|
+
|
|
71
|
+
for i in range(n):
|
|
72
|
+
# Apply log transform and normalize to 16-bit
|
|
73
|
+
x_log = log_transform(np.array([x_data[i]]))[0]
|
|
74
|
+
y_log = log_transform(np.array([y_data[i]]))[0]
|
|
75
|
+
z_log = log_transform(np.array([z_data[i]]))[0]
|
|
76
|
+
|
|
77
|
+
x_range = min_max[0][1] - min_max[0][0]
|
|
78
|
+
y_range = min_max[1][1] - min_max[1][0]
|
|
79
|
+
z_range = min_max[2][1] - min_max[2][0]
|
|
80
|
+
|
|
81
|
+
x_norm = 65535 * (x_log - min_max[0][0]) / x_range if x_range > 0 else 0
|
|
82
|
+
y_norm = 65535 * (y_log - min_max[1][0]) / y_range if y_range > 0 else 0
|
|
83
|
+
z_norm = 65535 * (z_log - min_max[2][0]) / z_range if z_range > 0 else 0
|
|
84
|
+
|
|
85
|
+
x_int = int(max(0, min(65535, x_norm)))
|
|
86
|
+
y_int = int(max(0, min(65535, y_norm)))
|
|
87
|
+
z_int = int(max(0, min(65535, z_norm)))
|
|
88
|
+
|
|
89
|
+
ti = i # layout is identity
|
|
90
|
+
|
|
91
|
+
# Low bytes
|
|
92
|
+
means_l[ti * 4] = x_int & 0xff
|
|
93
|
+
means_l[ti * 4 + 1] = y_int & 0xff
|
|
94
|
+
means_l[ti * 4 + 2] = z_int & 0xff
|
|
95
|
+
means_l[ti * 4 + 3] = 0xff
|
|
96
|
+
|
|
97
|
+
# High bytes
|
|
98
|
+
means_u[ti * 4] = (x_int >> 8) & 0xff
|
|
99
|
+
means_u[ti * 4 + 1] = (y_int >> 8) & 0xff
|
|
100
|
+
means_u[ti * 4 + 2] = (z_int >> 8) & 0xff
|
|
101
|
+
means_u[ti * 4 + 3] = 0xff
|
|
102
|
+
|
|
103
|
+
webp_l = encode_webp_lossless(means_l, width, height)
|
|
104
|
+
webp_u = encode_webp_lossless(means_u, width, height)
|
|
105
|
+
|
|
106
|
+
return webp_l, webp_u, {
|
|
107
|
+
'mins': [mm[0] for mm in min_max],
|
|
108
|
+
'maxs': [mm[1] for mm in min_max]
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def write_quaternions(data: Dict[str, np.ndarray], indices: np.ndarray, width: int, height: int) -> bytes:
|
|
113
|
+
"""
|
|
114
|
+
Write quaternion rotation data using smallest-three compression.
|
|
115
|
+
|
|
116
|
+
Stores three components (excluding the largest) in range [-1, 1] scaled by sqrt(2).
|
|
117
|
+
The alpha channel encodes which component was dropped (252-255).
|
|
118
|
+
"""
|
|
119
|
+
n = len(indices)
|
|
120
|
+
channels = 4
|
|
121
|
+
quats = np.zeros(width * height * channels, dtype=np.uint8)
|
|
122
|
+
|
|
123
|
+
rot_0 = data['rot_0'][indices]
|
|
124
|
+
rot_1 = data['rot_1'][indices]
|
|
125
|
+
rot_2 = data['rot_2'][indices]
|
|
126
|
+
rot_3 = data['rot_3'][indices]
|
|
127
|
+
|
|
128
|
+
sqrt2 = math.sqrt(2)
|
|
129
|
+
|
|
130
|
+
for i in range(n):
|
|
131
|
+
q = [rot_0[i], rot_1[i], rot_2[i], rot_3[i]]
|
|
132
|
+
|
|
133
|
+
# Normalize
|
|
134
|
+
length = math.sqrt(sum(c * c for c in q))
|
|
135
|
+
if length > 0:
|
|
136
|
+
q = [c / length for c in q]
|
|
137
|
+
|
|
138
|
+
# Find largest component
|
|
139
|
+
max_comp = max(range(4), key=lambda j: abs(q[j]))
|
|
140
|
+
|
|
141
|
+
# Ensure largest component is positive
|
|
142
|
+
if q[max_comp] < 0:
|
|
143
|
+
q = [-c for c in q]
|
|
144
|
+
|
|
145
|
+
# Scale by sqrt(2)
|
|
146
|
+
q = [c * sqrt2 for c in q]
|
|
147
|
+
|
|
148
|
+
# Get indices of the three smallest components
|
|
149
|
+
idx_map = {
|
|
150
|
+
0: [1, 2, 3],
|
|
151
|
+
1: [0, 2, 3],
|
|
152
|
+
2: [0, 1, 3],
|
|
153
|
+
3: [0, 1, 2]
|
|
154
|
+
}
|
|
155
|
+
idx = idx_map[max_comp]
|
|
156
|
+
|
|
157
|
+
ti = i
|
|
158
|
+
quats[ti * 4] = int(255 * (q[idx[0]] * 0.5 + 0.5))
|
|
159
|
+
quats[ti * 4 + 1] = int(255 * (q[idx[1]] * 0.5 + 0.5))
|
|
160
|
+
quats[ti * 4 + 2] = int(255 * (q[idx[2]] * 0.5 + 0.5))
|
|
161
|
+
quats[ti * 4 + 3] = 252 + max_comp
|
|
162
|
+
|
|
163
|
+
return encode_webp_lossless(quats, width, height)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def write_scales(data: Dict[str, np.ndarray], indices: np.ndarray, width: int, height: int, iterations: int) -> Tuple[bytes, List[float]]:
|
|
167
|
+
"""
|
|
168
|
+
Write scale data using k-means clustering to 256 values.
|
|
169
|
+
"""
|
|
170
|
+
n = len(indices)
|
|
171
|
+
channels = 4
|
|
172
|
+
|
|
173
|
+
# Cluster scale data
|
|
174
|
+
column_names = ['scale_0', 'scale_1', 'scale_2']
|
|
175
|
+
centroids, labels_dict = cluster_1d(data, column_names, 256, iterations)
|
|
176
|
+
|
|
177
|
+
# Build output texture
|
|
178
|
+
scales_buf = np.zeros(width * height * channels, dtype=np.uint8)
|
|
179
|
+
|
|
180
|
+
for i, idx in enumerate(indices):
|
|
181
|
+
ti = i
|
|
182
|
+
scales_buf[ti * 4] = labels_dict['scale_0'][idx]
|
|
183
|
+
scales_buf[ti * 4 + 1] = labels_dict['scale_1'][idx]
|
|
184
|
+
scales_buf[ti * 4 + 2] = labels_dict['scale_2'][idx]
|
|
185
|
+
scales_buf[ti * 4 + 3] = 255
|
|
186
|
+
|
|
187
|
+
return encode_webp_lossless(scales_buf, width, height), centroids.tolist()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def write_colors(data: Dict[str, np.ndarray], indices: np.ndarray, width: int, height: int, iterations: int) -> Tuple[bytes, List[float]]:
|
|
191
|
+
"""
|
|
192
|
+
Write color (DC spherical harmonic) and opacity data.
|
|
193
|
+
|
|
194
|
+
Colors are clustered to 256 values, opacity is stored as sigmoid(raw) in alpha channel.
|
|
195
|
+
"""
|
|
196
|
+
n = len(indices)
|
|
197
|
+
channels = 4
|
|
198
|
+
|
|
199
|
+
# Cluster color data
|
|
200
|
+
column_names = ['f_dc_0', 'f_dc_1', 'f_dc_2']
|
|
201
|
+
centroids, labels_dict = cluster_1d(data, column_names, 256, iterations)
|
|
202
|
+
|
|
203
|
+
# Calculate sigmoid of opacity
|
|
204
|
+
opacity_raw = data['opacity']
|
|
205
|
+
opacity_sigmoid = sigmoid(opacity_raw)
|
|
206
|
+
opacity_u8 = np.clip(opacity_sigmoid * 255, 0, 255).astype(np.uint8)
|
|
207
|
+
|
|
208
|
+
# Build output texture
|
|
209
|
+
sh0_buf = np.zeros(width * height * channels, dtype=np.uint8)
|
|
210
|
+
|
|
211
|
+
for i, idx in enumerate(indices):
|
|
212
|
+
ti = i
|
|
213
|
+
sh0_buf[ti * 4] = labels_dict['f_dc_0'][idx]
|
|
214
|
+
sh0_buf[ti * 4 + 1] = labels_dict['f_dc_1'][idx]
|
|
215
|
+
sh0_buf[ti * 4 + 2] = labels_dict['f_dc_2'][idx]
|
|
216
|
+
sh0_buf[ti * 4 + 3] = opacity_u8[idx]
|
|
217
|
+
|
|
218
|
+
return encode_webp_lossless(sh0_buf, width, height), centroids.tolist()
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def write_sh(data: Dict[str, np.ndarray], indices: np.ndarray, width: int, height: int,
|
|
222
|
+
sh_bands: int, iterations: int) -> Optional[Dict]:
|
|
223
|
+
"""
|
|
224
|
+
Write higher-order spherical harmonics data.
|
|
225
|
+
|
|
226
|
+
Uses two-level clustering:
|
|
227
|
+
1. Cluster all SH points into palette_size centroids
|
|
228
|
+
2. Quantize centroid values using 256-value codebook
|
|
229
|
+
"""
|
|
230
|
+
sh_coeffs = {1: 3, 2: 8, 3: 15}[sh_bands]
|
|
231
|
+
sh_column_names = SH_NAMES[:sh_coeffs * 3]
|
|
232
|
+
|
|
233
|
+
# Check if all SH columns exist
|
|
234
|
+
for name in sh_column_names:
|
|
235
|
+
if name not in data:
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
n = len(indices)
|
|
239
|
+
palette_size = min(64 * 1024, max(1024, 2 ** int(math.log2(n / 1024)) * 1024))
|
|
240
|
+
|
|
241
|
+
# Two-level clustering
|
|
242
|
+
labels, centroids_quantized, codebook = cluster_sh(data, sh_column_names, palette_size, iterations)
|
|
243
|
+
|
|
244
|
+
# Write centroids texture
|
|
245
|
+
n_centroids = len(centroids_quantized)
|
|
246
|
+
centroid_width = 64 * sh_coeffs
|
|
247
|
+
centroid_height = int(math.ceil(n_centroids / 64))
|
|
248
|
+
|
|
249
|
+
centroids_buf = np.zeros(centroid_width * centroid_height * 4, dtype=np.uint8)
|
|
250
|
+
|
|
251
|
+
for i, row in enumerate(centroids_quantized):
|
|
252
|
+
for j in range(sh_coeffs):
|
|
253
|
+
# R, G, B channels for each coefficient
|
|
254
|
+
if j * 3 < len(row):
|
|
255
|
+
centroids_buf[i * sh_coeffs * 4 + j * 4 + 0] = row[j * 3] if j * 3 < len(row) else 0
|
|
256
|
+
centroids_buf[i * sh_coeffs * 4 + j * 4 + 1] = row[j * 3 + 1] if j * 3 + 1 < len(row) else 0
|
|
257
|
+
centroids_buf[i * sh_coeffs * 4 + j * 4 + 2] = row[j * 3 + 2] if j * 3 + 2 < len(row) else 0
|
|
258
|
+
centroids_buf[i * sh_coeffs * 4 + j * 4 + 3] = 0xff
|
|
259
|
+
|
|
260
|
+
centroids_webp = encode_webp_lossless(centroids_buf, centroid_width, centroid_height)
|
|
261
|
+
|
|
262
|
+
# Write labels texture (16-bit indices)
|
|
263
|
+
labels_buf = np.zeros(width * height * 4, dtype=np.uint8)
|
|
264
|
+
|
|
265
|
+
for i, idx in enumerate(indices):
|
|
266
|
+
label = labels[idx]
|
|
267
|
+
ti = i
|
|
268
|
+
labels_buf[ti * 4 + 0] = label & 0xff
|
|
269
|
+
labels_buf[ti * 4 + 1] = (label >> 8) & 0xff
|
|
270
|
+
labels_buf[ti * 4 + 2] = 0
|
|
271
|
+
labels_buf[ti * 4 + 3] = 0xff
|
|
272
|
+
|
|
273
|
+
labels_webp = encode_webp_lossless(labels_buf, width, height)
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
'count': palette_size,
|
|
277
|
+
'bands': sh_bands,
|
|
278
|
+
'codebook': codebook.tolist(),
|
|
279
|
+
'files': ['shN_centroids.webp', 'shN_labels.webp'],
|
|
280
|
+
'centroids_webp': centroids_webp,
|
|
281
|
+
'labels_webp': labels_webp
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def detect_sh_bands(data: Dict[str, np.ndarray]) -> int:
|
|
286
|
+
"""Detect how many spherical harmonics bands are present in the data."""
|
|
287
|
+
# Band 1: f_rest_0 to f_rest_8 (9 coeffs)
|
|
288
|
+
# Band 2: f_rest_0 to f_rest_23 (24 coeffs)
|
|
289
|
+
# Band 3: f_rest_0 to f_rest_44 (45 coeffs)
|
|
290
|
+
|
|
291
|
+
for name in SH_NAMES[:9]:
|
|
292
|
+
if name not in data:
|
|
293
|
+
return 0
|
|
294
|
+
# Has at least band 1
|
|
295
|
+
|
|
296
|
+
for name in SH_NAMES[9:24]:
|
|
297
|
+
if name not in data:
|
|
298
|
+
return 1
|
|
299
|
+
# Has at least band 2
|
|
300
|
+
|
|
301
|
+
for name in SH_NAMES[24:45]:
|
|
302
|
+
if name not in data:
|
|
303
|
+
return 2
|
|
304
|
+
|
|
305
|
+
return 3
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def convert_ply_to_sog(input_path: str, output_path: str, iterations: int = 10, bundle: bool = True) -> None:
|
|
309
|
+
"""
|
|
310
|
+
Convert a Gaussian Splat PLY file to PlayCanvas SOG format.
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
input_path: Path to input .ply file
|
|
314
|
+
output_path: Path to output .sog file
|
|
315
|
+
iterations: Number of k-means iterations (default 10)
|
|
316
|
+
bundle: If True, create a ZIP bundle (default). If False, write separate files.
|
|
317
|
+
"""
|
|
318
|
+
print(f"Reading PLY file: {input_path}")
|
|
319
|
+
data = read_ply(input_path)
|
|
320
|
+
|
|
321
|
+
num_rows = len(data['x'])
|
|
322
|
+
print(f"Loaded {num_rows} splats")
|
|
323
|
+
|
|
324
|
+
# Generate Morton-ordered indices
|
|
325
|
+
print("Generating Morton order...")
|
|
326
|
+
indices = sort_morton_order(data['x'], data['y'], data['z'])
|
|
327
|
+
|
|
328
|
+
# Calculate texture dimensions (multiples of 4)
|
|
329
|
+
width = int(math.ceil(math.sqrt(num_rows) / 4)) * 4
|
|
330
|
+
height = int(math.ceil(num_rows / width / 4)) * 4
|
|
331
|
+
print(f"Texture size: {width}x{height}")
|
|
332
|
+
|
|
333
|
+
# Prepare output
|
|
334
|
+
output_files = {}
|
|
335
|
+
|
|
336
|
+
# Write positions
|
|
337
|
+
print("Writing positions...")
|
|
338
|
+
means_l, means_u, means_info = write_means(data, indices, width, height)
|
|
339
|
+
output_files['means_l.webp'] = means_l
|
|
340
|
+
output_files['means_u.webp'] = means_u
|
|
341
|
+
|
|
342
|
+
# Write quaternions
|
|
343
|
+
print("Writing quaternions...")
|
|
344
|
+
quats_webp = write_quaternions(data, indices, width, height)
|
|
345
|
+
output_files['quats.webp'] = quats_webp
|
|
346
|
+
|
|
347
|
+
# Write scales
|
|
348
|
+
print("Compressing scales...")
|
|
349
|
+
scales_webp, scales_codebook = write_scales(data, indices, width, height, iterations)
|
|
350
|
+
output_files['scales.webp'] = scales_webp
|
|
351
|
+
|
|
352
|
+
# Write colors
|
|
353
|
+
print("Compressing colors...")
|
|
354
|
+
sh0_webp, colors_codebook = write_colors(data, indices, width, height, iterations)
|
|
355
|
+
output_files['sh0.webp'] = sh0_webp
|
|
356
|
+
|
|
357
|
+
# Write spherical harmonics if present
|
|
358
|
+
sh_bands = detect_sh_bands(data)
|
|
359
|
+
sh_info = None
|
|
360
|
+
if sh_bands > 0:
|
|
361
|
+
print(f"Compressing spherical harmonics (bands={sh_bands})...")
|
|
362
|
+
sh_result = write_sh(data, indices, width, height, sh_bands, iterations)
|
|
363
|
+
if sh_result:
|
|
364
|
+
output_files['shN_centroids.webp'] = sh_result['centroids_webp']
|
|
365
|
+
output_files['shN_labels.webp'] = sh_result['labels_webp']
|
|
366
|
+
sh_info = {
|
|
367
|
+
'count': sh_result['count'],
|
|
368
|
+
'bands': sh_result['bands'],
|
|
369
|
+
'codebook': sh_result['codebook'],
|
|
370
|
+
'files': sh_result['files']
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
# Build metadata
|
|
374
|
+
print("Finalizing...")
|
|
375
|
+
meta = {
|
|
376
|
+
'version': 2,
|
|
377
|
+
'asset': {
|
|
378
|
+
'generator': 'SogPython v1.0.0'
|
|
379
|
+
},
|
|
380
|
+
'count': num_rows,
|
|
381
|
+
'means': {
|
|
382
|
+
'mins': means_info['mins'],
|
|
383
|
+
'maxs': means_info['maxs'],
|
|
384
|
+
'files': ['means_l.webp', 'means_u.webp']
|
|
385
|
+
},
|
|
386
|
+
'scales': {
|
|
387
|
+
'codebook': scales_codebook,
|
|
388
|
+
'files': ['scales.webp']
|
|
389
|
+
},
|
|
390
|
+
'quats': {
|
|
391
|
+
'files': ['quats.webp']
|
|
392
|
+
},
|
|
393
|
+
'sh0': {
|
|
394
|
+
'codebook': colors_codebook,
|
|
395
|
+
'files': ['sh0.webp']
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if sh_info:
|
|
400
|
+
meta['shN'] = sh_info
|
|
401
|
+
|
|
402
|
+
meta_json = json.dumps(meta, separators=(',', ':')).encode('utf-8')
|
|
403
|
+
|
|
404
|
+
if bundle:
|
|
405
|
+
# Write as ZIP archive
|
|
406
|
+
print(f"Writing SOG bundle: {output_path}")
|
|
407
|
+
with zipfile.ZipFile(output_path, 'w', zipfile.ZIP_STORED) as zf:
|
|
408
|
+
zf.writestr('meta.json', meta_json)
|
|
409
|
+
for filename, content in output_files.items():
|
|
410
|
+
zf.writestr(filename, content)
|
|
411
|
+
else:
|
|
412
|
+
# Write separate files
|
|
413
|
+
output_dir = Path(output_path).parent
|
|
414
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
415
|
+
|
|
416
|
+
# Write meta.json to the specified output path
|
|
417
|
+
with open(output_path, 'wb') as f:
|
|
418
|
+
f.write(meta_json)
|
|
419
|
+
|
|
420
|
+
for filename, content in output_files.items():
|
|
421
|
+
filepath = output_dir / filename
|
|
422
|
+
with open(filepath, 'wb') as f:
|
|
423
|
+
f.write(content)
|
|
424
|
+
print(f" Wrote: {filepath}")
|
|
425
|
+
|
|
426
|
+
print("Done!")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
if __name__ == '__main__':
|
|
430
|
+
import sys
|
|
431
|
+
if len(sys.argv) < 3:
|
|
432
|
+
print("Usage: python -m SogPython.sog_writer input.ply output.sog")
|
|
433
|
+
sys.exit(1)
|
|
434
|
+
|
|
435
|
+
convert_ply_to_sog(sys.argv[1], sys.argv[2])
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Math utility functions for SOG conversion."""
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def sigmoid(v: np.ndarray) -> np.ndarray:
|
|
7
|
+
"""Apply sigmoid function element-wise."""
|
|
8
|
+
return 1.0 / (1.0 + np.exp(-v))
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def log_transform(value: np.ndarray) -> np.ndarray:
|
|
12
|
+
"""Apply log transform: sign(x) * log(|x| + 1)."""
|
|
13
|
+
return np.sign(value) * np.log(np.abs(value) + 1)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: sogpy
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.2
|
|
4
4
|
Summary: Convert Gaussian Splat PLY files to PlayCanvas SOG format
|
|
5
5
|
Author: Sai Kiran Nunemunthala
|
|
6
6
|
License: MIT
|
|
@@ -35,6 +35,8 @@ Dynamic: license-file
|
|
|
35
35
|
|
|
36
36
|
Convert Gaussian Splat `.ply` files to PlayCanvas `.sog` format.
|
|
37
37
|
|
|
38
|
+
> ⚠️ **Note:** This is a direct Python conversion of [splat-transform](https://github.com/playcanvas/splat-transform)'s PLY to SOG Node.js code. One-shotted with Claude Opus 4.5 — use with caution! Contributions are very welcome.
|
|
39
|
+
|
|
38
40
|
## Installation
|
|
39
41
|
|
|
40
42
|
```bash
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
SogPython/__init__.py
|
|
5
|
+
SogPython/__main__.py
|
|
6
|
+
SogPython/cli.py
|
|
7
|
+
SogPython/kmeans.py
|
|
8
|
+
SogPython/morton_order.py
|
|
9
|
+
SogPython/ply_reader.py
|
|
10
|
+
SogPython/sog_writer.py
|
|
11
|
+
SogPython/utils.py
|
|
12
|
+
sogpy.egg-info/PKG-INFO
|
|
13
|
+
sogpy.egg-info/SOURCES.txt
|
|
14
|
+
sogpy.egg-info/dependency_links.txt
|
|
15
|
+
sogpy.egg-info/entry_points.txt
|
|
16
|
+
sogpy.egg-info/requires.txt
|
|
17
|
+
sogpy.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
SogPython
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|