open-standards/map/map-rs/crates/map-tiles/src/splat.rs
Transaction Science ba1212361f Add aac, map, sandbox standards; catalog the standards family
Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:

- aac/    — communication cargo class: portable boards + a ten-level
            offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/    — geospatial cargo class: range-requestable signed tilesets +
            an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
            compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.

Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 17:04:36 -04:00

492 lines
19 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! 3D Gaussian splat codec — the `F4:Photoreal` cargo codec.
//!
//! A compact, quantized, gzip-compressed container for 3D Gaussian splats, in the
//! spirit of Niantic SPZ / the `KHR_gaussian_splatting_compression` family:
//! positions as fixed-point, scales log-quantized, rotation quaternion and colour
//! as bytes. This is the reader **and** writer half a conforming F4 tile needs —
//! the codec the photoreal tier was missing (raster covers F2, MVT covers F3).
//!
//! Spherical-harmonic degree is recorded in the header; degree 0 (DC colour) is
//! encoded here, higher degrees are a forward-compatible extension.
use std::io::{Read, Write};
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use crate::TileError;
/// One 3D Gaussian splat: mean, per-axis scale, rotation quaternion `(x,y,z,w)`,
/// linear RGB DC colour, opacity, and higher-order **spherical-harmonic**
/// coefficients for view-dependent colour. `sh` holds the bands above degree 0
/// (the DC term is `color`), RGB per coefficient, length `(degree+1)² − 1`.
#[derive(Debug, Clone, PartialEq)]
pub struct Splat {
pub position: [f32; 3],
pub scale: [f32; 3],
pub rotation: [f32; 4],
pub color: [f32; 3],
pub opacity: f32,
pub sh: Vec<[f32; 3]>,
}
const MAGIC: &[u8; 4] = b"MSPL";
const VERSION: u8 = 1;
/// Fixed-point fractional bits for positions (±~8 units at ~0.24 mm on a unit globe).
const POS_FRAC: i32 = 12;
/// Log-scale quantization: `q = (ln(s) + LOG_BIAS) * LOG_SCALE`, clamped to u8.
const LOG_BIAS: f32 = 9.5;
const LOG_SCALE: f32 = 255.0 / 9.5;
/// Bytes per splat for the degree-0 base (pos 12 + scale 3 + rot 4 + colour 3 + opacity 1).
const BASE_STRIDE: usize = 23;
/// Fixed-point scale for SH coefficients (range ±~4, precision 1/32).
const SH_QUANT: f32 = 32.0;
/// SH coefficient count above degree 0 for a given degree: `(degree+1)² − 1`.
fn sh_count(degree: u8) -> usize {
let d = degree as usize + 1;
d * d - 1
}
/// Encode splats into a compact gzip-compressed F4 payload. The SH degree is
/// taken from the first splat's `sh` length (all splats must match).
pub fn encode_splats(splats: &[Splat]) -> Result<Vec<u8>, TileError> {
let coeffs = splats.first().map(|s| s.sh.len()).unwrap_or(0);
// degree d satisfies (d+1)² − 1 == coeffs.
let degree = ((coeffs + 1) as f64).sqrt().round() as u8 - 1;
if sh_count(degree) != coeffs {
return Err(TileError::GeometryError(format!("invalid SH coeff count {coeffs}")));
}
let stride = BASE_STRIDE + 3 * coeffs;
let mut out = Vec::with_capacity(16 + splats.len() * stride);
out.extend_from_slice(MAGIC);
out.push(VERSION);
out.push(degree);
out.push(POS_FRAC as u8);
out.push(0); // reserved
out.extend_from_slice(&(splats.len() as u32).to_le_bytes());
let mut body = Vec::with_capacity(splats.len() * stride);
for s in splats {
for &p in &s.position {
let q = (p * (1 << POS_FRAC) as f32).round() as i32;
body.extend_from_slice(&q.to_le_bytes());
}
for &sc in &s.scale {
body.push(quantize_log(sc));
}
for &r in &s.rotation {
body.push((r.clamp(-1.0, 1.0) * 127.0).round() as i8 as u8);
}
for &c in &s.color {
body.push((c.clamp(0.0, 1.0) * 255.0).round() as u8);
}
body.push((s.opacity.clamp(0.0, 1.0) * 255.0).round() as u8);
for coeff in &s.sh {
for &c in coeff {
body.push((c * SH_QUANT).round().clamp(-127.0, 127.0) as i8 as u8);
}
}
}
let mut gz = GzEncoder::new(Vec::new(), Compression::fast());
gz.write_all(&body).map_err(|e| TileError::CompressionError(e.to_string()))?;
let compressed = gz.finish().map_err(|e| TileError::CompressionError(e.to_string()))?;
out.extend_from_slice(&compressed);
Ok(out)
}
/// Decode an F4 splat payload (inverse of [`encode_splats`]).
pub fn decode_splats(bytes: &[u8]) -> Result<Vec<Splat>, TileError> {
if bytes.len() < 12 || &bytes[0..4] != MAGIC {
return Err(TileError::GeometryError("bad splat magic".into()));
}
if bytes[4] != VERSION {
return Err(TileError::GeometryError(format!("unsupported splat version {}", bytes[4])));
}
let degree = bytes[5];
let frac = bytes[6] as i32;
let count = u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]) as usize;
let coeffs = sh_count(degree);
let stride = BASE_STRIDE + 3 * coeffs;
let mut body = Vec::new();
GzDecoder::new(&bytes[12..])
.read_to_end(&mut body)
.map_err(|e| TileError::CompressionError(e.to_string()))?;
if body.len() < count * stride {
return Err(TileError::GeometryError("truncated splat body".into()));
}
let scale = (1 << frac) as f32;
let mut splats = Vec::with_capacity(count);
for i in 0..count {
let b = &body[i * stride..];
let pos = [
i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32 / scale,
i32::from_le_bytes([b[4], b[5], b[6], b[7]]) as f32 / scale,
i32::from_le_bytes([b[8], b[9], b[10], b[11]]) as f32 / scale,
];
let sc = [dequantize_log(b[12]), dequantize_log(b[13]), dequantize_log(b[14])];
let rot = [
b[15] as i8 as f32 / 127.0,
b[16] as i8 as f32 / 127.0,
b[17] as i8 as f32 / 127.0,
b[18] as i8 as f32 / 127.0,
];
let color = [b[19] as f32 / 255.0, b[20] as f32 / 255.0, b[21] as f32 / 255.0];
let opacity = b[22] as f32 / 255.0;
let sh = (0..coeffs)
.map(|k| {
let o = BASE_STRIDE + k * 3;
[
b[o] as i8 as f32 / SH_QUANT,
b[o + 1] as i8 as f32 / SH_QUANT,
b[o + 2] as i8 as f32 / SH_QUANT,
]
})
.collect();
splats.push(Splat { position: pos, scale: sc, rotation: rot, color, opacity, sh });
}
Ok(splats)
}
fn quantize_log(s: f32) -> u8 {
let v = (s.max(1e-5).ln() + LOG_BIAS) * LOG_SCALE;
v.round().clamp(0.0, 255.0) as u8
}
fn dequantize_log(q: u8) -> f32 {
(q as f32 / LOG_SCALE - LOG_BIAS).exp()
}
/// Zeroth-band SH normalization constant (the 3DGS DC convention).
const SH_C0: f32 = 0.282_094_79;
fn sigmoid(x: f32) -> f32 {
1.0 / (1.0 + (-x).exp())
}
/// Ingest a captured 3D Gaussian scene in the de-facto INRIA `.ply` interchange
/// format (the output of the reference 3DGS trainer and most tooling) into the
/// F4 [`Splat`] model — so a real capture can be re-encoded with [`encode_splats`]
/// into a conforming tile. Supports ASCII and binary-little-endian PLY.
///
/// The trainer's storage conventions are inverted here: scales are `exp`'d (stored
/// as log), opacity is `sigmoid`'d (stored as logit), the rotation `(w,x,y,z)` is
/// reordered to `(x,y,z,w)` and normalized, the DC colour is `0.5 + C₀·f_dc`, and
/// the `f_rest` higher SH coefficients (stored channel-major) are interleaved to
/// per-coefficient RGB.
pub fn read_ply(bytes: &[u8]) -> Result<Vec<Splat>, TileError> {
let marker = b"end_header";
let hpos = bytes
.windows(marker.len())
.position(|w| w == marker)
.ok_or_else(|| TileError::GeometryError("PLY: no end_header".into()))?;
// Body starts after the newline following the marker.
let body_start = bytes[hpos..]
.iter()
.position(|&b| b == b'\n')
.map(|n| hpos + n + 1)
.ok_or_else(|| TileError::GeometryError("PLY: header not terminated".into()))?;
let header = std::str::from_utf8(&bytes[..hpos])
.map_err(|_| TileError::GeometryError("PLY: non-UTF8 header".into()))?;
let mut ascii = false;
let mut count = 0usize;
let mut props: Vec<(String, PlyType)> = Vec::new();
let mut in_vertex = false;
for line in header.lines() {
let mut tok = line.split_whitespace();
match tok.next() {
Some("format") => {
ascii = matches!(tok.next(), Some("ascii"));
}
Some("element") => {
in_vertex = tok.next() == Some("vertex");
if in_vertex {
count = tok.next().and_then(|n| n.parse().ok()).unwrap_or(0);
}
}
Some("property") if in_vertex => {
let ty = tok.next().unwrap_or("");
if ty == "list" {
return Err(TileError::GeometryError("PLY: list props unsupported".into()));
}
let name = tok.next().unwrap_or("").to_string();
props.push((name, PlyType::parse(ty)?));
}
_ => {}
}
}
let is_le = header.contains("binary_little_endian");
if !ascii && !is_le {
return Err(TileError::GeometryError("PLY: unsupported format".into()));
}
let idx = |n: &str| props.iter().position(|(name, _)| name == n);
let rest_count = props.iter().filter(|(n, _)| n.starts_with("f_rest_")).count();
let coeffs = rest_count / 3;
// Read all vertices into a flat [vertex][prop] f32 table.
let mut table: Vec<f32> = Vec::with_capacity(count * props.len());
if ascii {
let body = std::str::from_utf8(&bytes[body_start..])
.map_err(|_| TileError::GeometryError("PLY: non-UTF8 body".into()))?;
for v in body.split_whitespace().take(count * props.len()) {
table.push(v.parse().map_err(|_| TileError::GeometryError("PLY: bad number".into()))?);
}
} else {
let stride: usize = props.iter().map(|(_, t)| t.size()).sum();
let body = &bytes[body_start..];
if body.len() < count * stride {
return Err(TileError::GeometryError("PLY: truncated body".into()));
}
for i in 0..count {
let mut off = i * stride;
for (_, t) in &props {
table.push(t.read_le(&body[off..]));
off += t.size();
}
}
}
if table.len() < count * props.len() {
return Err(TileError::GeometryError("PLY: truncated body".into()));
}
let get = |row: usize, name: &str| idx(name).map(|c| table[row * props.len() + c]);
let mut splats = Vec::with_capacity(count);
for r in 0..count {
let g = |n: &str| get(r, n).unwrap_or(0.0);
let rot_raw = [g("rot_0"), g("rot_1"), g("rot_2"), g("rot_3")];
let norm = (rot_raw.iter().map(|v| v * v).sum::<f32>()).sqrt().max(1e-12);
let rotation = [rot_raw[1] / norm, rot_raw[2] / norm, rot_raw[3] / norm, rot_raw[0] / norm];
let color = [
(0.5 + SH_C0 * g("f_dc_0")).clamp(0.0, 1.0),
(0.5 + SH_C0 * g("f_dc_1")).clamp(0.0, 1.0),
(0.5 + SH_C0 * g("f_dc_2")).clamp(0.0, 1.0),
];
// f_rest is channel-major: [R₀..R_{c-1}, G₀.., B₀..].
let sh = (0..coeffs)
.map(|k| {
[
g(&format!("f_rest_{k}")),
g(&format!("f_rest_{}", coeffs + k)),
g(&format!("f_rest_{}", 2 * coeffs + k)),
]
})
.collect();
splats.push(Splat {
position: [g("x"), g("y"), g("z")],
scale: [g("scale_0").exp(), g("scale_1").exp(), g("scale_2").exp()],
rotation,
color,
opacity: sigmoid(g("opacity")),
sh,
});
}
Ok(splats)
}
/// PLY scalar property types, enough to compute strides and read values as f32.
#[derive(Clone, Copy)]
enum PlyType {
F32,
F64,
I8,
U8,
I16,
U16,
I32,
U32,
}
impl PlyType {
fn parse(s: &str) -> Result<Self, TileError> {
Ok(match s {
"float" | "float32" => PlyType::F32,
"double" | "float64" => PlyType::F64,
"char" | "int8" => PlyType::I8,
"uchar" | "uint8" => PlyType::U8,
"short" | "int16" => PlyType::I16,
"ushort" | "uint16" => PlyType::U16,
"int" | "int32" => PlyType::I32,
"uint" | "uint32" => PlyType::U32,
other => return Err(TileError::GeometryError(format!("PLY: bad type {other}"))),
})
}
fn size(self) -> usize {
match self {
PlyType::I8 | PlyType::U8 => 1,
PlyType::I16 | PlyType::U16 => 2,
PlyType::F32 | PlyType::I32 | PlyType::U32 => 4,
PlyType::F64 => 8,
}
}
fn read_le(self, b: &[u8]) -> f32 {
match self {
PlyType::F32 => f32::from_le_bytes([b[0], b[1], b[2], b[3]]),
PlyType::F64 => f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) as f32,
PlyType::I8 => b[0] as i8 as f32,
PlyType::U8 => b[0] as f32,
PlyType::I16 => i16::from_le_bytes([b[0], b[1]]) as f32,
PlyType::U16 => u16::from_le_bytes([b[0], b[1]]) as f32,
PlyType::I32 => i32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32,
PlyType::U32 => u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as f32,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample() -> Vec<Splat> {
(0..256)
.map(|i| {
let t = i as f32 / 256.0;
Splat {
position: [t - 0.5, (t * std::f32::consts::TAU).sin() * 0.5, (t * std::f32::consts::PI).cos() * 0.5],
scale: [0.01 + t * 0.02, 0.008, 0.003],
rotation: [0.0, 0.0, (t * std::f32::consts::PI).sin(), (t * std::f32::consts::PI).cos()],
color: [t, 1.0 - t, 0.5],
opacity: 0.9,
sh: vec![],
}
})
.collect()
}
#[test]
fn roundtrips_within_quantization() {
let splats = sample();
let bytes = encode_splats(&splats).unwrap();
let back = decode_splats(&bytes).unwrap();
assert_eq!(back.len(), splats.len());
for (a, b) in splats.iter().zip(&back) {
for k in 0..3 {
assert!((a.position[k] - b.position[k]).abs() < 1e-3, "pos");
assert!((a.color[k] - b.color[k]).abs() < 0.01, "color");
assert!((a.scale[k] - b.scale[k]).abs() / a.scale[k] < 0.05, "scale");
}
assert!((a.opacity - b.opacity).abs() < 0.01);
}
}
#[test]
fn compresses_below_raw() {
let splats = sample();
let encoded = encode_splats(&splats).unwrap();
// Raw would be 13 floats * 4 bytes = 52 B/splat; quantized+gzip is far less.
assert!(encoded.len() < splats.len() * 52);
}
#[test]
fn rejects_bad_magic() {
assert!(decode_splats(b"nope............").is_err());
}
#[test]
fn reads_ascii_ply_with_trainer_conventions() {
// Degree-1 (9 f_rest) ASCII PLY, one vertex, INRIA property layout.
let header = "ply\nformat ascii 1.0\nelement vertex 1\n\
property float x\nproperty float y\nproperty float z\n\
property float scale_0\nproperty float scale_1\nproperty float scale_2\n\
property float rot_0\nproperty float rot_1\nproperty float rot_2\nproperty float rot_3\n\
property float opacity\n\
property float f_dc_0\nproperty float f_dc_1\nproperty float f_dc_2\n\
property float f_rest_0\nproperty float f_rest_1\nproperty float f_rest_2\n\
property float f_rest_3\nproperty float f_rest_4\nproperty float f_rest_5\n\
property float f_rest_6\nproperty float f_rest_7\nproperty float f_rest_8\n\
end_header\n";
// pos | log-scales | quat(w,x,y,z, unnormalized) | logit-opacity | f_dc | f_rest(R0..2,G0..2,B0..2)
let row = "1.0 2.0 3.0 0.0 -0.6931472 0.6931472 2.0 0.0 0.0 0.0 0.0 \
1.7724539 0.0 -1.7724539 0.1 0.2 0.3 0.4 0.5 0.6 0.7 0.8 0.9\n";
let ply = format!("{header}{row}");
let s = &read_ply(ply.as_bytes()).unwrap()[0];
assert_eq!(s.position, [1.0, 2.0, 3.0]);
// exp(log-scale): exp(0)=1, exp(-ln2)=0.5, exp(ln2)=2.
assert!((s.scale[0] - 1.0).abs() < 1e-4);
assert!((s.scale[1] - 0.5).abs() < 1e-4);
assert!((s.scale[2] - 2.0).abs() < 1e-4);
// rot (w,x,y,z)=(2,0,0,0) → normalized (x,y,z,w)=(0,0,0,1).
assert!((s.rotation[3] - 1.0).abs() < 1e-5 && s.rotation[0].abs() < 1e-5);
// opacity sigmoid(0)=0.5.
assert!((s.opacity - 0.5).abs() < 1e-5);
// DC: 0.5 + C0*f_dc. f_dc_0=1.7724539 → 0.5 + 0.2820948*1.7724539 ≈ 1.0 (clamped).
assert!((s.color[0] - 1.0).abs() < 1e-3);
assert!((s.color[1] - 0.5).abs() < 1e-3);
assert!((s.color[2] - 0.0).abs() < 1e-3);
// 3 SH coeffs, channel-major de-interleave: coeff0 = (R0,G0,B0)=(0.1,0.4,0.7).
assert_eq!(s.sh.len(), 3);
assert!((s.sh[0][0] - 0.1).abs() < 1e-5);
assert!((s.sh[0][1] - 0.4).abs() < 1e-5);
assert!((s.sh[0][2] - 0.7).abs() < 1e-5);
assert!((s.sh[2][2] - 0.9).abs() < 1e-5);
// Ingested capture re-encodes into a conforming F4 tile.
let bytes = encode_splats(&read_ply(ply.as_bytes()).unwrap()).unwrap();
assert_eq!(decode_splats(&bytes).unwrap().len(), 1);
}
#[test]
fn reads_binary_le_ply() {
// Minimal degree-0 binary PLY: x,y,z,scale_0..2,rot_0..3,opacity,f_dc_0..2.
let header = "ply\nformat binary_little_endian 1.0\nelement vertex 1\n\
property float x\nproperty float y\nproperty float z\n\
property float scale_0\nproperty float scale_1\nproperty float scale_2\n\
property float rot_0\nproperty float rot_1\nproperty float rot_2\nproperty float rot_3\n\
property float opacity\n\
property float f_dc_0\nproperty float f_dc_1\nproperty float f_dc_2\n\
end_header\n";
let vals: [f32; 14] = [
4.0, 5.0, 6.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
];
let mut bytes = header.as_bytes().to_vec();
for v in vals {
bytes.extend_from_slice(&v.to_le_bytes());
}
let s = &read_ply(&bytes).unwrap()[0];
assert_eq!(s.position, [4.0, 5.0, 6.0]);
assert!((s.scale[0] - 1.0).abs() < 1e-4); // exp(0)
assert!((s.opacity - 0.5).abs() < 1e-5); // sigmoid(0)
assert!(s.sh.is_empty()); // no f_rest → degree 0
}
#[test]
fn rejects_headerless_ply() {
assert!(read_ply(b"not a ply at all").is_err());
}
#[test]
fn sh_degree1_roundtrips() {
// Degree 1 → 3 SH coefficients (RGB each) above the DC term.
let splats: Vec<Splat> = (0..64)
.map(|i| {
let t = i as f32 / 64.0;
Splat {
position: [t, -t, 0.5],
scale: [0.01, 0.01, 0.004],
rotation: [0.0, 0.0, 0.0, 1.0],
color: [0.5, 0.5, 0.5],
opacity: 0.8,
sh: vec![[t - 0.5, 0.2, -0.3], [0.1, t, 0.0], [-0.2, 0.0, t * 0.5]],
}
})
.collect();
let bytes = encode_splats(&splats).unwrap();
// Header records degree 1.
assert_eq!(bytes[5], 1);
let back = decode_splats(&bytes).unwrap();
assert_eq!(back.len(), splats.len());
for (a, b) in splats.iter().zip(&back) {
assert_eq!(a.sh.len(), b.sh.len());
for (ca, cb) in a.sh.iter().zip(&b.sh) {
for k in 0..3 {
assert!((ca[k] - cb[k]).abs() < 1.0 / 32.0 + 1e-6, "sh coeff");
}
}
}
}
}