"""Camera, ray, and bounded-volume geometry in the OpenCV convention.""" from __future__ import annotations import math from collections.abc import Sequence import torch from torch import Tensor from mcss.types import Cameras def make_intrinsics( image_size: tuple[int, int], fov_x_degrees: float, *, device: torch.device | None = None ) -> Tensor: """Create an OpenCV pinhole matrix for an image with square pixels.""" height, width = image_size if height <= 0 or width <= 0: raise ValueError("image_size values must be positive") if not 0.0 < fov_x_degrees < 179.0: raise ValueError("fov_x_degrees must be in (0, 179)") focal = (width - 1) / (2.0 * math.tan(math.radians(fov_x_degrees) / 2.0)) return torch.tensor( [[focal, 0.0, (width - 1) / 2.0], [0.0, focal, (height - 1) / 2.0], [0.0, 0.0, 1.0]], dtype=torch.float32, device=device, ) def look_at(eye: Tensor, target: Tensor, up: Tensor | None = None) -> Tensor: """Return camera-to-world pose where camera axes are x-right, y-down, z-forward.""" if eye.shape != (3,) or target.shape != (3,): raise ValueError("look_at accepts eye and target tensors of shape [3]") if up is None: up = torch.tensor([0.0, 1.0, 0.0], dtype=eye.dtype, device=eye.device) if up.shape != (3,): raise ValueError("up must have shape [3]") forward = _normalize(target - eye) right = _normalize(torch.linalg.cross(up, forward)) camera_up = _normalize(torch.linalg.cross(forward, right)) pose = torch.eye(4, dtype=eye.dtype, device=eye.device) pose[:3, 0] = right pose[:3, 1] = -camera_up pose[:3, 2] = forward pose[:3, 3] = eye return pose def pixel_grid(height: int, width: int, *, device: torch.device, dtype: torch.dtype) -> Tensor: """Return homogeneous pixel centers [H, W, 3] with integer OpenCV coordinates.""" if height <= 0 or width <= 0: raise ValueError("height and width must be positive") y, x = torch.meshgrid( torch.arange(height, device=device, dtype=dtype), torch.arange(width, device=device, dtype=dtype), indexing="ij", ) return torch.stack((x, y, torch.ones_like(x)), dim=-1) def generate_rays(cameras: Cameras) -> tuple[Tensor, Tensor]: """Generate normalized world-space rays with shape ``[..., H, W, 3]``.""" height, width = cameras.image_size pixels = pixel_grid(height, width, device=cameras.device, dtype=cameras.dtype) inverse_intrinsics = torch.linalg.inv(cameras.intrinsics) directions_camera = torch.einsum("...ij,hwj->...hwi", inverse_intrinsics, pixels) directions_camera = _normalize(directions_camera) rotation = cameras.c2w[..., :3, :3] directions_world = torch.einsum("...ij,...hwj->...hwi", rotation, directions_camera) directions_world = _normalize(directions_world) origins = cameras.c2w[..., :3, 3].unsqueeze(-2).unsqueeze(-2).expand_as(directions_world) return origins, directions_world def project_world(points: Tensor, cameras: Cameras) -> tuple[Tensor, Tensor, Tensor]: """Project world points shaped ``[..., N, 3]`` through matching camera leading dimensions.""" if points.ndim < 2 or points.shape[-1] != 3: raise ValueError("points must have shape [..., N, 3]") if points.shape[:-2] != cameras.leading_shape: raise ValueError("points leading dimensions must match cameras") homogeneous = torch.cat((points, torch.ones_like(points[..., :1])), dim=-1) w2c = torch.linalg.inv(cameras.c2w) camera_points = torch.einsum("...ij,...nj->...ni", w2c[..., :3, :], homogeneous) depth = camera_points[..., 2] projected = torch.einsum("...ij,...nj->...ni", cameras.intrinsics, camera_points) pixels = projected[..., :2] / projected[..., 2:].clamp_min(torch.finfo(points.dtype).eps) height, width = cameras.image_size valid = ( (depth > torch.finfo(points.dtype).eps) & (pixels[..., 0] >= 0) & (pixels[..., 0] <= width - 1) & (pixels[..., 1] >= 0) & (pixels[..., 1] <= height - 1) ) return pixels, depth, valid def intersect_aabb( origins: Tensor, directions: Tensor, bounds: Tensor ) -> tuple[Tensor, Tensor, Tensor]: """Intersect normalized or non-normalized rays with one broadcastable axis-aligned box.""" if origins.shape != directions.shape or origins.shape[-1] != 3: raise ValueError("origins and directions must share shape [..., 3]") if bounds.shape[-2:] != (2, 3): raise ValueError("bounds must have shape [..., 2, 3]") lower = bounds[..., 0, :] upper = bounds[..., 1, :] while lower.ndim < origins.ndim: lower = lower.unsqueeze(-2) upper = upper.unsqueeze(-2) eps = torch.finfo(origins.dtype).eps parallel = directions.abs() <= eps safe_directions = torch.where(parallel, torch.ones_like(directions), directions) t0 = (lower - origins) / safe_directions t1 = (upper - origins) / safe_directions minimum = torch.minimum(t0, t1) maximum = torch.maximum(t0, t1) inside_parallel_slab = (origins >= lower) & (origins <= upper) minimum = torch.where( parallel & inside_parallel_slab, torch.full_like(minimum, -torch.inf), minimum ) maximum = torch.where( parallel & inside_parallel_slab, torch.full_like(maximum, torch.inf), maximum ) minimum = torch.where( parallel & ~inside_parallel_slab, torch.full_like(minimum, torch.inf), minimum ) maximum = torch.where( parallel & ~inside_parallel_slab, torch.full_like(maximum, -torch.inf), maximum ) near_raw = minimum.amax(dim=-1) far = maximum.amin(dim=-1) hit = (far >= torch.maximum(near_raw, torch.zeros_like(near_raw))) & (far > 0) near = near_raw.clamp_min(0.0) near = torch.where(hit, near, torch.zeros_like(near)) far = torch.where(hit, far, torch.zeros_like(far)) return near, far, hit def make_voxel_centers(bounds: Tensor, resolution: int | Sequence[int]) -> Tensor: """Create world-space center coordinates shaped ``[B, D, H, W, 3]``.""" if bounds.ndim != 3 or bounds.shape[1:] != (2, 3): raise ValueError("bounds must have shape [B, 2, 3]") if isinstance(resolution, int): depth = height = width = resolution else: if len(resolution) != 3: raise ValueError("resolution sequence must contain D, H, W") depth, height, width = resolution if min(depth, height, width) <= 0: raise ValueError("resolution values must be positive") z, y, x = torch.meshgrid( (torch.arange(depth, device=bounds.device, dtype=bounds.dtype) + 0.5) / depth, (torch.arange(height, device=bounds.device, dtype=bounds.dtype) + 0.5) / height, (torch.arange(width, device=bounds.device, dtype=bounds.dtype) + 0.5) / width, indexing="ij", ) fractions = torch.stack((x, y, z), dim=-1).unsqueeze(0) minimum = bounds[:, 0].view(-1, 1, 1, 1, 3) extent = (bounds[:, 1] - bounds[:, 0]).view(-1, 1, 1, 1, 3) return minimum + fractions * extent def transform_cameras(cameras: Cameras, transform: Tensor) -> Cameras: """Express calibrated cameras in a new frame using a rigid old-to-new transform.""" _validate_rigid_transform(transform) expanded = transform.to(device=cameras.device, dtype=cameras.dtype) while expanded.ndim < cameras.c2w.ndim: expanded = expanded.unsqueeze(-3) return Cameras(cameras.intrinsics.clone(), expanded @ cameras.c2w, cameras.image_size) def transform_points(points: Tensor, transform: Tensor) -> Tensor: """Apply a broadcastable rigid transform to Euclidean points shaped ``[..., 3]``.""" if points.ndim < 1 or points.shape[-1] != 3: raise ValueError("points must have shape [..., 3]") _validate_rigid_transform(transform) homogeneous = torch.cat((points, torch.ones_like(points[..., :1])), dim=-1) expanded = transform.to(device=points.device, dtype=points.dtype) while expanded.ndim < homogeneous.ndim + 1: expanded = expanded.unsqueeze(-3) return (homogeneous.unsqueeze(-2) @ expanded.transpose(-1, -2)).squeeze(-2)[..., :3] def transform_directions(directions: Tensor, transform: Tensor) -> Tensor: """Rotate direction vectors without applying the transform translation.""" if directions.ndim < 1 or directions.shape[-1] != 3: raise ValueError("directions must have shape [..., 3]") _validate_rigid_transform(transform) homogeneous = torch.cat((directions, torch.zeros_like(directions[..., :1])), dim=-1) expanded = transform.to(device=directions.device, dtype=directions.dtype) while expanded.ndim < homogeneous.ndim + 1: expanded = expanded.unsqueeze(-3) return (homogeneous.unsqueeze(-2) @ expanded.transpose(-1, -2)).squeeze(-2)[..., :3] def _validate_rigid_transform(transform: Tensor) -> None: if transform.ndim < 2 or transform.shape[-2:] != (4, 4): raise ValueError("transform must have shape [..., 4, 4]") if not torch.isfinite(transform).all(): raise ValueError("transform must contain only finite values") expected_row = torch.tensor( [0.0, 0.0, 0.0, 1.0], device=transform.device, dtype=transform.dtype ) if not torch.allclose(transform[..., 3, :], expected_row, atol=1e-5, rtol=1e-5): raise ValueError("transform must have homogeneous final row [0, 0, 0, 1]") def _normalize(vectors: Tensor) -> Tensor: return vectors / torch.linalg.vector_norm(vectors, dim=-1, keepdim=True).clamp_min( torch.finfo(vectors.dtype).eps )