SplatAnything / src /utils /save_utils.py
ritianyu's picture
Publish preview-first SplatAnything demo for ZeroGPU
dc09dcc verified
Raw History Blame Contribute Delete
6.35 kB
# Modified for SplatAnything: inference-only release.
from pathlib import Path
import numpy as np
import torch
from PIL import Image
from plyfile import PlyData, PlyElement
def save_image_png(path: Path, image_tensor: torch.Tensor) -> None:
# image_tensor: [H, W, 3]
img = (image_tensor.detach().cpu() * 255.0).to(torch.uint8).numpy()
Image.fromarray(img).save(str(path))
def save_depth_png(path: Path, depth_tensor: torch.Tensor) -> None:
# depth_tensor: [H, W]
d = depth_tensor.detach()
d = d - d.min()
d = d / (d.max() + 1e-9)
img = (d.clamp(0, 1) * 255.0).to(torch.uint8).cpu().numpy()
Image.fromarray(img, mode="L").save(str(path))
def save_depth_npy(path: Path, depth_tensor: torch.Tensor) -> None:
# depth_tensor: [H, W]
# Save actual depth values in numpy format
d = depth_tensor.detach().cpu().numpy()
np.save(str(path), d)
def _build_vertex_ply_element(pts: np.ndarray, colors: np.ndarray) -> PlyElement:
"""Build a PLY vertex element from points and colors arrays.
Args:
pts: Point coordinates, shape [N, 3], dtype float32
colors: RGB colors, shape [N, 3], dtype uint8
Returns:
PlyElement describing the vertices
"""
vertex_dtype = [("x", "f4"), ("y", "f4"), ("z", "f4"),
("red", "u1"), ("green", "u1"), ("blue", "u1")]
vertex_elements = np.empty(len(pts), dtype=vertex_dtype)
vertex_elements["x"] = pts[:, 0]
vertex_elements["y"] = pts[:, 1]
vertex_elements["z"] = pts[:, 2]
vertex_elements["red"] = colors[:, 0]
vertex_elements["green"] = colors[:, 1]
vertex_elements["blue"] = colors[:, 2]
return PlyElement.describe(vertex_elements, "vertex")
def save_scene_ply(path: Path,
points_xyz: torch.Tensor,
point_colors: torch.Tensor,
valid_mask: torch.Tensor = None) -> None:
"""Save point cloud to PLY format"""
pts = points_xyz.detach().cpu().to(torch.float32).numpy().reshape(-1, 3)
colors = point_colors.detach().cpu().to(torch.uint8).numpy().reshape(-1, 3)
# Filter out invalid points (NaN, Inf)
if valid_mask is None:
valid_mask = np.isfinite(pts).all(axis=1)
else:
valid_mask = valid_mask.detach().cpu().numpy().reshape(-1)
pts = pts[valid_mask]
colors = colors[valid_mask]
# Handle empty point cloud
if len(pts) == 0:
pts = np.array([[0, 0, 0]], dtype=np.float32)
colors = np.array([[255, 255, 255]], dtype=np.uint8)
PlyData([_build_vertex_ply_element(pts, colors)]).write(str(path))
def _build_gs_ply_data(means, scales, rotations, rgbs, opacities, quantile_threshold):
"""Build Gaussian splat PLY data with scale-based filtering.
Args:
means: Gaussian centers [N, 3]
scales: Gaussian scales [N, 3]
rotations: Gaussian rotations as quaternions [N, 4]
rgbs: RGB colors [N, 3]
opacities: Opacity values [N]
quantile_threshold: Percentile threshold for scale filtering (e.g. 0.98 or 0.90).
None -> no filtering, export ALL Gaussians (inspect the full cloud).
Returns:
PlyData object ready to be written or returned
"""
# None -> keep every Gaussian (no scale-based pruning); else drop the largest-scale tail.
if quantile_threshold is None:
filter_mask = torch.ones(scales.shape[0], dtype=torch.bool, device=scales.device)
else:
scale_threshold = torch.quantile(scales.max(dim=-1)[0], quantile_threshold, dim=0)
filter_mask = scales.max(dim=-1)[0] <= scale_threshold
means = means[filter_mask].reshape(-1, 3)
scales = scales[filter_mask].reshape(-1, 3)
rotations = rotations[filter_mask].reshape(-1, 4)
rgbs = rgbs[filter_mask].reshape(-1, 3)
opacities = opacities[filter_mask].reshape(-1)
attributes = ["x", "y", "z", "nx", "ny", "nz"]
for i in range(3):
attributes.append(f"f_dc_{i}")
attributes.append("opacity")
for i in range(3):
attributes.append(f"scale_{i}")
for i in range(4):
attributes.append(f"rot_{i}")
dtype_full = [(attribute, "f4") for attribute in attributes]
elements = np.empty(means.shape[0], dtype=dtype_full)
# 3DGS / SuperSplat PLY storage convention (see process_ply_to_splat):
# color = 0.5 + C0 * f_dc -> store the SH DC coefficient (rgbs arg)
# opacity = sigmoid(opacity) -> store the logit (inverse_sigmoid)
# scale = exp(scale) -> store log(scale)
# rgbs is the SH DC coefficient (NOT activated RGB); callers pass sh[..., 0, :].
opacity_logit = torch.logit(opacities.clamp(1e-6, 1 - 1e-6))
attributes_data = (
means.float().detach().cpu().numpy(),
torch.zeros_like(means).float().detach().cpu().numpy(),
rgbs.detach().cpu().contiguous().numpy(),
opacity_logit[..., None].detach().cpu().numpy(),
scales.log().detach().cpu().numpy(),
rotations.detach().cpu().numpy(),
)
attributes_data = np.concatenate(attributes_data, axis=1)
elements[:] = list(map(tuple, attributes_data))
return PlyData([PlyElement.describe(elements, "vertex")])
def save_gs_ply(path: Path,
means: torch.Tensor,
scales: torch.Tensor,
rotations: torch.Tensor,
rgbs: torch.Tensor,
opacities: torch.Tensor,
quantile_threshold: float = 0.98) -> None:
"""
Export Gaussian splat data to PLY format.
Args:
path: Output PLY file path
means: Gaussian centers [N, 3]
scales: Gaussian scales [N, 3]
rotations: Gaussian rotations as quaternions [N, 4]
rgbs: RGB colors [N, 3]
opacities: Opacity values [N]
quantile_threshold: scale-tail pruning quantile (default 0.98 drops the largest 2%
of Gaussians by scale). Pass None to export the FULL cloud with no filtering.
"""
# Ensure float32 for quantile and numpy conversion (bf16 not supported)
means, scales, rotations, rgbs, opacities = (
t.float() for t in (means, scales, rotations, rgbs, opacities)
)
plydata = _build_gs_ply_data(means, scales, rotations, rgbs, opacities,
quantile_threshold=quantile_threshold)
plydata.write(str(path))