SogReader.cs
using System.IO;
using System.IO.Compression;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Sandbox;
/// <summary>
/// Reads SOG v2 (Sorted Optimized Gaussians) compressed splat files.
/// SOG is a ZIP archive containing WebP-encoded textures and a JSON metadata file
/// that store Gaussian splat data in a compact, quantized format.
/// </summary>
public sealed class SogReader
{
#region Meta JSON Schema
private sealed class SogMeta
{
[JsonPropertyName( "version" )]
public int Version { get; set; }
[JsonPropertyName( "count" )]
public int Count { get; set; }
[JsonPropertyName( "antialias" )]
public bool Antialias { get; set; }
[JsonPropertyName( "means" )]
public MeansMeta Means { get; set; }
[JsonPropertyName( "scales" )]
public CodebookMeta Scales { get; set; }
[JsonPropertyName( "quats" )]
public FilesMeta Quats { get; set; }
[JsonPropertyName( "sh0" )]
public CodebookMeta Sh0 { get; set; }
}
private sealed class MeansMeta
{
[JsonPropertyName( "mins" )]
public float[] Mins { get; set; }
[JsonPropertyName( "maxs" )]
public float[] Maxs { get; set; }
[JsonPropertyName( "files" )]
public string[] Files { get; set; }
}
private sealed class CodebookMeta
{
[JsonPropertyName( "codebook" )]
public float[] Codebook { get; set; }
[JsonPropertyName( "files" )]
public string[] Files { get; set; }
}
private sealed class FilesMeta
{
[JsonPropertyName( "files" )]
public string[] Files { get; set; }
}
#endregion
/// <summary>
/// Read SOG compressed splat data and return it as PlyData for compatibility
/// with the existing rendering pipeline.
/// </summary>
public static PlyReader.PlyData Read( Stream stream )
{
using var archive = new ZipArchive( stream, ZipArchiveMode.Read );
// Parse metadata
var metaEntry = archive.GetEntry( "meta.json" )
?? throw new Exception( "SOG archive missing meta.json" );
SogMeta meta;
using ( var metaStream = metaEntry.Open() )
{
meta = JsonSerializer.Deserialize<SogMeta>( metaStream )
?? throw new Exception( "Failed to parse SOG meta.json" );
}
if ( meta.Version != 2 )
throw new Exception( $"Unsupported SOG version: {meta.Version}" );
int count = meta.Count;
if ( count == 0 )
return new PlyReader.PlyData { VertexCount = 0 };
// Decode all image textures from the archive into CPU-side pixel arrays.
// Each image is stored as lossless WebP to preserve quantized values exactly.
var meansLo = DecodeImage( archive, meta.Means.Files[0] );
var meansHi = DecodeImage( archive, meta.Means.Files[1] );
var quatsPx = DecodeImage( archive, meta.Quats.Files[0] );
var scalePx = DecodeImage( archive, meta.Scales.Files[0] );
var sh0Px = DecodeImage( archive, meta.Sh0.Files[0] );
ValidatePixelCount( meansLo, count, "means_l" );
ValidatePixelCount( meansHi, count, "means_u" );
ValidatePixelCount( quatsPx, count, "quats" );
ValidatePixelCount( scalePx, count, "scales" );
ValidatePixelCount( sh0Px, count, "sh0" );
var positions = new Vector3[count];
var rotations = new Vector4[count];
var scales = new Vector3[count];
var colors = new Color[count];
var opacities = new float[count];
DecodePositions( positions, count, meansLo, meansHi, meta.Means );
DecodeQuaternions( rotations, count, quatsPx );
DecodeScales( scales, count, scalePx, meta.Scales.Codebook );
DecodeColorsAndOpacity( colors, opacities, count, sh0Px, meta.Sh0.Codebook );
return new PlyReader.PlyData
{
Positions = positions,
Colors = colors,
Opacities = opacities,
Scales = scales,
Rotations = rotations,
HasColors = true,
HasOpacity = true,
HasScaleRotation = true,
VertexCount = count
};
}
/// <summary>
/// Extract a ZIP entry and decode its image data (WebP/PNG) to a Color32 pixel array
/// using s&box's Bitmap API backed by SkiaSharp.
/// </summary>
private static Color32[] DecodeImage( ZipArchive archive, string entryName )
{
var entry = archive.GetEntry( entryName )
?? throw new Exception( $"SOG archive missing file: {entryName}" );
byte[] imageData;
using ( var entryStream = entry.Open() )
using ( var ms = new MemoryStream() )
{
entryStream.CopyTo( ms );
imageData = ms.ToArray();
}
var bitmap = Bitmap.CreateFromBytes( imageData );
if ( bitmap is null )
throw new Exception( $"Failed to decode image: {entryName}" );
var pixels = bitmap.GetPixels32();
return pixels;
}
private static void ValidatePixelCount( Color32[] pixels, int requiredCount, string name )
{
if ( pixels.Length < requiredCount )
throw new Exception( $"SOG {name} texture has {pixels.Length} pixels, need {requiredCount}" );
}
/// <summary>
/// Decode positions from two 8-bit images that together form 16-bit quantized values per axis.
/// Values are in a log domain between per-axis min/max bounds; invLogTransform recovers world positions.
/// </summary>
private static void DecodePositions( Vector3[] positions, int count,
Color32[] lo, Color32[] hi, MeansMeta means )
{
float xMin = means.Mins[0], xRange = means.Maxs[0] - means.Mins[0];
float yMin = means.Mins[1], yRange = means.Maxs[1] - means.Mins[1];
float zMin = means.Mins[2], zRange = means.Maxs[2] - means.Mins[2];
// Avoid division by zero for degenerate single-point ranges
if ( xRange == 0 ) xRange = 1f;
if ( yRange == 0 ) yRange = 1f;
if ( zRange == 0 ) zRange = 1f;
const float inv65535 = 1f / 65535f;
for ( int i = 0; i < count; i++ )
{
// Combine upper/lower bytes into 16-bit per-axis quantized values
ushort qx = (ushort)((hi[i].r << 8) | lo[i].r);
ushort qy = (ushort)((hi[i].g << 8) | lo[i].g);
ushort qz = (ushort)((hi[i].b << 8) | lo[i].b);
// Dequantize to the log-domain range then invert the log transform
float nx = xMin + xRange * (qx * inv65535);
float ny = yMin + yRange * (qy * inv65535);
float nz = zMin + zRange * (qz * inv65535);
// SOG stores positions in the same coordinate convention as the source training
// (typically COLMAP: x-right, y-down, z-forward), matching PLY output directly.
positions[i] = new Vector3(
InvLogTransform( nx ),
InvLogTransform( ny ),
InvLogTransform( nz )
);
}
}
/// <summary>
/// Decode quaternions using smallest-three encoding.
/// RGB stores three quantized components; alpha (252-255) indicates which component was omitted.
/// </summary>
private static void DecodeQuaternions( Vector4[] rotations, int count, Color32[] pixels )
{
const float sqrt2 = 1.41421356237f;
for ( int i = 0; i < count; i++ )
{
byte tag = pixels[i].a;
// Alpha encodes the mode: 252=w largest, 253=x, 254=y, 255=z
if ( tag < 252 || tag > 255 )
{
rotations[i] = new Vector4( 1, 0, 0, 0 );
continue;
}
int mode = tag - 252;
// Dequantize stored components from [0,255] to [-1/√2, 1/√2]
float a = (pixels[i].r / 255f - 0.5f) * sqrt2;
float b = (pixels[i].g / 255f - 0.5f) * sqrt2;
float c = (pixels[i].b / 255f - 0.5f) * sqrt2;
// Reconstruct the omitted (largest) component from the unit constraint
float d = MathF.Sqrt( MathF.Max( 0f, 1f - a * a - b * b - c * c ) );
// Re-assemble quaternion as (w, x, y, z) matching PLY convention
float qw, qx, qy, qz;
switch ( mode )
{
case 0: qw = d; qx = a; qy = b; qz = c; break;
case 1: qw = a; qx = d; qy = b; qz = c; break;
case 2: qw = a; qx = b; qy = d; qz = c; break;
case 3: qw = a; qx = b; qy = c; qz = d; break;
default: qw = 1; qx = 0; qy = 0; qz = 0; break;
}
// Normalize to correct accumulated quantization error
float len = MathF.Sqrt( qw * qw + qx * qx + qy * qy + qz * qz );
if ( len > 0f )
rotations[i] = new Vector4( qw / len, qx / len, qy / len, qz / len );
else
rotations[i] = new Vector4( 1, 0, 0, 0 );
}
}
/// <summary>
/// Decode scales via codebook lookup. The codebook stores log-space values
/// (matching 3DGS training output); exp() converts to linear scale magnitudes
/// to match PlyReader's output convention.
/// </summary>
private static void DecodeScales( Vector3[] scales, int count, Color32[] pixels, float[] codebook )
{
for ( int i = 0; i < count; i++ )
{
float sx = MathF.Exp( codebook[pixels[i].r] );
float sy = MathF.Exp( codebook[pixels[i].g] );
float sz = MathF.Exp( codebook[pixels[i].b] );
scales[i] = new Vector3( sx, sy, sz );
}
}
/// <summary>
/// Decode colors and opacity from the sh0 image. RGB indices map into a codebook of
/// SH degree-0 DC coefficients; alpha stores sigmoid-space opacity directly.
/// </summary>
private static void DecodeColorsAndOpacity( Color[] colors, float[] opacities, int count,
Color32[] pixels, float[] codebook )
{
const float SH_C0 = 0.28209479177387814f;
for ( int i = 0; i < count; i++ )
{
// SH DC coefficient → linear color via same formula as PlyReader.SHToColor
float dc0 = codebook[pixels[i].r];
float dc1 = codebook[pixels[i].g];
float dc2 = codebook[pixels[i].b];
float r = Math.Clamp( SH_C0 * dc0 + 0.5f, 0f, 1f );
float g = Math.Clamp( SH_C0 * dc1 + 0.5f, 0f, 1f );
float b = Math.Clamp( SH_C0 * dc2 + 0.5f, 0f, 1f );
colors[i] = new Color( r, g, b, 1f );
// Alpha byte is already in sigmoid space (0-255 mapped to 0-1),
// matching PlyReader's output where opacities are sigmoid(logit).
opacities[i] = pixels[i].a / 255f;
}
}
/// <summary>
/// Inverse of logTransform(x) = sign(x) * ln(|x| + 1).
/// Recovers the original world-space coordinate from the log-encoded value.
/// </summary>
private static float InvLogTransform( float v )
{
float a = MathF.Abs( v );
float e = MathF.Exp( a ) - 1f;
return v < 0f ? -e : e;
}
}