# Organize imports from PIL import Image import numpy as np import torch import time from typing import Dict, Tuple, Any class Projection: """ A class to define supported projection types. """ PROJECTIONS = ["PINHOLE", "FISHEYE", "EQUIRECTANGULAR"] def map_grid( grid_torch: torch.Tensor, input_projection: str, output_projection: str, input_horizontal_fov: float, output_horizontal_fov: float, rotation_matrix: torch.Tensor = None ) -> np.ndarray: """ Maps a 2D grid from one projection type to another. Args: grid_torch (torch.Tensor): A 2D array of shape (height, width, 2) with x and y coordinates normalized to [-1, 1]. input_projection (str): The input projection type ("PINHOLE", "FISHEYE", "EQUIRECTANGULAR"). output_projection (str): The output projection type ("PINHOLE", "FISHEYE", "EQUIRECTANGULAR"). input_horizontal_fov (float): Horizontal field of view for the input projection in degrees. output_horizontal_fov (float): Horizontal field of view for the output projection in degrees. rotation_matrix (torch.Tensor, optional): A 4x4 rotation matrix. Defaults to identity matrix if not provided. Returns: np.ndarray: A 2D array of shape (height, width, 2) with the mapped x and y coordinates. """ with torch.no_grad(): if rotation_matrix is None: rotation_matrix = torch.eye(4) # Identity matrix rotation_matrix = rotation_matrix.float().to(grid_torch.device) # convert all floats to tensors input_horizontal_fov = torch.tensor(input_horizontal_fov, device=grid_torch.device).float() output_horizontal_fov = torch.tensor(output_horizontal_fov, device=grid_torch.device).float() # Calculate vertical field of view for input and output projections output_vertical_fov = output_horizontal_fov # Assuming square aspect ratio input_vertical_fov = input_horizontal_fov * (grid_torch.shape[0] / grid_torch.shape[1]) # Normalize the grid for vertical FOV adjustment normalized_grid = grid_torch.clone() normalized_grid[..., 1] *= (grid_torch.shape[0] / grid_torch.shape[1]) # Step 1: Map each pixel to its location on the sphere for the output projection if output_projection == "PINHOLE": D = 1.0 / torch.tan(torch.deg2rad(output_horizontal_fov) / 2) radius_to_center = torch.sqrt(normalized_grid[..., 0]**2 + normalized_grid[..., 1]**2) phi = torch.atan2(normalized_grid[..., 1], normalized_grid[..., 0]) theta = torch.atan2(radius_to_center, D) x = torch.sin(theta) * torch.cos(phi) y = torch.sin(theta) * torch.sin(phi) z = torch.cos(theta) elif output_projection == "FISHEYE": phi = torch.atan2(normalized_grid[..., 1], normalized_grid[..., 0]) radius = torch.sqrt(normalized_grid[..., 0]**2 + normalized_grid[..., 1]**2) theta = radius * torch.deg2rad(output_horizontal_fov) / 2 x = torch.sin(theta) * torch.cos(phi) y = torch.sin(theta) * torch.sin(phi) z = torch.cos(theta) elif output_projection == "EQUIRECTANGULAR": phi = grid_torch[..., 0] * torch.deg2rad(output_horizontal_fov) / 2 theta = grid_torch[..., 1] * torch.deg2rad(output_vertical_fov) / 2 y = torch.sin(theta) x = torch.cos(theta) * torch.sin(phi) z = torch.cos(theta) * torch.cos(phi) else: raise ValueError(f"Unsupported output projection: {output_projection}") # Step 2: Apply rotation matrix for yaw and pitch coords = torch.stack([x.flatten(), y.flatten(), z.flatten()], dim=-1) coords_homogeneous = torch.cat([coords, torch.ones((coords.shape[0], 1), device=coords.device)], dim=-1) coords_rotated = torch.matmul(rotation_matrix, coords_homogeneous.T).T coords = coords_rotated[..., :3] # Extract x, y, z after rotation # Step 3: Map rotated points back to the input projection if input_projection == "PINHOLE": D = 1.0 / torch.tan(torch.deg2rad(input_horizontal_fov) / 2) theta = torch.atan2(torch.sqrt(coords[..., 0]**2 + coords[..., 1]**2), coords[..., 2]) phi = torch.atan2(coords[..., 1], coords[..., 0]) radius = D * torch.tan(theta) x = radius * torch.cos(phi) y = radius * torch.sin(phi) mask = coords[..., 2] > 0 # Only keep points where z > 0 x[~mask] = 100 y[~mask] = 100 elif input_projection == "FISHEYE": theta = torch.atan2(torch.sqrt(coords[..., 0]**2 + coords[..., 1]**2), coords[..., 2]) phi = torch.atan2(coords[..., 1], coords[..., 0]) radius = theta / (torch.deg2rad(input_horizontal_fov) / 2) x = radius * torch.cos(phi) y = radius * torch.sin(phi) elif input_projection == "EQUIRECTANGULAR": theta = torch.asin(coords[..., 1]) phi = torch.atan2(coords[..., 0], coords[..., 2]) x = phi / (torch.deg2rad(input_horizontal_fov) / 2) y = theta / (torch.deg2rad(input_vertical_fov) / 2) else: raise ValueError(f"Unsupported input projection: {input_projection}") x = x.view(grid_torch.shape[0], grid_torch.shape[1]) y = y.view(grid_torch.shape[0], grid_torch.shape[1]) output_grid = torch.zeros_like(grid_torch) output_grid[..., 0] = x output_grid[..., 1] = y return output_grid class ReprojectImage: """ A node to reproject an image from one projection type to another. """ @classmethod def INPUT_TYPES(cls) -> Dict[str, Any]: """ Define the input types for the node. Returns: dict: A dictionary specifying the required input types. """ return { "required": { "image": ("IMAGE",), "input_horiszontal_fov": ("FLOAT", {"default": 90.0, "min": 0.0, "max": 360.0, "step": 1.0}), "output_horiszontal_fov": ("FLOAT", {"default": 90.0, "min": 0.0, "max": 360.0, "step": 1.0}), "input_projection": (Projection.PROJECTIONS, {"tooltip": "input projection type"}), "output_projection": (Projection.PROJECTIONS, {"tooltip": "output projection type"}), "output_width": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8}), "output_height": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8}), "inverse": ("BOOLEAN", {"default": False}), "feathering": ("INT", {"default": 40, "min": 0, "max": 16384, "step": 1}), }, "optional": { "mask": ("MASK",), "transform_matrix": ("MAT_4X4", {"default": None}), } } RETURN_TYPES: Tuple[str, str] = ("IMAGE", "MASK") RETURN_NAMES = ("reprojected image", "reprojected mask") FUNCTION: str = "reproject_image" CATEGORY: str = "Camera/Reprojection" def reproject_image( self, image: torch.Tensor, input_horiszontal_fov: float, output_horiszontal_fov: float, input_projection: str, output_projection: str, output_width: int, output_height: int, feathering: int, inverse: bool=False, transform_matrix: np.ndarray=None, mask: torch.Tensor = None ) -> Tuple[torch.Tensor, torch.Tensor]: """ Reproject an image from one projection type to another. Args: image (torch.Tensor): The input image tensor. input_horiszontal_fov (float): The horizontal field of view of the input image. output_horiszontal_fov (float): The horizontal field of view of the output image. input_projection (str): The projection type of the input image. output_projection (str): The projection type of the output image. output_width (int): The width of the output image. output_height (int): The height of the output image. transform_matrix (np.ndarray): The transformation matrix. inverse (bool): Whether to invert the transformation matrix. feathering (int): The feathering value for blending. mask (torch.Tensor, optional): The input mask tensor. Returns: Tuple[torch.Tensor, torch.Tensor]: The reprojected image (BHWC) and mask (HW), both normalized to 0-1. """ if transform_matrix is None: transform_matrix = np.eye(4) transform_matrix=torch.from_numpy(transform_matrix).to(image.device) if transform_matrix.shape != (4, 4): transform_matrix = transform_matrix.view(4, 4) transform_matrix = transform_matrix.float() if inverse: transform_matrix = torch.inverse(transform_matrix) # Create output grid d1, input_height, input_width, d4 = image.size() # Batch, height, width, channels image_tensor = image.permute(0, 3, 1, 2) # Convert BHWC to BCHW # Add alpha channel if missing if d4 != 4: alpha_channel = torch.ones((d1, 1, input_height, input_width), dtype=image_tensor.dtype, device=image_tensor.device) image_tensor = torch.cat((image_tensor, alpha_channel), dim=1) # Add 1-pixel border with alpha = 0 image_tensor[:, -1, :, 0] = 0 image_tensor[:, -1, :, -1] = 0 image_tensor[:, -1, 0, :] = 0 image_tensor[:, -1, -1, :] = 0 # pad till square with alpha = 0 if input_height != input_width: if input_height < input_width: pad_top = (input_width - input_height) // 2 pad_bottom = input_width - input_height - pad_top pad_left = 0 pad_right = 0 else: pad_top = 0 pad_bottom = 0 pad_left = (input_height - input_width) // 2 pad_right = input_height - input_width - pad_left image_tensor = torch.nn.functional.pad( image_tensor, (pad_left, pad_right, pad_top, pad_bottom), mode='constant', value=0 ) grid_y, grid_x = torch.meshgrid( torch.linspace(-1, 1, output_width, device=image_tensor.device), torch.linspace(-1, 1, output_height, device=image_tensor.device), indexing="ij" ) grid_init = torch.stack((grid_x, grid_y), dim=-1) grid = map_grid( grid_init, input_projection, output_projection, input_horiszontal_fov, output_horiszontal_fov, transform_matrix ) # Sample input image using the grid grid = grid.unsqueeze(0) # Add batch dimension sampled_tensor = torch.nn.functional.grid_sample( image_tensor, grid, mode='nearest', padding_mode='border', align_corners=False ) # Extract and normalize mask if mask is None: mask = sampled_tensor[:, -1:, :, :] # Extract alpha channel sampled_tensor = sampled_tensor * mask + 0.5 * (1 - mask) # Blend with gray background mask = torch.nn.functional.avg_pool2d( mask, kernel_size=feathering * 2 + 1, stride=1, padding=feathering ) mask = mask * (mask > 0) # Feathering on border of image mask = (1 - mask).clamp(0, 1).squeeze(0).squeeze(0) # Normalize to 0-1 range else: # Reproject the provided mask if mask.dim() == 2: mask = mask.unsqueeze(0).unsqueeze(0) # Add batch and channel dimensions elif mask.dim() == 3: mask = mask.unsqueeze(0) # Add batch dimension sampled_mask = torch.nn.functional.grid_sample( mask, grid, mode='nearest', padding_mode='border', align_corners=False ) sampled_mask = torch.nn.functional.avg_pool2d( sampled_mask, kernel_size=feathering * 2 + 1, stride=1, padding=feathering ) sampled_mask = sampled_mask * (sampled_mask > 0) # Apply feathering mask = sampled_mask.clamp(0, 1) # Normalize to 0-1 range # Normalize image to 0-1 range and convert to BHWC image = sampled_tensor[:, :-1, :, :].permute(0, 2, 3, 1) # BCHW to BHWC if output_projection == "FISHEYE": # add circular mask to image mask_circle = torch.sqrt(grid_x**2 + grid_y**2) <= 1.0 mask=mask*(mask_circle*1) image = image * mask_circle.unsqueeze(0).unsqueeze(-1) return image, mask[None, ...] # add batch dimension to mask class TransformToMatrix: """ A node to convert shiftX, shiftY, shiftZ, theta, and phi into a 4x4 transformation matrix. """ @classmethod def INPUT_TYPES(cls) -> Dict[str, Any]: """ Define the input types for the node. Returns: dict: A dictionary specifying the required input types. """ return { "required": { "shiftX": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.01}), "shiftY": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.01}), "shiftZ": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.01}), "theta": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 1.0}), "phi": ("FLOAT", {"default": 0.0, "min": -360.0, "max": 360.0, "step": 1.0}), } } RETURN_TYPES: Tuple[str] = ("MAT_4X4",) RETURN_NAMES = ("transformation matrix",) FUNCTION: str = "generate_matrix" CATEGORY: str = "Camera/Matrix" def generate_matrix( self, shiftX: float, shiftY: float, shiftZ: float, theta: float, phi: float, ) -> np.ndarray: """ Generate a 4x4 transformation matrix based on the inputs. Args: shiftX (float): Translation along the X-axis. shiftY (float): Translation along the Y-axis. shiftZ (float): Translation along the Z-axis. theta (float): Rotation angle around the Y-axis in degrees. phi (float): Rotation angle around the X-axis in degrees. inverse (bool): Whether to output the inverse matrix. Returns: np.ndarray: A 4x4 transformation matrix. """ # Translation matrix T = np.eye(4) T[0, 3] = shiftX T[1, 3] = shiftY T[2, 3] = shiftZ theta_rad = np.radians(theta) phi_rad = np.radians(phi) R_theta = np.array([ [np.cos(phi_rad), 0, np.sin(phi_rad), 0], [0, 1, 0, 0], [-np.sin(phi_rad), 0, np.cos(phi_rad), 0], [0, 0, 0, 1] ]) R_phi = np.array([ [1, 0, 0, 0], [0, np.cos(theta_rad), -np.sin(theta_rad), 0], [0, np.sin(theta_rad), np.cos(theta_rad), 0], [0, 0, 0, 1] ]) # Combine transformations M = np.matmul(T, np.matmul(R_theta, R_phi)) return M[None, ...] # Add batch dimension # module to manually set rotation matrix class TransformToMatrixManual: """ A node to manually set a 4x4 transformation matrix using 16 individual inputs. """ @classmethod def INPUT_TYPES(cls) -> Dict[str, Any]: """ Define the input types for the node. Returns: dict: A dictionary specifying the required input types. """ return { "required": { "m00": ("FLOAT", {"default": 1.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m01": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m02": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m03": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m10": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m11": ("FLOAT", {"default": 1.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m12": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m13": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m20": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m21": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m22": ("FLOAT", {"default": 1.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m23": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m30": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m31": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m32": ("FLOAT", {"default": 0.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), "m33": ("FLOAT", {"default": 1.0, "min": -1000.0, "max": 1000.0, "step": 0.1}), } } RETURN_TYPES: Tuple[str] = ("MAT_4X4",) RETURN_NAMES = ("transformation matrix",) FUNCTION: str = "generate_matrix" CATEGORY: str = "Camera/Matrix" def generate_matrix( self, m00: float, m01: float, m02: float, m03: float, m10: float, m11: float, m12: float, m13: float, m20: float, m21: float, m22: float, m23: float, m30: float, m31: float, m32: float, m33: float, ) -> np.ndarray: """ Generate a 4x4 transformation matrix based on the 16 inputs. Args: m00, m01, ..., m33 (float): Individual elements of the 4x4 matrix. Returns: np.ndarray: A 4x4 transformation matrix with batch dimension added. """ matrix = np.array([ [m00, m01, m02, m03], [m10, m11, m12, m13], [m20, m21, m22, m23], [m30, m31, m32, m33], ], dtype=np.float32) return matrix[None, ...] # Add batch dimension class ReprojectDepth: """ A node to reproject a depth tensor from one projection type to another, and also return a mask of valid pixels. """ @classmethod def INPUT_TYPES(cls) -> Dict[str, Any]: return { "required": { "depth": ("TENSOR",), "input_horizontal_fov": ("FLOAT", {"default": 90.0, "min": 0.0, "max": 360.0, "step": 1.0}), "output_horizontal_fov": ("FLOAT", {"default": 90.0, "min": 0.0, "max": 360.0, "step": 1.0}), "input_projection": (Projection.PROJECTIONS, {"tooltip": "input projection type"}), "output_projection": (Projection.PROJECTIONS, {"tooltip": "output projection type"}), "output_width": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8}), "output_height": ("INT", {"default": 0, "min": 0, "max": 16384, "step": 8}), "transform_matrix": ("MAT_4X4", {"default": None}), "inverse": ("BOOLEAN", {"default": False}), } } # Now returns both depth and mask RETURN_TYPES: Tuple[str, str] = ("TENSOR", "MASK") RETURN_NAMES = ("reprojected_depth", "reprojected_mask") FUNCTION: str = "reproject_depth" CATEGORY: str = "Camera/Reprojection" def reproject_depth( self, depth: torch.Tensor, input_horizontal_fov: float, output_horizontal_fov: float, input_projection: str, output_projection: str, output_width: int, output_height: int, transform_matrix: np.ndarray = None, inverse: bool = False, ) -> Tuple[torch.Tensor, torch.Tensor]: """ Reproject a depth tensor and return a mask of valid pixels. Returns: reprojected_depth: torch.Tensor shaped [H_out, W_out] (or batch if provided) reprojected_mask: torch.Tensor same shape, 1.0 where valid, 0.0 where out-of-bounds """ # Prepare transform device, dtype = depth.device, depth.dtype if transform_matrix is None: T = torch.eye(4, device=device, dtype=dtype) else: T = torch.from_numpy(transform_matrix).to(device=device, dtype=dtype).view(4, 4) if inverse: T = torch.inverse(T) # Normalize dims x = depth added_batch = added_channel = False if x.dim() == 2: x = x.unsqueeze(0).unsqueeze(0); added_batch = added_channel = True elif x.dim() == 3: x = x.unsqueeze(0); added_batch = True elif x.dim() == 4: x = x.permute(0, 3, 1, 2) else: raise ValueError(f"Unsupported depth tensor with {x.dim()} dims") B, C, H_in, W_in = x.shape H_out = output_height if output_height > 0 else H_in W_out = output_width if output_width > 0 else W_in # Build grid and reproject ys = torch.linspace(-1, 1, H_out, device=device) xs = torch.linspace(-1, 1, W_out, device=device) grid_y, grid_x = torch.meshgrid(ys, xs, indexing="ij") grid = torch.stack((grid_x, grid_y), dim=-1) grid = map_grid(grid, input_projection, output_projection, input_horizontal_fov, output_horizontal_fov, T) grid = grid.unsqueeze(0) # add batch # Sample depth sampled = torch.nn.functional.grid_sample( x, grid, mode='nearest', padding_mode='border', align_corners=False ) # Generate validity mask by sampling an all-ones tensor with zero padding mask_in = torch.ones_like(x) sampled_mask = torch.nn.functional.grid_sample( mask_in, grid, mode='nearest', padding_mode='zeros', align_corners=False ) # Squeeze back to remove added dims if added_batch: sampled = sampled.squeeze(0) sampled_mask = sampled_mask.squeeze(0) if added_channel: sampled = sampled.squeeze(0) sampled_mask = sampled_mask.squeeze(0) # Convert depth back to original format # sampled shape: [C, H_out, W_out] or [H_out, W_out] if single channel reprojected_depth = sampled reprojected_mask = (sampled_mask > 0.5).float() # if fisheye, add circular mask if output_projection == "FISHEYE": mask_circle = (grid_x**2 + grid_y**2) <= 1.0 reprojected_mask = reprojected_mask * mask_circle.unsqueeze(0).unsqueeze(0) return reprojected_depth.permute(0,2,3,1), reprojected_mask NODE_CLASS_MAPPINGS = { "ReprojectImage": ReprojectImage, "TransformToMatrix": TransformToMatrix, "TransformToMatrixManual": TransformToMatrixManual, "ReprojectDepth": ReprojectDepth }