@playcanvas/splat-transform 0.3.0 → 0.4.1
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.
- package/README.md +105 -19
- package/dist/index.mjs +442 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -5
package/README.md
CHANGED
|
@@ -1,39 +1,125 @@
|
|
|
1
1
|
# Splat Transform - 3D Gaussian Splat Converter
|
|
2
2
|
|
|
3
|
-
Splat Transform is an open source CLI tool for reading gaussian splat PLY files and writing them to PLY,
|
|
3
|
+
Splat Transform is an open source CLI tool for reading gaussian splat PLY files and writing them to PLY, Compressed PLY, CSV, and SOGS format.
|
|
4
4
|
|
|
5
5
|
Multiple files may be combined and transformed before being written to the output.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
|
+
|
|
8
9
|
First install the package globally:
|
|
9
|
-
|
|
10
|
+
|
|
11
|
+
```bash
|
|
10
12
|
npm install -g @playcanvas/splat-transform
|
|
11
13
|
```
|
|
12
14
|
|
|
13
|
-
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
14
17
|
```bash
|
|
15
|
-
|
|
16
|
-
|
|
18
|
+
splat-transform [GLOBAL] <input.{ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
**Key points:**
|
|
22
|
+
- Every time an `*.ply*` appears, it becomes the current working set; the following ACTIONS are applied in the order listed
|
|
23
|
+
- The last file on the command line is treated as the output; anything after it is interpreted as actions that modify the final result
|
|
24
|
+
|
|
25
|
+
## Supported Formats
|
|
26
|
+
|
|
27
|
+
**Input:**
|
|
28
|
+
- `.ply` - Standard PLY format
|
|
29
|
+
- `.splat` - Binary splat format (antimatter15 format)
|
|
30
|
+
- `.ksplat` - Compressed binary splat format (mkkellogg format)
|
|
31
|
+
|
|
32
|
+
**Output:**
|
|
33
|
+
- `.ply` - Standard PLY format
|
|
34
|
+
- `.compressed.ply` - Compressed PLY format
|
|
35
|
+
- `meta.json` - SOGS format (JSON + WebP images)
|
|
36
|
+
- `.csv` - Comma-separated values
|
|
37
|
+
|
|
38
|
+
## Actions
|
|
17
39
|
|
|
18
|
-
|
|
19
|
-
|
|
40
|
+
Actions can be repeated and applied in any order:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
-t, --translate x,y,z Translate splats by (x, y, z)
|
|
44
|
+
-r, --rotate x,y,z Rotate splats by Euler angles (deg)
|
|
45
|
+
-s, --scale x Uniformly scale splats by factor x
|
|
46
|
+
-n, --filterNaN Remove any Gaussian containing NaN/Inf
|
|
47
|
+
-c, --filterByValue name,cmp,value Keep splats where <name> <cmp> <value>
|
|
48
|
+
cmp ∈ {lt,lte,gt,gte,eq,neq}
|
|
49
|
+
-b, --filterBands {0|1|2|3} Strip spherical-harmonic bands > N
|
|
20
50
|
```
|
|
21
51
|
|
|
22
|
-
|
|
52
|
+
## Global Options
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
-w, --overwrite Overwrite output file if it already exists
|
|
56
|
+
-h, --help Show help and exit
|
|
57
|
+
-v, --version Show version and exit
|
|
23
58
|
```
|
|
24
|
-
# load input.ply and translate it by (1, 0, 0) and write the result to output.ply
|
|
25
|
-
splat-transform input.ply -t 1,0,0 output.ply
|
|
26
59
|
|
|
27
|
-
|
|
28
|
-
|
|
60
|
+
## Examples
|
|
61
|
+
|
|
62
|
+
### Basic Operations
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
# Simple format conversion
|
|
66
|
+
splat-transform input.ply output.csv
|
|
67
|
+
|
|
68
|
+
# Convert from .splat format
|
|
69
|
+
splat-transform input.splat output.ply
|
|
70
|
+
|
|
71
|
+
# Convert from .ksplat format
|
|
72
|
+
splat-transform input.ksplat output.ply
|
|
73
|
+
|
|
74
|
+
# Convert to compressed PLY
|
|
75
|
+
splat-transform input.ply output.compressed.ply
|
|
76
|
+
|
|
77
|
+
# Convert to SOGS format
|
|
78
|
+
splat-transform input.ply output/meta.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Transformations
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
# Scale and translate
|
|
85
|
+
splat-transform bunny.ply -s 0.5 -t 0,0,10 bunny_scaled.ply
|
|
86
|
+
|
|
87
|
+
# Rotate by 90 degrees around Y axis
|
|
88
|
+
splat-transform input.ply -r 0,90,0 output.ply
|
|
89
|
+
|
|
90
|
+
# Chain multiple transformations
|
|
91
|
+
splat-transform input.ply -s 2 -t 1,0,0 -r 0,0,45 output.ply
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Filtering
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
# Remove entries containing NaN and Inf
|
|
98
|
+
splat-transform input.ply --filterNaN output.ply
|
|
99
|
+
|
|
100
|
+
# Filter by opacity values (keep only splats with opacity > 0.5)
|
|
101
|
+
splat-transform input.ply -c opacity,gt,0.5 output.ply
|
|
102
|
+
|
|
103
|
+
# Strip spherical harmonic bands higher than 2
|
|
104
|
+
splat-transform input.ply --filterBands 2 output.ply
|
|
29
105
|
```
|
|
30
106
|
|
|
31
|
-
|
|
107
|
+
### Advanced Usage
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
# Combine multiple files with different transforms
|
|
111
|
+
splat-transform -w cloudA.ply -r 0,90,0 cloudB.ply -s 2 merged.compressed.ply
|
|
112
|
+
|
|
113
|
+
# Apply final transformations to combined result
|
|
114
|
+
splat-transform input1.ply input2.ply output.ply -t 0,0,10 -s 0.5
|
|
32
115
|
```
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-
|
|
116
|
+
|
|
117
|
+
## Getting Help
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
# Show version
|
|
121
|
+
splat-transform --version
|
|
122
|
+
|
|
123
|
+
# Show help
|
|
124
|
+
splat-transform --help
|
|
39
125
|
```
|
package/dist/index.mjs
CHANGED
|
@@ -1881,7 +1881,7 @@ class Quat {
|
|
|
1881
1881
|
}
|
|
1882
1882
|
}
|
|
1883
1883
|
|
|
1884
|
-
var version = "0.
|
|
1884
|
+
var version = "0.4.0";
|
|
1885
1885
|
|
|
1886
1886
|
class Column {
|
|
1887
1887
|
name;
|
|
@@ -2307,6 +2307,311 @@ const process = (dataTable, processActions) => {
|
|
|
2307
2307
|
return result;
|
|
2308
2308
|
};
|
|
2309
2309
|
|
|
2310
|
+
// Half-precision floating point decoder
|
|
2311
|
+
function decodeFloat16(encoded) {
|
|
2312
|
+
const signBit = (encoded >> 15) & 1;
|
|
2313
|
+
const exponent = (encoded >> 10) & 0x1f;
|
|
2314
|
+
const mantissa = encoded & 0x3ff;
|
|
2315
|
+
if (exponent === 0) {
|
|
2316
|
+
if (mantissa === 0) {
|
|
2317
|
+
return signBit ? -0 : 0.0;
|
|
2318
|
+
}
|
|
2319
|
+
// Denormalized number
|
|
2320
|
+
let m = mantissa;
|
|
2321
|
+
let exp = -14;
|
|
2322
|
+
while (!(m & 0x400)) {
|
|
2323
|
+
m <<= 1;
|
|
2324
|
+
exp--;
|
|
2325
|
+
}
|
|
2326
|
+
m &= 0x3ff;
|
|
2327
|
+
const finalExp = exp + 127;
|
|
2328
|
+
const finalMantissa = m << 13;
|
|
2329
|
+
const bits = (signBit << 31) | (finalExp << 23) | finalMantissa;
|
|
2330
|
+
return new Float32Array(new Uint32Array([bits]).buffer)[0];
|
|
2331
|
+
}
|
|
2332
|
+
if (exponent === 0x1f) {
|
|
2333
|
+
return mantissa === 0 ? (signBit ? -Infinity : Infinity) : NaN;
|
|
2334
|
+
}
|
|
2335
|
+
const finalExp = exponent - 15 + 127;
|
|
2336
|
+
const finalMantissa = mantissa << 13;
|
|
2337
|
+
const bits = (signBit << 31) | (finalExp << 23) | finalMantissa;
|
|
2338
|
+
return new Float32Array(new Uint32Array([bits]).buffer)[0];
|
|
2339
|
+
}
|
|
2340
|
+
const COMPRESSION_MODES = [
|
|
2341
|
+
{
|
|
2342
|
+
centerBytes: 12,
|
|
2343
|
+
scaleBytes: 12,
|
|
2344
|
+
rotationBytes: 16,
|
|
2345
|
+
colorBytes: 4,
|
|
2346
|
+
harmonicsBytes: 4,
|
|
2347
|
+
scaleStartByte: 12,
|
|
2348
|
+
rotationStartByte: 24,
|
|
2349
|
+
colorStartByte: 40,
|
|
2350
|
+
harmonicsStartByte: 44,
|
|
2351
|
+
scaleQuantRange: 1
|
|
2352
|
+
},
|
|
2353
|
+
{
|
|
2354
|
+
centerBytes: 6,
|
|
2355
|
+
scaleBytes: 6,
|
|
2356
|
+
rotationBytes: 8,
|
|
2357
|
+
colorBytes: 4,
|
|
2358
|
+
harmonicsBytes: 2,
|
|
2359
|
+
scaleStartByte: 6,
|
|
2360
|
+
rotationStartByte: 12,
|
|
2361
|
+
colorStartByte: 20,
|
|
2362
|
+
harmonicsStartByte: 24,
|
|
2363
|
+
scaleQuantRange: 32767
|
|
2364
|
+
},
|
|
2365
|
+
{
|
|
2366
|
+
centerBytes: 6,
|
|
2367
|
+
scaleBytes: 6,
|
|
2368
|
+
rotationBytes: 8,
|
|
2369
|
+
colorBytes: 4,
|
|
2370
|
+
harmonicsBytes: 1,
|
|
2371
|
+
scaleStartByte: 6,
|
|
2372
|
+
rotationStartByte: 12,
|
|
2373
|
+
colorStartByte: 20,
|
|
2374
|
+
harmonicsStartByte: 24,
|
|
2375
|
+
scaleQuantRange: 32767
|
|
2376
|
+
}
|
|
2377
|
+
];
|
|
2378
|
+
const HARMONICS_COMPONENT_COUNT = [0, 9, 24, 45];
|
|
2379
|
+
const readKsplat = async (fileHandle) => {
|
|
2380
|
+
const stats = await fileHandle.stat();
|
|
2381
|
+
const totalSize = stats.size;
|
|
2382
|
+
// Load complete file
|
|
2383
|
+
const fileBuffer = Buffer$1.alloc(totalSize);
|
|
2384
|
+
await fileHandle.read(fileBuffer, 0, totalSize, 0);
|
|
2385
|
+
const MAIN_HEADER_SIZE = 4096;
|
|
2386
|
+
const SECTION_HEADER_SIZE = 1024;
|
|
2387
|
+
if (totalSize < MAIN_HEADER_SIZE) {
|
|
2388
|
+
throw new Error('File too small to be valid .ksplat format');
|
|
2389
|
+
}
|
|
2390
|
+
// Parse main header
|
|
2391
|
+
const mainHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset, MAIN_HEADER_SIZE);
|
|
2392
|
+
const majorVersion = mainHeader.getUint8(0);
|
|
2393
|
+
const minorVersion = mainHeader.getUint8(1);
|
|
2394
|
+
if (majorVersion !== 0 || minorVersion < 1) {
|
|
2395
|
+
throw new Error(`Unsupported version ${majorVersion}.${minorVersion}`);
|
|
2396
|
+
}
|
|
2397
|
+
const maxSections = mainHeader.getUint32(4, true);
|
|
2398
|
+
const numSplats = mainHeader.getUint32(16, true);
|
|
2399
|
+
const compressionMode = mainHeader.getUint16(20, true);
|
|
2400
|
+
if (compressionMode > 2) {
|
|
2401
|
+
throw new Error(`Invalid compression mode: ${compressionMode}`);
|
|
2402
|
+
}
|
|
2403
|
+
const minHarmonicsValue = mainHeader.getFloat32(36, true) || -1.5;
|
|
2404
|
+
const maxHarmonicsValue = mainHeader.getFloat32(40, true) || 1.5;
|
|
2405
|
+
if (numSplats === 0) {
|
|
2406
|
+
throw new Error('Invalid .ksplat file: file is empty');
|
|
2407
|
+
}
|
|
2408
|
+
// First pass: scan all sections to find maximum harmonics degree
|
|
2409
|
+
let maxHarmonicsDegree = 0;
|
|
2410
|
+
for (let sectionIdx = 0; sectionIdx < maxSections; sectionIdx++) {
|
|
2411
|
+
const sectionHeaderOffset = MAIN_HEADER_SIZE + sectionIdx * SECTION_HEADER_SIZE;
|
|
2412
|
+
const sectionHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + sectionHeaderOffset, SECTION_HEADER_SIZE);
|
|
2413
|
+
const sectionSplatCount = sectionHeader.getUint32(0, true);
|
|
2414
|
+
if (sectionSplatCount === 0)
|
|
2415
|
+
continue; // Skip empty sections
|
|
2416
|
+
const harmonicsDegree = sectionHeader.getUint16(40, true);
|
|
2417
|
+
maxHarmonicsDegree = Math.max(maxHarmonicsDegree, harmonicsDegree);
|
|
2418
|
+
}
|
|
2419
|
+
// Initialize data storage with base columns
|
|
2420
|
+
const columns = [
|
|
2421
|
+
new Column('x', new Float32Array(numSplats)),
|
|
2422
|
+
new Column('y', new Float32Array(numSplats)),
|
|
2423
|
+
new Column('z', new Float32Array(numSplats)),
|
|
2424
|
+
new Column('scale_0', new Float32Array(numSplats)),
|
|
2425
|
+
new Column('scale_1', new Float32Array(numSplats)),
|
|
2426
|
+
new Column('scale_2', new Float32Array(numSplats)),
|
|
2427
|
+
new Column('f_dc_0', new Float32Array(numSplats)),
|
|
2428
|
+
new Column('f_dc_1', new Float32Array(numSplats)),
|
|
2429
|
+
new Column('f_dc_2', new Float32Array(numSplats)),
|
|
2430
|
+
new Column('opacity', new Float32Array(numSplats)),
|
|
2431
|
+
new Column('rot_0', new Float32Array(numSplats)),
|
|
2432
|
+
new Column('rot_1', new Float32Array(numSplats)),
|
|
2433
|
+
new Column('rot_2', new Float32Array(numSplats)),
|
|
2434
|
+
new Column('rot_3', new Float32Array(numSplats))
|
|
2435
|
+
];
|
|
2436
|
+
// Add spherical harmonics columns based on maximum degree found
|
|
2437
|
+
const maxHarmonicsComponentCount = HARMONICS_COMPONENT_COUNT[maxHarmonicsDegree];
|
|
2438
|
+
for (let i = 0; i < maxHarmonicsComponentCount; i++) {
|
|
2439
|
+
columns.push(new Column(`f_rest_${i}`, new Float32Array(numSplats)));
|
|
2440
|
+
}
|
|
2441
|
+
const { centerBytes, scaleBytes, rotationBytes, colorBytes, harmonicsBytes, scaleStartByte, rotationStartByte, colorStartByte, harmonicsStartByte, scaleQuantRange } = COMPRESSION_MODES[compressionMode];
|
|
2442
|
+
let currentSectionDataOffset = MAIN_HEADER_SIZE + maxSections * SECTION_HEADER_SIZE;
|
|
2443
|
+
let splatIndex = 0;
|
|
2444
|
+
// Process each section
|
|
2445
|
+
for (let sectionIdx = 0; sectionIdx < maxSections; sectionIdx++) {
|
|
2446
|
+
const sectionHeaderOffset = MAIN_HEADER_SIZE + sectionIdx * SECTION_HEADER_SIZE;
|
|
2447
|
+
const sectionHeader = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + sectionHeaderOffset, SECTION_HEADER_SIZE);
|
|
2448
|
+
const sectionSplatCount = sectionHeader.getUint32(0, true);
|
|
2449
|
+
const maxSectionSplats = sectionHeader.getUint32(4, true);
|
|
2450
|
+
const bucketCapacity = sectionHeader.getUint32(8, true);
|
|
2451
|
+
const bucketCount = sectionHeader.getUint32(12, true);
|
|
2452
|
+
const spatialBlockSize = sectionHeader.getFloat32(16, true);
|
|
2453
|
+
const bucketStorageSize = sectionHeader.getUint16(20, true);
|
|
2454
|
+
const quantizationRange = sectionHeader.getUint32(24, true) || scaleQuantRange;
|
|
2455
|
+
const fullBuckets = sectionHeader.getUint32(32, true);
|
|
2456
|
+
const partialBuckets = sectionHeader.getUint32(36, true);
|
|
2457
|
+
const harmonicsDegree = sectionHeader.getUint16(40, true);
|
|
2458
|
+
// Calculate layout
|
|
2459
|
+
const fullBucketSplats = fullBuckets * bucketCapacity;
|
|
2460
|
+
const partialBucketMetaSize = partialBuckets * 4;
|
|
2461
|
+
const totalBucketStorageSize = bucketStorageSize * bucketCount + partialBucketMetaSize;
|
|
2462
|
+
const harmonicsComponentCount = HARMONICS_COMPONENT_COUNT[harmonicsDegree];
|
|
2463
|
+
const bytesPerSplat = centerBytes + scaleBytes + rotationBytes +
|
|
2464
|
+
colorBytes + harmonicsComponentCount * harmonicsBytes;
|
|
2465
|
+
const sectionDataSize = bytesPerSplat * maxSectionSplats;
|
|
2466
|
+
// Calculate decompression parameters
|
|
2467
|
+
const positionScale = spatialBlockSize / 2.0 / quantizationRange;
|
|
2468
|
+
// Get bucket centers
|
|
2469
|
+
const bucketCentersOffset = currentSectionDataOffset + partialBucketMetaSize;
|
|
2470
|
+
const bucketCenters = new Float32Array(fileBuffer.buffer, fileBuffer.byteOffset + bucketCentersOffset, bucketCount * 3);
|
|
2471
|
+
// Get partial bucket sizes
|
|
2472
|
+
const partialBucketSizes = new Uint32Array(fileBuffer.buffer, fileBuffer.byteOffset + currentSectionDataOffset, partialBuckets);
|
|
2473
|
+
// Get splat data
|
|
2474
|
+
const splatDataOffset = currentSectionDataOffset + totalBucketStorageSize;
|
|
2475
|
+
const splatData = new DataView(fileBuffer.buffer, fileBuffer.byteOffset + splatDataOffset, sectionDataSize);
|
|
2476
|
+
// Harmonic value decoder
|
|
2477
|
+
const decodeHarmonics = (offset, component) => {
|
|
2478
|
+
switch (compressionMode) {
|
|
2479
|
+
case 0:
|
|
2480
|
+
return splatData.getFloat32(offset + harmonicsStartByte + component * 4, true);
|
|
2481
|
+
case 1:
|
|
2482
|
+
return decodeFloat16(splatData.getUint16(offset + harmonicsStartByte + component * 2, true));
|
|
2483
|
+
case 2: {
|
|
2484
|
+
const normalized = splatData.getUint8(offset + harmonicsStartByte + component) / 255;
|
|
2485
|
+
return minHarmonicsValue + normalized * (maxHarmonicsValue - minHarmonicsValue);
|
|
2486
|
+
}
|
|
2487
|
+
default:
|
|
2488
|
+
return 0;
|
|
2489
|
+
}
|
|
2490
|
+
};
|
|
2491
|
+
// Track partial bucket processing
|
|
2492
|
+
let currentPartialBucket = fullBuckets;
|
|
2493
|
+
let currentPartialBase = fullBucketSplats;
|
|
2494
|
+
// Process splats in this section
|
|
2495
|
+
for (let splatIdx = 0; splatIdx < sectionSplatCount; splatIdx++) {
|
|
2496
|
+
const splatByteOffset = splatIdx * bytesPerSplat;
|
|
2497
|
+
// Determine which bucket this splat belongs to
|
|
2498
|
+
let bucketIdx;
|
|
2499
|
+
if (splatIdx < fullBucketSplats) {
|
|
2500
|
+
bucketIdx = Math.floor(splatIdx / bucketCapacity);
|
|
2501
|
+
}
|
|
2502
|
+
else {
|
|
2503
|
+
const currentBucketSize = partialBucketSizes[currentPartialBucket - fullBuckets];
|
|
2504
|
+
if (splatIdx >= currentPartialBase + currentBucketSize) {
|
|
2505
|
+
currentPartialBucket++;
|
|
2506
|
+
currentPartialBase += currentBucketSize;
|
|
2507
|
+
}
|
|
2508
|
+
bucketIdx = currentPartialBucket;
|
|
2509
|
+
}
|
|
2510
|
+
// Decode position
|
|
2511
|
+
let x, y, z;
|
|
2512
|
+
if (compressionMode === 0) {
|
|
2513
|
+
x = splatData.getFloat32(splatByteOffset, true);
|
|
2514
|
+
y = splatData.getFloat32(splatByteOffset + 4, true);
|
|
2515
|
+
z = splatData.getFloat32(splatByteOffset + 8, true);
|
|
2516
|
+
}
|
|
2517
|
+
else {
|
|
2518
|
+
x = (splatData.getUint16(splatByteOffset, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3];
|
|
2519
|
+
y = (splatData.getUint16(splatByteOffset + 2, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3 + 1];
|
|
2520
|
+
z = (splatData.getUint16(splatByteOffset + 4, true) - quantizationRange) * positionScale + bucketCenters[bucketIdx * 3 + 2];
|
|
2521
|
+
}
|
|
2522
|
+
// Decode scales
|
|
2523
|
+
let scaleX, scaleY, scaleZ;
|
|
2524
|
+
if (compressionMode === 0) {
|
|
2525
|
+
scaleX = splatData.getFloat32(splatByteOffset + scaleStartByte, true);
|
|
2526
|
+
scaleY = splatData.getFloat32(splatByteOffset + scaleStartByte + 4, true);
|
|
2527
|
+
scaleZ = splatData.getFloat32(splatByteOffset + scaleStartByte + 8, true);
|
|
2528
|
+
}
|
|
2529
|
+
else {
|
|
2530
|
+
scaleX = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte, true));
|
|
2531
|
+
scaleY = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte + 2, true));
|
|
2532
|
+
scaleZ = decodeFloat16(splatData.getUint16(splatByteOffset + scaleStartByte + 4, true));
|
|
2533
|
+
}
|
|
2534
|
+
// Decode rotation quaternion
|
|
2535
|
+
let rot0, rot1, rot2, rot3;
|
|
2536
|
+
if (compressionMode === 0) {
|
|
2537
|
+
rot0 = splatData.getFloat32(splatByteOffset + rotationStartByte, true);
|
|
2538
|
+
rot1 = splatData.getFloat32(splatByteOffset + rotationStartByte + 4, true);
|
|
2539
|
+
rot2 = splatData.getFloat32(splatByteOffset + rotationStartByte + 8, true);
|
|
2540
|
+
rot3 = splatData.getFloat32(splatByteOffset + rotationStartByte + 12, true);
|
|
2541
|
+
}
|
|
2542
|
+
else {
|
|
2543
|
+
rot0 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte, true));
|
|
2544
|
+
rot1 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 2, true));
|
|
2545
|
+
rot2 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 4, true));
|
|
2546
|
+
rot3 = decodeFloat16(splatData.getUint16(splatByteOffset + rotationStartByte + 6, true));
|
|
2547
|
+
}
|
|
2548
|
+
// Decode color and opacity
|
|
2549
|
+
const red = splatData.getUint8(splatByteOffset + colorStartByte);
|
|
2550
|
+
const green = splatData.getUint8(splatByteOffset + colorStartByte + 1);
|
|
2551
|
+
const blue = splatData.getUint8(splatByteOffset + colorStartByte + 2);
|
|
2552
|
+
const opacity = splatData.getUint8(splatByteOffset + colorStartByte + 3);
|
|
2553
|
+
// Store position
|
|
2554
|
+
columns[0].data[splatIndex] = x;
|
|
2555
|
+
columns[1].data[splatIndex] = y;
|
|
2556
|
+
columns[2].data[splatIndex] = z;
|
|
2557
|
+
// Store scale (convert from linear in .ksplat to log scale for internal use)
|
|
2558
|
+
columns[3].data[splatIndex] = scaleX > 0 ? Math.log(scaleX) : -10;
|
|
2559
|
+
columns[4].data[splatIndex] = scaleY > 0 ? Math.log(scaleY) : -10;
|
|
2560
|
+
columns[5].data[splatIndex] = scaleZ > 0 ? Math.log(scaleZ) : -10;
|
|
2561
|
+
// Store color (convert from uint8 back to spherical harmonics)
|
|
2562
|
+
const SH_C0 = 0.28209479177387814;
|
|
2563
|
+
columns[6].data[splatIndex] = (red / 255.0 - 0.5) / SH_C0;
|
|
2564
|
+
columns[7].data[splatIndex] = (green / 255.0 - 0.5) / SH_C0;
|
|
2565
|
+
columns[8].data[splatIndex] = (blue / 255.0 - 0.5) / SH_C0;
|
|
2566
|
+
// Store opacity (convert from uint8 to float and apply inverse sigmoid)
|
|
2567
|
+
const epsilon = 1e-6;
|
|
2568
|
+
const normalizedOpacity = Math.max(epsilon, Math.min(1.0 - epsilon, opacity / 255.0));
|
|
2569
|
+
columns[9].data[splatIndex] = Math.log(normalizedOpacity / (1.0 - normalizedOpacity));
|
|
2570
|
+
// Store quaternion
|
|
2571
|
+
columns[10].data[splatIndex] = rot0;
|
|
2572
|
+
columns[11].data[splatIndex] = rot1;
|
|
2573
|
+
columns[12].data[splatIndex] = rot2;
|
|
2574
|
+
columns[13].data[splatIndex] = rot3;
|
|
2575
|
+
// Store spherical harmonics
|
|
2576
|
+
for (let i = 0; i < harmonicsComponentCount; i++) {
|
|
2577
|
+
// const channel = Math.floor(i / (harmonicsComponentCount / 3));
|
|
2578
|
+
// const coeff = i % (harmonicsComponentCount / 3);
|
|
2579
|
+
// const channel = i % 3;
|
|
2580
|
+
// const coeff = Math.floor(i / 3);
|
|
2581
|
+
let channel;
|
|
2582
|
+
let coeff;
|
|
2583
|
+
if (i < 9) {
|
|
2584
|
+
channel = Math.floor(i / 3);
|
|
2585
|
+
coeff = i % 3;
|
|
2586
|
+
}
|
|
2587
|
+
else if (i < 24) {
|
|
2588
|
+
channel = Math.floor((i - 9) / 5);
|
|
2589
|
+
coeff = (i - 9) % 5 + 3;
|
|
2590
|
+
}
|
|
2591
|
+
else {
|
|
2592
|
+
channel = Math.floor((i - 24) / 6);
|
|
2593
|
+
coeff = (i - 24) % 6 + 9;
|
|
2594
|
+
}
|
|
2595
|
+
const col = channel * (harmonicsComponentCount / 3) + coeff;
|
|
2596
|
+
columns[14 + col].data[splatIndex] = decodeHarmonics(splatByteOffset, i);
|
|
2597
|
+
}
|
|
2598
|
+
splatIndex++;
|
|
2599
|
+
}
|
|
2600
|
+
currentSectionDataOffset += sectionDataSize + totalBucketStorageSize;
|
|
2601
|
+
}
|
|
2602
|
+
if (splatIndex !== numSplats) {
|
|
2603
|
+
throw new Error(`Splat count mismatch: expected ${numSplats}, processed ${splatIndex}`);
|
|
2604
|
+
}
|
|
2605
|
+
const resultTable = new DataTable(columns);
|
|
2606
|
+
return {
|
|
2607
|
+
comments: [],
|
|
2608
|
+
elements: [{
|
|
2609
|
+
name: 'vertex',
|
|
2610
|
+
dataTable: resultTable
|
|
2611
|
+
}]
|
|
2612
|
+
};
|
|
2613
|
+
};
|
|
2614
|
+
|
|
2310
2615
|
const getDataType = (type) => {
|
|
2311
2616
|
switch (type) {
|
|
2312
2617
|
case 'char': return Int8Array;
|
|
@@ -2445,6 +2750,121 @@ const readPly = async (fileHandle) => {
|
|
|
2445
2750
|
};
|
|
2446
2751
|
};
|
|
2447
2752
|
|
|
2753
|
+
const readSplat = async (fileHandle) => {
|
|
2754
|
+
// Get file size to determine number of splats
|
|
2755
|
+
const fileStats = await fileHandle.stat();
|
|
2756
|
+
const fileSize = fileStats.size;
|
|
2757
|
+
// Each splat is 32 bytes
|
|
2758
|
+
const BYTES_PER_SPLAT = 32;
|
|
2759
|
+
if (fileSize % BYTES_PER_SPLAT !== 0) {
|
|
2760
|
+
throw new Error('Invalid .splat file: file size is not a multiple of 32 bytes');
|
|
2761
|
+
}
|
|
2762
|
+
const numSplats = fileSize / BYTES_PER_SPLAT;
|
|
2763
|
+
if (numSplats === 0) {
|
|
2764
|
+
throw new Error('Invalid .splat file: file is empty');
|
|
2765
|
+
}
|
|
2766
|
+
// Create columns for the standard Gaussian splat data
|
|
2767
|
+
const columns = [
|
|
2768
|
+
// Position
|
|
2769
|
+
new Column('x', new Float32Array(numSplats)),
|
|
2770
|
+
new Column('y', new Float32Array(numSplats)),
|
|
2771
|
+
new Column('z', new Float32Array(numSplats)),
|
|
2772
|
+
// Scale (stored as linear in .splat, convert to log for internal use)
|
|
2773
|
+
new Column('scale_0', new Float32Array(numSplats)),
|
|
2774
|
+
new Column('scale_1', new Float32Array(numSplats)),
|
|
2775
|
+
new Column('scale_2', new Float32Array(numSplats)),
|
|
2776
|
+
// Color/opacity
|
|
2777
|
+
new Column('f_dc_0', new Float32Array(numSplats)), // Red
|
|
2778
|
+
new Column('f_dc_1', new Float32Array(numSplats)), // Green
|
|
2779
|
+
new Column('f_dc_2', new Float32Array(numSplats)), // Blue
|
|
2780
|
+
new Column('opacity', new Float32Array(numSplats)),
|
|
2781
|
+
// Rotation quaternion
|
|
2782
|
+
new Column('rot_0', new Float32Array(numSplats)),
|
|
2783
|
+
new Column('rot_1', new Float32Array(numSplats)),
|
|
2784
|
+
new Column('rot_2', new Float32Array(numSplats)),
|
|
2785
|
+
new Column('rot_3', new Float32Array(numSplats))
|
|
2786
|
+
];
|
|
2787
|
+
// Read data in chunks
|
|
2788
|
+
const chunkSize = 1024;
|
|
2789
|
+
const numChunks = Math.ceil(numSplats / chunkSize);
|
|
2790
|
+
const chunkData = Buffer$1.alloc(chunkSize * BYTES_PER_SPLAT);
|
|
2791
|
+
for (let c = 0; c < numChunks; ++c) {
|
|
2792
|
+
const numRows = Math.min(chunkSize, numSplats - c * chunkSize);
|
|
2793
|
+
const bytesToRead = numRows * BYTES_PER_SPLAT;
|
|
2794
|
+
const { bytesRead } = await fileHandle.read(chunkData, 0, bytesToRead);
|
|
2795
|
+
if (bytesRead !== bytesToRead) {
|
|
2796
|
+
throw new Error('Failed to read expected amount of data from .splat file');
|
|
2797
|
+
}
|
|
2798
|
+
// Parse each splat in the chunk
|
|
2799
|
+
for (let r = 0; r < numRows; ++r) {
|
|
2800
|
+
const splatIndex = c * chunkSize + r;
|
|
2801
|
+
const offset = r * BYTES_PER_SPLAT;
|
|
2802
|
+
// Read position (3 × float32)
|
|
2803
|
+
const x = chunkData.readFloatLE(offset + 0);
|
|
2804
|
+
const y = chunkData.readFloatLE(offset + 4);
|
|
2805
|
+
const z = chunkData.readFloatLE(offset + 8);
|
|
2806
|
+
// Read scale (3 × float32)
|
|
2807
|
+
const scaleX = chunkData.readFloatLE(offset + 12);
|
|
2808
|
+
const scaleY = chunkData.readFloatLE(offset + 16);
|
|
2809
|
+
const scaleZ = chunkData.readFloatLE(offset + 20);
|
|
2810
|
+
// Read color and opacity (4 × uint8)
|
|
2811
|
+
const red = chunkData.readUInt8(offset + 24);
|
|
2812
|
+
const green = chunkData.readUInt8(offset + 25);
|
|
2813
|
+
const blue = chunkData.readUInt8(offset + 26);
|
|
2814
|
+
const opacity = chunkData.readUInt8(offset + 27);
|
|
2815
|
+
// Read rotation quaternion (4 × uint8)
|
|
2816
|
+
const rot0 = chunkData.readUInt8(offset + 28);
|
|
2817
|
+
const rot1 = chunkData.readUInt8(offset + 29);
|
|
2818
|
+
const rot2 = chunkData.readUInt8(offset + 30);
|
|
2819
|
+
const rot3 = chunkData.readUInt8(offset + 31);
|
|
2820
|
+
// Store position
|
|
2821
|
+
columns[0].data[splatIndex] = x;
|
|
2822
|
+
columns[1].data[splatIndex] = y;
|
|
2823
|
+
columns[2].data[splatIndex] = z;
|
|
2824
|
+
// Store scale (convert from linear in .splat to log scale for internal use)
|
|
2825
|
+
columns[3].data[splatIndex] = Math.log(scaleX);
|
|
2826
|
+
columns[4].data[splatIndex] = Math.log(scaleY);
|
|
2827
|
+
columns[5].data[splatIndex] = Math.log(scaleZ);
|
|
2828
|
+
// Store color (convert from uint8 back to spherical harmonics)
|
|
2829
|
+
const SH_C0 = 0.28209479177387814;
|
|
2830
|
+
columns[6].data[splatIndex] = (red / 255.0 - 0.5) / SH_C0;
|
|
2831
|
+
columns[7].data[splatIndex] = (green / 255.0 - 0.5) / SH_C0;
|
|
2832
|
+
columns[8].data[splatIndex] = (blue / 255.0 - 0.5) / SH_C0;
|
|
2833
|
+
// Store opacity (convert from uint8 to float and apply inverse sigmoid)
|
|
2834
|
+
const epsilon = 1e-6;
|
|
2835
|
+
const normalizedOpacity = Math.max(epsilon, Math.min(1.0 - epsilon, opacity / 255.0));
|
|
2836
|
+
columns[9].data[splatIndex] = Math.log(normalizedOpacity / (1.0 - normalizedOpacity));
|
|
2837
|
+
// Store rotation quaternion (convert from uint8 [0,255] to float [-1,1] and normalize)
|
|
2838
|
+
const rot0Norm = (rot0 / 255.0) * 2.0 - 1.0;
|
|
2839
|
+
const rot1Norm = (rot1 / 255.0) * 2.0 - 1.0;
|
|
2840
|
+
const rot2Norm = (rot2 / 255.0) * 2.0 - 1.0;
|
|
2841
|
+
const rot3Norm = (rot3 / 255.0) * 2.0 - 1.0;
|
|
2842
|
+
// Normalize quaternion
|
|
2843
|
+
const length = Math.sqrt(rot0Norm * rot0Norm + rot1Norm * rot1Norm + rot2Norm * rot2Norm + rot3Norm * rot3Norm);
|
|
2844
|
+
if (length > 0) {
|
|
2845
|
+
columns[10].data[splatIndex] = rot0Norm / length;
|
|
2846
|
+
columns[11].data[splatIndex] = rot1Norm / length;
|
|
2847
|
+
columns[12].data[splatIndex] = rot2Norm / length;
|
|
2848
|
+
columns[13].data[splatIndex] = rot3Norm / length;
|
|
2849
|
+
}
|
|
2850
|
+
else {
|
|
2851
|
+
// Default to identity quaternion if invalid
|
|
2852
|
+
columns[10].data[splatIndex] = 0.0;
|
|
2853
|
+
columns[11].data[splatIndex] = 0.0;
|
|
2854
|
+
columns[12].data[splatIndex] = 0.0;
|
|
2855
|
+
columns[13].data[splatIndex] = 1.0;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
return {
|
|
2860
|
+
comments: [],
|
|
2861
|
+
elements: [{
|
|
2862
|
+
name: 'vertex',
|
|
2863
|
+
dataTable: new DataTable(columns)
|
|
2864
|
+
}]
|
|
2865
|
+
};
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2448
2868
|
const sigmoid = (v) => 1 / (1 + Math.exp(-v));
|
|
2449
2869
|
|
|
2450
2870
|
const q = new Quat();
|
|
@@ -3016,9 +3436,23 @@ const writeSogs = async (fileHandle, dataTable, outputFilename) => {
|
|
|
3016
3436
|
const readFile = async (filename) => {
|
|
3017
3437
|
console.log(`reading '${filename}'...`);
|
|
3018
3438
|
const inputFile = await open(filename, 'r');
|
|
3019
|
-
const
|
|
3439
|
+
const lowerFilename = filename.toLowerCase();
|
|
3440
|
+
let fileData;
|
|
3441
|
+
if (lowerFilename.endsWith('.ksplat')) {
|
|
3442
|
+
fileData = await readKsplat(inputFile);
|
|
3443
|
+
}
|
|
3444
|
+
else if (lowerFilename.endsWith('.splat')) {
|
|
3445
|
+
fileData = await readSplat(inputFile);
|
|
3446
|
+
}
|
|
3447
|
+
else if (lowerFilename.endsWith('.ply')) {
|
|
3448
|
+
fileData = await readPly(inputFile);
|
|
3449
|
+
}
|
|
3450
|
+
else {
|
|
3451
|
+
await inputFile.close();
|
|
3452
|
+
throw new Error(`Unsupported input file type: ${filename}`);
|
|
3453
|
+
}
|
|
3020
3454
|
await inputFile.close();
|
|
3021
|
-
return
|
|
3455
|
+
return fileData;
|
|
3022
3456
|
};
|
|
3023
3457
|
const getOutputFormat = (filename) => {
|
|
3024
3458
|
const lowerFilename = filename.toLowerCase();
|
|
@@ -3034,9 +3468,7 @@ const getOutputFormat = (filename) => {
|
|
|
3034
3468
|
else if (lowerFilename.endsWith('.ply')) {
|
|
3035
3469
|
return 'ply';
|
|
3036
3470
|
}
|
|
3037
|
-
|
|
3038
|
-
throw new Error(`Unsupported output file type: ${filename}`);
|
|
3039
|
-
}
|
|
3471
|
+
throw new Error(`Unsupported output file type: ${filename}`);
|
|
3040
3472
|
};
|
|
3041
3473
|
const writeFile = async (filename, dataTable, options) => {
|
|
3042
3474
|
const outputFormat = getOutputFormat(filename);
|
|
@@ -3153,7 +3585,7 @@ const parseArguments = () => {
|
|
|
3153
3585
|
scale: { type: 'string', short: 's', multiple: true },
|
|
3154
3586
|
filterNaN: { type: 'boolean', short: 'n', multiple: true },
|
|
3155
3587
|
filterByValue: { type: 'string', short: 'c', multiple: true },
|
|
3156
|
-
filterBands: { type: 'string', short: 'b', multiple: true }
|
|
3588
|
+
filterBands: { type: 'string', short: 'b', multiple: true }
|
|
3157
3589
|
}
|
|
3158
3590
|
});
|
|
3159
3591
|
const parseNumber = (value) => {
|
|
@@ -3255,15 +3687,15 @@ Apply geometric transforms & filters to Gaussian-splat point clouds
|
|
|
3255
3687
|
===================================================================
|
|
3256
3688
|
|
|
3257
3689
|
USAGE
|
|
3258
|
-
splat-transform [GLOBAL] <input.ply> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
|
|
3690
|
+
splat-transform [GLOBAL] <input.{ply|splat|ksplat}> [ACTIONS] ... <output.{ply|compressed.ply|meta.json|csv}> [ACTIONS]
|
|
3259
3691
|
|
|
3260
|
-
• Every time an
|
|
3692
|
+
• Every time an input file appears, it becomes the current working set; the following
|
|
3261
3693
|
ACTIONS are applied in the order listed.
|
|
3262
3694
|
• The last file on the command line is treated as the output; anything after it is
|
|
3263
3695
|
interpreted as actions that modify the final result.
|
|
3264
3696
|
|
|
3265
3697
|
SUPPORTED INPUTS
|
|
3266
|
-
.ply
|
|
3698
|
+
.ply .splat .ksplat
|
|
3267
3699
|
|
|
3268
3700
|
SUPPORTED OUTPUTS
|
|
3269
3701
|
.ply .compressed.ply meta.json (SOGS) .csv
|