vendored model

This commit is contained in:
Andrea Pozzetti
2025-12-19 20:46:51 +01:00
parent c9695ded35
commit 2b0f8ec966
45 changed files with 5452 additions and 42 deletions
+5
View File
@@ -0,0 +1,5 @@
"""Contains utils packages.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
+386
View File
@@ -0,0 +1,386 @@
"""Contains utility functionality to render different modalities.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
import dataclasses
from typing import Literal, NamedTuple
import numpy as np
import torch
from .gaussians import Gaussians3D
from .linalg import eyes
TrajetoryType = Literal["swipe", "shake", "rotate", "rotate_forward"]
LookAtMode = Literal["point", "ahead"]
@dataclasses.dataclass
class CameraInfo:
"""Camera info for a pinhole camera."""
intrinsics: torch.Tensor
extrinsics: torch.Tensor
width: int
height: int
class FocusRange(NamedTuple):
"""Parametrizes a range of depth / disparity values."""
min: float
focus: float
max: float
@dataclasses.dataclass
class TrajectoryParams:
"""Parameters for trajectory."""
type: TrajetoryType = "rotate_forward"
lookat_mode: LookAtMode = "point"
max_disparity: float = 0.08
max_zoom: float = 0.15
distance_m: float = 0.0
num_steps: int = 60
num_repeats: int = 1
def compute_max_offset(
scene: Gaussians3D,
params: TrajectoryParams,
resolution_px: tuple[int, int],
f_px: float,
) -> np.ndarray:
"""Compute the maximum offset for camera along X/Y/Z axis."""
scene_points = scene.mean_vectors
extrinsics = torch.eye(4).to(scene_points.device)
min_depth, _, _ = _compute_depth_quantiles(scene_points, extrinsics)
r_px = resolution_px
diagonal = np.sqrt((r_px[0] / f_px) ** 2 + (r_px[1] / f_px) ** 2)
max_lateral_offset_m = params.max_disparity * diagonal * min_depth
max_medial_offset_m = params.max_zoom * min_depth
max_offset_xyz_m = np.array([max_lateral_offset_m, max_lateral_offset_m, max_medial_offset_m])
return max_offset_xyz_m
def create_eye_trajectory(
scene: Gaussians3D,
params: TrajectoryParams,
resolution_px: tuple[int, int],
f_px: float,
) -> list[torch.Tensor]:
"""Create eye trajectory for trajectory type."""
max_offset_xyz_m = compute_max_offset(
scene,
params,
resolution_px,
f_px,
)
# We place the eye trajectory at z=distance plane (default=0),
# assuming portal plane is placed at z=natural_distance.
if params.type == "swipe":
return create_eye_trajectory_swipe(
max_offset_xyz_m, params.distance_m, params.num_steps, params.num_repeats
)
elif params.type == "shake":
return create_eye_trajectory_shake(
max_offset_xyz_m, params.distance_m, params.num_steps, params.num_repeats
)
elif params.type == "rotate":
return create_eye_trajectory_rotate(
max_offset_xyz_m, params.distance_m, params.num_steps, params.num_repeats
)
elif params.type == "rotate_forward":
return create_eye_trajectory_rotate_forward(
max_offset_xyz_m, params.distance_m, params.num_steps, params.num_repeats
)
else:
raise ValueError(f"Invalid trajectory type {params.type}.")
def create_eye_trajectory_swipe(
offset_xyz_m: np.ndarray,
distance_m: float,
num_steps: int,
num_repeats: int,
) -> list[torch.Tensor]:
"""Create a left to right swipe trajectory."""
offset_x_m, _, _ = offset_xyz_m
eye_positions = [
torch.tensor([x, 0, distance_m], dtype=torch.float32)
for x in np.linspace(-offset_x_m, offset_x_m, num_steps)
]
return eye_positions * num_repeats
def create_eye_trajectory_shake(
offset_xyz_m: np.ndarray,
distance_m: float,
num_steps: int,
num_repeats: int,
) -> list[torch.Tensor]:
"""Create a left right shake followed by an up down shake trajectory."""
num_steps_total = num_steps * num_repeats
num_steps_horizontal = num_steps_total // 2
num_steps_vertical = num_steps_total - num_steps_horizontal
offset_x_m, offset_y_m, _ = offset_xyz_m
eye_positions: list[torch.Tensor] = []
eye_positions.extend(
torch.tensor(
[offset_x_m * np.sin(2 * np.pi * t), 0.0, distance_m],
dtype=torch.float32,
)
for t in np.linspace(0, num_repeats, num_steps_horizontal)
)
eye_positions.extend(
torch.tensor(
[0.0, offset_y_m * np.sin(2 * np.pi * t), distance_m],
dtype=torch.float32,
)
for t in np.linspace(0, num_repeats, num_steps_vertical)
)
return eye_positions
def create_eye_trajectory_rotate(
offset_xyz_m: np.ndarray,
distance_m: float,
num_steps: int,
num_repeats: int,
) -> list[torch.Tensor]:
"""Create a rotating trajectory."""
num_steps_total = num_steps * num_repeats
offset_x_m, offset_y_m, _ = offset_xyz_m
eye_positions = [
torch.tensor(
[
offset_x_m * np.sin(2 * np.pi * t),
offset_y_m * np.cos(2 * np.pi * t),
distance_m,
],
dtype=torch.float32,
)
for t in np.linspace(0, num_repeats, num_steps_total)
]
return eye_positions
def create_eye_trajectory_rotate_forward(
offset_xyz_m: np.ndarray,
distance_m: float,
num_steps: int,
num_repeats: int,
) -> list[torch.Tensor]:
"""Create a rotating trajectory."""
num_steps_total = num_steps * num_repeats
offset_x_m, _, offset_z_m = offset_xyz_m
eye_positions = [
torch.tensor(
[
offset_x_m * np.sin(2 * np.pi * t),
0.0,
distance_m + offset_z_m * (1.0 - np.cos(2 * np.pi * t)) / 2,
],
dtype=torch.float32,
)
for t in np.linspace(0, num_repeats, num_steps_total)
]
return eye_positions
def create_camera_model(
scene: Gaussians3D,
intrinsics: torch.Tensor,
resolution_px: tuple[int, int],
lookat_mode: LookAtMode = "point",
) -> PinholeCameraModel:
"""Create camera model to simulate general pinhole camera."""
screen_extrinsics = torch.eye(4)
screen_intrinsics = intrinsics.clone()
image_width, image_height = resolution_px
screen_resolution_px = get_screen_resolution_px_from_input(
width=image_width, height=image_height
)
screen_intrinsics[0] *= screen_resolution_px[0] / image_width
screen_intrinsics[1] *= screen_resolution_px[1] / image_height
camera_model = PinholeCameraModel(
scene,
screen_extrinsics=screen_extrinsics,
screen_intrinsics=screen_intrinsics,
screen_resolution_px=screen_resolution_px,
focus_depth_quantile=0.1,
min_depth_focus=2.0,
lookat_mode=lookat_mode,
)
return camera_model
def create_camera_matrix(
position: torch.Tensor,
look_at_position: torch.Tensor | None = None,
world_up: torch.Tensor | None = None,
inverse: bool = False,
) -> torch.Tensor:
"""Create camera matrix from vectors."""
device = position.device
if look_at_position is None:
look_at_position = torch.zeros(3, device=device)
if world_up is None:
world_up = torch.tensor([0.0, 0.0, 1.0], device=device)
position, look_at_position, world_up = torch.broadcast_tensors(
position, look_at_position, world_up
)
camera_front = look_at_position - position
camera_front = camera_front / camera_front.norm(dim=-1, keepdim=True)
camera_right = torch.cross(camera_front, world_up, dim=-1)
camera_right = camera_right / camera_right.norm(dim=-1, keepdim=True)
camera_down = torch.cross(camera_front, camera_right, dim=-1)
rotation_matrix = torch.stack([camera_right, camera_down, camera_front], dim=-1)
matrix = eyes(dim=4, shape=position.shape[:-1], device=device)
if inverse:
matrix[..., :3, :3] = rotation_matrix.transpose(-1, -2)
matrix[..., :3, 3:4] = -rotation_matrix.transpose(-1, -2) @ position[..., None]
else:
matrix[..., :3, :3] = rotation_matrix
matrix[..., :3, 3] = position
return matrix
class PinholeCameraModel:
"""Camera model that focuses on point."""
def __init__(
self,
scene: Gaussians3D,
screen_extrinsics: torch.Tensor,
screen_intrinsics: torch.Tensor,
screen_resolution_px: tuple[int, int],
focus_depth_quantile: float = 0.1,
min_depth_focus: float = 2.0,
lookat_point: tuple[float, float, float] | None = None,
lookat_mode: LookAtMode = "point",
) -> None:
"""Initialize GeneralPinholeCameraModel.
Args:
scene: The scene to display.
screen_extrinsics: Extrinsics of the default position.
screen_intrinsics: Intrinsics to use for rendering.
screen_resolution_px: Width and height to render.
focus_depth_quantile: Where inside the depth range to focus on.
min_depth_focus: Depth to focus at.
lookat_point: a point that the camera's Z axis directs towards.
lookat_mode: "point" to look at a fixed point,
"ahead" to look straight ahead.
"""
self.scene = scene
self.screen_extrinsics = screen_extrinsics
self.screen_intrinsics = screen_intrinsics
self.screen_resolution_px = screen_resolution_px
self.focus_depth_quantile = focus_depth_quantile
self.min_depth_focus = min_depth_focus
self.lookat_point = lookat_point
self.lookat_mode = lookat_mode
scene_points = scene.mean_vectors
if scene_points.ndim == 3:
scene_points = scene_points[0]
elif scene_points.ndim != 2:
raise ValueError("Unsupported dimensionality of scene points.")
self._scene_points = scene_points.cpu()
self.depth_quantiles = _compute_depth_quantiles(
self._scene_points,
self.screen_extrinsics,
q_focus=self.focus_depth_quantile,
)
def compute(self, eye_pos: torch.Tensor) -> CameraInfo:
"""Compute camera for eye position."""
extrinsics = self.screen_extrinsics.clone()
origin = eye_pos if self.lookat_mode == "ahead" else torch.zeros(3)
if self.lookat_point is None:
depth_focus = max(self.min_depth_focus, self.depth_quantiles.focus)
look_at_position = origin + torch.tensor([0.0, 0.0, depth_focus])
else:
look_at_position = origin + torch.tensor([*self.lookat_point])
world_up = torch.tensor([0.0, -1.0, 0.0])
extrinsics_modifier = create_camera_matrix(
eye_pos, look_at_position, world_up, inverse=True
)
extrinsics = extrinsics_modifier @ self.screen_extrinsics
camera_info = CameraInfo(
intrinsics=self.screen_intrinsics,
extrinsics=extrinsics,
width=self.screen_resolution_px[0],
height=self.screen_resolution_px[1],
)
return camera_info
def set_screen_extrinsics(self, new_value: torch.Tensor) -> None:
"""Modify the default extrinsics."""
self.screen_extrinsics = new_value
self.depth_quantiles = _compute_depth_quantiles(self._scene_points, self.screen_extrinsics)
def get_screen_resolution_px_from_input(width: int, height: int) -> tuple[int, int]:
"""Get resolution for metadata dictionary."""
resolution_px = (width, height)
# halve the dimensions for super large image
if resolution_px[1] > 3000:
resolution_px = (resolution_px[0] // 2, resolution_px[1] // 2)
# for mp4 compatibility, enforce dimensions to even number,
# otherwise could not be played in browser
if resolution_px[0] % 2 != 0:
resolution_px = (resolution_px[0] + 1, resolution_px[1])
if resolution_px[1] % 2 != 0:
resolution_px = (resolution_px[0], resolution_px[1] + 1)
return resolution_px
def _compute_depth_quantiles(
points: torch.Tensor,
extrinsics: torch.Tensor,
q_near: float = 0.001,
q_focus: float = 0.1,
q_far: float = 0.999,
) -> FocusRange:
"""Compute disparity quantiles for scene and extrinsics id."""
points_local = points @ extrinsics[:3, :3].T + extrinsics[:3, 3]
depth_values = points_local[..., 2].flatten()
depth_values = depth_values[depth_values > 0]
q_values = torch.tensor([q_near, q_focus, q_far])
depth_quantiles_pt = torch.quantile(depth_values.cpu(), q_values)
depth_quantiles = FocusRange(
min=float(depth_quantiles_pt[0]),
focus=float(depth_quantiles_pt[1]),
max=float(depth_quantiles_pt[2]),
)
return depth_quantiles
+88
View File
@@ -0,0 +1,88 @@
"""Contains color space utility functions.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
import logging
from typing import Literal
import torch
from sharp.utils.robust import robust_where
LOGGER = logging.getLogger(__name__)
ColorSpace = Literal["sRGB", "linearRGB"]
def encode_color_space(color_space: ColorSpace) -> int:
"""Encode color space to integer."""
return 0 if color_space == "sRGB" else 1
def decode_color_space(color_space_index: int) -> ColorSpace:
"""Decode color space index to color space."""
return "sRGB" if color_space_index == 0 else "linearRGB"
def sRGB2linearRGB(sRGB: torch.Tensor) -> torch.Tensor:
"""SRGB to linearRGB conversion function.
Reference:
https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf
Section 7.7.7
Args:
sRGB: Input image tensor in sRGB space.
"""
# We need to use robust_where to clamp the second branch.
# Otherwise, torch.where will lead to NaN in the backward pass, see
# https://github.com/pytorch/pytorch/issues/68425
THRESHOLD = 0.04045
def branch_true_func(x):
return x / 12.92
def branch_false_func(x):
return ((x + 0.055) / 1.055) ** 2.4
return robust_where(
sRGB <= THRESHOLD,
sRGB,
branch_true_func,
branch_false_func,
branch_false_safe_value=THRESHOLD,
)
def linearRGB2sRGB(linearRGB: torch.Tensor) -> torch.Tensor:
"""LinearRGB to sRGB conversion function.
Reference:
https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf
Section 7.7.7
Args:
linearRGB: Input image tensor in linearRGB space.
"""
# We need to use robust_where to clamp the second branch.
# Otherwise, torch.where will lead to NaN in the backward pass, see
# https://github.com/pytorch/pytorch/issues/68425
THRESHOLD = 0.0031308
def branch_true_func(x):
return x * 12.92
def branch_false_func(x):
return 1.055 * (x ** (1 / 2.4)) - 0.055
return robust_where(
linearRGB <= THRESHOLD,
linearRGB,
branch_true_func,
branch_false_func,
branch_false_safe_value=THRESHOLD,
)
+483
View File
@@ -0,0 +1,483 @@
"""Contains basic data structures and functionality for 3D Gaussians.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any, Literal, NamedTuple
import numpy as np
import torch
from plyfile import PlyData, PlyElement
from sharp.utils import color_space as cs_utils
from sharp.utils import linalg
LOGGER = logging.getLogger(__name__)
BackgroundColor = Literal["black", "white", "random_color", "random_pixel"]
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
def to(self, device: torch.device) -> 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),
)
class SceneMetaData(NamedTuple):
"""Meta data about Gaussian scene."""
focal_length_px: float
resolution_px: tuple[int, int]
color_space: cs_utils.ColorSpace
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
image_width, image_height = image_shape
# This matrix converts OpenCV pixel coordinates to NDC coordinates where
# (-1, 1) denotes the top left and (1, 1) the bottom right of the image.
#
# Note that premultiplying the intrinsics with ndc_matrix typically yields a matrix
# that simply scales the x-axis by 2 * focal_length / image_width and the y-axis by
# 2 * focal_length / image_height.
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,
)
return torch.linalg.inv(ndc_matrix @ intrinsics @ extrinsics)
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."""
unprojection_matrix = get_unprojection_matrix(extrinsics, intrinsics, image_shape)
gaussians = apply_transform(gaussians_ndc, unprojection_matrix[:3])
return gaussians
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 3x4.
Returns:
The transformed Gaussians.
Note: This operation is not differentiable.
"""
transform_linear = transform[..., :3, :3]
transform_offset = transform[..., :3, 3]
mean_vectors = gaussians.mean_vectors @ transform_linear.T + transform_offset
covariance_matrices = compose_covariance_matrices(
gaussians.quaternions, gaussians.singular_values
)
covariance_matrices = (
transform_linear @ covariance_matrices @ transform_linear.transpose(-1, -2)
)
quaternions, singular_values = decompose_covariance_matrices(covariance_matrices)
return Gaussians3D(
mean_vectors=mean_vectors,
singular_values=singular_values,
quaternions=quaternions,
colors=gaussians.colors,
opacities=gaussians.opacities,
)
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.
"""
device = covariance_matrices.device
dtype = covariance_matrices.dtype
# We convert to fp64 to avoid numerical errors.
covariance_matrices = covariance_matrices.detach().cpu().to(torch.float64)
rotations, singular_values_2, _ = torch.linalg.svd(covariance_matrices)
# NOTE: in SVD, it is possible that U and VT are both reflections.
# We need to correct them.
batch_idx, gaussian_idx = torch.where(torch.linalg.det(rotations) < 0)
num_reflections = len(gaussian_idx)
if num_reflections > 0:
LOGGER.warning(
"Received %d reflection matrices from SVD. Flipping them to rotations.",
num_reflections,
)
# Flip the last column of reflection and make it a rotation.
rotations[batch_idx, gaussian_idx, :, -1] *= -1
quaternions = linalg.quaternions_from_rotation_matrices(rotations)
quaternions = quaternions.to(dtype=dtype, device=device)
singular_values = singular_values_2.sqrt().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 covariances matrices.
"""
device = quaternions.device
rotations = linalg.rotation_matrices_from_quaternions(quaternions)
diagonal_matrix = torch.eye(3, device=device) * singular_values[..., :, None]
return rotations @ diagonal_matrix.square() @ rotations.transpose(-1, -2)
def convert_spherical_harmonics_to_rgb(sh0: torch.Tensor) -> torch.Tensor:
"""Convert degree-0 spherical harmonics to RGB.
Reference:
https://en.wikipedia.org/wiki/Table_of_spherical_harmonics
"""
coeff_degree0 = np.sqrt(1.0 / (4.0 * np.pi))
return sh0 * coeff_degree0 + 0.5
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 load_ply(path: Path) -> tuple[Gaussians3D, SceneMetaData]:
"""Loads a ply from a file."""
plydata = PlyData.read(path)
vertices = next(filter(lambda x: x.name == "vertex", plydata.elements))
properties = ["x", "y", "z"]
properties.extend([f"f_dc_{i}" for i in range(3)])
properties.extend([f"scale_{i}" for i in range(3)])
properties.extend([f"rot_{i}" for i in range(3)])
for prop in properties:
if prop not in vertices:
raise KeyError(f"Incompatible ply file: property {prop} not found in ply elements.")
mean_vectors = np.stack(
(
np.asarray(vertices["x"]),
np.asarray(vertices["y"]),
np.asarray(vertices["z"]),
),
axis=1,
)
scale_logits = np.stack(
(
np.asarray(vertices["scale_0"]),
np.asarray(vertices["scale_1"]),
np.asarray(vertices["scale_2"]),
),
axis=1,
)
quaternions = np.stack(
(
np.asarray(vertices["rot_0"]),
np.asarray(vertices["rot_1"]),
np.asarray(vertices["rot_2"]),
np.asarray(vertices["rot_3"]),
),
axis=1,
)
spherical_harmonics_deg0 = np.stack(
(
np.asarray(vertices["f_dc_0"]),
np.asarray(vertices["f_dc_1"]),
np.asarray(vertices["f_dc_2"]),
),
axis=1,
)
colors = convert_spherical_harmonics_to_rgb(spherical_harmonics_deg0)
opacity_logits = np.asarray(vertices["opacity"])[..., None]
supplement_elements = [element for element in plydata.elements if element.name != "vertex"]
supplement_data: dict[str, Any] = {}
supplement_keys = ["extrinsic", "intrinsic", "color_space", "image_size"]
for element in supplement_elements:
for key in supplement_keys:
if key not in supplement_data and key in element:
supplement_data[key] = np.asarray(element[key])
# Parse intrinsics and image_size.
if "intrinsic" in supplement_data:
intrinsics_data = supplement_data["intrinsic"]
# Legacy: image_size is contained in intrinsic element.
if "image_size" not in supplement_data:
if len(intrinsics_data) != 4:
raise ValueError(
"Expect legacy intrinsics with len=4 containing image size, "
f"but received len={len(intrinsics_data)}"
)
focal_length_px = (intrinsics_data[0], intrinsics_data[1])
width = int(intrinsics_data[2])
height = int(intrinsics_data[3])
else:
if len(intrinsics_data) != 9:
raise ValueError(
"Expect 9 elements in intrinsics, " f"but received {len(intrinsics_data)}."
)
intrinsics_matrix = intrinsics_data.reshape((3, 3))
focal_length_px = (intrinsics_matrix[0, 0], intrinsics_matrix[1, 1])
image_size_data = supplement_data["image_size"]
width = image_size_data[0]
height = image_size_data[1]
# Default to VGA resolution: focal length = 512, image size = (640, 480).
else:
focal_length_px = (512, 512)
width = 640
height = 480
# Parse extrinsics.
extrinsics_data = supplement_data.get("extrinsic", np.eye(4).flatten())
extrinsics_matrix = np.eye(4)
# Legacy: extrinsics store 12 elements.
if len(extrinsics_data) == 12:
extrinsics_matrix[:3] = extrinsics_data.reshape((3, 4))
extrinsics_matrix[:3, :3] = extrinsics_matrix[:3, :3].copy().T
elif len(extrinsics_data) == 16:
extrinsics_matrix[:] = extrinsics_data.reshape((4, 4))
else:
raise ValueError(f"Unrecognized extrinsics matrix shape {len(extrinsics_data)}")
# Parse color space.
color_space_index = supplement_data.get("color_space", 1)
color_space = cs_utils.decode_color_space(color_space_index)
colors = torch.from_numpy(colors).view(1, -1, 3).float()
if color_space == "sRGB":
# Convert to linearRGB for proper alpha blending.
colors = cs_utils.sRGB2linearRGB(colors.flatten(0, 1)).view(1, -1, 3)
color_space = "linearRGB"
mean_vectors = torch.from_numpy(mean_vectors).view(1, -1, 3).float()
quaternions = torch.from_numpy(quaternions).view(1, -1, 4).float()
singular_values = torch.exp(torch.from_numpy(scale_logits).view(1, -1, 3)).float()
opacities = torch.sigmoid(torch.from_numpy(opacity_logits).view(1, -1)).float()
gaussians = Gaussians3D(
mean_vectors=mean_vectors,
quaternions=quaternions,
singular_values=singular_values,
opacities=opacities,
colors=colors,
)
metadata = SceneMetaData(focal_length_px[0], (width, height), color_space)
return gaussians, metadata
@torch.no_grad()
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))
xyz = gaussians.mean_vectors.flatten(0, 1)
scale_logits = torch.log(gaussians.singular_values).flatten(0, 1)
quaternions = gaussians.quaternions.flatten(0, 1)
# SHARP takes an image, convert it to sRGB color space as input,
# and predicts linearRGB Gaussians as output.
# The SHARP renderer would blend linearRGB Gaussians and convert rendered images and videos
# back to sRGB for the best display quality.
#
# However, public renderers do not have such linear2sRGB conversions after rendering.
# If they render linearRGB Gaussians as-is, the output would be dark without Gamma correction.
#
# To make it compatible to public renderers, we force convert linearRGB to sRGB during export.
# - The SHARP renderer will still handle conversions properly.
# - Public renderers will be mostly working fine when regarding sRGB images as linearRGB images,
# although for the best performance, it is recommended to apply the conversions.
colors = convert_rgb_to_spherical_harmonics(
cs_utils.linearRGB2sRGB(gaussians.colors.flatten(0, 1))
)
color_space_index = cs_utils.encode_color_space("sRGB")
# Store opacity logits.
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")
# Load image-wise metadata.
image_height, image_width = image_shape
# Export image size.
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")
# Export intrinsics.
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")
# Export dummy extrinsics.
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")
# Export number of frames and particles per frame.
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")
# Export disparity ranges for transform.
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")
# Export colorspace.
dtype_color_space = [("color_space", "u1")]
color_space_array = np.empty(1, dtype=dtype_color_space)
color_space_array[:] = np.array([color_space_index]).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,
]
)
plydata.write(path)
return plydata
+191
View File
@@ -0,0 +1,191 @@
"""Contains utility code for gsplat renderer.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
from pathlib import Path
from typing import NamedTuple
import gsplat
import torch
from torch import nn
from sharp.utils import color_space as cs_utils
from sharp.utils import io, vis
from sharp.utils.gaussians import BackgroundColor, Gaussians3D
class RenderingOutputs(NamedTuple):
"""Outputs of 3D Gaussians renderer."""
color: torch.Tensor
depth: torch.Tensor
alpha: torch.Tensor
def write_renderings(rendering: RenderingOutputs, output_folder: Path, filename: str):
"""Write rendered color/depth/alpha to files."""
batch_size = len(rendering.color)
if batch_size != 1:
raise RuntimeError("We only support saving rendering of batch size = 1")
def _save_image_tensor(tensor: torch.Tensor, suffix: str):
np_array = tensor.permute(1, 2, 0).numpy()
io.save_image(np_array, (output_folder / filename).with_suffix(suffix))
color = (rendering.color[0].cpu() * 255.0).to(dtype=torch.uint8)
colorized_depth = vis.colorize_depth(rendering.depth[0], val_max=100.0)
colorized_alpha = vis.colorize_alpha(rendering.alpha[0])
_save_image_tensor(color, ".color.png")
_save_image_tensor(colorized_depth, ".depth.png")
_save_image_tensor(colorized_alpha, ".alpha.png")
class GSplatRenderer(nn.Module):
"""Module to render 3D Gaussians to images using gsplat."""
color_space: cs_utils.ColorSpace
background_color: BackgroundColor
def __init__(
self,
color_space: cs_utils.ColorSpace = "sRGB",
background_color: BackgroundColor = "black",
low_pass_filter_eps: float = 0.0,
) -> None:
"""Initialize gsplat renderer.
Args:
color_space: The color space to use for rendering.
background_color: The background color to use for rendering.
low_pass_filter_eps: The epsilon value for the low pass filter.
"""
super().__init__()
self.color_space = color_space
self.background_color = background_color
self.low_pass_filter_eps = low_pass_filter_eps
def forward(
self,
gaussians: Gaussians3D,
extrinsics: torch.Tensor,
intrinsics: torch.Tensor,
image_width: int,
image_height: int,
) -> RenderingOutputs:
"""Predict images from gaussians.
Args:
gaussians: The Gaussians to render.
extrinsics: The extrinsics of the camera to render to in OpenCV format.
intrinsics: The intriniscs of the camera to render to in OpenCV format.
image_width: The desired output image width.
image_height: The desired output image height.
"""
batch_size = len(gaussians.mean_vectors)
outputs_list: list[RenderingOutputs] = []
for ib in range(batch_size):
colors, alphas, meta = gsplat.rendering.rasterization(
means=gaussians.mean_vectors[ib],
quats=gaussians.quaternions[ib],
scales=gaussians.singular_values[ib],
opacities=gaussians.opacities[ib],
colors=gaussians.colors[ib],
viewmats=extrinsics[ib : ib + 1],
Ks=intrinsics[ib : ib + 1, :3, :3],
width=image_width,
height=image_height,
render_mode="RGB+D",
rasterize_mode="classic",
absgrad=False,
packed=False,
eps2d=self.low_pass_filter_eps,
)
rendered_color = colors[..., 0:3].permute([0, 3, 1, 2])
rendered_depth_unnormalized = colors[..., 3:4].permute([0, 3, 1, 2])
rendered_alpha = alphas.permute([0, 3, 1, 2])
# Compose with background color.
rendered_color = self.compose_with_background(
rendered_color, rendered_alpha, self.background_color
)
# Colorspace conversion.
if self.color_space == "sRGB":
pass
elif self.color_space == "linearRGB":
rendered_color = cs_utils.linearRGB2sRGB(rendered_color)
else:
ValueError("Unsupported ColorSpace type.")
# splats: (B, N, 10)
cov2d = self._conics_to_covars2d(meta["conics"])
# Set the cov2d of invisible splats to 1 to avoid nan in condition number calculation..
splats_visible_mask = meta["depths"] > 1e-2
cov2d[~splats_visible_mask][..., 0, 0] = 1
cov2d[~splats_visible_mask][..., 1, 1] = 1
cov2d[~splats_visible_mask][..., 0, 1] = 0
# Normalize the depth by alpha.
rendered_depth = rendered_depth_unnormalized / torch.clip(rendered_alpha, min=1e-8)
outputs = RenderingOutputs(
color=rendered_color,
depth=rendered_depth,
alpha=rendered_alpha,
)
outputs_list.append(outputs)
return RenderingOutputs(
color=torch.cat([item.color for item in outputs_list], dim=0).contiguous(),
depth=torch.cat([item.depth for item in outputs_list], dim=0).contiguous(),
alpha=torch.cat([item.alpha for item in outputs_list], dim=0).contiguous(),
)
@staticmethod
def compose_with_background(
rendered_rgb: torch.Tensor,
rendered_alpha: torch.Tensor,
background_color: BackgroundColor,
) -> torch.Tensor:
"""Compose rendered RGB with background color."""
if background_color == "black":
return rendered_rgb
elif background_color == "white":
return rendered_rgb + (1.0 - rendered_alpha)
elif background_color == "random_color":
return (
rendered_rgb
+ (1.0 - rendered_alpha)
* torch.rand(3, dtype=rendered_rgb.dtype, device=rendered_rgb.device)[
None, :, None, None
]
)
elif background_color == "random_pixel":
return rendered_rgb + (1.0 - rendered_alpha) * torch.rand_like(rendered_rgb)
else:
raise ValueError("Unsupported BackgroundColor type.")
@staticmethod
def _conics_to_covars2d(conics: torch.Tensor, eps=1e-8) -> torch.Tensor:
"""Convert conics to covariance matrices."""
a = conics[..., 0]
b = conics[..., 1]
c = conics[..., 2]
# Reconstruct determinant.
det = 1 / (a * c - b**2 + eps)
det = det.clamp(min=eps)
# Reconstruct covars2d.
covars2d = torch.zeros(*conics.shape[:-1], 2, 2, device=conics.device)
covars2d[..., 1, 1] = a * det
covars2d[..., 0, 0] = c * det
covars2d[..., 0, 1] = -b * det
covars2d[..., 1, 0] = -b * det
covars2d = torch.nan_to_num(covars2d, nan=0.0, posinf=0.0, neginf=0.0)
return covars2d
+213
View File
@@ -0,0 +1,213 @@
"""Contains image IO.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
import io
import logging
from pathlib import Path
from typing import IO, Any, Protocol
import imageio.v2 as iio
import numpy as np
import pillow_heif
import torch
from PIL import ExifTags, Image, TiffTags
from .vis import METRIC_DEPTH_MAX_CLAMP_METER, colorize_depth
LOGGER = logging.getLogger(__name__)
# NOTE: unused, kept for reference.
Image.MAX_IMAGE_PIXELS = 200000000
def load_rgb(
path: Path, auto_rotate: bool = True, remove_alpha: bool = True
) -> tuple[np.ndarray, list[bytes] | None, float]:
"""Load an RGB image."""
LOGGER.debug(f"Loading image {path} ...")
if path.suffix.lower() in [".heic"]:
heif_file = pillow_heif.open_heif(path, convert_hdr_to_8bit=True)
img_pil = heif_file.to_pillow()
else:
img_pil = Image.open(path)
img_exif = extract_exif(img_pil)
icc_profile = img_pil.info.get("icc_profile", None)
# Rotate the image.
if auto_rotate:
exif_orientation = img_exif.get("Orientation", 1)
if exif_orientation == 3:
img_pil = img_pil.transpose(Image.ROTATE_180)
elif exif_orientation == 6:
img_pil = img_pil.transpose(Image.ROTATE_270)
elif exif_orientation == 8:
img_pil = img_pil.transpose(Image.ROTATE_90)
elif exif_orientation != 1:
LOGGER.warning(f"Ignoring image orientation {exif_orientation}.")
# Extract the focal length.
f_35mm = img_exif.get("FocalLengthIn35mmFilm", img_exif.get("FocalLenIn35mmFilm", None))
if f_35mm is None or f_35mm < 1:
f_35mm = img_exif.get("FocalLength", None)
if f_35mm is None:
LOGGER.warn(f"Did not find focallength in exif data of {path} - Setting to 30mm.")
f_35mm = 30.0
if f_35mm < 10.0:
LOGGER.info("Found focal length below 10mm, assuming it's not for 35mm.")
# This is a very crude approximation.
f_35mm *= 8.4
img = np.asarray(img_pil)
# Convert to RGB if single channel.
if img.ndim < 3 or img.shape[2] == 1:
img = np.dstack((img, img, img))
if remove_alpha:
img = img[:, :, :3]
LOGGER.debug(f"\tHxW: {img.shape[0]}x{img.shape[1]}")
LOGGER.debug(f"\tfocal length @ 35mm film: {f_35mm}mm")
f_px = convert_focallength(img.shape[1], img.shape[0], f_35mm)
LOGGER.debug(f"\tfocal length: {f_px:.2f}px")
return img, icc_profile, f_px
def extract_exif(img_pil: Image.Image) -> dict[str, Any]:
"""Return exif information as a dictionary."""
# Get full exif description from get_ifd(0x8769):
# cf https://pillow.readthedocs.io/en/stable/releasenotes/8.2.0.html#image-getexif-exif-and-gps-ifd # noqa
img_exif = img_pil.getexif().get_ifd(0x8769)
exif_dict = {ExifTags.TAGS[k]: v for k, v in img_exif.items() if k in ExifTags.TAGS}
# https://pillow.readthedocs.io/en/stable/_modules/PIL/TiffTags.html# # noqa
tiff_tags = img_pil.getexif()
tiff_dict = {TiffTags.TAGS_V2[k].name: v for k, v in tiff_tags.items() if k in TiffTags.TAGS_V2}
return {**exif_dict, **tiff_dict}
def convert_focallength(width: float, height: float, f_mm: float = 30) -> float:
"""Converts a focal length given in mm to pixels."""
return f_mm * np.sqrt(width**2.0 + height**2.0) / np.sqrt(36**2 + 24**2)
def save_image(
image: np.ndarray,
output_path: Path,
icc_profile: list[bytes] | None = None,
jpeg_quality: int = 92,
) -> None:
"""Save image to given path."""
output_path.parent.mkdir(parents=True, exist_ok=True)
extensions_to_format = Image.registered_extensions()
try:
format = extensions_to_format[output_path.suffix.lower()]
except KeyError:
raise ValueError(f"Unsupported output format {output_path.suffix}.")
with output_path.open("wb") as file_handle:
write_image(
image,
file_handle,
format,
icc_profile=icc_profile,
jpeg_quality=jpeg_quality,
)
def write_image(
image: np.ndarray,
output_io: IO[bytes],
format="jpg",
icc_profile: list[bytes] | None = None,
jpeg_quality: int = 92,
):
"""Write image to binary stream."""
pil_config = {}
if format == "JPEG":
pil_config["quality"] = jpeg_quality
image_pil = Image.fromarray(image)
# Workaround to error [io.UnsupportedOperation: seek].
if format == "TIFF":
bytes_io = io.BytesIO()
image_pil.save(bytes_io, format="TIFF")
bytes_io.seek(0)
output_io.write(bytes_io.read())
return
image_pil.save(output_io, format, icc_profile=icc_profile, **pil_config)
def get_supported_image_extensions(with_heic: bool = True) -> list[str]:
"""Return supported image extensions."""
exts = Image.registered_extensions()
supported_extensions = {ex for ex, f in exts.items() if f in Image.OPEN}
if with_heic:
supported_extensions.add(".heic")
supported_extensions_upper = {ex.upper() for ex in supported_extensions}
return list(supported_extensions | supported_extensions_upper)
def get_supported_video_extensions():
"""Return supported video extensions."""
supported_extensions = {".mp4", ".mov"}
supported_extensions_upper = {ext.upper() for ext in supported_extensions}
return list(supported_extensions | supported_extensions_upper)
class OutputWriter(Protocol):
"""Protocol for writing output to disk."""
def add_frame(self, image: torch.Tensor, depth: torch.Tensor) -> None:
"""Add a single frame to output."""
...
def close(self) -> None:
"""Finish writing."""
...
class VideoWriter(OutputWriter):
"""Output writer for video output."""
def __init__(self, output_path: Path, fps: float = 30.0, render_depth: bool = True) -> None:
"""Initialize VideoWriter."""
output_path.parent.mkdir(exist_ok=True, parents=True)
self.output_path = output_path
self.image_writer = iio.get_writer(output_path, fps=fps)
self.max_depth_estimate = None
if render_depth:
self.depth_writer = iio.get_writer(output_path.with_suffix(".depth.mp4"), fps=fps)
def add_frame(self, image: torch.Tensor, depth: torch.Tensor) -> None:
"""Add a single frame to output."""
image_np = image.detach().cpu().numpy()
self.image_writer.append_data(image_np)
if self.depth_writer is not None:
if self.max_depth_estimate is None:
self.max_depth_estimate = depth.max().item()
colored_depth_pt = colorize_depth(
depth,
min(self.max_depth_estimate, METRIC_DEPTH_MAX_CLAMP_METER), # type: ignore[call-overload]
)
colored_depth_np = colored_depth_pt.squeeze(0).permute(1, 2, 0).cpu().numpy()
self.depth_writer.append_data(colored_depth_np)
def close(self):
"""Finish writing."""
self.image_writer.close()
+104
View File
@@ -0,0 +1,104 @@
"""Contains linear algebra related utility functions.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
import torch
import torch.nn.functional as F
from scipy.spatial.transform import Rotation
def rotation_matrices_from_quaternions(quaternions: torch.Tensor) -> torch.Tensor:
"""Convert batch of quaternions into rotations matrices.
Args:
quaternions: The quaternions convert to matrices.
Returns:
The rotations matrices corresponding to the (normalized) quaternions.
"""
device = quaternions.device
shape = quaternions.shape[:-1]
quaternions = quaternions / torch.linalg.norm(quaternions, dim=-1, keepdim=True)
real_part = quaternions[..., 0]
vector_part = quaternions[..., 1:]
vector_cross = get_cross_product_matrix(vector_part)
real_part = real_part[..., None, None]
matrix_outer = vector_part[..., :, None] * vector_part[..., None, :]
matrix_diag = real_part.square() * eyes(3, shape=shape, device=device)
matrix_cross_1 = 2 * real_part * vector_cross
matrix_cross_2 = vector_cross @ vector_cross
return matrix_outer + matrix_diag + matrix_cross_1 + matrix_cross_2
def quaternions_from_rotation_matrices(matrices: torch.Tensor) -> torch.Tensor:
"""Convert batch of rotation matrices to quaternions.
Args:
matrices: The matrices to convert to quaternions.
Returns:
The quaternions corresponding to the rotation matrices.
Note: this operation is not differentiable and will be performed on the CPU.
"""
if not matrices.shape[-2:] == (3, 3):
raise ValueError(f"matrices have invalid shape {matrices.shape}")
matrices_np = matrices.detach().cpu().numpy()
quaternions_np = Rotation.from_matrix(matrices_np.reshape(-1, 3, 3)).as_quat()
# We use a convention where the w component is at the start of the quaternion.
quaternions_np = quaternions_np[:, [3, 0, 1, 2]]
quaternions_np = quaternions_np.reshape(matrices_np.shape[:-2] + (4,))
return torch.as_tensor(quaternions_np, device=matrices.device, dtype=matrices.dtype)
def get_cross_product_matrix(vectors: torch.Tensor) -> torch.Tensor:
"""Generate cross product matrix for vector exterior product."""
if not vectors.shape[-1] == 3:
raise ValueError("Only 3-dimensional vectors are supported")
device = vectors.device
shape = vectors.shape[:-1]
unit_basis = eyes(3, shape=shape, device=device)
# We compute the matrix by multiplying each column of unit_basis with the
# corresponding vector.
return torch.cross(vectors[..., :, None], unit_basis, dim=-2)
def eyes(
dim: int, shape: tuple[int, ...], device: torch.device | str | None = None
) -> torch.Tensor:
"""Create batch of identity matrices."""
return torch.eye(dim, device=device).broadcast_to(shape + (dim, dim)).clone()
def quaternion_product(q1, q2):
"""Compute dot product between two quaternions."""
real_1 = q1[..., :1]
real_2 = q2[..., :1]
vector_1 = q1[..., 1:]
vector_2 = q2[..., 1:]
real_out = real_1 * real_2 - (vector_1 * vector_2).sum(dim=-1, keepdim=True)
vector_out = real_1 * vector_2 + real_2 * vector_1 + torch.cross(vector_1, vector_2)
return torch.concatenate([real_out, vector_out], dim=-1)
def quaternion_conj(q):
"""Get conjugate of a quaternion."""
real = q[..., :1]
vector = q[..., 1:]
return torch.concatenate([real, -vector], dim=-1)
def project(u: torch.Tensor, basis: torch.Tensor) -> torch.Tensor:
"""Project tensor u to unit basis a."""
unit_u = F.normalize(u, dim=-1)
inner_prod = (unit_u * basis).sum(dim=-1, keepdim=True)
return inner_prod * u
+45
View File
@@ -0,0 +1,45 @@
"""Contains logging related utility functions.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
import logging
import sys
from pathlib import Path
def configure(log_level: int, log_path: Path | None = None, prefix: str | None = None) -> None:
"""Configure logger globally.
Args:
log_level: The desired verbosity level.
log_path: The path to write logs to.
prefix: The prefix of the logger.
"""
logger = logging.getLogger(prefix)
# Reset logger to initial state (e.g. to avoid side effects from imports).
for handler in logger.handlers:
logger.removeHandler(handler)
for filter in logger.filters:
logger.removeFilter(filter)
# Set level.
logger.setLevel(log_level)
formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
# Set up console handler.
stdout_handler = logging.StreamHandler(sys.stdout)
stdout_handler.setFormatter(formatter)
logger.addHandler(stdout_handler)
# Set up file handler.
if log_path is not None:
file_handler = logging.FileHandler(log_path, mode="w")
file_handler.setFormatter(formatter)
logger.addHandler(file_handler)
+183
View File
@@ -0,0 +1,183 @@
"""Contains utility math functions.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
from typing import Any, Callable, Literal, NamedTuple, Tuple, Union
import torch
from torch import autograd
ActivationType = Literal[
"linear",
"exp",
"sigmoid",
"softplus",
"relu_with_pushback",
"hard_sigmoid_with_pushback",
]
ActivationFunction = Callable[[torch.Tensor], torch.Tensor]
class ActivationPair(NamedTuple):
"""A pair of forward and inverse activation functions."""
forward: ActivationFunction
inverse: ActivationFunction
def create_activation_pair(activation_type: ActivationType) -> ActivationPair:
"""Create activation function and corresponding inverse function.
Args:
activation_type: The activation type to create.
Returns:
The corresponding activation functions and the corresponding inverse function.
"""
if activation_type == "linear":
return ActivationPair(lambda x: x, lambda x: x)
elif activation_type == "exp":
return ActivationPair(torch.exp, torch.log)
elif activation_type == "sigmoid":
return ActivationPair(torch.sigmoid, inverse_sigmoid)
elif activation_type == "softplus":
return ActivationPair(torch.nn.functional.softplus, inverse_softplus)
elif activation_type == "relu_with_pushback":
return ActivationPair(relu_with_pushback, lambda x: x)
elif activation_type == "hard_sigmoid_with_pushback":
return ActivationPair(hard_sigmoid_with_pushback, lambda x: 6.0 * x - 3.0)
else:
raise ValueError(f"Unsupported activation function: {activation_type}.")
def inverse_sigmoid(tensor: torch.Tensor) -> torch.Tensor:
"""Compute inverse sigmoid."""
return torch.log(tensor / (1.0 - tensor))
def inverse_softplus(tensor: torch.Tensor, eps: float = 1e-06) -> torch.Tensor:
"""Compute inverse softplus."""
tensor = tensor.clamp_min(eps)
sigmoid = torch.sigmoid(-tensor)
exp = sigmoid / (1.0 - sigmoid)
return tensor + torch.log(-exp + 1.0)
# The first value describes the threshold from where clamping will be applied, while
# the second value describes the value to clamp with.
SoftClampRange = Tuple[Union[torch.Tensor, float], Union[torch.Tensor, float]]
def softclamp(
tensor: torch.Tensor,
min: SoftClampRange | None = None,
max: SoftClampRange | None = None,
) -> torch.Tensor:
"""Clamp tensor to min/max in differentiable way.
Args:
tensor: The tensor to clamp.
min: Pair of threshold to start clamping and value to clamp to.
The first value should be larger than the second.
max: Pair of threshold to start clamping and value to clamp to.
The first value should be smaller than the second.
Returns:
The clamped tensor.
"""
def normalize(clamp_range: SoftClampRange) -> torch.Tensor:
value0, value1 = clamp_range
return value0 + (value1 - value0) * torch.tanh((tensor - value0) / (value1 - value0))
tensor_clamped = tensor
if min is not None:
tensor_clamped = torch.maximum(tensor_clamped, normalize(min))
if max is not None:
tensor_clamped = torch.minimum(tensor_clamped, normalize(max))
return tensor_clamped
class ClampWithPushback(autograd.Function):
"""Implementation of clamp_with_pushback function."""
@staticmethod
def forward(
ctx: Any,
tensor: torch.Tensor,
min: float | None,
max: float | None,
pushback: float,
) -> torch.Tensor:
"""Apply clamp."""
if min is not None and max is not None and min >= max:
raise ValueError("Only min < max is supported.")
ctx.save_for_backward(tensor)
ctx.min = min
ctx.max = max
ctx.pushback = pushback
return torch.clamp(tensor, min=min, max=max)
@staticmethod
def backward( # type: ignore[override] # Deal with buggy torch annotations.
ctx: Any, grad_in: torch.Tensor
) -> tuple[torch.Tensor, None, None, None]:
"""Compute gradient of clamp with pushback."""
grad_out = grad_in.clone()
(tensor,) = ctx.saved_tensors
if ctx.min is not None:
mask_min = tensor < ctx.min
grad_out[mask_min] = -ctx.pushback
if ctx.max is not None:
mask_max = tensor > ctx.max
grad_out[mask_max] = ctx.pushback
return grad_out, None, None, None
def clamp_with_pushback(
tensor: torch.Tensor,
min: float | None = None,
max: float | None = None,
pushback: float = 1e-2,
) -> torch.Tensor:
"""Variant of clamp function which avoid the vanishing gradient problem.
This function is equivalent to adding a regularizer of the form
pushback * sum_i (
relu(min - preactivation_i) + relu(preactivation_i - max)
)
to the full loss function, which pushes clamped values back.
When used in minimization problems, pushback should be greater than
zero. In maximization problems, pushback should be smaller than zero.
"""
output = ClampWithPushback.apply(tensor, min, max, pushback)
assert isinstance(output, torch.Tensor)
return output
def hard_sigmoid_with_pushback(x: torch.Tensor, slope: float = 1.0 / 6.0) -> torch.Tensor:
"""Apply hard sigmoid with pushback.
For compatibility reasons, we follow the default PyTorch implementation with a
default slope of 1/6:
https://pytorch.org/docs/stable/generated/torch.nn.Hardsigmoid.html
"""
return clamp_with_pushback(slope * x + 0.5, min=0.0, max=1.0)
def relu_with_pushback(x: torch.Tensor) -> torch.Tensor:
"""Compute relu with pushback."""
return clamp_with_pushback(x, min=0.0)
+32
View File
@@ -0,0 +1,32 @@
"""Contains utility functionality to modify torch modules.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
from typing import Any
from torch import nn
NORM_LAYER_TYPES = tuple(module_type for name, module_type in nn.__dict__.items() if "Norm" in name)
BATCH_NORM_LAYER_TYPES = tuple(
module_type for name, module_type in nn.__dict__.items() if "BatchNorm" in name
)
def freeze_norm_layer(module: nn.Module) -> nn.Module:
"""Freeze all normalization layers."""
def set_module_eval_mode(module: nn.Module, _: Any) -> None:
module.eval()
for submodule in module.modules():
if isinstance(submodule, NORM_LAYER_TYPES):
submodule.requires_grad_(False)
# This is to ensure that batch norm layers are always called
# with the precomputed running statistics.
submodule.register_forward_pre_hook(set_module_eval_mode)
return module
+45
View File
@@ -0,0 +1,45 @@
"""Utility function for loss implementations.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
from typing import Callable
import torch
def robust_where(
condition: torch.Tensor,
input: torch.Tensor,
branch_true_func: Callable[[torch.Tensor], torch.Tensor],
branch_false_func: Callable[[torch.Tensor], torch.Tensor],
branch_true_safe_value: float | None = None,
branch_false_safe_value: float | None = None,
) -> torch.Tensor:
"""Robust torch.where function to avoid NaN in backward pass.
See https://github.com/pytorch/pytorch/issues/68425
Args:
condition: When True (nonzero), yield branch_true_func(input),
otherwise yield branch_false_func(input)
input: The input tensor for torch.where
branch_true_func: Callable for values at indices where condition is True.
branch_false_func: Callable for values at indices where condition is False.
branch_true_safe_value: Safe value to replace the true branch.
branch_false_safe_value: Safe value to replace the false branch.
"""
input_1 = input
input_2 = input
if branch_true_safe_value is not None:
input_1 = torch.where(condition, input_1, branch_true_safe_value)
if branch_false_safe_value is not None:
input_2 = torch.where(~condition, input_2, branch_false_safe_value)
return torch.where(
condition,
branch_true_func(input_1),
branch_false_func(input_2),
)
+24
View File
@@ -0,0 +1,24 @@
"""Utility functions for training.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from torch.utils.checkpoint import checkpoint
def checkpoint_wrapper(self, fn, *args):
"""Helper function that applies checkpointing.
If enabled applies grad checkpointing, otherwise just executes the function normally.
"""
if not hasattr(self, "grad_checkpointing"):
raise AttributeError(
"Trying to apply grad checkpointing on a model that does not have a grad_checkpointing "
"attribute."
)
if self.grad_checkpointing:
return checkpoint(fn, *args, use_reentrant=False)
else:
return fn(*args)
+77
View File
@@ -0,0 +1,77 @@
"""Utility functions for visualization.
For licensing see accompanying LICENSE file.
Copyright (C) 2025 Apple Inc. All Rights Reserved.
"""
from __future__ import annotations
import numpy as np
import torch
from matplotlib import pyplot as plt
METRIC_DEPTH_MAX_CLAMP_METER = 50.0
def colorize_depth(depth: torch.Tensor, val_max: float = 10.0) -> torch.Tensor:
"""Colorize depth map."""
depth_channels = depth.shape[-3]
# When we have a general depth/disparity map, output the color map as is.
if depth_channels == 1:
return colorize_scalar_map(
depth.squeeze(-3), val_min=0.0, val_max=val_max, color_map="turbo"
)
# When we have a multi-layered depth/disparity map,
# we concatenate the color maps horizontally and output it.
else:
colored_depths = []
for c in range(depth_channels):
colored_depths.append(
colorize_scalar_map(
depth[..., c, :, :], val_min=0.0, val_max=val_max, color_map="turbo"
)
)
return torch.cat(colored_depths, dim=-1)
def colorize_alpha(alpha: torch.Tensor) -> torch.Tensor:
"""Colorize alpha map."""
return colorize_scalar_map(alpha.squeeze(-3), val_min=0.0, val_max=1.0, color_map="coolwarm")
def colorize_scalar_map(
scalar_map: torch.Tensor, val_min=0.0, val_max=1.0, color_map: str = "jet"
) -> torch.Tensor:
"""Colorize a scalar map of.
Args:
scalar_map: Map of with format BHW.
val_min: Minimu value to display.
val_max: Maximum value to display.
color_map: Which color map to use. Will be passed to matplotlob.
Returns:
A colorized image with format BHWC.
"""
if scalar_map.ndim not in (2, 3, 4):
raise ValueError("Only scalar maps of 2 or 3 or 4 dimensions supported.")
cmap = plt.get_cmap(color_map)
scalar_map_np = scalar_map.detach().cpu().float().numpy()
scalar_map_np = (scalar_map_np - val_min) / (val_max - val_min)
scalar_map_np = np.clip(scalar_map_np, a_min=0.0, a_max=1.0)
color_map_np = cmap(scalar_map_np)[..., :3]
tensor = torch.as_tensor(color_map_np * 255.0, dtype=torch.uint8)
if tensor.ndim == 3:
return tensor.permute(2, 0, 1)
elif tensor.ndim == 4:
return tensor.permute(0, 3, 1, 2)
elif tensor.ndim == 5:
return tensor.permute(0, 1, 4, 2, 3)
else:
assert False, "Invalid tensor shape encountered."