Spaces:
Running on Zero
Running on Zero
| """Contains basic data structures and functionality for 3D Gaussians.""" | |
| from __future__ import annotations | |
| from pathlib import Path | |
| from typing import NamedTuple | |
| import numpy as np | |
| import torch | |
| from plyfile import PlyData, PlyElement | |
| from src.utils import linalg | |
| from src.utils.color_space import ( | |
| encode_color_space, | |
| linearRGB2sRGB, | |
| ) | |
| class Gaussians3D(NamedTuple): | |
| """Represents a collection of 3D Gaussians.""" | |
| mean_vectors: torch.Tensor | |
| singular_values: torch.Tensor | |
| quaternions: torch.Tensor | |
| colors: torch.Tensor | |
| opacities: torch.Tensor | |
| covariances: torch.Tensor | None = None | |
| def to(self, device: torch.device | str) -> "Gaussians3D": | |
| """Move Gaussians to device.""" | |
| return Gaussians3D( | |
| mean_vectors=self.mean_vectors.to(device), | |
| singular_values=self.singular_values.to(device), | |
| quaternions=self.quaternions.to(device), | |
| colors=self.colors.to(device), | |
| opacities=self.opacities.to(device), | |
| covariances=self.covariances.to(device) if self.covariances is not None else None, | |
| ) | |
| def get_unprojection_matrix( | |
| extrinsics: torch.Tensor, | |
| intrinsics: torch.Tensor, | |
| image_shape: tuple[int, int], | |
| ) -> torch.Tensor: | |
| """Compute unprojection matrix to transform Gaussians to Euclidean space. | |
| Args: | |
| extrinsics: The 4x4 extrinsics matrix of the camera view. | |
| intrinsics: The 4x4 intrinsics matrix of the camera view. | |
| image_shape: The (width, height) of the input image. | |
| Returns: | |
| A 4x4 matrix to transform Gaussians from NDC space to Euclidean space. | |
| """ | |
| device = intrinsics.device | |
| dtype = intrinsics.dtype | |
| image_width, image_height = image_shape | |
| if intrinsics.shape[-2:] == (3, 3): | |
| intrinsics_4x4 = torch.eye(4, device=device, dtype=dtype).expand( | |
| *intrinsics.shape[:-2], 4, 4 | |
| ).clone() | |
| intrinsics_4x4[..., :3, :3] = intrinsics | |
| intrinsics = intrinsics_4x4 | |
| ndc_matrix = torch.tensor( | |
| [ | |
| [2.0 / image_width, 0.0, -1.0, 0.0], | |
| [0.0, 2.0 / image_height, -1.0, 0.0], | |
| [0.0, 0.0, 1.0, 0.0], | |
| [0.0, 0.0, 0.0, 1.0], | |
| ], | |
| device=device, | |
| dtype=dtype, | |
| ) | |
| # linalg.inv does not support low-precision dtypes (bf16/fp16); compute in fp32. | |
| # Keep the result in fp32 — geometry must not downcast. | |
| matrix = (ndc_matrix @ intrinsics @ extrinsics).float() | |
| return torch.linalg.inv(matrix) | |
| def unproject_gaussians( | |
| gaussians_ndc: Gaussians3D, | |
| extrinsics: torch.Tensor, | |
| intrinsics: torch.Tensor, | |
| image_shape: tuple[int, int], | |
| ) -> Gaussians3D: | |
| """Unproject Gaussians from NDC space to world coordinates. | |
| Args: | |
| gaussians_ndc: Flattened per-view Gaussians with shape [B, V*N, ...]. | |
| extrinsics: World-to-camera matrices with shape [B, V, 4, 4]. | |
| intrinsics: Camera intrinsics with shape [B, V, 3, 3] or [B, V, 4, 4]. | |
| image_shape: Image shape (width, height). | |
| Returns: | |
| World-space Gaussians with shape [B, V*N, ...]. | |
| """ | |
| num_views = extrinsics.shape[-3] | |
| num_gaussians = gaussians_ndc.mean_vectors.shape[1] | |
| if num_gaussians % num_views != 0: | |
| raise ValueError( | |
| f"Expected flattened Gaussian count {num_gaussians} to be divisible by num_views {num_views}." | |
| ) | |
| gaussians_per_view = num_gaussians // num_views | |
| gaussians_grouped = Gaussians3D( | |
| mean_vectors=gaussians_ndc.mean_vectors.reshape( | |
| gaussians_ndc.mean_vectors.shape[0], | |
| num_views, | |
| gaussians_per_view, | |
| 3, | |
| ), | |
| singular_values=gaussians_ndc.singular_values.reshape( | |
| gaussians_ndc.singular_values.shape[0], | |
| num_views, | |
| gaussians_per_view, | |
| 3, | |
| ), | |
| quaternions=gaussians_ndc.quaternions.reshape( | |
| gaussians_ndc.quaternions.shape[0], | |
| num_views, | |
| gaussians_per_view, | |
| 4, | |
| ), | |
| colors=gaussians_ndc.colors.reshape( | |
| gaussians_ndc.colors.shape[0], | |
| num_views, | |
| gaussians_per_view, | |
| 3, | |
| ), | |
| opacities=gaussians_ndc.opacities.reshape( | |
| gaussians_ndc.opacities.shape[0], | |
| num_views, | |
| gaussians_per_view, | |
| ), | |
| covariances=gaussians_ndc.covariances.reshape( | |
| gaussians_ndc.covariances.shape[0], | |
| num_views, | |
| gaussians_per_view, | |
| 3, | |
| 3, | |
| ) if gaussians_ndc.covariances is not None else None, | |
| ) | |
| unprojection_matrix = get_unprojection_matrix(extrinsics, intrinsics, image_shape) | |
| gaussians = apply_transform( | |
| gaussians_grouped, | |
| unprojection_matrix[..., :3, :], | |
| ) | |
| return Gaussians3D( | |
| mean_vectors=gaussians.mean_vectors.reshape( | |
| gaussians.mean_vectors.shape[0], | |
| num_gaussians, | |
| 3, | |
| ), | |
| singular_values=gaussians.singular_values.reshape( | |
| gaussians.singular_values.shape[0], | |
| num_gaussians, | |
| 3, | |
| ), | |
| quaternions=gaussians.quaternions.reshape( | |
| gaussians.quaternions.shape[0], | |
| num_gaussians, | |
| 4, | |
| ), | |
| colors=gaussians.colors.reshape( | |
| gaussians.colors.shape[0], | |
| num_gaussians, | |
| 3, | |
| ), | |
| opacities=gaussians.opacities.reshape( | |
| gaussians.opacities.shape[0], | |
| num_gaussians, | |
| ), | |
| covariances=gaussians.covariances.reshape( | |
| gaussians.covariances.shape[0], | |
| num_gaussians, | |
| 3, 3, | |
| ) if gaussians.covariances is not None else None, | |
| ) | |
| def apply_transform( | |
| gaussians: Gaussians3D, | |
| transform: torch.Tensor, | |
| ) -> Gaussians3D: | |
| """Apply an affine transformation to 3D Gaussians. | |
| Args: | |
| gaussians: The Gaussians to transform. | |
| transform: An affine transform with shape [..., 3, 4]. | |
| Returns: | |
| The transformed Gaussians. World-space covariance matrices are stored in | |
| the ``covariances`` field and are fully differentiable. ``quaternions`` | |
| and ``singular_values`` remain in NDC form because rendering consumes | |
| ``covariances`` directly. Export performs decomposition separately. | |
| """ | |
| transform_linear = transform[..., :3, :3] | |
| transform_offset = transform[..., :3, 3] | |
| mean_vectors = gaussians.mean_vectors @ transform_linear.transpose(-1, -2) | |
| mean_vectors = mean_vectors + transform_offset[..., None, :] | |
| # Differentiable covariance transform: M @ Sigma_ndc @ M^T | |
| covariance_matrices = ( | |
| gaussians.covariances | |
| if gaussians.covariances is not None | |
| else compose_covariance_matrices( | |
| gaussians.quaternions, | |
| gaussians.singular_values, | |
| ) | |
| ) | |
| world_covariances = ( | |
| transform_linear.unsqueeze(-3) | |
| ) | |
| return Gaussians3D( | |
| mean_vectors=mean_vectors, | |
| singular_values=gaussians.singular_values, | |
| quaternions=gaussians.quaternions, | |
| colors=gaussians.colors, | |
| opacities=gaussians.opacities, | |
| covariances=world_covariances, | |
| ) | |
| def decompose_covariance_matrices( | |
| covariance_matrices: torch.Tensor, | |
| ) -> tuple[torch.Tensor, torch.Tensor]: | |
| """Decompose 3D covariance matrices into quaternions and singular values. | |
| Args: | |
| covariance_matrices: The covariance matrices to decompose. | |
| Returns: | |
| Quaternion and singular values corresponding to the orientation and scales of | |
| the diagonalized matrix. | |
| Note: | |
| This operation is not differentiable. | |
| """ | |
| eigval_eps = 1e-12 | |
| device = covariance_matrices.device | |
| dtype = covariance_matrices.dtype | |
| batch_shape = covariance_matrices.shape[:-2] | |
| covariance_matrices = covariance_matrices.detach().cpu().to(torch.float64) | |
| covariance_matrices = covariance_matrices.reshape(-1, 3, 3) | |
| covariance_matrices = 0.5 * ( | |
| covariance_matrices + covariance_matrices.transpose(-1, -2) | |
| ) | |
| eigvals, eigvecs = torch.linalg.eigh(covariance_matrices) | |
| sort_idx = torch.argsort(eigvals, dim=-1, descending=True) | |
| eigvals = torch.gather(eigvals, -1, sort_idx) | |
| eigvecs = torch.gather( | |
| eigvecs, | |
| -1, | |
| sort_idx.unsqueeze(-2).expand(-1, 3, 3), | |
| ) | |
| eigvals = eigvals.clamp_min(eigval_eps) | |
| det = torch.linalg.det(eigvecs) | |
| reflection_idx = torch.where(det < 0)[0] | |
| if reflection_idx.numel() > 0: | |
| eigvecs[reflection_idx, :, -1] *= -1 | |
| rotations = eigvecs.reshape(batch_shape + (3, 3)) | |
| singular_values = eigvals.sqrt().reshape(batch_shape + (3,)) | |
| quaternions = linalg.quaternions_from_rotation_matrices(rotations) | |
| quaternions = quaternions.to(dtype=dtype, device=device) | |
| quaternions = quaternions / quaternions.norm(dim=-1, keepdim=True).clamp_min(1e-12) | |
| quaternions = canonicalize_quaternions(quaternions) | |
| singular_values = singular_values.to(dtype=dtype, device=device) | |
| return quaternions, singular_values | |
| def compose_covariance_matrices( | |
| quaternions: torch.Tensor, | |
| singular_values: torch.Tensor, | |
| ) -> torch.Tensor: | |
| """Compose 3D covariance matrices into quaternions and singular values. | |
| Args: | |
| quaternions: The quaternions describing the principal basis. | |
| singular_values: The scales of the diagonalized matrix. | |
| Returns: | |
| The 3x3 covariance matrices. | |
| """ | |
| device = quaternions.device | |
| rotations = linalg.rotation_matrices_from_quaternions(quaternions) | |
| diagonal_matrix = torch.eye(3, device=device, dtype=quaternions.dtype) * singular_values[..., :, None] | |
| return rotations @ diagonal_matrix.square() @ rotations.transpose(-1, -2) | |
| def canonicalize_quaternions(quaternions: torch.Tensor) -> torch.Tensor: | |
| """Canonicalize quaternion signs for viewer-stable export. | |
| Args: | |
| quaternions: Quaternions in wxyz order with shape [..., 4]. | |
| Returns: | |
| Quaternions with the largest-magnitude component forced to be non-negative. | |
| """ | |
| largest_idx = quaternions.abs().argmax(dim=-1, keepdim=True) | |
| signs = torch.gather(quaternions, -1, largest_idx).sign() | |
| signs = torch.where(signs == 0, torch.ones_like(signs), signs) | |
| return quaternions * signs | |
| def prepare_gaussians_for_ply_export(gaussians: Gaussians3D) -> Gaussians3D: | |
| """Convert Gaussians into SuperSplat-compatible q+s parameters for PLY export. | |
| Args: | |
| gaussians: Gaussians with shape [B, N, ...]. If ``covariances`` is | |
| available, it is treated as the source of truth and decomposed into a | |
| canonical world-space q+s representation. | |
| Returns: | |
| Gaussians with viewer-compatible world-space q+s in ``wxyz`` order. | |
| """ | |
| if gaussians.covariances is not None: | |
| quaternions, singular_values = decompose_covariance_matrices(gaussians.covariances) | |
| else: | |
| singular_values = gaussians.singular_values.clamp_min(1e-8) | |
| quaternions = gaussians.quaternions | |
| quaternions = quaternions / quaternions.norm(dim=-1, keepdim=True).clamp_min(1e-12) | |
| quaternions = canonicalize_quaternions(quaternions) | |
| return Gaussians3D( | |
| mean_vectors=gaussians.mean_vectors, | |
| singular_values=singular_values.clamp_min(1e-8), | |
| quaternions=quaternions, | |
| colors=gaussians.colors, | |
| opacities=gaussians.opacities, | |
| covariances=gaussians.covariances, | |
| ) | |
| def convert_rgb_to_spherical_harmonics(rgb: torch.Tensor) -> torch.Tensor: | |
| """Convert RGB to degree-0 spherical harmonics. | |
| Reference: | |
| https://en.wikipedia.org/wiki/Table_of_spherical_harmonics | |
| """ | |
| coeff_degree0 = np.sqrt(1.0 / (4.0 * np.pi)) | |
| return (rgb - 0.5) / coeff_degree0 | |
| def save_ply( | |
| gaussians: Gaussians3D, | |
| f_px: float, | |
| image_shape: tuple[int, int], | |
| path: Path, | |
| ) -> PlyData: | |
| """Save a predicted Gaussian3D to a ply file.""" | |
| def _inverse_sigmoid(tensor: torch.Tensor) -> torch.Tensor: | |
| return torch.log(tensor / (1.0 - tensor)) | |
| gaussians = prepare_gaussians_for_ply_export(gaussians) | |
| xyz = gaussians.mean_vectors.flatten(0, 1) | |
| scale_logits = torch.log(gaussians.singular_values).flatten(0, 1) | |
| quaternions = gaussians.quaternions.flatten(0, 1) | |
| colors = convert_rgb_to_spherical_harmonics( | |
| linearRGB2sRGB(gaussians.colors.flatten(0, 1)) | |
| ) | |
| opacity_logits = _inverse_sigmoid(gaussians.opacities).flatten(0, 1).unsqueeze(-1) | |
| attributes = torch.cat( | |
| ( | |
| xyz, | |
| colors, | |
| opacity_logits, | |
| scale_logits, | |
| quaternions, | |
| ), | |
| dim=1, | |
| ) | |
| dtype_full = [ | |
| (attribute, "f4") | |
| for attribute in ["x", "y", "z"] | |
| + [f"f_dc_{i}" for i in range(3)] | |
| + ["opacity"] | |
| + [f"scale_{i}" for i in range(3)] | |
| + [f"rot_{i}" for i in range(4)] | |
| ] | |
| num_gaussians = len(xyz) | |
| elements = np.empty(num_gaussians, dtype=dtype_full) | |
| elements[:] = list(map(tuple, attributes.detach().cpu().numpy())) | |
| vertex_elements = PlyElement.describe(elements, "vertex") | |
| image_height, image_width = image_shape | |
| dtype_image_size = [("image_size", "u4")] | |
| image_size_array = np.empty(2, dtype=dtype_image_size) | |
| image_size_array[:] = np.array([image_width, image_height]) | |
| image_size_element = PlyElement.describe(image_size_array, "image_size") | |
| dtype_intrinsic = [("intrinsic", "f4")] | |
| intrinsic_array = np.empty(9, dtype=dtype_intrinsic) | |
| intrinsic = np.array( | |
| [ | |
| f_px, | |
| 0, | |
| image_width * 0.5, | |
| 0, | |
| f_px, | |
| image_height * 0.5, | |
| 0, | |
| 0, | |
| 1, | |
| ] | |
| ) | |
| intrinsic_array[:] = intrinsic.flatten() | |
| intrinsic_element = PlyElement.describe(intrinsic_array, "intrinsic") | |
| dtype_extrinsic = [("extrinsic", "f4")] | |
| extrinsic_array = np.empty(16, dtype=dtype_extrinsic) | |
| extrinsic_array[:] = np.eye(4).flatten() | |
| extrinsic_element = PlyElement.describe(extrinsic_array, "extrinsic") | |
| dtype_frames = [("frame", "i4")] | |
| frame_array = np.empty(2, dtype=dtype_frames) | |
| frame_array[:] = np.array([1, num_gaussians], dtype=np.int32) | |
| frame_element = PlyElement.describe(frame_array, "frame") | |
| dtype_disparity = [("disparity", "f4")] | |
| disparity_array = np.empty(2, dtype=dtype_disparity) | |
| disparity = 1.0 / gaussians.mean_vectors[0, ..., -1] | |
| quantiles = ( | |
| torch.quantile(disparity, q=torch.tensor([0.1, 0.9], device=disparity.device)) | |
| .float() | |
| .cpu() | |
| .numpy() | |
| ) | |
| disparity_array[:] = quantiles | |
| disparity_element = PlyElement.describe(disparity_array, "disparity") | |
| dtype_color_space = [("color_space", "u1")] | |
| color_space_array = np.empty(1, dtype=dtype_color_space) | |
| color_space_array[:] = np.array([encode_color_space("sRGB")]).flatten() | |
| color_space_element = PlyElement.describe(color_space_array, "color_space") | |
| dtype_version = [("version", "u1")] | |
| version_array = np.empty(3, dtype=dtype_version) | |
| version_array[:] = np.array([1, 5, 0], dtype=np.uint8).flatten() | |
| version_element = PlyElement.describe(version_array, "version") | |
| plydata = PlyData( | |
| [ | |
| vertex_elements, | |
| extrinsic_element, | |
| intrinsic_element, | |
| image_size_element, | |
| frame_element, | |
| disparity_element, | |
| color_space_element, | |
| version_element, | |
| ] | |
| ) | |
| path.parent.mkdir(parents=True, exist_ok=True) | |
| plydata.write(path) | |
| return plydata | |