Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,14 @@ splat-transform [GLOBAL] input [ACTIONS] ... output [ACTIONS]
| `.webp` | ❌ | ✅ | Lossless WebP image rendered from a camera view via GPU rasterizer |
| `null` | ❌ | ✅ | Discard output (useful with `--stats` for analysis-only runs) |

### Antialiased and 2DGS scenes

Scenes trained with antialiasing or as 2DGS are tagged, and the tag is preserved where the output format can hold it. `--info` reports it as `model`.

On read: a PLY header comment — Brush's `comment SplatRenderMode: default | mip | 2dgs` or Postshot's `comment antialiased 0 | 1` (last one wins); SPZ's antialiased header bit; a SOG `meta.json` `"model"` entry. A PLY with `scale_0`/`scale_1` but no `scale_2` is read as 2DGS regardless of comments, and the missing column is materialized as a zero-thickness scale so the rest of the pipeline is unaffected.

On write: `.ply` and `.compressed.ply` carry `comment SplatRenderMode: mip | 2dgs` (Brush's spelling, whichever form was read); `.sog` and `meta.json` carry `"model": "antialiased" | "2dgs"`; `.spz` sets its antialiased bit, and warns that it cannot represent 2DGS. A 2DGS PLY output drops the `scale_2` column again. Other output formats have nowhere to record it and drop the tag silently. Combining inputs whose models disagree warns and writes the result untagged.

## Actions

Actions execute in the order specified and can be repeated. Any action may appear after any input or output file:
Expand Down
10 changes: 9 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
processSourceBridged,
readFile,
readPly,
resolveSplatModel,
revision,
selectLod,
stackLods,
Expand Down Expand Up @@ -1223,12 +1224,19 @@ const main = async () => {
return concatSource(unified, pool);
}
// Mismatched layouts: combine() can union them, concatSource can't.
// A DataTable carries no model tag, so resolve it here as
// concatSource would (mixed -> 'default', with a warning).
const model = resolveSplatModel(sources.map(s => s.meta.model));
if (sources.some(s => s.meta.model !== model)) {
const seen = [...new Set(sources.map(s => s.meta.model))].join(', ');
logger.warn(`mixed splat models (${seen}); writing the result as '${model}'`);
}
const dts: DataTable[] = [];
for (const s of sources) {
dts.push(await materializeToDataTable(s, pool));
await s.close();
}
return dataTableToChunkSource(combine(dts), pool.chunkSize);
return dataTableToChunkSource(combine(dts), pool.chunkSize, undefined, model);
};

const phase = logger.group(`Output ${outputArg.filename}`, { index: phaseTotal, total: phaseTotal });
Expand Down
5 changes: 5 additions & 0 deletions src/lib/chunk/in-memory.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type SplatModel } from '../splat-model';
import { type Transform } from '../utils';
import { type ChunkData } from './data';
import {
Expand Down Expand Up @@ -178,6 +179,7 @@ type LayerBuffers = ReadonlyArray<ReadonlyArray<ArrayBuffer>>;
* @param params.chunkSize - Gaussians per chunk.
* @param params.shBands - SH band count of the color layer.
* @param params.extraColumns - Descriptors for the `other` layer columns.
* @param params.model - How the scene was trained. Default: `default`.
* @param params.transform - Pending coordinate-space transform.
* @param params.lodCounts - Gaussians per LOD; `lodCounts[0]` must equal `numGaussians`.
* @param params.position - Per-LOD per-chunk position buffers, or undefined.
Expand All @@ -191,6 +193,8 @@ const createInMemoryChunkSource = (params: {
chunkSize: number;
shBands: SHBands;
extraColumns?: ReadonlyArray<ExtraColumn>;
/** How the scene was trained; untagged sources are `default`. */
model?: SplatModel;
transform: Transform;
/** Gaussians per LOD. `lodCounts[0]` must equal `numGaussians`. */
lodCounts: ReadonlyArray<number>;
Expand Down Expand Up @@ -259,6 +263,7 @@ const createInMemoryChunkSource = (params: {
chunkSize,
numChunks,
shBands,
model: params.model ?? 'default',
extraColumns: extras,
transform,
availableLayers,
Expand Down
3 changes: 3 additions & 0 deletions src/lib/chunk/source.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { type SplatModel } from '../splat-model';
import { type Transform } from '../utils';
import { type ChunkData } from './data';
import { type ExtraColumn, type ChunkLayer, type SHBands, type LayerLayout } from './layout';
Expand Down Expand Up @@ -27,6 +28,8 @@ type ChunkSourceMetadata = {
readonly numChunks: ReadonlyArray<number>;
/** SH band count present in the source. */
readonly shBands: SHBands;
/** How the scene was trained, and so how it must be evaluated. */
readonly model: SplatModel;
/** Extra non-standard columns mapped to the `other` layer. */
readonly extraColumns: ReadonlyArray<ExtraColumn>;
/** Coordinate-space transform; applied lazily when consumed. */
Expand Down
6 changes: 5 additions & 1 deletion src/lib/compat/data-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type SHBands
} from '../chunk';
import { Column, DataTable } from '../data-table';
import { type SplatModel } from '../splat-model';
import { type Transform } from '../utils';

/**
Expand Down Expand Up @@ -161,12 +162,14 @@ const splitToChunks = (
* @param dataTable - The legacy table to convert.
* @param chunkSize - Gaussians per chunk (default {@link DEFAULT_CHUNK_SIZE}).
* @param indices - Optional ordered row indices to gather; output row `i` is `dataTable` row `indices[i]`.
* @param model - How the scene was trained (a `DataTable` carries no tag of its own). Defaults to `default`.
* @returns A CPU-resident `InMemoryChunkSource` over the repacked data.
*/
const dataTableToChunkSource = (
dataTable: DataTable,
chunkSize: number = DEFAULT_CHUNK_SIZE,
indices?: Uint32Array
indices?: Uint32Array,
model?: SplatModel
): InMemoryChunkSource => {
const count = indices ? indices.length : dataTable.numRows;
const shBands = detectShBands(dataTable);
Expand Down Expand Up @@ -254,6 +257,7 @@ const dataTableToChunkSource = (
numGaussians: count,
chunkSize,
shBands,
model,
extraColumns: extras,
transform,
lodCounts: [count],
Expand Down
1 change: 1 addition & 0 deletions src/lib/decimate-uniform/decimate-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ const decimateSource = async (
chunkSize: src.meta.chunkSize,
numChunks: [Math.ceil(outCount / src.meta.chunkSize)],
shBands: src.meta.shBands,
model: src.meta.model,
extraColumns: src.meta.extraColumns,
transform: src.meta.transform,
availableLayers: src.meta.availableLayers,
Expand Down
1 change: 1 addition & 0 deletions src/lib/decimate/decimate-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,7 @@ const decimateSource = async (
chunkSize: src.meta.chunkSize,
numChunks: [Math.ceil(outCount / src.meta.chunkSize)],
shBands: src.meta.shBands,
model: src.meta.model,
extraColumns: src.meta.extraColumns,
transform: src.meta.transform,
availableLayers: src.meta.availableLayers,
Expand Down
5 changes: 5 additions & 0 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ export type {
// Structural combinators (lazy views over sources)
export { bakeTransform, concatSource, selectLod, stackLods } from './ops';

// How a scene was trained (carried on `ChunkSourceMetadata.model`). The per-format
// spellings of the tag live with their reader/writer.
export { isSplatModel, resolveSplatModel } from './splat-model';
export type { SplatModel } from './splat-model';

// Action processing over a source: `processSource` streams and throws on
// actions that need the DataTable bridge; `processSourceBridged` handles every
// action, materializing only the DataTable-only runs as islands.
Expand Down
12 changes: 12 additions & 0 deletions src/lib/ops/concat-source.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
type ChunkSource,
type ChunkSourceMetadata
} from '../chunk';
import { resolveSplatModel } from '../splat-model';
import { logger } from '../utils';

const LAYERS: ChunkLayer[] = ['position', 'geometric', 'color', 'other'];

Expand Down Expand Up @@ -76,6 +78,15 @@ const concatSource = (allSources: ChunkSource[], pool: ChunkDataPool): ChunkSour
sources = [allSources[0]];
}

// Unlike the structural mismatches above, disagreeing models don't block the
// concat: no output format can hold two, so fall back to plain gaussians
// (which every variant renders acceptably as) rather than mistagging.
const model = resolveSplatModel(sources.map(s => s.meta.model));
if (sources.some(s => s.meta.model !== model)) {
const seen = [...new Set(sources.map(s => s.meta.model))].join(', ');
logger.warn(`mixed splat models (${seen}); writing the result as '${model}'`);
}

const S = ref.chunkSize;
// Per-source gaussian counts and the output-row offset each source begins at.
const counts = sources.map(s => s.meta.numGaussians);
Expand All @@ -88,6 +99,7 @@ const concatSource = (allSources: ChunkSource[], pool: ChunkDataPool): ChunkSour

const meta: ChunkSourceMetadata = {
...ref,
model,
numGaussians: total,
numLods: 1,
lodCounts: [total],
Expand Down
1 change: 1 addition & 0 deletions src/lib/readers/read-lcc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -684,6 +684,7 @@ const readLccSource = async (
chunkSize,
numChunks: lodCounts.map(c => Math.ceil(c / chunkSize)),
shBands,
model: 'default', // lcc carries no training-model tag
extraColumns,
transform: LCC_TRANSFORM(),
availableLayers: new Set<ChunkLayer>(['position', 'geometric', 'color', 'other']),
Expand Down
93 changes: 86 additions & 7 deletions src/lib/readers/read-ply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
} from '../chunk';
import { Column, DataTable } from '../data-table';
import { type ReadSource, type ReadStream } from '../io/read';
import { type SplatModel } from '../splat-model';
import { logger, Transform } from '../utils';

type PlyProperty = {
Expand Down Expand Up @@ -52,6 +53,49 @@ type PlyData = {

const GEOMETRIC_COLS = ['rot_0', 'rot_1', 'rot_2', 'rot_3', 'scale_0', 'scale_1', 'scale_2', 'opacity'];
const COLOR_DC_COLS = ['f_dc_0', 'f_dc_1', 'f_dc_2'];
const SCALE_2_WORD = GEOMETRIC_COLS.indexOf('scale_2');
// The geometric columns a 2DGS record carries (everything but the third scale).
const GEOMETRIC_COLS_2DGS = GEOMETRIC_COLS.filter(c => c !== 'scale_2');

// Brush's `SplatRenderMode: <mode>` (brush-serde export.rs/import.rs) — the wire
// form we also write, see `splatModelComment` in the writers.
const MODE_COMMENT_KEY = 'splatrendermode: ';
const MODE_TO_MODEL: Readonly<Record<string, SplatModel>> = {
default: 'default',
mip: 'antialiased',
'2dgs': '2dgs'
};

// Postshot's flag.
const AA_COMMENT_KEY = 'antialiased ';

/**
* Detect the splat model from a PLY header's comments. Two producer forms are
* recognized: Brush's `SplatRenderMode: default | mip | 2dgs` and Postshot's
* `antialiased 0 | 1`.
*
* Matching is case-insensitive and the last match wins (as Brush's own importer
* does), so a file carrying both forms is decided by header order. Unrecognized
* comments are ignored.
*
* @param comments - Header comments, without their leading `comment `.
* @returns The detected model, or `default` if nothing matched.
*/
const splatModelFromComments = (comments: string[]): SplatModel => {
let model: SplatModel = 'default';
for (const comment of comments) {
const lower = comment.toLowerCase().trim();
if (lower.startsWith(MODE_COMMENT_KEY)) {
const mode = MODE_TO_MODEL[lower.substring(MODE_COMMENT_KEY.length).trim()];
if (mode) model = mode;
} else if (lower.startsWith(AA_COMMENT_KEY)) {
const value = lower.substring(AA_COMMENT_KEY.length).trim();
if (value === '1') model = 'antialiased';
else if (value === '0') model = 'default';
}
}
return model;
};

const getDataType = (type: string) => {
switch (type) {
Expand Down Expand Up @@ -364,6 +408,7 @@ const readCompressedChunked = (source: ReadSource, header: PlyHeader, pool: Chun
chunkSize,
numChunks: [numChunks],
shBands,
model: splatModelFromComments(header.comments),
extraColumns: [],
transform: Transform.PLY.clone(),
availableLayers,
Expand Down Expand Up @@ -549,7 +594,15 @@ const readCompressedChunked = (source: ReadSource, header: PlyHeader, pool: Chun
type FillField = { recordOffset: number; read: ValueReader; dstByteOffset: number; uint: boolean };
// `srcIdx`/`dstIdx` are float indices for the all-float fast path; `allFloat`
// is true only when the whole record is float (so a Float32Array view is valid).
type LayerPlan = { stride: number; fields: FillField[]; allFloat: boolean; srcIdx: Uint32Array; dstIdx: Uint32Array };
type LayerPlan = {
stride: number;
fields: FillField[];
allFloat: boolean;
srcIdx: Uint32Array;
dstIdx: Uint32Array;
// 2DGS only: the record has no scale_2, so the slot is filled with a constant.
synthScale2: boolean;
};

/**
* Open a gaussian-splat PLY as a {@link ChunkSource}. The single public PLY reader.
Expand Down Expand Up @@ -617,9 +670,19 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise<ChunkSo

const has = (name: string) => recordOffset.has(name);
const hasPosition = ['x', 'y', 'z'].every(has);
const hasGeometric = GEOMETRIC_COLS.every(has);
const hasColor = COLOR_DC_COLS.every(has);

// A 2DGS scene has no third scale axis, so its PLY carries scale_0/scale_1
// only. Structural evidence outranks any header comment: the missing column
// is materialized as -Infinity log scale (linear 0 — a zero-thickness
// surfel) so the rest of the pipeline sees an ordinary geometric layer.
// Only a file that is otherwise a complete gaussian record qualifies — a
// point cloud that happens to carry two scales is not a 2DGS scene.
const is2dgs = !has('scale_2') && GEOMETRIC_COLS_2DGS.every(has);
const geometricCols = is2dgs ? GEOMETRIC_COLS_2DGS : GEOMETRIC_COLS;
const hasGeometric = geometricCols.every(has);
const model = is2dgs ? '2dgs' : splatModelFromComments(header.comments);

// SH band count from the highest f_rest_* index present.
let highestRest = -1;
for (const p of properties) {
Expand All @@ -632,7 +695,8 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise<ChunkSo
throw new Error(`readPly: unrecognized f_rest_* count ${restCount}`);
}

// Non-standard columns become `other` extras (in file order).
// Non-standard columns become `other` extras (in file order). scale_2 stays
// standard for 2DGS: it's absent, not extra.
const standard = new Set<string>(['x', 'y', 'z', ...GEOMETRIC_COLS, ...COLOR_DC_COLS]);
const extras: ExtraColumn[] = properties
.filter(p => !standard.has(p.name) && !/^f_rest_\d+$/.test(p.name))
Expand All @@ -646,24 +710,27 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise<ChunkSo
const fieldNames = (layer: ChunkLayer): string[] => {
switch (layer) {
case 'position': return ['x', 'y', 'z'];
case 'geometric': return GEOMETRIC_COLS;
case 'geometric': return geometricCols;
case 'color': return [...COLOR_DC_COLS, ...Array.from({ length: SH_REST_COUNTS[shBands] }, (_, k) => `f_rest_${k}`)];
case 'other': return extras.map(e => e.name);
}
};
const uintByName = new Map(extras.map(e => [e.name, e.type === 'uint32']));

const layerPlan = (layer: ChunkLayer, stride: number): LayerPlan => {
// The geometric layer's destination slot is its canonical position (so a
// 2DGS record, missing scale_2, still writes opacity at word 7); every
// other layer packs in file order.
const fields: FillField[] = fieldNames(layer).map((name, idx) => ({
recordOffset: recordOffset.get(name)!,
read: reader.get(name)!,
dstByteOffset: idx * 4,
dstByteOffset: (layer === 'geometric' ? GEOMETRIC_COLS.indexOf(name) : idx) * 4,
uint: uintByName.get(name) ?? false
}));
const allFloat = recordAllFloat && fields.every(f => !f.uint);
const srcIdx = new Uint32Array(fields.map(f => f.recordOffset >> 2));
const dstIdx = new Uint32Array(fields.map(f => f.dstByteOffset >> 2));
return { stride, fields, allFloat, srcIdx, dstIdx };
return { stride, fields, allFloat, srcIdx, dstIdx, synthScale2: is2dgs && layer === 'geometric' };
};

const availableLayers = new Set<ChunkLayer>();
Expand Down Expand Up @@ -701,13 +768,25 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise<ChunkSo
chunkSize,
numChunks: [numChunks],
shBands,
model,
extraColumns: extras,
transform: Transform.PLY.clone(),
availableLayers,
layouts
};

const fill = (recordBytes: Uint8Array, count: number, chunkData: ChunkData, plan: LayerPlan, dstRow: number): void => {
// 2DGS: no source field targets the scale_2 slot, so fill it here (order
// relative to the de-interleave below doesn't matter). -Infinity log scale
// is linear 0 — a zero-thickness surfel.
if (plan.synthScale2) {
const dstF32 = new Float32Array(chunkData.data);
const dStrideF = plan.stride >> 2;
for (let i = 0; i < count; i++) {
dstF32[(dstRow + i) * dStrideF + SCALE_2_WORD] = -Infinity;
}
}

// Fast path: whole-float record -> de-interleave via Float32Array views
// (no DataView). Little-endian only, matching the binary PLY format.
if (plan.allFloat) {
Expand Down Expand Up @@ -838,4 +917,4 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise<ChunkSo
return fileChunkSource(source, meta, read);
};

export { PlyData, decodePlyToDataTable, readPly };
export { PlyData, decodePlyToDataTable, readPly, splatModelFromComments };
Loading