import copy from typing import Dict, List, Tuple import torch from pytorch360convert import c2e, e2c, e2e, e2p class C2ENode: """ Cubemap To Equirectangular Node """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "e_img": ("IMAGE", {"default": None}), "h": ("INT", {"default": -1}), "w": ("INT", {"default": -1}), "padding_mode": ( ["bilinear", "bicubic", "nearest"], {"default": "bilinear"}, ), "cube_format": ( ["stack", "dice", "horizon", "list", "dict"], {"default": "stack"}, ), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Equirectangular Image",) FUNCTION = "c2e" CATEGORY = "pytorch360convert" def c2e( self, e_img: torch.Tensor, h=None, w=None, padding_mode: str = "bilinear", cube_format: str = "stack", ) -> Tuple[torch.Tensor]: assert e_img.shape[0] == 6, f"Input should have 6 faces, got {e_img.shape[0]}" h = None if h < 1 else h w = None if w < 1 else w return ( c2e( e_img.squeeze(0), h=h, w=w, cube_format=cube_format, channels_first=False, ).unsqueeze(0), ) class E2CNode: """ Equirectangular to Cubemap Node """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "e_img": ("IMAGE", {"default": None}), "face_width": ("INT", {"default": -1}), "padding_mode": ( ["bilinear", "bicubic", "nearest"], {"default": "bilinear"}, ), "cube_format": ( ["stack", "dice", "horizon", "list", "dict"], {"default": "stack"}, ), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Cubemap Image",) FUNCTION = "e2c" CATEGORY = "pytorch360convert" def e2c( self, e_img: torch.Tensor, face_width: int = -1, padding_mode: str = "bilinear", cube_format: str = "stack", ) -> Tuple[torch.Tensor]: assert e_img.shape[0] == 1, ( "Only a batch size of 1 is currently" + f"supported, got {e_img.shape[0]}" ) face_width = e_img.shape[1] // 2 if face_width < 1 else face_width output = e2c( e_img.squeeze(0), face_w=face_width, mode=padding_mode, cube_format=cube_format, channels_first=False, ) output = output.unsqueeze(0) if output.dim() == 3 else output return (output,) class E2PNode: """ Equirectangular to Perspective Node """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "e_img": ("IMAGE", {"default": None}), "fov_deg_h": ("FLOAT", {"default": 90.0}), "fov_deg_v": ("FLOAT", {"default": 90.0}), "h_deg": ("FLOAT", {"default": 0.0}), "v_deg": ("FLOAT", {"default": 0.0}), "out_h": ("INT", {"default": 512}), "out_w": ("INT", {"default": 512}), "in_rot_deg": ("FLOAT", {"default": 0.0}), "padding_mode": ( ["bilinear", "bicubic", "nearest"], {"default": "bilinear"}, ), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Perspective Image",) FUNCTION = "e2p" CATEGORY = "pytorch360convert" def e2p( self, e_img: torch.Tensor, fov_deg_h: float = 90.0, fov_deg_v: float = 90.0, h_deg: float = 0.0, v_deg: float = 0.0, out_h: int = 512, out_w: int = 512, in_rot_deg: float = 0.0, padding_mode: str = "bilinear", ) -> Tuple[torch.Tensor]: assert e_img.dim() == 4, f"e_img should have 4 dimensions, got {e_img.dim()}" return ( e2p( e_img, fov_deg=(fov_deg_h, fov_deg_v), h_deg=h_deg, v_deg=v_deg, out_hw=(out_h, out_w), in_rot_deg=in_rot_deg, mode=padding_mode, channels_first=False, ), ) class E2ENode: """ Equirectangular Rotation Node """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "e_img": ("IMAGE", {"default": None}), "roll": ("FLOAT", {"default": 0.0}), "h_deg": ("FLOAT", {"default": 0.0}), "v_deg": ("FLOAT", {"default": 0.0}), "padding_mode": ( ["bilinear", "bicubic", "nearest"], {"default": "bilinear"}, ), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Rotated Image",) FUNCTION = "e2e" CATEGORY = "pytorch360convert" def e2e( self, e_img: torch.Tensor, roll: float = 0.0, h_deg: float = 0.0, v_deg: float = 0.0, padding_mode: str = "bilinear", ) -> Tuple[torch.Tensor]: assert e_img.dim() == 4, f"e_img should have 4 dimensions, got {e_img.dim()}" return ( e2e( e_img=e_img, h_deg=h_deg, v_deg=v_deg, roll=roll, mode=padding_mode, channels_first=False, ), ) class SplitFacesNode: """ Split a stack of cube faces for easy manipulation. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "face_stack": ("IMAGE", {"default": None}), }, } RETURN_TYPES = ("IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE", "IMAGE") RETURN_NAMES = ("Front", "Right", "Back", "Left", "Up", "Down") FUNCTION = "split_faces" CATEGORY = "pytorch360convert" def split_faces(self, face_stack: torch.Tensor) -> Tuple[torch.Tensor, ...]: assert ( face_stack.dim() == 4 ), f"face_stack should have 4 dimensions, got {face_stack.dim()}" assert ( face_stack.shape[0] == 6 ), f"face_stack should have 6 faces, got {face_stack.shape[0]}" outputs = [face_stack[i].unsqueeze(0) for i in range(face_stack.shape[0])] return tuple(outputs) class StackFacesNode: """ Combine multiple cube faces into a stack. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "Front": ("IMAGE", {"default": None}), "Right": ("IMAGE", {"default": None}), "Back": ("IMAGE", {"default": None}), "Left": ("IMAGE", {"default": None}), "Up": ("IMAGE", {"default": None}), "Down": ("IMAGE", {"default": None}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Cubemap stack",) FUNCTION = "stack_faces" CATEGORY = "pytorch360convert" def stack_faces( self, Front: torch.Tensor, Right: torch.Tensor, Back: torch.Tensor, Left: torch.Tensor, Up: torch.Tensor, Down: torch.Tensor, ) -> Tuple[torch.Tensor]: assert ( Front.dim() == 4 and Right.dim() == 4 and Back.dim() == 4 and Left.dim() == 4 and Up.dim() == 4 and Down.dim() == 4 ) return (torch.cat([Front, Right, Back, Left, Up, Down], 0),) class RollImageNode: """ Roll an image to make face seams easier to remove or access. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "image": ("IMAGE", {"default": None}), "roll_x": ("INT", {"default": 0}), "roll_y": ("INT", {"default": 0}), "roll_x_by_50_percent": ( "BOOLEAN", { "default": False, "tooltip": "Ignores roll_x and roll_y. Shifts image horizontally by 50%.", }, ), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Rolled Image",) FUNCTION = "roll_image" CATEGORY = "pytorch360convert" def roll_image( self, image: torch.Tensor, roll_x: int = 0, roll_y: int = 0, roll_x_by_50_percent: bool = False, ) -> Tuple[torch.Tensor]: assert image.dim() == 4, f"image should have 4 dimensions, got {image.dim()}" if roll_x_by_50_percent: _, H, W, _ = image.shape px_half = W // 2 roll_y = 0 roll_x = px_half return (torch.roll(image, shifts=(roll_y, roll_x), dims=(1, 2)),) class C2EMaskedDiffNode: """ Cubemap To Equirectangular with Masked Diff Node """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "original_faces": ("IMAGE", {"default": None}), "modified_faces": ("IMAGE", {"default": None}), "original_equi": ("IMAGE", {"default": None}), "padding_mode": ( ["bilinear", "bicubic", "nearest"], {"default": "bilinear"}, ), "cube_format": ( ["stack", "dice", "horizon", "list", "dict"], {"default": "stack"}, ), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Equirectangular Image",) FUNCTION = "c2e_masked_diff" CATEGORY = "pytorch360convert" def c2e_masked_diff( self, original_faces: torch.Tensor, modified_faces: torch.Tensor, original_equi: torch.Tensor, padding_mode: str = "bilinear", cube_format: str = "stack", ) -> Tuple[torch.Tensor]: assert original_equi.shape[0] == 1, ( "Only a batch size of 1 is currently supported" + f"for original_equi, got {original_equi.shape[0]}" ) assert ( original_faces.shape[0] == 6 ), f"original_faces should have 6 faces, got {original_faces.shape[0]}" assert ( modified_faces.shape[0] == 6 ), f"modified_faces should have 6 faces, got {modified_faces.shape[0]}" new_equi = c2e(modified_faces, cube_format=cube_format, channels_first=False) faces_mask = original_faces != modified_faces mask_equi = c2e( faces_mask.float(), cube_format=cube_format, channels_first=False ).bool() return (torch.where(mask_equi, new_equi, original_equi),) class CropImageWithCoordsNode: """ Crop an section of an image for manipulation """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "image": ("IMAGE", {"default": None}), "crop_h": ("INT", {"default": 0}), "crop_w": ("INT", {"default": 0}), "crop_h2": ("INT", {"default": -1}), "crop_w2": ("INT", {"default": -1}), }, } RETURN_TYPES = ( "IMAGE", "LIST", ) RETURN_NAMES = ( "Cropped Image", "Coords", ) FUNCTION = "crop_image" CATEGORY = "pytorch360convert" def crop_image( self, image: torch.Tensor, crop_h: int = 0, crop_w: int = 0, crop_h2: int = -1, crop_w2: int = -1, ) -> Tuple[torch.Tensor, List[int]]: assert image.dim() == 4, f"image should have 4 dimensions, got {image.dim()}" _, H, W, _ = image.shape # Calculate the center crop indices if crop_h2 < 1: start_h = (H - crop_h) // 2 end_h = start_h + crop_h else: start_h = crop_h end_h = crop_h2 if crop_w2 < 1: start_w = (W - crop_w) // 2 end_w = start_w + crop_w else: start_w = crop_w end_w = crop_w2 cropped_tensor = image[:, start_h:end_h, start_w:end_w, :] coords = [start_h, end_h, start_w, end_w] return ( cropped_tensor, coords, ) class PasteImageWithCoordsNode: """ Add a cropped section back to the original image. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "full_image": ("IMAGE", {"default": None}), "cropped_image": ("IMAGE", {"default": None}), "coords": ("LIST", {"default": None}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Pasted Image",) FUNCTION = "paste_image" CATEGORY = "pytorch360convert" def paste_image( self, full_image: torch.Tensor, cropped_image: torch.Tensor, coords: List[int] ) -> Tuple[torch.Tensor]: assert ( full_image.dim() == 4 ), f"full_image should have 4 dimensions, got {full_image.dim()}" assert ( cropped_image.dim() == 4 ), f"cropped_image should have 4 dimensions, got {cropped_image.dim()}" assert len(coords) == 4 start_h, end_h, start_w, end_w = coords full_image[:, start_h:end_h, start_w:end_w, :] = cropped_image return (full_image,) class Pad180To360Node: """ Apply padding to a 180 degree equirectangular image, to make it 360 degrees. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "image": ("IMAGE", {"default": None}), "fill_value": ("FLOAT", {"default": 0.0}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Padded 360 Image",) FUNCTION = "pad_180_to_360_image" CATEGORY = "pytorch360convert" def pad_180_to_360_image( self, image: torch.Tensor, fill_value: float = 0.0 ) -> Tuple[torch.Tensor]: assert image.dim() == 4, f"image should have 4 dimensions, got {image.dim()}" image = image.permute(0, 3, 1, 2) H, W = image.shape[2:] pad_left = W // 2 pad_right = W - pad_left image_padded = torch.nn.functional.pad( image, (pad_left, pad_right), mode="constant", value=fill_value ) image_padded = image_padded.permute(0, 2, 3, 1) return (image_padded,) class Crop360To180Node: """ Crop a 360 degree equirectangular image to the central 180 degree part. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "image": ("IMAGE", {"default": None}), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Cropped 180 Image",) FUNCTION = "crop_360_to_180_image" CATEGORY = "pytorch360convert" def crop_360_to_180_image(self, image: torch.Tensor) -> Tuple[torch.Tensor]: """ Crop a 360-degree equirectangular image to the central 180-degree part. Args: image (torch.Tensor): The 360-degree equirectangular image. Shape should be: (B, H, W, C). Returns: torch.Tensor: The cropped 180-degree equirectangular image. Shape will be: (B, H, W//2, C) where the width is halved. Raises: ValueError: If the input image width is less than or equal to 1. """ assert image.dim() == 4, f"image should have 4 dimensions, got {image.dim()}" _, _, width, _ = image.shape # Crop the central 180-degree part by slicing the image. crop_start = width // 4 crop_end = 3 * width // 4 # Crop along the width dimension (left and right 180 degrees) cropped_img = image[:, :, crop_start:crop_end, :] return (cropped_img,) class StereoToMonoScopicNode: """ Split a stereo image into 2 monoscopic images. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "image": ("IMAGE", {"default": None}), "split_direction": ( ["horizontal", "vertical"], {"default": "horizontal"}, ), "larger_side": (["first", "second"], {"default": "first"}), }, } RETURN_TYPES = ( "IMAGE", "IMAGE", ) RETURN_NAMES = ( "First Image", "Second Image", ) FUNCTION = "split_stereo_image" CATEGORY = "pytorch360convert" def split_stereo_image( self, image: torch.Tensor, split_direction: str = "horizontal", larger_side: str = "first", ) -> Tuple[torch.Tensor, torch.Tensor]: """ Splits a batch of tensor images (BHWC format) into two halves either vertically or horizontally, splitting exactly down the middle. In case of odd dimensions, one side gets an extra pixel. Args: image (torch.Tensor): The tensor image batch to split. Shape should be: (B, H, W, C). split_direction (str, optional): The direction to split the image. Either "horizontal" or "vertical". Default: "horizontal" larger_side (str, optional): Specifies which side gets the extra pixel when dimensions are odd. "first" gives the extra pixel to the first side (top/left), "second" gives it to the second side (bottom/right). Default: "first" Returns: Tuple[torch.Tensor, torch.Tensor]: A tuple containing the first and second halves of the split images. The shape of each tensor is: - For horizontal split: (B, H/2, W, C) - For vertical split: (B, H, W/2, C) Raises: ValueError: If `split_direction` is not "horizontal" or "vertical". """ _, height, width, channels = image.shape if split_direction == "vertical": # Split horizontally (by height) mid_point = height // 2 if height % 2 != 0: # If height is odd if larger_side == "first": first_half = image[:, : mid_point + 1, :, :] second_half = image[:, mid_point + 1 :, :, :] else: # "second" first_half = image[:, :mid_point, :, :] second_half = image[:, mid_point:, :] else: # Even height, just split in the middle first_half = image[:, :mid_point, :, :] second_half = image[:, mid_point:, :] elif split_direction == "horizontal": # Split vertically (by width) mid_point = width // 2 if width % 2 != 0: # If width is odd if larger_side == "first": first_half = image[:, :, : mid_point + 1, :] second_half = image[:, :, mid_point + 1 :, :] else: # "second" first_half = image[:, :, :mid_point, :] second_half = image[:, :, mid_point:, :] else: # Even width, just split in the middle first_half = image[:, :, :mid_point, :] second_half = image[:, :, mid_point:, :] else: raise ValueError( "Invalid split direction. Please choose" + " 'horizontal' or 'vertical'. " + f"Got {split_direction}" ) return (first_half, second_half) class MonoScopicToStereoNode: """ Merge two monoscopic images into a stereo image. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "first_image": ("IMAGE", {"default": None}), "second_image": ("IMAGE", {"default": None}), "merge_direction": ( ["horizontal", "vertical"], {"default": "horizontal"}, ), }, } RETURN_TYPES = ("IMAGE",) RETURN_NAMES = ("Stereo Image",) FUNCTION = "merge_monoscopic_to_stereo" CATEGORY = "pytorch360convert" def merge_monoscopic_to_stereo( self, first_image: torch.Tensor, second_image: torch.Tensor, merge_direction: str = "horizontal", ) -> Tuple[torch.Tensor]: """ Merges two monoscopic images into a single stereo image by concatenating the two images either horizontally or vertically. Args: first_image (torch.Tensor): The left monoscopic image. Shape should be: (B, H, W, C). second_image (torch.Tensor): The right monoscopic image. Shape should be: (B, H, W, C). merge_direction (str, optional): The direction to merge the images. Either "horizontal" or "vertical". Default: "horizontal" Returns: torch.Tensor: The merged stereo image. Raises: ValueError: If `merge_direction` is not "horizontal" or "vertical". """ f_batch_size, f_height, f_width, f_channels = first_image.shape s_batch_size, s_height, s_width, s_channels = second_image.shape if merge_direction == "horizontal": assert f_batch_size == s_batch_size, ( "Batch size must match: " + f"{f_batch_size} vs {s_batch_size}" ) assert f_height == s_height, ( "Height must match: " + f"{f_height} vs {s_height}" ) assert f_channels == s_channels, ( "Channels must match: " + f"{f_channels} vs {s_channels}" ) # Concatenate horizontally (by width) stereo_image = torch.cat((first_image, second_image), dim=2) elif merge_direction == "vertical": assert f_batch_size == s_batch_size, ( "Batch size must match: " + f"{f_batch_size} vs {s_batch_size}" ) assert f_width == s_width, "Width must match: " + f"{f_width} vs {s_width}" assert f_channels == s_channels, ( "Channels must match: " + f"{f_channels} vs {s_channels}" ) # Concatenate vertically (by height) stereo_image = torch.cat((first_image, second_image), dim=1) else: raise ValueError( "Invalid split direction. Please choose" + " 'horizontal' or 'vertical'. " + f"Got {merge_direction}" ) return (stereo_image,) def _conv_forward( self, x: torch.Tensor, weight: torch.Tensor, bias: torch.Tensor ) -> torch.Tensor: x = torch.nn.functional.pad(x, self.padding_values_x, mode="circular") x = torch.nn.functional.pad(x, self.padding_values_y, mode="constant") return torch.nn.functional.conv2d( x, weight, bias, self.stride, (0, 0), self.dilation, self.groups ) def _apply_circular_conv2d_padding( model: torch.nn.Module, is_vae: bool = False, x_axis_only: bool = True ) -> torch.nn.Module: for layer in model.first_stage_model.modules() if is_vae else model.modules(): if isinstance(layer, torch.nn.Conv2d): if x_axis_only: layer.padding_values_x = ( layer._reversed_padding_repeated_twice[0], layer._reversed_padding_repeated_twice[1], 0, 0, ) layer.padding_values_y = ( 0, 0, layer._reversed_padding_repeated_twice[2], layer._reversed_padding_repeated_twice[3], ) layer._conv_forward = _conv_forward.__get__(layer, torch.nn.Conv2d) else: layer.padding_mode = "circular" return model class ApplyCircularConvPaddingModel: @classmethod def INPUT_TYPES(s): return { "required": { "model": ( "MODEL", {"tooltip": "Model to add circular x-axis conv2d padding to."}, ), "inplace": ( "BOOLEAN", { "default": True, "tooltip": "Modify " + "the already loaded model (True) or a copy of the model (False). " + "If True, model will have to be reloaded to restore padding to " + "the original values. Modifying inplace will use less memory.", }, ), "x_axis_only": ( "BOOLEAN", { "default": True, "tooltip": "Apply" + " circular padding only to the x-axis or to both the x and y axes.", }, ), }, } CATEGORY = "pytorch360convert" RETURN_TYPES = ("MODEL",) FUNCTION = "run" def run( self, model: torch.nn.Module, inplace: bool = True, x_axis_only: bool = True ) -> Tuple[torch.nn.Module]: if inplace: use_model = model else: use_model = copy.deepcopy(model) _apply_circular_conv2d_padding(use_model.model, False, x_axis_only) return (use_model,) class ApplyCircularConvPaddingVAE: @classmethod def INPUT_TYPES(cls): return { "required": { "vae": ( "VAE", {"tooltip": "VAE to add circular x-axis conv2d padding to."}, ), "inplace": ( "BOOLEAN", { "default": True, "tooltip": "Modify " + "the already loaded VAE (True) or a copy of the VAE (False). " + "If True, VAE will have to be reloaded to restore padding to " + "the original values. Modifying inplace will use less memory.", }, ), "x_axis_only": ( "BOOLEAN", { "default": True, "tooltip": "Apply" + " circular padding only to the x-axis or to both the x and y axes.", }, ), } } RETURN_TYPES = ("VAE",) FUNCTION = "run" CATEGORY = "pytorch360convert" def run( self, vae: torch.nn.Module, inplace: bool = True, x_axis_only: bool = True ) -> Tuple[torch.nn.Module]: if inplace: use_vae = vae else: use_vae = copy.deepcopy(vae) _apply_circular_conv2d_padding(use_vae, True, x_axis_only) return (use_vae,) def _create_center_seam_mask(x: torch.Tensor, frac_width: float = 0.10) -> torch.Tensor: """ For a ComfyUI-style mask: shape [B, H, W], values 0 or 1. Args: x (torch.Tensor): input tensor with shape [B, H, W, C]. frac_width (float, optional): fraction of input width for the vertical strip. Returns: mask: torch.Tensor of shape [B, H, W] with float values 0.0 & 1.0. """ # Extract batch, height, and width from x B, H, W, _ = x.shape strip = max(1, int(W * frac_width)) x0 = (W - strip) // 2 x1 = x0 + strip # Create the mask with zeros mask = torch.zeros((B, H, W), dtype=x.dtype, device=x.device) # Fill the vertical strip with ones mask[:, :, x0:x1] = 1.0 return mask class CreateSeamMask: """ Create a seam mask for inpainting on equirectangular images. """ @classmethod def INPUT_TYPES(s) -> Dict: return { "required": { "image": ("IMAGE", {"default": None}), "seam_mask_width": ("FLOAT", {"default": 0.10}), }, } RETURN_TYPES = ("MASK",) RETURN_NAMES = ("Seam Mask",) FUNCTION = "run" CATEGORY = "pytorch360convert" def run( self, image: torch.Tensor, seam_mask_width: float = 0.10, ) -> Tuple[torch.Tensor]: assert image.dim() == 4, "Image should have 4 dimensions" _, H, W, _ = image.shape px_half = W // 2 image_rolled = torch.roll(image, shifts=(0, px_half), dims=(1, 2)) seam_mask = _create_center_seam_mask(image, frac_width=seam_mask_width) return (seam_mask,)