Source code for gsplat.rendering

# SPDX-FileCopyrightText: Copyright 2024-2025 the Regents of the University of California, Nerfstudio Team and contributors. All rights reserved.
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from dataclasses import dataclass
import math
from typing import Any, Dict, Optional, Tuple, cast

import torch
import torch.distributed
import torch.nn.functional as F
from torch import Tensor
from typing_extensions import Literal
from .trace import trace_function
from .profile import capture_inputs

from .cuda._wrapper import (
    RollingShutterType,
    CameraModel,
    FThetaCameraDistortionParameters,
    FThetaPolynomialType,
    RowOffsetStructuredSpinningLidarModelParametersExt,
    UnscentedTransformParameters,
    ExternalDistortionModelMeta,
    ExternalDistortionModelParameters,
    ExternalDistortionReferencePolynomial,
    BivariateWindshieldModelParameters,
    fully_fused_projection,
    fully_fused_projection_with_ut,
    isect_offset_encode,
    isect_tiles,
    isect_tiles_lidar,
    _make_lazy_cuda_func,
    _make_lazy_cuda_obj,
    rasterize_to_pixels,
    rasterize_to_pixels_eval3d,
    rasterize_to_pixels_eval3d_extra,
    renderer_config_mixed_batch,
    renderer_config_parallel_batch,
    spherical_harmonics,
)
from .utils import depth_to_normal, get_projection_matrix


# Fused post-transform codes for the proj_features assembler (SHPostOp in CUDA):
# none -> identity, shift -> +0.5, shift_relu -> max(x + 0.5, 0).
_POST_CODE = {"none": 0, "shift": 1, "shift_relu": 2}


# Gaussian depth modes (D/ED): use projection depth (controlled by global_z_order)
# Hit distance modes (d/Ed): compute along-ray distance in rasterization
RenderMode = Literal["RGB", "d", "Ed", "D", "ED", "RGB-d", "RGB-Ed", "RGB+D", "RGB+ED"]
RasterizeMode = Literal["classic", "antialiased"]


[docs] class RendererConfig: """Base class for public rasterizer selection configs. Instantiate one of the concrete renderer configs instead of this base class. Unsupported subclasses remain possible so future policies can fail with an explicit "unsupported" error at the validation boundary. """ def __new__(cls, *args, **kwargs): if cls is RendererConfig: raise TypeError( "RendererConfig is a base class; instantiate " "RendererConfig_MixedBatch or RendererConfig_ParallelBatch." ) return super().__new__(cls)
[docs] @dataclass class RendererConfig_MixedBatch(RendererConfig): """Eval3d rasterizer: serial-batch forward, batch-parallel backward. "Mixed" = the two passes batch differently. The forward composites each tile's depth-sorted Gaussian batches serially (one CTA per tile, front to back); the backward is batch-parallel (one CTA per batch). """
[docs] @dataclass class RendererConfig_ParallelBatch(RendererConfig): """Eval3d rasterizer: batch-parallel forward and backward. Both passes are batch-parallel (one CTA per batch); the forward adds a partials/scan/replay pipeline so independent batches composite concurrently, at the cost of per-batch forward state persisted for the backward. The backward kernel is shared with MixedBatch. """
def _validate_renderer_config(renderer_config: RendererConfig) -> None: if renderer_config is None: raise TypeError("renderer_config must be a RendererConfig instance, got None.") if not isinstance(renderer_config, RendererConfig): raise TypeError( "renderer_config must be a RendererConfig instance, " f"got {type(renderer_config).__name__}." ) if isinstance(renderer_config, RendererConfig_MixedBatch): return if isinstance(renderer_config, RendererConfig_ParallelBatch): return raise NotImplementedError( f"Unsupported renderer_config type: {type(renderer_config).__name__}." ) def _renderer_config_type(renderer_config: RendererConfig) -> Any: _validate_renderer_config(renderer_config) if isinstance(renderer_config, RendererConfig_MixedBatch): return renderer_config_mixed_batch() if isinstance(renderer_config, RendererConfig_ParallelBatch): return renderer_config_parallel_batch() raise AssertionError( f"unreachable renderer_config: {type(renderer_config).__name__}" ) # TODO: RenderMode should be an enum so that we can add these query methods to it. # The problem is that it'd break backward compatibllity due to some symbols used, e.g. RGB+D or RGB-d. def render_mode_has_color(mode: RenderMode) -> bool: return mode in {"RGB", "RGB-d", "RGB-Ed", "RGB+D", "RGB+ED"} def render_mode_has_hit_distance(mode: RenderMode) -> bool: return mode in {"d", "Ed", "RGB-d", "RGB-Ed"} def render_mode_has_depth(mode: RenderMode) -> bool: return mode in {"D", "ED", "RGB+D", "RGB+ED"} def render_mode_has_expected_depth(mode: RenderMode) -> bool: return mode in {"Ed", "ED", "RGB-Ed", "RGB+ED"} def render_mode_has_depth_channel(mode: RenderMode) -> bool: return render_mode_has_depth(mode) or render_mode_has_hit_distance(mode) def render_mode_has_only_depth_channel(mode: RenderMode) -> bool: return render_mode_has_depth_channel(mode) and not render_mode_has_color(mode) def render_mode_has_only_color(mode: RenderMode) -> bool: return not render_mode_has_depth_channel(mode) and render_mode_has_color(mode) def _validate_3dgut_rasterize_mode( rasterize_mode: RasterizeMode, *, with_ut: bool, with_eval3d: bool, ) -> None: if rasterize_mode != "classic" and (with_ut or with_eval3d): raise ValueError( "3DGUT rendering only supports rasterize_mode='classic'. " f"Got rasterize_mode='{rasterize_mode}' with " f"with_ut={with_ut} and with_eval3d={with_eval3d}." ) def _get_default_nccl_process_group_name() -> str: """Return the default NCCL process-group name used by c10d functional ops.""" if not torch.distributed.is_available(): raise ValueError("distributed=True requires torch.distributed to be available.") if not torch.distributed.is_initialized(): raise ValueError( "distributed=True requires an initialized default torch.distributed " "process group." ) import torch.distributed.distributed_c10d as distributed_c10d process_group = distributed_c10d._get_default_group() backend = torch.distributed.get_backend(process_group) if str(backend).lower() != "nccl": raise ValueError( "distributed=True currently supports only the default NCCL process " f"group; got backend '{backend}'." ) return distributed_c10d._get_process_group_name(process_group) def _resolve_tile_size( tile_size: Optional[int], with_eval3d: bool, width: int, height: int ) -> int: """Resolve a None-valued tile_size to the path-correct default. The 3DGS kernel is compiled with TILE_SIZE=16 only; the 3DGUT kernel dispatches at compile time on tile_size in {8, 16} and the optimum is workload-dependent: - tile=8 (CTA=32, PPT=2) is the compact-CTA path. Wins below 1080p where smaller per-tile shmem lets many CTAs co-reside per SM and intersect+sort cost is small. - tile=16 (CTA=256, PPT=1) is one thread per pixel. Wins at 1080p+ where intersect+sort dominates and fewer/larger tiles shrink it. Spinning lidar grids are wide but shallow (e.g. pandar128 = 128 rows x 3600 cols, at128 = 128 x 1200); min gates on the row count keeps lidar at tile=8 alongside sub-1080p cameras. Cameras at 1080p+ have min(W,H) >= 1080 and pick tile=16. Callers must pass `width`/`height` AFTER any lidar dim override so the gate sees the right dims for lidar. Explicit non-None tile_size is returned unchanged (caller override wins). """ if tile_size is not None: return tile_size if with_eval3d: return 16 if min(width, height) >= 1080 else 8 return 16
[docs] @trace_function("render") @capture_inputs(envvar="GSPLAT_INPUT_CAPTURE_RASTERIZATION") def rasterization( means: Tensor, # [..., N, 3] quats: Tensor, # [..., N, 4] scales: Tensor, # [..., N, 3] opacities: Tensor, # [..., N] colors: Optional[ Tensor ], # [..., (C,) N, D] for post-activation colors, or [N, K, D] for SH coefficients; None for depth-only render_modes viewmats: Tensor, # [..., C, 4, 4] Ks: Tensor, # [..., C, 3, 3] width: int, height: int, near_plane: float = 0.01, far_plane: float = 1e10, radius_clip: float = 0.0, eps2d: float = 0.3, sh_degree: Optional[int] = None, packed: bool = True, tile_size: Optional[int] = None, backgrounds: Optional[Tensor] = None, render_mode: RenderMode = "RGB", sparse_grad: bool = False, absgrad: bool = False, rasterize_mode: RasterizeMode = "classic", channel_chunk: int = 32, distributed: bool = False, camera_model: CameraModel = "pinhole", segmented: bool = False, covars: Optional[Tensor] = None, with_ut: bool = False, with_eval3d: bool = False, return_normals: bool = False, global_z_order: bool = True, rays: Optional[ Tensor ] = None, # [..., C, H, W, 6] -> ox, oy, oz, dx*spread, dy*spread, dz*spread # distortion radial_coeffs: Optional[Tensor] = None, # [..., C, 6] or [..., C, 4] tangential_coeffs: Optional[Tensor] = None, # [..., C, 2] thin_prism_coeffs: Optional[Tensor] = None, # [..., C, 4] ftheta_coeffs: Optional[FThetaCameraDistortionParameters] = None, lidar_coeffs: Optional[RowOffsetStructuredSpinningLidarModelParametersExt] = None, external_distortion_coeffs: Optional[ExternalDistortionModelParameters] = None, # rolling shutter rolling_shutter: RollingShutterType = RollingShutterType.GLOBAL, viewmats_rs: Optional[Tensor] = None, # [..., C, 4, 4] # unscented transform (for 3DGUT) ut_params: Optional[UnscentedTransformParameters] = None, # extra signal channels (order in output: RGB, depth, extra) extra_signals: Optional[ Tensor ] = None, # [..., (C,) N, E], or [N, K, E] when extra_signals_sh_degree set extra_signals_sh_degree: Optional[ int ] = None, # Currently only None or 3 is accepted. renderer_config: Optional[RendererConfig] = None, ) -> Tuple[Tensor, Tensor, Dict]: """Rasterize a set of 3D Gaussians (N) to a batch of image planes (C). This function provides a handful features for 3D Gaussian rasterization, which we detail in the following notes. A complete profiling of the these features can be found in the :ref:`profiling` page. .. note:: **Multi-GPU Distributed Rasterization**: This function can be used in a multi-GPU distributed scenario by setting `distributed` to True. When `distributed` is True, a subset of total Gaussians could be passed into this function in each rank, and the function will collaboratively render a set of images using Gaussians from all ranks. Note to achieve balanced computation, it is recommended (not enforced) to have similar number of Gaussians in each rank. But we do enforce that the number of cameras to be rendered in each rank is the same. The function will return the rendered images corresponds to the input cameras in each rank, and allows for gradients to flow back to the Gaussians living in other ranks. For the details, please refer to the paper `On Scaling Up 3D Gaussian Splatting Training <https://arxiv.org/abs/2406.18533>`_. .. note:: **Batch Rasterization**: This function allows for rasterizing a set of 3D Gaussians to a batch of images in one go, by simplly providing the batched `viewmats` and `Ks`. .. note:: **Support N-D Features**: If `sh_degree` is None, the `colors` is expected to be with shape [..., N, D] or [..., C, N, D], in which D is the channel of the features to be rendered. The computation is slow when D > 32 at the moment. If `sh_degree` is set, the `colors` is expected to be the SH coefficients with shape [N, K, D], shared across all batch and camera dims (i.e. no leading `...` or `C` dims), where K is the number of SH bases and D is the number of feature channels. In this case, it is expected that :math:`(\\textit{sh_degree} + 1) ^ 2 \\leq K`, where `sh_degree` controls the activated bases in the SH coefficients. .. note:: **Depth Rendering**: This function supports colors or/and depths via `render_mode`. **Gaussian Depth Modes** (use projection depth, controlled by `global_z_order`): - "D": Accumulated Gaussian depth :math:`\\sum_i w_i z_i` - "ED": Expected Gaussian depth :math:`\\frac{\\sum_i w_i z_i}{\\sum_i w_i}` - "RGB+D": RGB + accumulated Gaussian depth - "RGB+ED": RGB + expected Gaussian depth **Hit Distance Modes** (compute along-ray distance in rasterization): - "d": Accumulated hit distance :math:`\\sum_i w_i d_i` - "Ed": Expected hit distance :math:`\\frac{\\sum_i w_i d_i}{\\sum_i w_i}` - "RGB-d": RGB + accumulated hit distance - "RGB-Ed": RGB + expected hit distance "RGB" renders only the colored image. For combined modes, depth is the last channel. When extra_signals are present, render_colors is RGB + depth only (4 channels); extra channels are returned in ``meta["render_extra_signals"]``. .. note:: **Extra signals**: Optional `extra_signals` are rendered and returned in ``meta["render_extra_signals"]`` (shape [..., C, height, width, E]). If `extra_signals_sh_degree` is set, extra_signals are SH coefficients of shape [N, K, E] (shared across batch/camera dims), evaluated per view. .. note:: **Memory-Speed Trade-off**: The `packed` argument provides a trade-off between memory footprint and runtime. If `packed` is True, the intermediate results are packed into sparse tensors, which is more memory efficient but might be slightly slower. This is especially helpful when the scene is large and each camera sees only a small portion of the scene. If `packed` is False, the intermediate results are with shape [..., C, N, ...], which is faster but might consume more memory. .. note:: **Sparse Gradients**: If `sparse_grad` is True, the gradients for {means, quats, scales} will be stored in a `COO sparse layout <https://pytorch.org/docs/stable/generated/torch.sparse_coo_tensor.html>`_. This can be helpful for saving memory for training when the scene is large and each iteration only activates a small portion of the Gaussians. Usually a sparse optimizer is required to work with sparse gradients, such as `torch.optim.SparseAdam <https://pytorch.org/docs/stable/generated/torch.optim.SparseAdam.html#sparseadam>`_. This argument is only effective when `packed` is True. .. note:: **Speed-up for Large Scenes**: The `radius_clip` argument is extremely helpful for speeding up large scale scenes or scenes with large depth of fields. Gaussians with 2D radius smaller or equal than this value (in pixel unit) will be skipped during rasterization. This will skip all the far-away Gaussians that are too small to be seen in the image. But be warned that if there are close-up Gaussians that are also below this threshold, they will also get skipped (which is rarely happened in practice). This is by default disabled by setting `radius_clip` to 0.0. .. note:: **Antialiased Rendering**: If `rasterize_mode` is "antialiased", the function will apply a view-dependent compensation factor :math:`\\rho=\\sqrt{\\frac{Det(\\Sigma)}{Det(\\Sigma+ \\epsilon I)}}` to Gaussian opacities, where :math:`\\Sigma` is the projected 2D covariance matrix and :math:`\\epsilon` is the `eps2d`. This will make the rendered image more antialiased, as proposed in the paper `Mip-Splatting: Alias-free 3D Gaussian Splatting <https://arxiv.org/pdf/2311.16493>`_. .. note:: **AbsGrad**: If `absgrad` is True, the absolute gradients of the projected 2D means will be computed during the backward pass, which could be accessed by `meta["means2d"].absgrad`. This is an implementation of the paper `AbsGS: Recovering Fine Details for 3D Gaussian Splatting <https://arxiv.org/abs/2404.10484>`_, which is shown to be more effective for splitting Gaussians during training. .. note:: **Camera Distortion and Rolling Shutter**: The function supports rendering with opencv distortion formula for pinhole and fisheye cameras (`radial_coeffs`, `tangential_coeffs`, `thin_prism_coeffs`). It also supports rolling shutter rendering with the `rolling_shutter` argument. We take reference from the paper `3DGUT: Enabling Distorted Cameras and Secondary Rays in Gaussian Splatting <https://arxiv.org/abs/2412.12507>`_. .. warning:: This function is currently not differentiable w.r.t. the camera intrinsics `Ks`. Args: means: The 3D centers of the Gaussians. [..., N, 3] quats: The quaternions of the Gaussians (wxyz convension). It's not required to be normalized. [..., N, 4] scales: The scales of the Gaussians. [..., N, 3] opacities: The opacities of the Gaussians. [..., N] colors: The colors of the Gaussians. [..., (C,) N, D] for post-activation colors, or [N, K, D] for SH coefficients (shared across batch/camera dims). viewmats: The world-to-cam transformation of the cameras. [..., C, 4, 4] Ks: The camera intrinsics. [..., C, 3, 3] width: The width of the image. For lidar sensors, this is ignored. The width is taken from lidar_coeffs.n_columns. height: The height of the image. For lidar sensors, this is ignored. The height is taken from lidar_coeffs.n_rows. near_plane: The near plane for clipping. Default is 0.01. far_plane: The far plane for clipping. Default is 1e10. radius_clip: Gaussians with 2D radius smaller or equal than this value will be skipped. This is extremely helpful for speeding up large scale scenes. Default is 0.0. eps2d: An epsilon added to the egienvalues of projected 2D covariance matrices. This will prevents the projected GS to be too small. For example eps2d=0.3 leads to minimal 3 pixel unit. Default is 0.3. sh_degree: The SH degree to use, which can be smaller than the total number of bands. If set, the `colors` should be [N, K, D] SH coefficients (shared across batch/camera dims), else the `colors` should be [..., (C,) N, D] post-activation color values. Default is None. packed: Whether to use packed mode which is more memory efficient but might or might not be as fast. Default is True. tile_size: The size of the tiles for rasterization. Default is 16. (Note: other values are not tested) backgrounds: The background colors. [..., C, D]. Default is None. render_mode: The rendering mode. Supported modes are "RGB", "d", "Ed", "D", "ED", "RGB-d", "RGB-Ed", "RGB+D", and "RGB+ED". "RGB" renders the colored image. Gaussian depth modes (D, ED, RGB+D, RGB+ED) use projection depth. Hit distance modes (d, Ed, RGB-d, RGB-Ed) compute along-ray distance. Expected modes (Ed, ED) are normalized by opacity. Default is "RGB". sparse_grad: If true, the gradients for {means, quats, scales} will be stored in a COO sparse layout. This can be helpful for saving memory. Default is False. absgrad: If true, the absolute gradients of the projected 2D means will be computed during the backward pass, which could be accessed by `meta["means2d"].absgrad`. Default is False. rasterize_mode: The rasterization mode. Supported modes are "classic" and "antialiased". Default is "classic". channel_chunk: The number of channels to render in one go. Default is 32. If the required rendering channels are larger than this value, the rendering will be done looply in chunks. distributed: Whether to use distributed rendering. Default is False. If True, The input Gaussians are expected to be a subset of scene in each rank, and the function will collaboratively render the images for all ranks. camera_model: The camera model to use. Supported models are "pinhole", "ortho", "fisheye", and "ftheta". Default is "pinhole". segmented: Whether to use segmented radix sort. Default is False. Segmented radix sort performs sorting in segments, which is more efficient for the sorting operation itself. However, since it requires offset indices as input, additional global memory access is needed, which results in slower overall performance in most use cases. covars: Optional covariance matrices of the Gaussians. If provided, the `quats` and `scales` will be ignored. [..., N, 3, 3], Default is None. with_ut: Whether to use Unscented Transform (UT) for projection. Default is False. with_eval3d: Whether to calculate Gaussian response in 3D world space, instead of 2D image space. Default is False. return_normals: Whether to compute and return accumulated normals per pixel. Normals are computed from Gaussian quaternions (canonical normal = (0,0,1) transformed by rotation, flipped if facing away from ray). Requires with_eval3d=True. Default is False. global_z_order: Whether to use z-depth (True) or Euclidean distance (False) for sorting Gaussians during rasterization. When True, Gaussians are sorted by their z-coordinate in camera space. When False, they are sorted by their Euclidean distance from the camera origin. Default is True. radial_coeffs: Opencv pinhole/fisheye radial distortion coefficients. Default is None. For pinhole camera, the shape should be [..., C, 6]. For fisheye camera, the shape should be [..., C, 4]. tangential_coeffs: Opencv pinhole tangential distortion coefficients. Default is None. The shape should be [..., C, 2] if provided. thin_prism_coeffs: Opencv pinhole thin prism distortion coefficients. Default is None. The shape should be [..., C, 4] if provided. ftheta_coeffs: F-Theta camera distortion coefficients shared for all cameras. Default is None. See `FThetaCameraDistortionParameters` for details. rolling_shutter: The rolling shutter type. Default `RollingShutterType.GLOBAL` means global shutter. viewmats_rs: The second viewmat when rolling shutter is used. Default is None. renderer_config: The eval3d rasterizer implementation selector. Default is :class:`RendererConfig_MixedBatch`, which uses the existing mixed-batch rasterizer implementation. Non-default configs require ``with_eval3d=True``. Returns: A tuple: **render_colors**: The rendered colors. [..., C, height, width, X]. X depends on the `render_mode` and input `colors`. If `render_mode` is "RGB", X is D; if `render_mode` is "D" or "ED", X is 1; if `render_mode` is "RGB+D" or "RGB+ED", X is D+1. **render_alphas**: The rendered alphas. [..., C, height, width, 1]. **meta**: A dictionary of intermediate results of the rasterization. Examples: .. code-block:: python >>> # define Gaussians >>> means = torch.randn((100, 3), device=device) >>> quats = torch.randn((100, 4), device=device) >>> scales = torch.rand((100, 3), device=device) * 0.1 >>> colors = torch.rand((100, 3), device=device) >>> opacities = torch.rand((100,), device=device) >>> # define cameras >>> viewmats = torch.eye(4, device=device)[None, :, :] >>> Ks = torch.tensor([ >>> [300., 0., 150.], [0., 300., 100.], [0., 0., 1.]], device=device)[None, :, :] >>> width, height = 300, 200 >>> # render >>> colors, alphas, meta = rasterization( >>> means, quats, scales, opacities, colors, viewmats, Ks, width, height >>> ) >>> print (colors.shape, alphas.shape) torch.Size([1, 200, 300, 3]) torch.Size([1, 200, 300, 1]) >>> print (meta.keys()) dict_keys(['camera_ids', 'gaussian_ids', 'radii', 'means2d', 'depths', 'conics', 'opacities', 'tile_width', 'tile_height', 'tiles_per_gauss', 'isect_ids', 'flatten_ids', 'isect_offsets', 'width', 'height', 'tile_size']) """ has_color = render_mode_has_color(render_mode) external_distortion_coeffs = cast( Optional[BivariateWindshieldModelParameters], external_distortion_coeffs ) if lidar_coeffs is not None: width = lidar_coeffs.n_columns height = lidar_coeffs.n_rows tile_size = _resolve_tile_size(tile_size, with_eval3d, width, height) if covars is not None: quats, scales = None, None # convert covars from 3x3 matrix to upper-triangular 6D vector tri_indices = ([0, 0, 0, 1, 1, 2], [0, 1, 2, 1, 2, 2]) covars = covars[..., tri_indices[0], tri_indices[1]] # Resolve the renderer configuration (MixedBatch / ParallelBatch). The # custom-class instances live only in Python; flatten to the CUDA enum # before crossing into the C++ orchestration op. ParallelBatch is only # valid on the eval3d (from-world) path. if renderer_config is None: renderer_config = RendererConfig_MixedBatch() _validate_renderer_config(renderer_config) if not with_eval3d and not isinstance(renderer_config, RendererConfig_MixedBatch): raise ValueError( f"{type(renderer_config).__name__} requires with_eval3d=True; " "the non-eval3d path only supports RendererConfig_MixedBatch." ) renderer_config_impl = _renderer_config_type(renderer_config) # Both paths run the single C++ orchestrator. `distributed` is honored as # passed (no world_size-based downgrade): at one rank the gathers/scatters are # identities, so distributed=True stays numerically identical to the # single-GPU path and remains exercisable on a single GPU. Python derives the # NCCL group name (which also validates an initialized NCCL group) and the # world size; the C++ op owns all distributed validation. if distributed: process_group_name = _get_default_nccl_process_group_name() world_size = torch.distributed.get_world_size() else: process_group_name = None world_size = 1 camera_model_type = _make_lazy_cuda_obj(f"CameraModelType.{camera_model.upper()}") sh_degree_value = sh_degree if sh_degree is not None else -1 extra_signals_sh_degree_value = ( extra_signals_sh_degree if extra_signals_sh_degree is not None else -1 ) ftheta_params = ( ftheta_coeffs if ftheta_coeffs is not None else FThetaCameraDistortionParameters() ) ( render_colors, render_alphas, render_extra_signals, render_normals, means2d_absgrad, batch_ids, camera_ids, gaussian_ids, radii, means2d, depths, conics, projected_opacities, tiles_per_gauss, isect_ids, flatten_ids, isect_offsets, tile_width, tile_height, ) = _make_lazy_cuda_func("rasterization_3dgs")( means.contiguous(), covars.contiguous() if covars is not None else None, quats.contiguous() if quats is not None else None, scales.contiguous() if scales is not None else None, opacities.contiguous(), colors.contiguous() if (has_color and colors is not None) else None, viewmats.contiguous(), Ks.contiguous(), width, height, tile_size, eps2d, near_plane, far_plane, radius_clip, backgrounds.contiguous() if backgrounds is not None else None, packed, sparse_grad, absgrad, rasterize_mode == "antialiased", rasterize_mode == "classic", camera_model_type, segmented, channel_chunk, has_color, sh_degree_value, extra_signals.contiguous() if extra_signals is not None else None, extra_signals_sh_degree_value, render_mode_has_depth_channel(render_mode), render_mode_has_expected_depth(render_mode), with_eval3d, with_ut, rays.contiguous() if rays is not None else None, viewmats_rs.contiguous() if viewmats_rs is not None else None, ut_params if ut_params is not None else UnscentedTransformParameters(), rolling_shutter, radial_coeffs.contiguous() if radial_coeffs is not None else None, tangential_coeffs.contiguous() if tangential_coeffs is not None else None, thin_prism_coeffs.contiguous() if thin_prism_coeffs is not None else None, ftheta_params, lidar_coeffs.to_cpp() if lidar_coeffs is not None else None, external_distortion_coeffs, global_z_order, render_mode_has_hit_distance(render_mode), return_normals, renderer_config_impl, process_group_name, world_size, ) if absgrad and not with_eval3d: means2d.absgrad = means2d_absgrad if not packed: batch_ids = None camera_ids = None gaussian_ids = None batch_dims = means.shape[:-2] B = math.prod(batch_dims) C = viewmats.shape[-3] meta = { "batch_ids": batch_ids, "camera_ids": camera_ids, "gaussian_ids": gaussian_ids, "radii": radii, "means2d": means2d, "depths": depths, "conics": conics, "opacities": projected_opacities, "tile_width": tile_width, "tile_height": tile_height, "tiles_per_gauss": tiles_per_gauss, "isect_ids": isect_ids, "flatten_ids": flatten_ids, "isect_offsets": isect_offsets, "width": width, "height": height, "tile_size": tile_size, "n_batches": B, "n_cameras": C, } if extra_signals is not None: meta["render_extra_signals"] = render_extra_signals if return_normals: meta["normals"] = render_normals return render_colors, render_alphas, meta
def _maybe_evaluate_sh( sh_degree, features, means, radii, viewmats, batch_dims, C, N, clamp ): num_batch_dims = len(batch_dims) # Turn features into [..., C, N, D] or [..., nnz, D] to pass into rasterize_to_pixels() if sh_degree is None: # Colors are post-activation values, with shape [..., N, D] or [..., C, N, D] if features.dim() == num_batch_dims + 2: # Turn [..., N, D] into [..., C, N, D] features = torch.broadcast_to( features[..., None, :, :], batch_dims + (C, N, -1) ) else: # features is already [..., C, N, D] pass else: camtoworlds = torch.inverse(viewmats) # [..., C, 4, 4] dirs = means[..., None, :, :] - camtoworlds[..., None, :3, 3] # [..., C, N, 3] masks = (radii > 0).all(dim=-1) # [..., C, N] features = spherical_harmonics( sh_degree, dirs, features, masks=masks ) # [..., C, N, D] if clamp: # make it apple-to-apple with Inria's CUDA Backend. features = torch.clamp_min(features + 0.5, 0.0) else: features = features + 0.5 return features def _rasterization( means: Tensor, # [..., N, 3] quats: Tensor, # [..., N, 4] scales: Tensor, # [..., N, 3] opacities: Tensor, # [..., N] colors: Tensor, # [..., (C,) N, D] for post-activation colors, or [N, K, D] for SH coefficients viewmats: Tensor, # [..., C, 4, 4] Ks: Tensor, # [..., C, 3, 3] width: int, height: int, near_plane: float = 0.01, far_plane: float = 1e10, eps2d: float = 0.3, sh_degree: Optional[int] = None, tile_size: Optional[int] = None, rays: Optional[ Tensor ] = None, # [..., C, H, W, 6] -> ox, oy, oz, dx*spread, dy*spread, dz*spread backgrounds: Optional[Tensor] = None, render_mode: RenderMode = "RGB", rasterize_mode: RasterizeMode = "classic", channel_chunk: int = 32, batch_per_iter: int = 100, with_eval3d: bool = False, with_ut: bool = False, camera_model: CameraModel = "pinhole", lidar_coeffs: Optional[RowOffsetStructuredSpinningLidarModelParametersExt] = None, extra_signals: Optional[ Tensor ] = None, # [..., (C,) N, E], or [N, K, E] when extra_signals_sh_degree set extra_signals_sh_degree: Optional[int] = None, ) -> Tuple[Tensor, Tensor, Dict]: """A version of rasterization() that utilies on PyTorch's autograd. .. note:: This function still relies on gsplat's CUDA backend for some computation, but the entire differentiable graph is on of PyTorch (and nerfacc) so could use Pytorch's autograd for backpropagation. .. note:: This function relies on installing latest nerfacc, via: pip install git+https://github.com/nerfstudio-project/nerfacc .. note:: Compared to rasterization(), this function does not support some arguments such as `packed`, `sparse_grad` and `absgrad`. """ from gsplat.cuda._torch_impl import ( _fully_fused_projection, _rasterize_to_pixels, ) from gsplat.cuda._torch_impl_eval3d import _rasterize_to_pixels_eval3d from gsplat.cuda._torch_impl_ut import _fully_fused_projection_with_ut from gsplat.cuda._math import _quat_scale_to_covar_preci if lidar_coeffs is not None: width = lidar_coeffs.n_columns height = lidar_coeffs.n_rows tile_size = _resolve_tile_size(tile_size, with_eval3d, width, height) has_color = render_mode_has_color(render_mode) _validate_3dgut_rasterize_mode( rasterize_mode, with_ut=with_ut, with_eval3d=with_eval3d ) batch_dims = means.shape[:-2] num_batch_dims = len(batch_dims) B = math.prod(batch_dims) N = means.shape[-2] C = viewmats.shape[-3] D = ( colors.shape[-1] if has_color else 0 ) # number of primary color channels; 0 for depth-only I = B * C H = height W = width device = means.device assert means.shape == batch_dims + (N, 3), means.shape assert quats.shape == batch_dims + (N, 4), quats.shape assert scales.shape == batch_dims + (N, 3), scales.shape assert opacities.shape == batch_dims + (N,), opacities.shape assert viewmats.shape == batch_dims + (C, 4, 4), viewmats.shape assert Ks.shape == batch_dims + (C, 3, 3), Ks.shape assert rays is None or rays.shape == batch_dims + (C, H, W, 6), rays.shape if has_color: if sh_degree is None: # treat colors as post-activation values, should be in shape [..., N, D] or [..., C, N, D] assert ( colors.dim() == num_batch_dims + 2 and colors.shape[:-1] == batch_dims + (N,) ) or ( colors.dim() == num_batch_dims + 3 and colors.shape[:-1] == batch_dims + (C, N) ), colors.shape else: # treat colors as SH coefficients, must be in shape [N, K, D]. # Allowing for activating partial SH bands. assert colors.dim() == 3 and colors.shape[0] == N, colors.shape assert (sh_degree + 1) ** 2 <= colors.shape[-2], colors.shape if with_ut: radii, means2d, depths, conics, compensations = _fully_fused_projection_with_ut( means=means, quats=quats, scales=scales, opacities=opacities, viewmats=viewmats, Ks=Ks, width=width, height=height, eps2d=eps2d, near_plane=near_plane, far_plane=far_plane, calc_compensations=(rasterize_mode == "antialiased"), camera_model=camera_model, lidar_coeffs=lidar_coeffs, ) else: if rays is not None: raise ValueError("Rays input is only supported with with_eval3d=True") assert camera_model == "pinhole", camera_model # Project Gaussians to 2D. # The results are with shape [..., C, N, ...]. Only the elements with radii > 0 are valid. covars, _ = _quat_scale_to_covar_preci(quats, scales, True, False, triu=False) radii, means2d, depths, conics, compensations = _fully_fused_projection( means, covars, viewmats, Ks, width, height, eps2d=eps2d, near_plane=near_plane, far_plane=far_plane, calc_compensations=(rasterize_mode == "antialiased"), ) opacities = torch.broadcast_to( opacities[..., None, :], batch_dims + (C, N) ) # [..., C, N] batch_ids, camera_ids, gaussian_ids = None, None, None image_ids = None if compensations is not None: opacities = opacities * compensations # Identify intersecting tiles if lidar_coeffs is not None: tile_width = lidar_coeffs.tiling.n_bins_azimuth tile_height = lidar_coeffs.tiling.n_bins_elevation tiles_per_gauss, isect_ids, flatten_ids = isect_tiles_lidar( lidar_coeffs, means2d, radii, depths, packed=False, n_images=I, image_ids=image_ids, gaussian_ids=gaussian_ids, ) else: tile_width = math.ceil(width / float(tile_size)) tile_height = math.ceil(height / float(tile_size)) tiles_per_gauss, isect_ids, flatten_ids = isect_tiles( means2d, radii, depths, tile_size, tile_width, tile_height, packed=False, n_images=I, image_ids=image_ids, gaussian_ids=gaussian_ids, conics=None if with_ut else conics, opacities=None if with_ut else opacities, ) isect_offsets = isect_offset_encode(isect_ids, I, tile_width, tile_height) isect_offsets = isect_offsets.reshape(batch_dims + (C, tile_height, tile_width)) # Turn colors into [..., C, N, D] or [..., nnz, D] to pass into rasterize_to_pixels() # Make sure they're clamped if evaluating SH. if has_color: colors = _maybe_evaluate_sh( sh_degree, colors, means, radii, viewmats, batch_dims, C, N, True ) # Now do the same to the extra signals. if extra_signals is not None: # Do not clamp it. extra_signals = _maybe_evaluate_sh( extra_signals_sh_degree, extra_signals, means, radii, viewmats, batch_dims, C, N, False, ) if has_color: # Concatenate colors and extra_signals for joint rasterization. assert colors.shape[:-1] == extra_signals.shape[:-1], ( colors.shape, extra_signals.shape, ) colors = torch.cat([colors, extra_signals], dim=-1) # Rasterize to pixels if render_mode_has_depth_channel(render_mode) and render_mode_has_color( render_mode ): colors = torch.cat((colors, depths[..., None]), dim=-1) if backgrounds is not None: backgrounds = torch.cat( [ backgrounds, torch.zeros(batch_dims + (C, 1), device=backgrounds.device), ], dim=-1, ) elif render_mode_has_only_depth_channel(render_mode): # In depth-only mode, extra_signals were not concatenated into colors # above. Place them before depth so depth stays last. if extra_signals is not None and not has_color: colors = torch.cat([extra_signals, depths[..., None]], dim=-1) else: colors = depths[..., None] if backgrounds is not None: backgrounds = torch.zeros( batch_dims + (C, colors.shape[-1]), device=backgrounds.device ) else: # RGB pass # Chunking logic for both eval3d and standard paths if colors.shape[-1] > channel_chunk: # slice into chunks n_chunks = (colors.shape[-1] + channel_chunk - 1) // channel_chunk render_colors, render_alphas = [], [] for i in range(n_chunks): colors_chunk = colors[..., i * channel_chunk : (i + 1) * channel_chunk] backgrounds_chunk = ( backgrounds[..., i * channel_chunk : (i + 1) * channel_chunk] if backgrounds is not None else None ) if with_eval3d: # Using CUDA code due to its speed. This function is already # being thoroughtly tested in test_basic.py render_colors_, render_alphas_ = rasterize_to_pixels_eval3d( means=means, quats=quats, scales=scales, colors_chunk=colors_chunk, opacities=opacities, viewmats=viewmats, camera_model=camera_model, Ks=Ks, image_width=width, image_height=height, rays=rays, lidar_coeffs=lidar_coeffs, tile_size=tile_size, isect_offsets=isect_offsets, flatten_ids=flatten_ids, backgrounds=backgrounds_chunk, ) else: if rays is not None: raise ValueError( "Rays input is only supported with with_eval3d=True" ) assert camera_model == "pinhole", camera_model render_colors_, render_alphas_ = _rasterize_to_pixels( means2d, conics, colors_chunk, opacities, width, height, tile_size, isect_offsets, flatten_ids, backgrounds=backgrounds_chunk, batch_per_iter=batch_per_iter, ) render_colors.append(render_colors_) render_alphas.append(render_alphas_) render_colors = torch.cat(render_colors, dim=-1) render_alphas = render_alphas[0] # discard the rest else: # No chunking needed if with_eval3d: # Using CUDA code due to its speed. This function is already # being thoroughtly tested in test_basic.py render_colors, render_alphas = rasterize_to_pixels_eval3d( means=means, quats=quats, scales=scales, colors=colors, opacities=opacities, viewmats=viewmats, Ks=Ks, image_width=width, image_height=height, rays=rays, camera_model=camera_model, lidar_coeffs=lidar_coeffs, tile_size=tile_size, isect_offsets=isect_offsets, flatten_ids=flatten_ids, backgrounds=backgrounds, ) else: if rays is not None: raise ValueError("Rays input is only supported with with_eval3d=True") assert camera_model == "pinhole", camera_model render_colors, render_alphas = _rasterize_to_pixels( means2d, conics, colors, opacities, width, height, tile_size, isect_offsets, flatten_ids, backgrounds=backgrounds, batch_per_iter=batch_per_iter, ) if extra_signals is not None: # Extract the extra signals (per ray) from render_colors E = extra_signals.shape[-1] render_extra_signals = render_colors[..., D : D + E] # Leave only colors (and possibly depth) if render_mode_has_depth_channel(render_mode): render_depth = render_colors[..., -1:] # Normalize depth for expected modes (Ed, ED, RGB-Ed, RGB+ED) if render_mode_has_expected_depth(render_mode): render_depth = render_depth / render_alphas.clamp(min=1e-10) render_colors = torch.cat([render_colors[..., 0:D], render_depth], dim=-1) else: render_colors = render_colors[..., 0:D] else: render_extra_signals = None # Normalize depth for expected modes (Ed, ED, RGB-Ed, RGB+ED) if render_mode_has_expected_depth(render_mode): # normalize the accumulated depth to get the expected depth render_depth = render_colors[..., -1:] / render_alphas.clamp(min=1e-10) render_colors = torch.cat([render_colors[..., :D], render_depth], dim=-1) meta = { "batch_ids": batch_ids, "camera_ids": camera_ids, "gaussian_ids": gaussian_ids, "radii": radii, "means2d": means2d, "depths": depths, "conics": conics, "opacities": opacities, "tile_width": tile_width, "tile_height": tile_height, "tiles_per_gauss": tiles_per_gauss, "isect_ids": isect_ids, "flatten_ids": flatten_ids, "isect_offsets": isect_offsets, "width": width, "height": height, "tile_size": tile_size, "n_batches": B, "n_cameras": C, } if render_extra_signals is not None: meta["render_extra_signals"] = render_extra_signals return render_colors, render_alphas, meta # def rasterization_legacy_wrapper( # means: Tensor, # [N, 3] # quats: Tensor, # [N, 4] # scales: Tensor, # [N, 3] # opacities: Tensor, # [N] # colors: Tensor, # [N, D] or [N, K, D] # viewmats: Tensor, # [C, 4, 4] # Ks: Tensor, # [C, 3, 3] # width: int, # height: int, # near_plane: float = 0.01, # eps2d: float = 0.3, # sh_degree: Optional[int] = None, # tile_size: int = 16, # backgrounds: Optional[Tensor] = None, # **kwargs, # ) -> Tuple[Tensor, Tensor, Dict]: # """Wrapper for old version gsplat. # .. warning:: # This function exists for comparison purpose only. So we skip collecting # the intermidiate variables, and only return an empty dict. # """ # from gsplat.cuda_legacy._wrapper import ( # project_gaussians, # rasterize_gaussians, # spherical_harmonics, # ) # assert eps2d == 0.3, "This is hard-coded in CUDA to be 0.3" # C = len(viewmats) # render_colors, render_alphas = [], [] # for cid in range(C): # fx, fy = Ks[cid, 0, 0], Ks[cid, 1, 1] # cx, cy = Ks[cid, 0, 2], Ks[cid, 1, 2] # viewmat = viewmats[cid] # means2d, depths, radii, conics, _, num_tiles_hit, _ = project_gaussians( # means3d=means, # scales=scales, # glob_scale=1.0, # quats=quats, # viewmat=viewmat, # fx=fx, # fy=fy, # cx=cx, # cy=cy, # img_height=height, # img_width=width, # block_width=tile_size, # clip_thresh=near_plane, # ) # if colors.dim() == 3: # c2w = viewmat.inverse() # viewdirs = means - c2w[:3, 3] # # viewdirs = F.normalize(viewdirs, dim=-1).detach() # if sh_degree is None: # sh_degree = int(math.sqrt(colors.shape[1]) - 1) # colors = spherical_harmonics(sh_degree, viewdirs, colors) # [N, 3] # background = ( # backgrounds[cid] # if backgrounds is not None # else torch.zeros(colors.shape[-1], device=means.device) # ) # render_colors_, render_alphas_ = rasterize_gaussians( # xys=means2d, # depths=depths, # radii=radii, # conics=conics, # num_tiles_hit=num_tiles_hit, # colors=colors, # opacity=opacities[..., None], # img_height=height, # img_width=width, # block_width=tile_size, # background=background, # return_alpha=True, # ) # render_colors.append(render_colors_) # render_alphas.append(render_alphas_[..., None]) # render_colors = torch.stack(render_colors, dim=0) # render_alphas = torch.stack(render_alphas, dim=0) # return render_colors, render_alphas, {}
[docs] def rasterization_inria_wrapper( means: Tensor, # [..., N, 3] quats: Tensor, # [..., N, 4] scales: Tensor, # [..., N, 3] opacities: Tensor, # [..., N] colors: Tensor, # [..., N, D] for post-activation colors, or [N, K, 3] for SH coefficients viewmats: Tensor, # [..., C, 4, 4] Ks: Tensor, # [..., C, 3, 3] width: int, height: int, near_plane: float = 0.01, far_plane: float = 100.0, eps2d: float = 0.3, sh_degree: Optional[int] = None, backgrounds: Optional[Tensor] = None, **kwargs, ) -> Tuple[Tensor, Tensor, Dict]: """Wrapper for Inria's rasterization backend. .. warning:: This function exists for comparison purpose only. Only rendered image is returned. .. warning:: Inria's CUDA backend has its own LICENSE, so this function should be used with the respect to the original LICENSE at: https://github.com/graphdeco-inria/diff-gaussian-rasterization """ from diff_gaussian_rasterization import ( GaussianRasterizationSettings, GaussianRasterizer, ) assert eps2d == 0.3, "This is hard-coded in CUDA to be 0.3" batch_dims = means.shape[:-2] num_batch_dims = len(batch_dims) N = means.shape[-2] B = math.prod(batch_dims) C = viewmats.shape[-3] I = B * C device = means.device channels = colors.shape[-1] assert means.shape == batch_dims + (N, 3), means.shape assert quats.shape == batch_dims + (N, 4), quats.shape assert scales.shape == batch_dims + (N, 3), scales.shape assert opacities.shape == batch_dims + (N,), opacities.shape assert viewmats.shape == batch_dims + (C, 4, 4), viewmats.shape assert Ks.shape == batch_dims + (C, 3, 3), Ks.shape if sh_degree is None: # treat colors as post-activation values, should be in shape [..., N, D] or [..., C, N, D] assert ( colors.dim() == num_batch_dims + 2 and colors.shape[:-1] == batch_dims + (N,) ) or ( colors.dim() == num_batch_dims + 3 and colors.shape[:-1] == batch_dims + (C, N) ), colors.shape else: # treat colors as SH coefficients, must be in shape [N, K, 3]. # Allowing for activating partial SH bands. assert ( colors.dim() == 3 and colors.shape[0] == N and colors.shape[-1] == 3 ), colors.shape assert (sh_degree + 1) ** 2 <= colors.shape[-2], colors.shape # flatten all batch dimensions means = means.reshape(B, N, 3) quats = quats.reshape(B, N, 4) scales = scales.reshape(B, N, 3) opacities = opacities.reshape(B, N) viewmats = viewmats.reshape(B, C, 4, 4) Ks = Ks.reshape(B, C, 3, 3) if sh_degree is not None: # SH coefficients are deduplicated as (N, K, 3), shared across batch dims. colors = colors.unsqueeze(0).expand(B, -1, -1, -1) elif colors.dim() == num_batch_dims + 2: colors = colors.reshape(B, N, -1) elif colors.dim() == num_batch_dims + 3: colors = colors.reshape(B, C, N, -1) # rasterization from inria does not do normalization internally quats = F.normalize(quats, dim=-1) # [N, 4] render_colors = [] for bid in range(B): for cid in range(C): FoVx = 2 * math.atan(width / (2 * Ks[bid, cid, 0, 0].item())) FoVy = 2 * math.atan(height / (2 * Ks[bid, cid, 1, 1].item())) tanfovx = math.tan(FoVx * 0.5) tanfovy = math.tan(FoVy * 0.5) world_view_transform = viewmats[bid, cid].transpose(0, 1) projection_matrix = get_projection_matrix( znear=near_plane, zfar=far_plane, fovX=FoVx, fovY=FoVy, device=device ).transpose(0, 1) full_proj_transform = ( world_view_transform.unsqueeze(0).bmm(projection_matrix.unsqueeze(0)) ).squeeze(0) camera_center = world_view_transform.inverse()[3, :3] background = ( backgrounds[bid, cid] if backgrounds is not None else torch.zeros(3, device=device) ) raster_settings = GaussianRasterizationSettings( image_height=height, image_width=width, tanfovx=tanfovx, tanfovy=tanfovy, bg=background, scale_modifier=1.0, viewmatrix=world_view_transform, projmatrix=full_proj_transform, sh_degree=0 if sh_degree is None else sh_degree, campos=camera_center, prefiltered=False, debug=False, ) rasterizer = GaussianRasterizer(raster_settings=raster_settings) means2D = torch.zeros_like(means, requires_grad=True, device=device) render_colors_ = [] for i in range(0, channels, 3): _colors = colors[bid, ..., i : i + 3] if _colors.shape[-1] < 3: pad = torch.zeros( _colors.shape[:-1], 3 - _colors.shape[-1], device=device ) _colors = torch.cat([_colors, pad], dim=-1) _render_colors_, radii = rasterizer( means3D=means[bid], means2D=means2D[bid], shs=_colors if colors.dim() == 4 else None, colors_precomp=_colors if colors.dim() == 3 else None, opacities=opacities[..., None], scales=scales[bid], rotations=quats[bid], cov3D_precomp=None, ) if _colors.shape[-1] < 3: _render_colors_ = _render_colors_[..., : _colors.shape[-1]] render_colors_.append(_render_colors_) render_colors_ = torch.cat(render_colors_, dim=-1) render_colors_ = render_colors_.permute(1, 2, 0) # [H, W, 3] render_colors.append(render_colors_) render_colors = torch.stack(render_colors, dim=0) render_colors = render_colors.reshape(batch_dims + (height, width, channels)) return render_colors, None, {}
###### 2DGS ######
[docs] def rasterization_2dgs( means: Tensor, # [..., N, 3] quats: Tensor, # [..., N, 4] scales: Tensor, # [..., N, 3] opacities: Tensor, # [..., N] colors: Tensor, # [..., (C,) N, D] for post-activation colors, or [N, K, D] for SH coefficients viewmats: Tensor, # [..., C, 4, 4] Ks: Tensor, # [..., C, 3, 3] width: int, height: int, near_plane: float = 0.01, far_plane: float = 1e10, radius_clip: float = 0.0, eps2d: float = 0.3, sh_degree: Optional[int] = None, packed: bool = False, tile_size: int = 16, backgrounds: Optional[Tensor] = None, render_mode: RenderMode = "RGB", sparse_grad: bool = False, absgrad: bool = False, distloss: bool = False, depth_mode: Literal["expected", "median"] = "expected", ) -> Tuple[Tensor, Tensor, Tensor, Tensor, Tensor, Tensor, Dict]: """Rasterize a set of 2D Gaussians (N) to a batch of image planes (C). This function supports a handful of features, similar to the :func:`rasterization` function. .. warning:: This function is currently not differentiable w.r.t. the camera intrinsics `Ks`. Args: means: The 3D centers of the Gaussians. [..., N, 3] quats: The quaternions of the Gaussians (wxyz convension). It's not required to be normalized. [..., N, 4] scales: The scales of the Gaussians. [..., N, 3] opacities: The opacities of the Gaussians. [..., N] colors: The colors of the Gaussians. [..., (C,) N, D] for post-activation colors, or [N, K, D] for SH coefficients (shared across batch/camera dims). viewmats: The world-to-cam transformation of the cameras. [..., C, 4, 4] Ks: The camera intrinsics. [..., C, 3, 3] width: The width of the image. height: The height of the image. near_plane: The near plane for clipping. Default is 0.01. far_plane: The far plane for clipping. Default is 1e10. radius_clip: Gaussians with 2D radius smaller or equal than this value will be skipped. This is extremely helpful for speeding up large scale scenes. Default is 0.0. eps2d: An epsilon added to the egienvalues of projected 2D covariance matrices. This will prevents the projected GS to be too small. For example eps2d=0.3 leads to minimal 3 pixel unit. Default is 0.3. sh_degree: The SH degree to use, which can be smaller than the total number of bands. If set, the `colors` should be [N, K, D] SH coefficients (shared across batch/camera dims), else the `colors` should [(C,) N, D] post-activation color values. Default is None. packed: Whether to use packed mode which is more memory efficient but might or might not be as fast. Default is True. tile_size: The size of the tiles for rasterization. Default is 16. (Note: other values are not tested) backgrounds: The background colors. [C, D]. Default is None. render_mode: The rendering mode. Supported modes are "RGB", "Ed", "D", "ED", "RGB+D", and "RGB+ED". "RGB" renders the colored image. Gaussian depth modes (D, ED, RGB+D, RGB+ED) use projection depth. Expected modes (Ed, ED) are normalized by opacity. Default is "RGB". sparse_grad (Experimental): If true, the gradients for {means, quats, scales} will be stored in a COO sparse layout. This can be helpful for saving memory. Default is False. absgrad: If true, the absolute gradients of the projected 2D means will be computed during the backward pass, which could be accessed by `meta["means2d"].absgrad`. Default is False. channel_chunk: The number of channels to render in one go. Default is 32. If the required rendering channels are larger than this value, the rendering will be done looply in chunks. distloss: If true, use distortion regularization to get better geometry detail. depth_mode: render depth mode. Choose from expected depth and median depth. Returns: A tuple: **render_colors**: The rendered colors. [..., C, height, width, X]. X depends on the `render_mode` and input `colors`. If `render_mode` is "RGB", X is D; if `render_mode` is "D" or "ED", X is 1; if `render_mode` is "RGB+D" or "RGB+ED", X is D+1. **render_alphas**: The rendered alphas. [..., C, height, width, 1]. **render_normals**: The rendered normals. [..., C, height, width, 3]. **surf_normals**: surface normal from depth. [..., C, height, width, 3] **render_distort**: The rendered distortions. [..., C, height, width, 1]. L1 version, different from L2 version in 2DGS paper. **render_median**: The rendered median depth. [..., C, height, width, 1]. **meta**: A dictionary of intermediate results of the rasterization. Examples: .. code-block:: python >>> # define Gaussians >>> means = torch.randn((100, 3), device=device) >>> quats = torch.randn((100, 4), device=device) >>> scales = torch.rand((100, 3), device=device) * 0.1 >>> colors = torch.rand((100, 3), device=device) >>> opacities = torch.rand((100,), device=device) >>> # define cameras >>> viewmats = torch.eye(4, device=device)[None, :, :] >>> Ks = torch.tensor([ >>> [300., 0., 150.], [0., 300., 100.], [0., 0., 1.]], device=device)[None, :, :] >>> width, height = 300, 200 >>> # render >>> colors, alphas, normals, surf_normals, distort, median_depth, meta = rasterization_2dgs( >>> means, quats, scales, opacities, colors, viewmats, Ks, width, height >>> ) >>> print (colors.shape, alphas.shape) torch.Size([1, 200, 300, 3]) torch.Size([1, 200, 300, 1]) >>> print (normals.shape, surf_normals.shape) torch.Size([1, 200, 300, 3]) torch.Size([1, 200, 300, 3]) >>> print (distort.shape, median_depth.shape) torch.Size([1, 200, 300, 1]) torch.Size([1, 200, 300, 1]) >>> print (meta.keys()) dict_keys(['camera_ids', 'gaussian_ids', 'radii', 'means2d', 'depths', 'ray_transforms', 'opacities', 'normals', 'tile_width', 'tile_height', 'tiles_per_gauss', 'isect_ids', 'flatten_ids', 'isect_offsets', 'width', 'height', 'tile_size', 'n_cameras', 'render_distort', 'gradient_2dgs']) """ ( render_colors, render_alphas, render_normals, render_normals_from_depth, render_distort, render_median, means2d_absgrad, camera_ids, gaussian_ids, radii, means2d, depths, ray_transforms, opacities, normals, tiles_per_gauss, isect_ids, flatten_ids, isect_offsets, densify, tile_width, tile_height, n_cameras, ) = _make_lazy_cuda_func("rasterization_2dgs")( means.contiguous(), quats.contiguous(), scales.contiguous(), opacities.contiguous(), colors.contiguous(), viewmats.contiguous(), Ks.contiguous(), width, height, tile_size, eps2d, near_plane, far_plane, radius_clip, backgrounds.contiguous() if backgrounds is not None else None, packed, sparse_grad, absgrad, distloss, sh_degree, render_mode, depth_mode, ) if absgrad: means2d.absgrad = means2d_absgrad meta = { "camera_ids": camera_ids, "gaussian_ids": gaussian_ids, "radii": radii, "means2d": means2d, "depths": depths, "ray_transforms": ray_transforms, "opacities": opacities, "normals": normals, "tile_width": tile_width, "tile_height": tile_height, "tiles_per_gauss": tiles_per_gauss, "isect_ids": isect_ids, "flatten_ids": flatten_ids, "isect_offsets": isect_offsets, "width": width, "height": height, "tile_size": tile_size, "n_cameras": n_cameras, "render_distort": render_distort, "gradient_2dgs": densify, # This holds the gradient used for densification for 2dgs } return ( render_colors, render_alphas, render_normals, render_normals_from_depth, render_distort, render_median, meta, )
[docs] def rasterization_2dgs_inria_wrapper( means: Tensor, # [N, 3] quats: Tensor, # [N, 4] scales: Tensor, # [N, 3] opacities: Tensor, # [N] colors: Tensor, # [N, D] or [N, K, 3] viewmats: Tensor, # [C, 4, 4] Ks: Tensor, # [C, 3, 3] width: int, height: int, near_plane: float = 0.01, far_plane: float = 100.0, eps2d: float = 0.3, sh_degree: Optional[int] = None, backgrounds: Optional[Tensor] = None, depth_ratio: int = 0, **kwargs, ) -> Tuple[Tuple, Dict]: """Wrapper for 2DGS's rasterization backend which is based on Inria's backend. Install the 2DGS rasterization backend from https://github.com/hbb1/diff-surfel-rasterization Credit to Jeffrey Hu https://github.com/jefequien """ from diff_surfel_rasterization import ( GaussianRasterizationSettings, GaussianRasterizer, ) assert eps2d == 0.3, "This is hard-coded in CUDA to be 0.3" C = len(viewmats) device = means.device channels = colors.shape[-1] # rasterization from inria does not do normalization internally quats = F.normalize(quats, dim=-1) # [N, 4] scales = scales[:, :2] # [N, 2] render_colors = [] for cid in range(C): FoVx = 2 * math.atan(width / (2 * Ks[cid, 0, 0].item())) FoVy = 2 * math.atan(height / (2 * Ks[cid, 1, 1].item())) tanfovx = math.tan(FoVx * 0.5) tanfovy = math.tan(FoVy * 0.5) world_view_transform = viewmats[cid].transpose(0, 1) projection_matrix = get_projection_matrix( znear=near_plane, zfar=far_plane, fovX=FoVx, fovY=FoVy, device=device ).transpose(0, 1) full_proj_transform = ( world_view_transform.unsqueeze(0).bmm(projection_matrix.unsqueeze(0)) ).squeeze(0) camera_center = world_view_transform.inverse()[3, :3] background = ( backgrounds[cid] if backgrounds is not None else torch.zeros(3, device=device) ) raster_settings = GaussianRasterizationSettings( image_height=height, image_width=width, tanfovx=tanfovx, tanfovy=tanfovy, bg=background, scale_modifier=1.0, viewmatrix=world_view_transform, projmatrix=full_proj_transform, sh_degree=0 if sh_degree is None else sh_degree, campos=camera_center, prefiltered=False, debug=False, ) rasterizer = GaussianRasterizer(raster_settings=raster_settings) means2D = torch.zeros_like(means, requires_grad=True, device=device) render_colors_ = [] for i in range(0, channels, 3): _colors = colors[..., i : i + 3] if _colors.shape[-1] < 3: pad = torch.zeros( _colors.shape[0], 3 - _colors.shape[-1], device=device ) _colors = torch.cat([_colors, pad], dim=-1) _render_colors_, radii, allmap = rasterizer( means3D=means, means2D=means2D, shs=_colors if colors.dim() == 3 else None, colors_precomp=_colors if colors.dim() == 2 else None, opacities=opacities[:, None], scales=scales, rotations=quats, cov3D_precomp=None, ) if _colors.shape[-1] < 3: _render_colors_ = _render_colors_[:, :, : _colors.shape[-1]] render_colors_.append(_render_colors_) render_colors_ = torch.cat(render_colors_, dim=-1) render_colors_ = render_colors_.permute(1, 2, 0) # [H, W, 3] render_colors.append(render_colors_) render_colors = torch.stack(render_colors, dim=0) # additional maps allmap = allmap.permute(1, 2, 0).unsqueeze(0) # [1, H, W, C] render_depth_expected = allmap[..., 0:1] render_alphas = allmap[..., 1:2] render_normal = allmap[..., 2:5] render_depth_median = allmap[..., 5:6] render_dist = allmap[..., 6:7] render_normal = render_normal @ (world_view_transform[:3, :3].T) render_depth_expected = render_depth_expected / render_alphas render_depth_expected = torch.nan_to_num(render_depth_expected, 0, 0) render_depth_median = torch.nan_to_num(render_depth_median, 0, 0) # render_depth is either median or expected by setting depth_ratio to 1 or 0 # for bounded scene, use median depth, i.e., depth_ratio = 1; # for unbounded scene, use expected depth, i.e., depth_ratio = 0, to reduce disk aliasing. render_depth = ( render_depth_expected * (1 - depth_ratio) + (depth_ratio) * render_depth_median ) normals_surf = depth_to_normal( render_depth, torch.linalg.inv_ex(viewmats).inverse, Ks ) normals_surf = normals_surf * (render_alphas).detach() render_colors = torch.cat([render_colors, render_depth], dim=-1) meta = { "normals_rend": render_normal, "normals_surf": normals_surf, "render_distloss": render_dist, "means2d": means2D, "width": width, "height": height, "radii": radii.unsqueeze(0), "n_cameras": C, "gaussian_ids": None, } return (render_colors, render_alphas), meta