From 6ec8010577ce4b03ee40344da404ea52f7b02c7a Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Thu, 30 Jul 2026 16:26:52 +0100 Subject: [PATCH 1/2] latest --- README.md | 8 + src/cli/index.ts | 10 +- src/lib/chunk/in-memory.ts | 5 + src/lib/chunk/source.ts | 3 + src/lib/compat/data-table.ts | 6 +- src/lib/decimate-uniform/decimate-source.ts | 1 + src/lib/decimate/decimate-source.ts | 1 + src/lib/index.ts | 5 + src/lib/ops/concat-source.ts | 12 + src/lib/readers/read-lcc.ts | 1 + src/lib/readers/read-ply.ts | 89 ++++++- src/lib/readers/read-sog.ts | 14 +- src/lib/readers/read-splat.ts | 1 + src/lib/readers/read-spz.ts | 7 +- src/lib/source-info.ts | 3 + src/lib/splat-model.ts | 40 +++ src/lib/spz-module.ts | 13 +- src/lib/write.ts | 28 ++- src/lib/writers/utils.ts | 23 +- src/lib/writers/write-compressed-ply.ts | 11 +- src/lib/writers/write-ply-streaming.ts | 31 ++- src/lib/writers/write-sog.ts | 9 +- src/lib/writers/write-spz.ts | 11 +- test/helpers/test-utils.mjs | 4 +- test/splat-model.test.mjs | 256 ++++++++++++++++++++ 25 files changed, 551 insertions(+), 41 deletions(-) create mode 100644 src/lib/splat-model.ts create mode 100644 test/splat-model.test.mjs diff --git a/README.md b/README.md index 16b0a051..739dbb34 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/cli/index.ts b/src/cli/index.ts index 1467c43b..7658f28a 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -26,6 +26,7 @@ import { processSourceBridged, readFile, readPly, + resolveSplatModel, revision, selectLod, stackLods, @@ -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 }); diff --git a/src/lib/chunk/in-memory.ts b/src/lib/chunk/in-memory.ts index e8e65b6c..807dd39d 100644 --- a/src/lib/chunk/in-memory.ts +++ b/src/lib/chunk/in-memory.ts @@ -1,3 +1,4 @@ +import { type SplatModel } from '../splat-model'; import { type Transform } from '../utils'; import { type ChunkData } from './data'; import { @@ -178,6 +179,7 @@ type LayerBuffers = ReadonlyArray>; * @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. @@ -191,6 +193,8 @@ const createInMemoryChunkSource = (params: { chunkSize: number; shBands: SHBands; extraColumns?: ReadonlyArray; + /** How the scene was trained; untagged sources are `default`. */ + model?: SplatModel; transform: Transform; /** Gaussians per LOD. `lodCounts[0]` must equal `numGaussians`. */ lodCounts: ReadonlyArray; @@ -259,6 +263,7 @@ const createInMemoryChunkSource = (params: { chunkSize, numChunks, shBands, + model: params.model ?? 'default', extraColumns: extras, transform, availableLayers, diff --git a/src/lib/chunk/source.ts b/src/lib/chunk/source.ts index fb6e4fba..5497ae64 100644 --- a/src/lib/chunk/source.ts +++ b/src/lib/chunk/source.ts @@ -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'; @@ -27,6 +28,8 @@ type ChunkSourceMetadata = { readonly numChunks: ReadonlyArray; /** 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; /** Coordinate-space transform; applied lazily when consumed. */ diff --git a/src/lib/compat/data-table.ts b/src/lib/compat/data-table.ts index 64963f02..d81e5db9 100644 --- a/src/lib/compat/data-table.ts +++ b/src/lib/compat/data-table.ts @@ -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'; /** @@ -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); @@ -254,6 +257,7 @@ const dataTableToChunkSource = ( numGaussians: count, chunkSize, shBands, + model, extraColumns: extras, transform, lodCounts: [count], diff --git a/src/lib/decimate-uniform/decimate-source.ts b/src/lib/decimate-uniform/decimate-source.ts index 2ec9ac67..5e99503b 100644 --- a/src/lib/decimate-uniform/decimate-source.ts +++ b/src/lib/decimate-uniform/decimate-source.ts @@ -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, diff --git a/src/lib/decimate/decimate-source.ts b/src/lib/decimate/decimate-source.ts index f676397c..ed9603fa 100644 --- a/src/lib/decimate/decimate-source.ts +++ b/src/lib/decimate/decimate-source.ts @@ -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, diff --git a/src/lib/index.ts b/src/lib/index.ts index aad13f4d..d51a1669 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -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. diff --git a/src/lib/ops/concat-source.ts b/src/lib/ops/concat-source.ts index c4e2e130..f8720422 100644 --- a/src/lib/ops/concat-source.ts +++ b/src/lib/ops/concat-source.ts @@ -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']; @@ -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); @@ -88,6 +99,7 @@ const concatSource = (allSources: ChunkSource[], pool: ChunkDataPool): ChunkSour const meta: ChunkSourceMetadata = { ...ref, + model, numGaussians: total, numLods: 1, lodCounts: [total], diff --git a/src/lib/readers/read-lcc.ts b/src/lib/readers/read-lcc.ts index 171ebc34..c16e5a81 100644 --- a/src/lib/readers/read-lcc.ts +++ b/src/lib/readers/read-lcc.ts @@ -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(['position', 'geometric', 'color', 'other']), diff --git a/src/lib/readers/read-ply.ts b/src/lib/readers/read-ply.ts index 55b6676b..cfc95c61 100644 --- a/src/lib/readers/read-ply.ts +++ b/src/lib/readers/read-ply.ts @@ -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 = { @@ -52,6 +53,47 @@ 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'); + +// Brush's `SplatRenderMode: ` (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> = { + 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) { @@ -364,6 +406,7 @@ const readCompressedChunked = (source: ReadSource, header: PlyHeader, pool: Chun chunkSize, numChunks: [numChunks], shBands, + model: splatModelFromComments(header.comments), extraColumns: [], transform: Transform.PLY.clone(), availableLayers, @@ -549,7 +592,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. @@ -617,9 +668,17 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise 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. + const is2dgs = !has('scale_2') && ['scale_0', 'scale_1'].every(has); + const geometricCols = is2dgs ? GEOMETRIC_COLS.filter(c => c !== 'scale_2') : 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) { @@ -632,7 +691,8 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise(['x', 'y', 'z', ...GEOMETRIC_COLS, ...COLOR_DC_COLS]); const extras: ExtraColumn[] = properties .filter(p => !standard.has(p.name) && !/^f_rest_\d+$/.test(p.name)) @@ -646,7 +706,7 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise { 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); } @@ -654,16 +714,19 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise [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(); @@ -701,6 +764,7 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise { + // 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) { @@ -838,4 +913,4 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise { + if (value === undefined) return 'default'; + if (isSplatModel(value)) return value; + logger.warn(`unrecognized splat model '${value}' in meta.json, reading as 'default'`); + return 'default'; +}; + // Inverse of logTransform(x) = sign(x) * ln(|x| + 1) const invLogTransform = (v: number) => { const a = Math.abs(v); @@ -186,6 +197,7 @@ const readSogSourceV2 = async ( chunkSize, numChunks: [count === 0 ? 0 : Math.ceil(count / chunkSize)], shBands, + model: modelFromMeta(meta.model), extraColumns: [], transform: Transform.PLY.clone(), availableLayers: new Set(['position', 'geometric', 'color']), diff --git a/src/lib/readers/read-splat.ts b/src/lib/readers/read-splat.ts index 87011beb..a5a51f8b 100644 --- a/src/lib/readers/read-splat.ts +++ b/src/lib/readers/read-splat.ts @@ -116,6 +116,7 @@ const readSplat = async (source: ReadSource, pool: ChunkDataPool): Promise => { const numSplats = header.getUint32(8, true); const shDegree = header.getUint8(12); const fractionalBits = header.getUint8(13); + const antialiased = (header.getUint8(14) & FLAG_ANTIALIASED) !== 0; if (shDegree < 0 || shDegree >= HARMONICS_COMPONENT_COUNT.length) { throw new Error(`Unsupported SH degree ${shDegree}`); } @@ -158,6 +161,7 @@ const parseSpz = async (source: ReadSource): Promise => { numSplats, shBands, fractionalBits, + antialiased, positions: streams[0], alphas: streams[1], colors: streams[2], @@ -180,7 +184,7 @@ const parseSpz = async (source: ReadSource): Promise => { const scales = new DataView(buf, off, scalesByteSize); off += scalesByteSize; const rotations = new DataView(buf, off, rotationsByteSize); off += rotationsByteSize; const sh = new DataView(buf, off, shByteSize); - return { version, numSplats, shBands, fractionalBits, positions, alphas, colors, scales, rotations, sh }; + return { version, numSplats, shBands, fractionalBits, antialiased, positions, alphas, colors, scales, rotations, sh }; }; // Reusable scratch for smallest-three quaternion decoding. @@ -224,6 +228,7 @@ const readSpz = async (source: ReadSource, pool: ChunkDataPool): Promise(['position', 'geometric', 'color']), diff --git a/src/lib/source-info.ts b/src/lib/source-info.ts index 9577c472..17877ca0 100644 --- a/src/lib/source-info.ts +++ b/src/lib/source-info.ts @@ -36,6 +36,7 @@ const buildSourceInfo = (meta: ChunkSourceMetadata, format?: InputFormat) => ({ numLods: meta.numLods, lodCounts: [...meta.lodCounts], shBands: meta.shBands, + model: meta.model, layers: orderedLayers(meta.availableLayers), extraColumns: meta.extraColumns.map(e => ({ name: e.name, type: e.type })) }); @@ -55,6 +56,8 @@ const sourceInfoLines = (meta: ChunkSourceMetadata, format?: InputFormat): strin `lods: ${meta.numLods}`, `lod counts: ${meta.lodCounts.join(', ')}`, `sh bands: ${meta.shBands}`, + // untagged scenes say nothing, so existing output is unchanged + ...(meta.model === 'default' ? [] : [`model: ${meta.model}`]), `layers: ${orderedLayers(meta.availableLayers).join(', ')}`, `extra columns: ${meta.extraColumns.length > 0 ? meta.extraColumns.map(e => `${e.name} (${e.type})`).join(', ') : '(none)'}` ]; diff --git a/src/lib/splat-model.ts b/src/lib/splat-model.ts new file mode 100644 index 00000000..e0eba79f --- /dev/null +++ b/src/lib/splat-model.ts @@ -0,0 +1,40 @@ +/** + * How a scene was trained, and therefore how a renderer must evaluate it. + * + * - `default` - ordinary gaussians, no special evaluation + * - `antialiased` - trained with antialiasing (mip-splatting style screen-space filter) + * - `2dgs` - trained as 2D gaussian surfels (no third scale axis) + * + * The variants are mutually exclusive, hence one enum rather than independent + * flags. A source that carries no tag reads as `default`. + */ +type SplatModel = 'default' | 'antialiased' | '2dgs'; + +/** Every model value, for validating externally-supplied strings. */ +const SPLAT_MODELS: ReadonlyArray = ['default', 'antialiased', '2dgs']; + +/** + * Narrow an externally-supplied string to a {@link SplatModel}. Per-format + * readers use this on whatever their container spells the tag as. + * + * @param value - The candidate value. + * @returns True if `value` is a model name. + */ +const isSplatModel = (value: unknown): value is SplatModel => { + return SPLAT_MODELS.includes(value as SplatModel); +}; + +/** + * Resolve the model of a combined scene. Mixing models can't be represented in + * one output, so any disagreement falls back to `default` — the safe read, since + * every variant renders acceptably (if not optimally) as ordinary gaussians. + * + * @param models - The models of the inputs being combined. + * @returns The agreed model, or `default` when they disagree. + */ +const resolveSplatModel = (models: SplatModel[]): SplatModel => { + const first = models[0] ?? 'default'; + return models.every(m => m === first) ? first : 'default'; +}; + +export { type SplatModel, isSplatModel, resolveSplatModel }; diff --git a/src/lib/spz-module.ts b/src/lib/spz-module.ts index 2b3db13d..58d87009 100644 --- a/src/lib/spz-module.ts +++ b/src/lib/spz-module.ts @@ -1,4 +1,5 @@ import { Column, DataTable, convertToSpace } from './data-table'; +import { type SplatModel } from './splat-model'; import { Transform } from './utils'; type CoordinateSystem = { @@ -41,6 +42,9 @@ type CreateSpzModule = () => Promise; const SPZ_SH_COMPONENTS = [0, 9, 24, 45, 72] as const; +// Log-scale floor for SPZ output, matching the compressed-PLY writer's clamp. +const MIN_LOG_SCALE = -20; + let spzModulePromise: Promise | null = null; // `@adobe/spz`'s GaussianCloud uses PLY-native conventions for scales/colors/alphas: @@ -155,7 +159,7 @@ const gaussianCloudToDataTable = (cloud: GaussianCloud) => { return new DataTable(columns, Transform.PLY); }; -const dataTableToGaussianCloud = (dataTable: DataTable): GaussianCloud => { +const dataTableToGaussianCloud = (dataTable: DataTable, model: SplatModel = 'default'): GaussianCloud => { const plyDataTable = convertToSpace(dataTable, Transform.PLY); const shColumnCount = getShColumnCount(plyDataTable); const shDegree = getShDegreeFromCount(shColumnCount); @@ -199,7 +203,10 @@ const dataTableToGaussianCloud = (dataTable: DataTable): GaussianCloud => { scales[i3] = scale0[i]; scales[i3 + 1] = scale1[i]; - scales[i3 + 2] = scale2[i]; + // 2DGS carries -Infinity here; the SPZ encoder quantizes log scales, so + // floor it at the smallest value the format resolves rather than feeding + // it an infinity. SPZ has no 2DGS flag, so the axis is merely tiny. + scales[i3 + 2] = Math.max(scale2[i], MIN_LOG_SCALE); colors[i3] = color0[i]; colors[i3 + 1] = color1[i]; @@ -223,7 +230,7 @@ const dataTableToGaussianCloud = (dataTable: DataTable): GaussianCloud => { return { numPoints, shDegree, - antialiased: false, + antialiased: model === 'antialiased', extensions: [], positions, scales, diff --git a/src/lib/write.ts b/src/lib/write.ts index 99a88a2d..8ae3838a 100644 --- a/src/lib/write.ts +++ b/src/lib/write.ts @@ -2,8 +2,10 @@ import { type ChunkDataPool, type ChunkLayer, type ChunkSource } from './chunk'; import { materializeToDataTable } from './compat/data-table'; import { DataTable } from './data-table'; import { type FileSystem } from './io/write'; +import { type SplatModel } from './splat-model'; import { type DeviceCreator, type Options } from './types'; import { writeCompressedPly, writeCsv, writeGlb, writeHtml, writeImage, writePly, writeSog, writeSogSource, writeSpz, writeVoxel } from './writers'; +import { splatModelComment } from './writers/utils'; import { writeCompressedPlySource } from './writers/write-compressed-ply'; import { writePlyStreaming } from './writers/write-ply-streaming'; import { writeSplatStreaming } from './writers/write-splat-streaming'; @@ -37,6 +39,8 @@ type WriteOptions = { outputFormat: OutputFormat; /** The splat data to write. */ dataTable: DataTable; + /** How the scene was trained. Defaults to `default` (untagged). */ + model?: SplatModel; /** Processing options. */ options: Options; /** Optional function to create a GPU device for compression. */ @@ -112,6 +116,7 @@ const getOutputFormat = (filename: string, options: Options): OutputFormat => { */ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { const { filename, outputFormat, dataTable, options, createDevice } = writeOptions; + const model = writeOptions.model ?? 'default'; // Each writer is responsible for opening its own `Writing` log group and // emitting `filename (size)` info entries per output file. @@ -124,6 +129,7 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { await writeSog({ filename, dataTable, + model, bundle: outputFormat === 'sog-bundle', iterations: options.iterations ?? 10, createDevice @@ -132,26 +138,36 @@ const writeFile = async (writeOptions: WriteOptions, fs: FileSystem) => { case 'lod': throw new Error('lod-meta.json output is written from a multi-LOD ChunkSource via writeLodSource, not from a DataTable.'); case 'compressed-ply': - await writeCompressedPly({ filename, dataTable }, fs); + await writeCompressedPly({ filename, dataTable, model }, fs); break; case 'splat': throw new Error('splat output is written from a ChunkSource via writeSource, not from a DataTable.'); - case 'ply': + case 'ply': { + const comment = splatModelComment(model); + // 2DGS has no third scale axis: it was materialized on read to keep + // the pipeline uniform, so drop it again here. + const columns = model === '2dgs' ? + dataTable.columns.filter(c => c.name !== 'scale_2') : + dataTable.columns; await writePly({ filename, plyData: { - comments: [], + comments: comment ? [comment] : [], elements: [{ name: 'vertex', - dataTable + dataTable: columns.length === dataTable.columns.length ? + dataTable : + new DataTable(columns, dataTable.transform) }] } }, fs); break; + } case 'spz': await writeSpz({ filename, dataTable, + model, version: options.spzVersion ?? 4 }, fs); break; @@ -271,14 +287,14 @@ const writeSource = async (writeSourceOptions: WriteSourceOptions, fs: FileSyste // layers so color and SH are never loaded (they were previously read // into the full table and discarded). const dataTable = await materializeToDataTable(source, pool, new Set(['position', 'geometric'])); - await writeFile({ filename, outputFormat, dataTable, options, createDevice }, fs); + await writeFile({ filename, outputFormat, dataTable, model: source.meta.model, options, createDevice }, fs); break; } default: { // No streaming writer yet — materialize and delegate to the DataTable // writer (the inline bridge around the unconverted writer). const dataTable = await materializeToDataTable(source, pool); - await writeFile({ filename, outputFormat, dataTable, options, createDevice }, fs); + await writeFile({ filename, outputFormat, dataTable, model: source.meta.model, options, createDevice }, fs); } } }; diff --git a/src/lib/writers/utils.ts b/src/lib/writers/utils.ts index c56c01b6..d63b3d52 100644 --- a/src/lib/writers/utils.ts +++ b/src/lib/writers/utils.ts @@ -1,5 +1,26 @@ +import { type SplatModel } from '../splat-model'; import { fmtBytes, logger } from '../utils'; +// Brush's spelling of the model, which its importer (brush-serde import.rs) and +// ours both read — `default` is what an untagged file already means, so it's +// never written. +const MODEL_TO_MODE: Readonly> = { + default: null, + antialiased: 'mip', + '2dgs': '2dgs' +}; + +/** + * The PLY header comment tagging a splat model, shared by the three PLY writers. + * + * @param model - The model to tag. + * @returns The comment text (without the leading `comment `), or `null` for an untagged (`default`) scene. + */ +const splatModelComment = (model: SplatModel): string | null => { + const mode = MODEL_TO_MODE[model]; + return mode && `SplatRenderMode: ${mode}`; +}; + /** * Emit a single `Writing`-group entry as ` ()`. * @@ -14,4 +35,4 @@ const logWrittenFile = (filename: string, bytes: number): void => { logger.info(`${filename} (${fmtBytes(bytes)})`); }; -export { logWrittenFile }; +export { logWrittenFile, splatModelComment }; diff --git a/src/lib/writers/write-compressed-ply.ts b/src/lib/writers/write-compressed-ply.ts index 727a59e2..481866d4 100644 --- a/src/lib/writers/write-compressed-ply.ts +++ b/src/lib/writers/write-compressed-ply.ts @@ -1,11 +1,12 @@ import { basename } from 'pathe'; import { CompressedChunk } from './compressed-chunk'; -import { logWrittenFile } from './utils'; +import { logWrittenFile, splatModelComment } from './utils'; import { type ChunkSource, type ChunkDataPool } from '../chunk'; import { materializeToDataTable } from '../compat/data-table'; import { DataTable, sortMortonOrder, convertToSpace, getSHBands, shRestNames } from '../data-table'; import { type FileSystem } from '../io/write'; +import { type SplatModel } from '../splat-model'; import { logger, Transform } from '../utils'; import { version } from '../version'; @@ -33,6 +34,7 @@ const CHUNK_SIZE = 256; type WriteCompressedPlyOptions = { filename: string; dataTable: DataTable; + model?: SplatModel; }; /** @@ -61,10 +63,15 @@ const writeCompressedPly = async (options: WriteCompressedPlyOptions, fs: FileSy new Array(outputSHCoeffs * 3).fill('').map((_, i) => `property uchar f_rest_${i}`) ].flat() : []; + // The packed layout is fixed, so a 2DGS scene keeps its (materialized) third + // scale — the comment is what tells a viewer to ignore it. + const modelComment = splatModelComment(options.model ?? 'default'); + const headerText = [ 'ply', 'format binary_little_endian 1.0', `comment ${generatedByString}`, + ...(modelComment ? [`comment ${modelComment}`] : []), `element chunk ${numChunks}`, chunkProps.map(p => `property float ${p}`), `element vertex ${numSplats}`, @@ -161,7 +168,7 @@ const writeCompressedPlySource = async ( fs: FileSystem ): Promise => { const dataTable = await materializeToDataTable(source, pool); - await writeCompressedPly({ filename: options.filename, dataTable }, fs); + await writeCompressedPly({ filename: options.filename, dataTable, model: source.meta.model }, fs); }; export { writeCompressedPly, writeCompressedPlySource }; diff --git a/src/lib/writers/write-ply-streaming.ts b/src/lib/writers/write-ply-streaming.ts index 32e27862..038e4a2e 100644 --- a/src/lib/writers/write-ply-streaming.ts +++ b/src/lib/writers/write-ply-streaming.ts @@ -5,6 +5,7 @@ import { type ChunkDataPool, SH_REST_COUNTS } from '../chunk'; +import { splatModelComment } from './utils'; import { type FileSystem } from '../io/write'; import { bakeTransform } from '../ops'; import { logger, Transform } from '../utils'; @@ -21,9 +22,9 @@ type WritePlyStreamingOptions = { // it's a u32 (an `other` extra) rather than f32. type OutColumn = { name: string; layer: ChunkLayer; layerByteOffset: number; uint: boolean }; -// A "run": a maximal contiguous span of output columns fed by a single layer. -// Interleaving each run is a straight per-row block copy (see writePlyStreaming). -type LayerRun = { layer: ChunkLayer; dstStart: number; words: number; srcStrideWords: number }; +// A "run": a maximal span of output columns fed by consecutive words of a single +// layer. Interleaving each run is a straight per-row block copy (see writePlyStreaming). +type LayerRun = { layer: ChunkLayer; srcStart: number; dstStart: number; words: number; srcStrideWords: number }; /** * Stream a {@link ChunkSource} out to a binary little-endian PLY file. @@ -63,7 +64,10 @@ const writePlyStreaming = async ( ); } if (layers.has('geometric')) { + // 2DGS has no third scale axis: the column was materialized on read to + // keep the layer uniform, so drop it again here. GEOMETRIC_COLS.forEach((name, i) => { + if (meta.model === '2dgs' && name === 'scale_2') return; columns.push({ name, layer: 'geometric', layerByteOffset: i * 4, uint: false }); }); } @@ -95,26 +99,29 @@ const writePlyStreaming = async ( // // A "run" captures one such block: copy `words` words per row from the layer // buffer (row stride `srcStrideWords`) into the record at word offset `dstStart`. + // A dropped column (2DGS omits scale_2) splits its layer into two runs rather + // than breaking the copy: a run ends where the source words stop being + // consecutive, and each run records where in the layer row it starts. const runs: LayerRun[] = []; for (let c = 0; c < columns.length;) { const layer = columns[c].layer; + const srcStart = columns[c].layerByteOffset >> 2; let e = c; - while (e < columns.length && columns[e].layer === layer) { - // Invariant the block copy relies on: output word (e - c) reads source - // word (e - c). Fail loud if column ordering ever stops matching. - if ((columns[e].layerByteOffset >> 2) !== e - c) { - throw new Error('writePlyStreaming: layer columns not in packed canonical order'); - } + while (e < columns.length && + columns[e].layer === layer && + (columns[e].layerByteOffset >> 2) === srcStart + (e - c)) { e++; } - runs.push({ layer, dstStart: c, words: e - c, srcStrideWords: meta.layouts[layer]!.stride >> 2 }); + runs.push({ layer, srcStart, dstStart: c, words: e - c, srcStrideWords: meta.layouts[layer]!.stride >> 2 }); c = e; } - // Header (matches writePly: no comments here, single vertex element). + // Header: the model tag (when any), then a single vertex element. + const modelComment = splatModelComment(meta.model); const header = [ 'ply', 'format binary_little_endian 1.0', + ...(modelComment ? [`comment ${modelComment}`] : []), `element vertex ${N}`, ...columns.map(c => `property ${plyType(c.uint)} ${c.name}`), 'end_header' @@ -157,7 +164,7 @@ const writePlyStreaming = async ( const ds = run.dstStart; const w = run.words; for (let i = 0; i < count; i++) { - const s = i * ss; + const s = i * ss + run.srcStart; const d = i * recordF + ds; for (let j = 0; j < w; j++) outU32[d + j] = src[s + j]; } diff --git a/src/lib/writers/write-sog.ts b/src/lib/writers/write-sog.ts index 2e939698..84672185 100644 --- a/src/lib/writers/write-sog.ts +++ b/src/lib/writers/write-sog.ts @@ -14,6 +14,7 @@ import { type FileSystem, writeFile, ZipFileSystem } from '../io/write'; import { bakeTransform } from '../ops'; import { sortMortonInterleaved } from '../ops/morton-order'; import { kmeansInterleaved } from '../spatial'; +import { type SplatModel } from '../splat-model'; import type { DeviceCreator } from '../types'; import { logger, sigmoid, Transform } from '../utils'; import { version } from '../version'; @@ -381,6 +382,8 @@ const writeSogSource = async ( version: 2, asset: { generator: `splat-transform v${version}` }, count: numRows, + // untagged scenes stay byte-identical to pre-3.2 output + ...(meta.model === 'default' ? {} : { model: meta.model }), means: { mins: meansMeta.mins, maxs: meansMeta.maxs, files: ['means_l.webp', 'means_u.webp'] }, scales: { codebook: scalesCodebook, files: ['scales.webp'] }, quats: { files: ['quats.webp'] }, @@ -415,7 +418,7 @@ const writeSogSource = async ( } }; -type WriteSogOptions = WriteSogSourceOptions & { dataTable: DataTable }; +type WriteSogOptions = WriteSogSourceOptions & { dataTable: DataTable; model?: SplatModel }; /** * DataTable-input adapter over {@link writeSogSource}, for callers that still @@ -428,9 +431,9 @@ type WriteSogOptions = WriteSogSourceOptions & { dataTable: DataTable }; * @ignore */ const writeSog = async (options: WriteSogOptions, fs: FileSystem): Promise => { - const { dataTable, ...rest } = options; + const { dataTable, model, ...rest } = options; const pool = createChunkDataPool(); - const source = dataTableToChunkSource(dataTable, pool.chunkSize); + const source = dataTableToChunkSource(dataTable, pool.chunkSize, undefined, model); await writeSogSource(source, pool, rest, fs); }; diff --git a/src/lib/writers/write-spz.ts b/src/lib/writers/write-spz.ts index 9758f29c..73920f54 100644 --- a/src/lib/writers/write-spz.ts +++ b/src/lib/writers/write-spz.ts @@ -2,12 +2,14 @@ import { basename } from 'pathe'; import { logWrittenFile } from './utils'; import { type FileSystem, writeFile } from '../io/write'; +import { type SplatModel } from '../splat-model'; import { dataTableToGaussianCloud, getSpzModule, makeSpzPackOptions } from '../spz-module'; import { logger } from '../utils'; type WriteSpzOptions = { filename: string; dataTable: import('../data-table').DataTable; + model?: SplatModel; version?: 3 | 4; }; @@ -20,12 +22,17 @@ type WriteSpzOptions = { * @ignore */ const writeSpz = async (options: WriteSpzOptions, fs: FileSystem) => { - const { filename, dataTable, version = 4 } = options; + const { filename, dataTable, model = 'default', version = 4 } = options; const writingGroup = logger.group('Writing'); + // SPZ's header has an antialiased bit but no 2DGS state. + if (model === '2dgs') { + logger.warn('spz cannot represent a 2dgs scene; writing it as ordinary gaussians with a near-zero third scale'); + } + const spz = await getSpzModule(); const packOptions = await makeSpzPackOptions({ version }); - const cloud = dataTableToGaussianCloud(dataTable); + const cloud = dataTableToGaussianCloud(dataTable, model); const bytes = spz.saveSpzToBuffer(cloud, packOptions); if (version === 4) { diff --git a/test/helpers/test-utils.mjs b/test/helpers/test-utils.mjs index 9ae71821..ada25dd6 100644 --- a/test/helpers/test-utils.mjs +++ b/test/helpers/test-utils.mjs @@ -109,9 +109,10 @@ function createMinimalTestData(options = {}) { /** * Encodes a DataTable to PLY binary format. * @param {DataTable} dataTable - The data to encode + * @param {string[]} comments - Header comments (without the leading `comment `) * @returns {Uint8Array} PLY file as binary data */ -function encodePlyBinary(dataTable) { +function encodePlyBinary(dataTable, comments = []) { const columns = dataTable.columns; const numRows = dataTable.numRows; @@ -132,6 +133,7 @@ function encodePlyBinary(dataTable) { const headerLines = [ 'ply', 'format binary_little_endian 1.0', + ...comments.map(c => `comment ${c}`), `element vertex ${numRows}`, ...columns.map(c => `property ${columnTypeToPlyType(c.dataType)} ${c.name}`), 'end_header' diff --git a/test/splat-model.test.mjs b/test/splat-model.test.mjs new file mode 100644 index 00000000..6b95a5c9 --- /dev/null +++ b/test/splat-model.test.mjs @@ -0,0 +1,256 @@ +/** + * Splat model (default / antialiased / 2dgs) detection and propagation. + * + * - comment parsing: Brush's `SplatRenderMode:` and Postshot's `antialiased N`; + * - PLY / compressed-PLY / SOG / SPZ outputs carry the tag (PLY always in + * Brush's spelling, whichever form it was read from); + * - a 2DGS PLY (no scale_2) reads with a full geometric layer and writes back + * without the column; + * - mixed inputs collapse to `default` rather than mistagging; + * - untagged scenes are unchanged (no comment, no meta key). + */ + +import assert from 'node:assert'; +import { dirname, join } from 'node:path'; +import { describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +import { createTestDataTable, encodePlyBinary } from './helpers/test-utils.mjs'; +import { createChunkDataPool } from '../src/lib/chunk/index.js'; +import { materializeToDataTable } from '../src/lib/compat/data-table.js'; +import { + DataTable, WebPCodec, + MemoryFileSystem, MemoryReadFileSystem, + resolveSplatModel, writeFile, writeSource +} from '../src/lib/index.js'; +import { concatSource } from '../src/lib/ops/index.js'; +import { readPly, splatModelFromComments } from '../src/lib/readers/read-ply.js'; +import { splatModelComment } from '../src/lib/writers/utils.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +WebPCodec.wasmUrl = join(__dirname, '..', 'lib', 'webp.wasm'); + +const sourceFromBytes = (bytes) => { + const rfs = new MemoryReadFileSystem(); + rfs.set('in.ply', bytes); + return rfs.createSource('in.ply'); +}; + +// Open a PLY built from `dataTable` (+ header comments) as a ChunkSource. +const openPly = async (dataTable, comments = []) => { + const pool = createChunkDataPool(); + const source = await readPly(await sourceFromBytes(encodePlyBinary(dataTable, comments)), pool); + return { source, pool }; +}; + +// A 2DGS table: the standard columns minus scale_2. +const make2dgsTable = (count = 16) => { + const table = createTestDataTable(count); + return new DataTable(table.columns.filter(c => c.name !== 'scale_2'), table.transform); +}; + +// The header text of a PLY, up to and including end_header. +const plyHeaderText = (bytes) => { + const text = Buffer.from(bytes.buffer, bytes.byteOffset, Math.min(bytes.byteLength, 4096)).toString('latin1'); + return text.substring(0, text.indexOf('end_header') + 'end_header'.length); +}; + +// Write chunk-natively — the path the CLI takes (streaming PLY, native SOG). +const writeChunked = async (source, pool, filename, outputFormat) => { + const fs = new MemoryFileSystem(); + await writeSource({ filename, outputFormat, source, pool, options: {} }, fs); + return fs.results; +}; + +// Write through the DataTable writers (the compat API surface). +const writeTabular = async (source, pool, filename, outputFormat) => { + const fs = new MemoryFileSystem(); + const dataTable = await materializeToDataTable(source, pool); + await writeFile({ filename, outputFormat, dataTable, model: source.meta.model, options: {} }, fs); + return fs.results; +}; + +describe('splatModelFromComments', () => { + it('reads Brush\'s SplatRenderMode', () => { + assert.strictEqual(splatModelFromComments(['SplatRenderMode: mip']), 'antialiased'); + assert.strictEqual(splatModelFromComments(['SplatRenderMode: default']), 'default'); + assert.strictEqual(splatModelFromComments(['SplatRenderMode: 2dgs']), '2dgs'); + }); + + it('reads Postshot\'s antialiased flag', () => { + assert.strictEqual(splatModelFromComments(['antialiased 1']), 'antialiased'); + assert.strictEqual(splatModelFromComments(['antialiased 0']), 'default'); + }); + + it('ignores case, surrounding whitespace and unrelated comments', () => { + assert.strictEqual(splatModelFromComments([' SPLATRENDERMODE: MIP ']), 'antialiased'); + assert.strictEqual(splatModelFromComments(['Exported from Brush', 'SH degree: 3']), 'default'); + assert.strictEqual(splatModelFromComments([]), 'default'); + }); + + it('falls back to default for an unknown mode', () => { + assert.strictEqual(splatModelFromComments(['SplatRenderMode: banana']), 'default'); + }); + + it('takes the last match across both forms', () => { + assert.strictEqual(splatModelFromComments(['antialiased 1', 'SplatRenderMode: default']), 'default'); + assert.strictEqual(splatModelFromComments(['SplatRenderMode: default', 'antialiased 1']), 'antialiased'); + }); + + it('emits Brush\'s spelling, and nothing for default', () => { + assert.strictEqual(splatModelComment('antialiased'), 'SplatRenderMode: mip'); + assert.strictEqual(splatModelComment('2dgs'), 'SplatRenderMode: 2dgs'); + assert.strictEqual(splatModelComment('default'), null); + }); +}); + +describe('antialiased scenes', () => { + it('round-trips a Brush-tagged PLY', async () => { + const { source, pool } = await openPly(createTestDataTable(16), [ + 'Exported from Brush', 'SH degree: 0', 'SplatRenderMode: mip' + ]); + assert.strictEqual(source.meta.model, 'antialiased'); + + const out = await writeChunked(source, pool, 'out.ply', 'ply'); + assert.match(plyHeaderText(out.get('out.ply')), /comment SplatRenderMode: mip/); + }); + + it('retags a Postshot-tagged PLY in Brush\'s spelling', async () => { + const { source, pool } = await openPly(createTestDataTable(16), ['antialiased 1']); + assert.strictEqual(source.meta.model, 'antialiased'); + + const header = plyHeaderText((await writeChunked(source, pool, 'out.ply', 'ply')).get('out.ply')); + assert.match(header, /comment SplatRenderMode: mip/); + assert.doesNotMatch(header, /antialiased/); + }); + + it('tags compressed PLY output', async () => { + const { source, pool } = await openPly(createTestDataTable(16), ['antialiased 1']); + const out = await writeChunked(source, pool, 'out.compressed.ply', 'compressed-ply'); + assert.match(plyHeaderText(out.get('out.compressed.ply')), /comment SplatRenderMode: mip/); + }); + + it('tags SOG meta.json', async () => { + const { source, pool } = await openPly(createTestDataTable(16), ['SplatRenderMode: mip']); + const out = await writeChunked(source, pool, 'meta.json', 'sog'); + const meta = JSON.parse(Buffer.from(out.get('meta.json')).toString()); + assert.strictEqual(meta.model, 'antialiased'); + }); + + it('sets the SPZ antialiased header bit', async () => { + const { source, pool } = await openPly(createTestDataTable(16), ['SplatRenderMode: mip']); + const out = await writeChunked(source, pool, 'out.spz', 'spz'); + const bytes = out.get('out.spz'); + assert.strictEqual(bytes[14] & 0x1, 0x1); + + // and comes back as antialiased on read + const rfs = new MemoryReadFileSystem(); + rfs.set('in.spz', bytes); + const { readSpz } = await import('../src/lib/readers/read-spz.js'); + const pool2 = createChunkDataPool(); + const spzSource = await readSpz(await rfs.createSource('in.spz'), pool2); + assert.strictEqual(spzSource.meta.model, 'antialiased'); + }); +}); + +describe('2dgs scenes', () => { + it('reads a PLY with no scale_2 as a full geometric layer', async () => { + const { source, pool } = await openPly(make2dgsTable(16)); + assert.strictEqual(source.meta.model, '2dgs'); + assert.ok(source.meta.availableLayers.has('geometric'), 'geometric layer present'); + assert.strictEqual(source.meta.extraColumns.length, 0, 'scale_2 is absent, not extra'); + + const table = await materializeToDataTable(source, pool); + const scale2 = table.getColumnByName('scale_2').data; + const scale1 = table.getColumnByName('scale_1').data; + const opacity = table.getColumnByName('opacity').data; + const reference = createTestDataTable(16); + for (let i = 0; i < 16; i++) { + assert.strictEqual(scale2[i], -Infinity, `row ${i} scale_2`); + // the columns either side of the gap still land in their own slots + assert.strictEqual(scale1[i], reference.getColumnByName('scale_1').data[i], `row ${i} scale_1`); + assert.strictEqual(opacity[i], reference.getColumnByName('opacity').data[i], `row ${i} opacity`); + } + }); + + it('structural evidence outranks a contradicting comment', async () => { + const { source } = await openPly(make2dgsTable(8), ['SplatRenderMode: mip']); + assert.strictEqual(source.meta.model, '2dgs'); + }); + + it('drops scale_2 again on PLY output, keeping the tag', async () => { + const { source, pool } = await openPly(make2dgsTable(16)); + const header = plyHeaderText((await writeChunked(source, pool, 'out.ply', 'ply')).get('out.ply')); + assert.match(header, /comment SplatRenderMode: 2dgs/); + assert.match(header, /property float scale_1/); + assert.doesNotMatch(header, /scale_2/); + }); + + // The streaming writer copies each layer as contiguous 32-bit word runs; + // omitting a column mid-layer splits the geometric run in two, so check the + // values on the far side of the gap still land in the right output column. + it('writes correct values around the dropped column (both writers)', async () => { + const reference = createTestDataTable(16); + for (const write of [writeChunked, writeTabular]) { + const { source, pool } = await openPly(make2dgsTable(16)); + const bytes = (await write(source, pool, 'out.ply', 'ply')).get('out.ply'); + + const pool2 = createChunkDataPool(); + const table = await materializeToDataTable( + await readPly(await sourceFromBytes(bytes), pool2), pool2 + ); + for (const name of ['rot_3', 'scale_0', 'scale_1', 'opacity', 'f_dc_0']) { + assert.deepStrictEqual( + Array.from(table.getColumnByName(name).data), + Array.from(reference.getColumnByName(name).data), + `${name} via ${write === writeChunked ? 'writeSource' : 'writeFile'}` + ); + } + // re-reading the output re-materializes the column + assert.strictEqual(table.getColumnByName('scale_2').data[0], -Infinity); + } + }); + + it('tags SOG meta.json and keeps three scale channels', async () => { + const { source, pool } = await openPly(make2dgsTable(16)); + const out = await writeChunked(source, pool, 'meta.json', 'sog'); + const meta = JSON.parse(Buffer.from(out.get('meta.json')).toString()); + assert.strictEqual(meta.model, '2dgs'); + assert.ok(meta.scales.codebook.length > 0, 'scales are still encoded'); + }); +}); + +describe('combining sources', () => { + it('keeps a shared model', async () => { + const pool = createChunkDataPool(); + const a = await readPly(await sourceFromBytes(encodePlyBinary(createTestDataTable(16), ['antialiased 1'])), pool); + const b = await readPly(await sourceFromBytes(encodePlyBinary(createTestDataTable(16), ['SplatRenderMode: mip'])), pool); + assert.strictEqual(concatSource([a, b], pool).meta.model, 'antialiased'); + }); + + it('falls back to default when models disagree', async () => { + const pool = createChunkDataPool(); + const aa = await readPly(await sourceFromBytes(encodePlyBinary(createTestDataTable(16), ['antialiased 1'])), pool); + const plain = await readPly(await sourceFromBytes(encodePlyBinary(createTestDataTable(16))), pool); + assert.strictEqual(concatSource([aa, plain], pool).meta.model, 'default'); + + assert.strictEqual(resolveSplatModel(['2dgs', 'default']), 'default'); + assert.strictEqual(resolveSplatModel(['antialiased', '2dgs']), 'default'); + assert.strictEqual(resolveSplatModel([]), 'default'); + }); +}); + +describe('untagged scenes', () => { + it('adds no comment and no meta key', async () => { + const { source, pool } = await openPly(createTestDataTable(16)); + assert.strictEqual(source.meta.model, 'default'); + + const ply = await writeChunked(source, pool, 'out.ply', 'ply'); + assert.doesNotMatch(plyHeaderText(ply.get('out.ply')), /comment/); + + const { source: s2, pool: p2 } = await openPly(createTestDataTable(16)); + const sog = await writeChunked(s2, p2, 'meta.json', 'sog'); + const meta = JSON.parse(Buffer.from(sog.get('meta.json')).toString()); + assert.ok(!('model' in meta), 'no model key for an untagged scene'); + }); +}); From d3686101ea4f22c3088314007f897d05014fea95 Mon Sep 17 00:00:00 2001 From: Donovan Hutchence Date: Thu, 30 Jul 2026 21:58:01 +0100 Subject: [PATCH 2/2] latest --- src/lib/readers/read-ply.ts | 8 ++++++-- src/lib/spz-module.ts | 8 +------- src/lib/writers/write-spz.ts | 6 ++++-- test/splat-model.test.mjs | 37 +++++++++++++++++++++++++++++++++++- 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/src/lib/readers/read-ply.ts b/src/lib/readers/read-ply.ts index cfc95c61..3d96c86b 100644 --- a/src/lib/readers/read-ply.ts +++ b/src/lib/readers/read-ply.ts @@ -54,6 +54,8 @@ 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: ` (brush-serde export.rs/import.rs) — the wire // form we also write, see `splatModelComment` in the writers. @@ -674,8 +676,10 @@ const readPly = async (source: ReadSource, pool: ChunkDataPool): Promise c !== 'scale_2') : GEOMETRIC_COLS; + // 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); diff --git a/src/lib/spz-module.ts b/src/lib/spz-module.ts index 58d87009..c8b09eec 100644 --- a/src/lib/spz-module.ts +++ b/src/lib/spz-module.ts @@ -42,9 +42,6 @@ type CreateSpzModule = () => Promise; const SPZ_SH_COMPONENTS = [0, 9, 24, 45, 72] as const; -// Log-scale floor for SPZ output, matching the compressed-PLY writer's clamp. -const MIN_LOG_SCALE = -20; - let spzModulePromise: Promise | null = null; // `@adobe/spz`'s GaussianCloud uses PLY-native conventions for scales/colors/alphas: @@ -203,10 +200,7 @@ const dataTableToGaussianCloud = (dataTable: DataTable, model: SplatModel = 'def scales[i3] = scale0[i]; scales[i3 + 1] = scale1[i]; - // 2DGS carries -Infinity here; the SPZ encoder quantizes log scales, so - // floor it at the smallest value the format resolves rather than feeding - // it an infinity. SPZ has no 2DGS flag, so the axis is merely tiny. - scales[i3 + 2] = Math.max(scale2[i], MIN_LOG_SCALE); + scales[i3 + 2] = scale2[i]; colors[i3] = color0[i]; colors[i3 + 1] = color1[i]; diff --git a/src/lib/writers/write-spz.ts b/src/lib/writers/write-spz.ts index 73920f54..a6b0b3c8 100644 --- a/src/lib/writers/write-spz.ts +++ b/src/lib/writers/write-spz.ts @@ -25,9 +25,11 @@ const writeSpz = async (options: WriteSpzOptions, fs: FileSystem) => { const { filename, dataTable, model = 'default', version = 4 } = options; const writingGroup = logger.group('Writing'); - // SPZ's header has an antialiased bit but no 2DGS state. + // SPZ's header has an antialiased bit but no 2DGS state. The third scale + // still encodes: SPZ's quantized log-scale range saturates at its floor, so + // the flat axis survives as the smallest scale the format can express. if (model === '2dgs') { - logger.warn('spz cannot represent a 2dgs scene; writing it as ordinary gaussians with a near-zero third scale'); + logger.warn('spz cannot represent a 2dgs scene; writing it as ordinary gaussians with a minimal third scale'); } const spz = await getSpzModule(); diff --git a/test/splat-model.test.mjs b/test/splat-model.test.mjs index 6b95a5c9..85b0fbfd 100644 --- a/test/splat-model.test.mjs +++ b/test/splat-model.test.mjs @@ -5,7 +5,7 @@ * - PLY / compressed-PLY / SOG / SPZ outputs carry the tag (PLY always in * Brush's spelling, whichever form it was read from); * - a 2DGS PLY (no scale_2) reads with a full geometric layer and writes back - * without the column; + * without the column, and is only inferred from an otherwise-complete record; * - mixed inputs collapse to `default` rather than mistagging; * - untagged scenes are unchanged (no comment, no meta key). */ @@ -178,6 +178,19 @@ describe('2dgs scenes', () => { assert.strictEqual(source.meta.model, '2dgs'); }); + // Two scales alone don't make a 2DGS scene — a point cloud missing rotation + // or opacity has no geometric layer to tag, so it must stay untagged rather + // than claiming a model it can't honour. + it('does not infer 2dgs from an incomplete geometric record', async () => { + const table = createTestDataTable(8); + const keep = ['x', 'y', 'z', 'scale_0', 'scale_1', 'f_dc_0', 'f_dc_1', 'f_dc_2']; + const partial = new DataTable(table.columns.filter(c => keep.includes(c.name)), table.transform); + + const { source } = await openPly(partial); + assert.strictEqual(source.meta.model, 'default'); + assert.ok(!source.meta.availableLayers.has('geometric'), 'no geometric layer'); + }); + it('drops scale_2 again on PLY output, keeping the tag', async () => { const { source, pool } = await openPly(make2dgsTable(16)); const header = plyHeaderText((await writeChunked(source, pool, 'out.ply', 'ply')).get('out.ply')); @@ -211,6 +224,28 @@ describe('2dgs scenes', () => { } }); + // SPZ can't hold the tag, so the flat axis has to survive as data. Its + // quantized log-scale range saturates, which is what turns the synthesized + // -Infinity into an encodable value — the writer does no clamping of its own, + // so this guards against a future encoder emitting garbage for it. + it('encodes the flat axis to SPZ as a finite minimal scale', async () => { + const { source, pool } = await openPly(make2dgsTable(16)); + const bytes = (await writeChunked(source, pool, 'out.spz', 'spz')).get('out.spz'); + + const rfs = new MemoryReadFileSystem(); + rfs.set('in.spz', bytes); + const { readSpz } = await import('../src/lib/readers/read-spz.js'); + const pool2 = createChunkDataPool(); + const table = await materializeToDataTable(await readSpz(await rfs.createSource('in.spz'), pool2), pool2); + + const scale2 = table.getColumnByName('scale_2').data; + const scale1 = table.getColumnByName('scale_1').data; + for (let i = 0; i < 16; i++) { + assert.ok(Number.isFinite(scale2[i]), `row ${i} scale_2 is finite (got ${scale2[i]})`); + assert.ok(scale2[i] < scale1[i], `row ${i} scale_2 is the flattest axis`); + } + }); + it('tags SOG meta.json and keeps three scale channels', async () => { const { source, pool } = await openPly(make2dgsTable(16)); const out = await writeChunked(source, pool, 'meta.json', 'sog');