sogpy 1.0.1__py3-none-any.whl → 1.0.2__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.
SogPython/__init__.py ADDED
@@ -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"]
SogPython/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """CLI entry point for SogPython package."""
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == '__main__':
6
+ main()
SogPython/cli.py ADDED
@@ -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()
SogPython/kmeans.py ADDED
@@ -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])
SogPython/utils.py ADDED
@@ -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.1
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
@@ -0,0 +1,14 @@
1
+ SogPython/__init__.py,sha256=Ti6ObkhdwWcWeYYNCHI1HoItVwRtOtNuogWNeQmDCEs,327
2
+ SogPython/__main__.py,sha256=XoPPTOAhqA6H8hnL3ScskAuG6KlYHG0YNuAVyd7r66s,107
3
+ SogPython/cli.py,sha256=I-cdz24J_aD_l3BIpXEt9O0lnZZmpPUuroqlrN6369E,1505
4
+ SogPython/kmeans.py,sha256=WMCLwoxl4wpnJJVkFiyrx9Bqn_qwEIYTDHcZhBz9eX4,6938
5
+ SogPython/morton_order.py,sha256=VqQR0HgOcwDTEVeNQiffcUxzfwUDstvy0VCpnbFeu0I,2862
6
+ SogPython/ply_reader.py,sha256=uCzbaeeg7gEB7q9Qga-Pq3S20a1TdEWB8B_svA_Trz0,4765
7
+ SogPython/sog_writer.py,sha256=kzEE2w5d-ItfyDicZIhwANFwC0Dxo_1GZdgcx8ezNKE,14797
8
+ SogPython/utils.py,sha256=8Ax_tY0Wi1et3lpUSKo5uA9OVXo0JbTqUrxZ18Qu6qc,359
9
+ sogpy-1.0.2.dist-info/licenses/LICENSE,sha256=mpsXTefte3G1DXWr26libFtoeBCPQ-IP6jKjyGus2JY,1079
10
+ sogpy-1.0.2.dist-info/METADATA,sha256=p7CkUV36BEXVeD0qb_C0sCIzGL3Z7X-ORi2LSkTdX_Q,2775
11
+ sogpy-1.0.2.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
12
+ sogpy-1.0.2.dist-info/entry_points.txt,sha256=tyH3YLVEYtnfLxGz2wDRw0sk0PJStagzOad4OcQGc-8,45
13
+ sogpy-1.0.2.dist-info/top_level.txt,sha256=TXTJX_Nz_uzJam-x3xH33S0JBd-MA1DiMqXtLHHdVlw,10
14
+ sogpy-1.0.2.dist-info/RECORD,,
@@ -0,0 +1 @@
1
+ SogPython
@@ -1,6 +0,0 @@
1
- sogpy-1.0.1.dist-info/licenses/LICENSE,sha256=mpsXTefte3G1DXWr26libFtoeBCPQ-IP6jKjyGus2JY,1079
2
- sogpy-1.0.1.dist-info/METADATA,sha256=6fWJB6nnIEHke0AiMD5xv0iAaj_Sd0_t7oT39daSWJY,2775
3
- sogpy-1.0.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
4
- sogpy-1.0.1.dist-info/entry_points.txt,sha256=tyH3YLVEYtnfLxGz2wDRw0sk0PJStagzOad4OcQGc-8,45
5
- sogpy-1.0.1.dist-info/top_level.txt,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
6
- sogpy-1.0.1.dist-info/RECORD,,
@@ -1 +0,0 @@
1
-
File without changes