"""Format detection and constants for Gaussian splatting PLY files."""
from pathlib import Path
from typing import Any
__all__ = [
"detect_format",
"get_sh_degree_from_property_count",
"SH_C0",
"CHUNK_SIZE",
"PROPERTY_COUNTS_BY_SH_DEGREE",
"PROPERTY_COUNT_TO_SH_DEGREE",
"SH_BANDS_TO_DEGREE",
"EXPECTED_PROPERTIES_BY_SH_DEGREE",
]
# Property counts by SH degree
PROPERTY_COUNTS_BY_SH_DEGREE = {
0: 14, # xyz(3) + f_dc(3) + opacity(1) + scales(3) + quats(4)
1: 23, # +9 f_rest
2: 38, # +24 f_rest
3: 59, # +45 f_rest
}
# Reverse lookup: property count -> SH degree (for fast lookup)
PROPERTY_COUNT_TO_SH_DEGREE = {
14: 0,
23: 1,
38: 2,
59: 3,
}
# SH bands to degree mapping (for shN.shape[1] -> degree conversion)
SH_BANDS_TO_DEGREE = {
3: 1, # SH1: 3 bands
8: 2, # SH2: 8 bands
15: 3, # SH3: 15 bands
}
# Mapping from SH degree to number of f_rest bands
_SH_DEGREE_TO_REST_BANDS = {
0: 0,
1: 9,
2: 24,
3: 45,
}
def _build_property_list(sh_degree: int) -> list[str]:
"""Build property list for given SH degree.
:param sh_degree: SH degree (0-3)
:returns: List of property names in order
"""
base_properties = [
"x",
"y",
"z",
"f_dc_0",
"f_dc_1",
"f_dc_2",
]
rest_bands = _SH_DEGREE_TO_REST_BANDS[sh_degree]
rest_properties = [f"f_rest_{i}" for i in range(rest_bands)]
suffix_properties = [
"opacity",
"scale_0",
"scale_1",
"scale_2",
"rot_0",
"rot_1",
"rot_2",
"rot_3",
]
return base_properties + rest_properties + suffix_properties
# Expected property names in order for each SH degree
EXPECTED_PROPERTIES_BY_SH_DEGREE: dict[int, list[str]] = {
degree: _build_property_list(degree) for degree in range(4)
}
# Compressed format constants
CHUNK_SIZE = 256
CHUNK_SIZE_SHIFT = 8 # log2(256) - for fast division using bit shift
COMPRESSED_CHUNK_PROPERTIES = 18 # min/max bounds (6*3)
COMPRESSED_VERTEX_PROPERTIES = 4 # packed position, rotation, scale, color
# SH coefficient for color conversion
SH_C0 = 0.28209479177387814 # sqrt(1/(4*pi))
def _parse_ply_header(file_path: Path) -> tuple[dict[str, dict[str, Any]], int]:
"""Parse PLY header to extract element definitions.
:param file_path: Path to PLY file
:returns: Tuple of (elements_dict, header_size_bytes)
:raises ValueError: If PLY format is invalid
"""
elements = {}
current_element = None
header_size = 0
with open(file_path, "rb") as f:
line = f.readline()
header_size += len(line)
if line.strip() != b"ply":
raise ValueError("Not a valid PLY file")
while True:
line = f.readline()
header_size += len(line)
line_str = line.decode("ascii").strip()
if line_str == "end_header":
break
parts = line_str.split()
if not parts:
continue
if parts[0] == "element":
element_name = parts[1]
element_count = int(parts[2])
elements[element_name] = {"count": element_count, "properties": []}
current_element = element_name
elif parts[0] == "property" and current_element:
prop_type = parts[1]
prop_name = parts[2]
elements[current_element]["properties"].append((prop_type, prop_name))
return elements, header_size
# Expected chunk property names for compressed format
_EXPECTED_CHUNK_NAMES = [
"min_x",
"min_y",
"min_z",
"max_x",
"max_y",
"max_z",
"min_scale_x",
"min_scale_y",
"min_scale_z",
"max_scale_x",
"max_scale_y",
"max_scale_z",
"min_r",
"min_g",
"min_b",
"max_r",
"max_g",
"max_b",
]
# Expected vertex property names for compressed format
_EXPECTED_VERTEX_NAMES = [
"packed_position",
"packed_rotation",
"packed_scale",
"packed_color",
]
def _is_compressed_format(elements: dict[str, dict[str, Any]]) -> bool:
"""Check if elements dict represents compressed format.
:param elements: Parsed PLY header elements
:returns: True if compressed format detected
"""
# Must have chunk and vertex elements
required_elements = {"chunk", "vertex"}
if not required_elements.issubset(elements):
return False
chunk_elem = elements["chunk"]
vertex_elem = elements["vertex"]
# Check chunk element (18 float properties)
chunk_props = chunk_elem["properties"]
if len(chunk_props) != COMPRESSED_CHUNK_PROPERTIES:
return False
# Verify chunk property types and names match expected format
if not all(
prop_type == "float" and prop_name == expected_name
for (prop_type, prop_name), expected_name in zip(
chunk_props, _EXPECTED_CHUNK_NAMES, strict=False
)
):
return False
# Check vertex element (4 uint properties)
vertex_props = vertex_elem["properties"]
if len(vertex_props) < COMPRESSED_VERTEX_PROPERTIES:
return False
# Verify vertex property types and names match expected format
if not all(
prop_type == "uint" and prop_name == expected_name
for (prop_type, prop_name), expected_name in zip(
vertex_props[:COMPRESSED_VERTEX_PROPERTIES], _EXPECTED_VERTEX_NAMES, strict=False
)
):
return False
# Check chunk count matches splat count
num_chunks = chunk_elem["count"]
num_vertices = vertex_elem["count"]
expected_chunks = (num_vertices + CHUNK_SIZE - 1) // CHUNK_SIZE
return num_chunks == expected_chunks
[docs]
def get_sh_degree_from_property_count(property_count: int) -> int | None:
"""Get SH degree from property count.
:param property_count: Number of properties in vertex element
:returns: SH degree (0-3) or None if unknown
"""
return PROPERTY_COUNT_TO_SH_DEGREE.get(property_count)