# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """ Camera utility functions. """ import math import torch from gem.utils.geo_transform import convert_bbx_xys_to_lurb, cvt_to_bi01_p2d def perspective_projection(points, K): """Standard pinhole camera projection. Args: points: (*, J, 3) K: (*, 3, 3) Returns: p2d: (*, J, 2) """ projected = points / points[..., -1:].clamp(min=1e-6) projected = torch.einsum("...ij,...kj->...ki", K, projected.float()) return projected[..., :2] def compute_transl_full_cam(pred_cam, bbx_xys, K_fullimg): """Convert weak-perspective camera parameters to full-perspective translation. Args: pred_cam: (*, 3) [s, tx, ty] bbx_xys: (*, 3) [cx, cy, size] K_fullimg: (*, 3, 3) Returns: cam_t: (*, 3) """ s, tx, ty = pred_cam[..., 0], pred_cam[..., 1], pred_cam[..., 2] focal_length = K_fullimg[..., 0, 0] icx = K_fullimg[..., 0, 2] icy = K_fullimg[..., 1, 2] sb = s * bbx_xys[..., 2] cx = 2 * (bbx_xys[..., 0] - icx) / (sb + 1e-9) cy = 2 * (bbx_xys[..., 1] - icy) / (sb + 1e-9) tz = 2 * focal_length / (sb + 1e-9) return torch.stack([tx + cx, ty + cy, tz], dim=-1) def get_a_pred_cam(transl, bbx_xys, K_fullimg): """Inverse of compute_transl_full_cam: full translation → weak-perspective params. Args: transl: (*, 3) bbx_xys: (*, 3) K_fullimg: (*, 3, 3) one extra leading dim compared to transl/bbx_xys Returns: pred_cam: (*, 3) [s, tx, ty] """ f = K_fullimg[..., 0, 0] cx = K_fullimg[..., 0, 2] cy = K_fullimg[..., 1, 2] gt_s = 2 * f / (transl[..., 2] * bbx_xys[..., 2]) gt_x = transl[..., 0] - transl[..., 2] / f * (bbx_xys[..., 0] - cx) gt_y = transl[..., 1] - transl[..., 2] / f * (bbx_xys[..., 1] - cy) return torch.stack([gt_s, gt_x, gt_y], dim=-1) def compute_bbox_info_bedlam(bbx_xys, K_fullimg): """Compute BEDLAM-style bounding box info (normalised by focal length). Args: bbx_xys: (*, 3) [cx, cy, size] K_fullimg: (*, 3, 3) Returns: bbox_info: (*, 3) """ fl = K_fullimg[..., 0, 0].unsqueeze(-1) icx = K_fullimg[..., 0, 2] icy = K_fullimg[..., 1, 2] cx, cy, b = bbx_xys[..., 0], bbx_xys[..., 1], bbx_xys[..., 2] bbox_info = torch.stack([cx - icx, cy - icy, b], dim=-1) return bbox_info / fl def safely_render_x3d_K(x3d, K_fullimg, thr=0.3): """Project 3D vertices to 2D, clamping near-zero depths for safety. Args: x3d: (*, V, 3) K_fullimg: (*, 3, 3) thr: minimum depth threshold Returns: i_x2d: (*, V, 2) """ x3d = x3d.clone() x3d[..., 2] = x3d[..., 2].clamp(min=thr) return perspective_projection(x3d, K_fullimg) def estimate_K(width, height): """Estimate default pinhole intrinsics from image dimensions. Uses max(w, h) as focal length — a reasonable approximation when the true focal length is unknown. Args: width, height: image dimensions (int or float) Returns: K: (3, 3) camera intrinsics tensor """ focal = float(max(width, height)) K = torch.eye(3) K[0, 0] = focal K[1, 1] = focal K[0, 2] = float(width) / 2.0 K[1, 2] = float(height) / 2.0 return K def resize_K(K, scale): """Scale camera intrinsics for a resized image. Args: K: (*, 3, 3) intrinsics tensor scale: scalar scale factor Returns: K_new: (*, 3, 3) scaled intrinsics """ K_new = K.clone().float() K_new[..., :2, :] = K_new[..., :2, :] * scale return K_new def create_camera_sensor(width, height, fov_deg=60): """Create camera intrinsics from image dimensions and field-of-view angle. Args: width, height: image dimensions fov_deg: horizontal field of view in degrees Returns: (width, height, K) where K is (3, 3) """ focal = float(width) / (2.0 * math.tan(math.radians(fov_deg / 2.0))) K = torch.eye(3) K[0, 0] = focal K[1, 1] = focal K[0, 2] = float(width) / 2.0 K[1, 2] = float(height) / 2.0 return width, height, K def get_bbx_xys_from_xyxy(xyxy, base_zoom=1.0, minimal_size=10): """Convert bounding box from [x1, y1, x2, y2] to [cx, cy, size]. Args: xyxy: (..., 4) bounding box in xyxy format base_zoom: zoom factor applied to the box size minimal_size: minimum allowed box size Returns: xys: (..., 3) [cx, cy, size] """ x1, y1, x2, y2 = xyxy[..., 0], xyxy[..., 1], xyxy[..., 2], xyxy[..., 3] cx = (x1 + x2) / 2 cy = (y1 + y2) / 2 size = torch.max(x2 - x1, y2 - y1) * base_zoom size = size.clamp(min=minimal_size) return torch.stack([cx, cy, size], dim=-1) def project_to_bi01(points, bbx_xys, K_fullimg): """Project 3D points to bounding-box-normalised [0,1] 2D coordinates. Args: points: (*, J, 3) bbx_xys: (*, 3) K_fullimg: (*, 3, 3) Returns: p2d_bi01: (*, J, 2) """ p2d = perspective_projection(points, K_fullimg) bbx_lurb = convert_bbx_xys_to_lurb(bbx_xys) return cvt_to_bi01_p2d(p2d, bbx_lurb)