diff --git a/CITATION.cff b/CITATION.cff new file mode 100644 index 0000000..8945261 --- /dev/null +++ b/CITATION.cff @@ -0,0 +1,17 @@ +abstract: "Utilities for converting between different cubemap, equirectangular, and panoramic." +authors: + - family-names: Egan + given-names: Ben +cff-version: 1.2.0 +date-released: "2024-12-15" +keywords: + - equirectangular + - panorama + - "360 degrees" + - "360 degree images" + - cubemap + - research +license: MIT +message: "If you use this software, please cite it using these metadata." +repository-code: "https://github.com/ProGamerGov/pytorch360convert" +title: "pytorch360convert" \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0c65d8a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Ben Egan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/ReadMe.md b/ReadMe.md new file mode 100644 index 0000000..2083ed1 --- /dev/null +++ b/ReadMe.md @@ -0,0 +1,118 @@ +# 📷 PyTorch 360° Image Conversion Toolkit for ComfyUI + +## Overview + +This project implements the functions from [pytorch360convert](https://github.com/ProGamerGov/pytorch360convert) as custom [ComfyUI](https://github.com/comfyanonymous/ComfyUI) nodes, along with a few additional useful functions for working with equirectangular 360 images and cubemaps. + +The pytorch360convert library provides powerful and differentiable image transformation utilities for converting between different panoramic image formats like Equirectangular (360°) Images, Cubemap Representations, and Perspective Projections. + + +## 🔧 Requirements + +- [pytorch360convert](https://github.com/ProGamerGov/pytorch360convert) + + +## 📦 Installation + +If the provided installation information below is not enough, then please see here for more details on installing custom ComfyUI nodes: https://www.comflowy.com/advanced/how-to-install-comfyui-extension + +### ComfyUI Manager install + +If [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) is installed, then you can download via pasting in the github link to this repo, or via the built-in install menu. + +### Installation for portable ComfyUI + +#### Basic install + +Download this repo as a zip file and then unzip it inside the ComfyUI custom_nodes directory. + +Then install the dependency: + +``` +python -m pip install pytorch360convert +``` + +#### git install + +If using Windows, you can install git from here: https://git-scm.com/download/win if you have not done so already and wish to use Git. Then navigate to the custom nodes directory and then run: + +``` +git clone https://github.com/ProGamerGov/ComfyUI_pytorch360convert --recursive +``` + +Then install the dependency for Linux and MacOS: + +``` +python -m pip install pytorch360convert +``` + +Or for Windows: + +``` +path/to/python.exe -m pip install pytorch360convert +``` + +## 💡 Usage Examples + +See the provided workflow files in the `examples` directory for examples of how to use each new node. + + +## 📚 Nodes + +The custom nodes added by this project are available under the 'pytorch360convert' heading in the node selection tab. + +### Equirectangular to Cubemap (E2C) + +The E2C node converts equirectangular images into cubemaps. For more information see the pytorch360convert repo [here](https://github.com/ProGamerGov/pytorch360convert?tab=readme-ov-file#-basic-functions). + +### Cubemap to Equirectangular (C2E) + +The C2E node converts cubemaps into equirectangular images. For more information see the pytorch360convert repo [here](https://github.com/ProGamerGov/pytorch360convert?tab=readme-ov-file#-basic-functions). + +### Equirectangular to Perspective (E2P) + +The E2P node converts equirectangular images into perspective images. For more information see the pytorch360convert repo [here](https://github.com/ProGamerGov/pytorch360convert?tab=readme-ov-file#-basic-functions). + +### Equirectangular to Equirectangular (E2E) + +The E2E node rotates an equirectangular image along one or more axes (roll, pitch, and yaw) to produce a horizontal shift, vertical shift, or to roll the image. For more information see the pytorch360convert repo [here](https://github.com/ProGamerGov/pytorch360convert?tab=readme-ov-file#-basic-functions). + +### Roll Image Axes + +The Roll Image node rolls an image along the height dimension (y axis) or the width dimension (x axis). This can help make artifacts like seams more accessible. Note that y axis rotations are not correct for final equirectangular outputs. Use the E2E node instead for y axis rotations. + +### Crop Image with Coords & Paste Image with Coords + +These two nodes allow you to crop a section of an image out to make things inpainting more memory efficient, before pasting the cropped section back into the original image. + +By default, the cropping node center crops images according to the provided crop_h and crop_w values. If crop_h2 and crop_w2 are used, then the image will be cropped according to `[..., crop_h:crop_h2, crop_w:crop_w2, ...]`. + +### Masked Diff C2E + +This is an experimental node for only pasting the modified parts of face images to the original equirectangular image. This is meant to help lessen the loss of image quality caused by converting between equirectangular and cubemap formats. + + +## 🤝 Contributing + +Contributions are welcome! Please feel free to submit a Pull Request. + + +## 🔬 Citation + +If you use this library in your research or project, please refer to the included [CITATION.cff](CITATION.cff) file or cite it as follows: + +### BibTeX +```bibtex +@misc{egan2024pytorch360convert, + title={PyTorch 360° Image Conversion Toolkit}, + author={Egan, Ben}, + year={2024}, + publisher={GitHub}, + howpublished={\url{https://github.com/ProGamerGov/pytorch360convert}} +} +``` + +### APA Style +``` +Egan, B. (2024). PyTorch 360° Image Conversion Toolkit [Computer software]. GitHub. https://github.com/ProGamerGov/pytorch360convert +``` diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..d18a135 --- /dev/null +++ b/__init__.py @@ -0,0 +1,38 @@ +from .nodes import ( + C2EMaskedDiffNode, + C2ENode, + CropImageWithCoordsNode, + E2CNode, + E2ENode, + E2PNode, + PasteImageWithCoordsNode, + RollImageNode, + SplitFacesNode, + StackFacesNode, +) + +NODE_CLASS_MAPPINGS = { + "Cubemap to Equirectangular": C2ENode, + "Equirectangular to Cubemap": E2CNode, + "Equirectangular to Perspective": E2PNode, + "Equirectangular Rotation": E2ENode, + "Split Cubemap Faces": SplitFacesNode, + "Stack Cubemap Faces": StackFacesNode, + "Roll Image Axes": RollImageNode, + "Masked Diff C2E": C2EMaskedDiffNode, + "Crop Image with Coords": CropImageWithCoordsNode, + "Paste Image with Coords": PasteImageWithCoordsNode, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "Cubemap to Equirectangular": "Cubemap to Equirectangular", + "Equirectangular to Cubemap": "Equirectangular to Cubemap", + "Equirectangular to Perspective": "Equirectangular to Perspective", + "Equirectangular Rotation": "Equirectangular Rotation", + "Split Cubemap Faces": "Split Cubemap Faces", + "Stack Cubemap Faces": "Stack Cubemap Faces", + "Roll Image Axes": "Roll Image Axes", + "Masked Diff C2E": "Masked Diff C2E", + "Crop Image with Coords": "Crop Image with Coords", + "Paste Image with Coords": "Paste Image with Coords", +} diff --git a/examples/crop_and_paste.json b/examples/crop_and_paste.json new file mode 100644 index 0000000..9571022 --- /dev/null +++ b/examples/crop_and_paste.json @@ -0,0 +1,257 @@ +{ + "last_node_id": 5, + "last_link_id": 6, + "nodes": [ + { + "id": 2, + "type": "Crop Image with Coords", + "pos": [ + 1879.891357421875, + 288.3313293457031 + ], + "size": [ + 315, + 150 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "Cropped Image", + "type": "IMAGE", + "links": [ + 3, + 6 + ], + "slot_index": 0 + }, + { + "name": "Coords", + "type": "LIST", + "links": [ + 4 + ], + "slot_index": 1 + } + ], + "properties": { + "Node name for S&R": "Crop Image with Coords" + }, + "widgets_values": [ + 512, + 512, + -1, + -1 + ] + }, + { + "id": 3, + "type": "Paste Image with Coords", + "pos": [ + 2268.15673828125, + 191.24176025390625 + ], + "size": [ + 289.79998779296875, + 66 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "full_image", + "type": "IMAGE", + "link": 2 + }, + { + "name": "cropped_image", + "type": "IMAGE", + "link": 3 + }, + { + "name": "coords", + "type": "LIST", + "link": 4 + } + ], + "outputs": [ + { + "name": "Pasted Image", + "type": "IMAGE", + "links": [ + 5 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "Paste Image with Coords" + } + }, + { + "id": 4, + "type": "PreviewImage", + "pos": [ + 2647.94677734375, + 187.39999389648438 + ], + "size": [ + 297.22064208984375, + 246 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 5 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 5, + "type": "PreviewImage", + "pos": [ + 2244.199462890625, + 317.08172607421875 + ], + "size": [ + 363.06500244140625, + 246 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 6 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 1, + "type": "LoadImage", + "pos": [ + 1515.659912109375, + 192.93235778808594 + ], + "size": [ + 315, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1, + 2 + ], + "slot_index": 0 + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "example_world_map_equirectangular.png", + "image" + ] + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 1, + 0, + 3, + 0, + "IMAGE" + ], + [ + 3, + 2, + 0, + 3, + 1, + "IMAGE" + ], + [ + 4, + 2, + 1, + 3, + 2, + "LIST" + ], + [ + 5, + 3, + 0, + 4, + 0, + "IMAGE" + ], + [ + 6, + 2, + 0, + 5, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.8730237586043446, + "offset": [ + -1084.3447094095168, + 198.94417177397622 + ] + }, + "node_versions": { + "comfy-core": "0.3.12" + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/examples/cubemap2equirectangular.json b/examples/cubemap2equirectangular.json new file mode 100644 index 0000000..efe1d17 --- /dev/null +++ b/examples/cubemap2equirectangular.json @@ -0,0 +1,226 @@ +{ + "last_node_id": 5, + "last_link_id": 4, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [ + 2049.27001953125, + 263.6300048828125 + ], + "size": [ + 315, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "example_world_map_equirectangular.png", + "image" + ] + }, + { + "id": 2, + "type": "Equirectangular to Cubemap", + "pos": [ + 2436.27001953125, + 264.6300048828125 + ], + "size": [ + 327.5999755859375, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "e_img", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "Cubemap Image", + "type": "IMAGE", + "links": [ + 2, + 4 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "Equirectangular to Cubemap" + }, + "widgets_values": [ + -1, + "bilinear", + "stack" + ] + }, + { + "id": 3, + "type": "Cubemap to Equirectangular", + "pos": [ + 2818.27001953125, + 265.6300048828125 + ], + "size": [ + 342.5999755859375, + 130 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "e_img", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [ + { + "name": "Equirectangular Image", + "type": "IMAGE", + "links": [ + 3 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "Cubemap to Equirectangular" + }, + "widgets_values": [ + -1, + -1, + "bilinear", + "stack" + ] + }, + { + "id": 4, + "type": "PreviewImage", + "pos": [ + 3224.27001953125, + 264.1300048828125 + ], + "size": [ + 283, + 246 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 5, + "type": "PreviewImage", + "pos": [ + 2824.27001953125, + 476.6300048828125 + ], + "size": [ + 283, + 246 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 2, + 0, + 3, + 0, + "IMAGE" + ], + [ + 3, + 3, + 0, + 4, + 0, + "IMAGE" + ], + [ + 4, + 2, + 0, + 5, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.8264462809917354, + "offset": [ + -1544.6999999999987, + 71.54000000000008 + ] + }, + "node_versions": { + "comfy-core": "0.3.12" + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/examples/equirectangular2equirectangular.json b/examples/equirectangular2equirectangular.json new file mode 100644 index 0000000..6d837eb --- /dev/null +++ b/examples/equirectangular2equirectangular.json @@ -0,0 +1,143 @@ +{ + "last_node_id": 3, + "last_link_id": 2, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [ + 592, + 208 + ], + "size": [ + 315, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "example_world_map_equirectangular.png", + "image" + ] + }, + { + "id": 3, + "type": "PreviewImage", + "pos": [ + 1363, + 208 + ], + "size": [ + 310, + 282 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 2, + "type": "Equirectangular Rotation", + "pos": [ + 969, + 208.5 + ], + "size": [ + 315, + 130 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "e_img", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "Rotated Image", + "type": "IMAGE", + "links": [ + 2 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "Equirectangular Rotation" + }, + "widgets_values": [ + 25, + 90, + 45, + "bilinear" + ] + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 2, + 0, + 3, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [ + 0, + 0 + ] + }, + "node_versions": { + "comfy-core": "0.3.12" + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/examples/equirectangular2perspective.json b/examples/equirectangular2perspective.json new file mode 100644 index 0000000..6a83473 --- /dev/null +++ b/examples/equirectangular2perspective.json @@ -0,0 +1,147 @@ +{ + "last_node_id": 3, + "last_link_id": 2, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [ + 550, + 225 + ], + "size": [ + 315, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "example_world_map_equirectangular.png", + "image" + ] + }, + { + "id": 3, + "type": "PreviewImage", + "pos": [ + 1318, + 222 + ], + "size": [ + 310, + 282 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 2, + "type": "Equirectangular to Perspective", + "pos": [ + 895, + 223 + ], + "size": [ + 378, + 226 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "e_img", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "Perspective Image", + "type": "IMAGE", + "links": [ + 2 + ], + "slot_index": 0 + } + ], + "properties": { + "Node name for S&R": "Equirectangular to Perspective" + }, + "widgets_values": [ + 90, + 90, + 0, + 0, + 512, + 512, + 25, + "bilinear" + ] + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 2, + 0, + 3, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [ + 0, + 0 + ] + }, + "node_versions": { + "comfy-core": "0.3.12" + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/examples/roll_image.json b/examples/roll_image.json new file mode 100644 index 0000000..fc05afa --- /dev/null +++ b/examples/roll_image.json @@ -0,0 +1,141 @@ +{ + "last_node_id": 3, + "last_link_id": 2, + "nodes": [ + { + "id": 1, + "type": "LoadImage", + "pos": [ + 4046.295654296875, + 841.81591796875 + ], + "size": [ + 315, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "example_world_map_equirectangular.png", + "image" + ] + }, + { + "id": 2, + "type": "Roll Image Axes", + "pos": [ + 4421.23828125, + 841.5401000976562 + ], + "size": [ + 315, + 82 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "image", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "Rolled Image", + "type": "IMAGE", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "Roll Image Axes" + }, + "widgets_values": [ + 2000, + 0 + ] + }, + { + "id": 3, + "type": "PreviewImage", + "pos": [ + 4798.23828125, + 841.5401000976562 + ], + "size": [ + 348, + 315 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 2, + 0, + 3, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 1, + "offset": [ + -3500.2956000000004, + -505.54010000000017 + ] + }, + "node_versions": { + "comfy-core": "0.3.12" + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/examples/split_and_merge_faces.json b/examples/split_and_merge_faces.json new file mode 100644 index 0000000..de43128 --- /dev/null +++ b/examples/split_and_merge_faces.json @@ -0,0 +1,589 @@ +{ + "last_node_id": 12, + "last_link_id": 16, + "nodes": [ + { + "id": 3, + "type": "Split Cubemap Faces", + "pos": [ + 3476.61962890625, + 1111.1317138671875 + ], + "size": [ + 239.40000915527344, + 126 + ], + "flags": {}, + "order": 2, + "mode": 0, + "inputs": [ + { + "name": "face_stack", + "type": "IMAGE", + "link": 2 + } + ], + "outputs": [ + { + "name": "Front", + "type": "IMAGE", + "links": [ + 7, + 9 + ], + "slot_index": 0 + }, + { + "name": "Right", + "type": "IMAGE", + "links": [ + 4, + 10 + ], + "slot_index": 1 + }, + { + "name": "Back", + "type": "IMAGE", + "links": [ + 5, + 11 + ], + "slot_index": 2 + }, + { + "name": "Left", + "type": "IMAGE", + "links": [ + 6, + 12 + ], + "slot_index": 3 + }, + { + "name": "Up", + "type": "IMAGE", + "links": [ + 3, + 13 + ], + "slot_index": 4 + }, + { + "name": "Down", + "type": "IMAGE", + "links": [ + 8, + 14 + ], + "slot_index": 5 + } + ], + "properties": { + "Node name for S&R": "Split Cubemap Faces" + } + }, + { + "id": 4, + "type": "PreviewImage", + "pos": [ + 3750.492431640625, + 146.76121520996094 + ], + "size": [ + 212.45692443847656, + 246 + ], + "flags": {}, + "order": 7, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 3 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 5, + "type": "PreviewImage", + "pos": [ + 3516.74169921875, + 448.58148193359375 + ], + "size": [ + 212.45692443847656, + 246 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 4 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 6, + "type": "PreviewImage", + "pos": [ + 3753.04931640625, + 446.3210754394531 + ], + "size": [ + 212.45692443847656, + 246 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 5 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 7, + "type": "PreviewImage", + "pos": [ + 3997.806884765625, + 448.323486328125 + ], + "size": [ + 212.45692443847656, + 246 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 6 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 8, + "type": "PreviewImage", + "pos": [ + 4234.89990234375, + 447.094970703125 + ], + "size": [ + 212.45692443847656, + 246 + ], + "flags": {}, + "order": 3, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 7 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 9, + "type": "PreviewImage", + "pos": [ + 3759.2265625, + 748.9763793945312 + ], + "size": [ + 212.45692443847656, + 246 + ], + "flags": {}, + "order": 8, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 8 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 10, + "type": "Stack Cubemap Faces", + "pos": [ + 3843.63623046875, + 1106.7528076171875 + ], + "size": [ + 239.40000915527344, + 126 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "Front", + "type": "IMAGE", + "link": 9 + }, + { + "name": "Right", + "type": "IMAGE", + "link": 10 + }, + { + "name": "Back", + "type": "IMAGE", + "link": 11 + }, + { + "name": "Left", + "type": "IMAGE", + "link": 12 + }, + { + "name": "Up", + "type": "IMAGE", + "link": 13 + }, + { + "name": "Down", + "type": "IMAGE", + "link": 14 + } + ], + "outputs": [ + { + "name": "Cubemap stack", + "type": "IMAGE", + "links": [ + 15 + ] + } + ], + "properties": { + "Node name for S&R": "Stack Cubemap Faces" + } + }, + { + "id": 12, + "type": "PreviewImage", + "pos": [ + 4557.3388671875, + 995.196533203125 + ], + "size": [ + 297.22064208984375, + 246 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 16 + } + ], + "outputs": [], + "properties": { + "Node name for S&R": "PreviewImage" + } + }, + { + "id": 1, + "type": "LoadImage", + "pos": [ + 2665.410400390625, + 1113.3798828125 + ], + "size": [ + 315, + 314 + ], + "flags": {}, + "order": 0, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "IMAGE", + "type": "IMAGE", + "links": [ + 1 + ] + }, + { + "name": "MASK", + "type": "MASK", + "links": null + } + ], + "properties": { + "Node name for S&R": "LoadImage" + }, + "widgets_values": [ + "example_world_map_equirectangular.png", + "image" + ] + }, + { + "id": 2, + "type": "Equirectangular to Cubemap", + "pos": [ + 3062.2333984375, + 1114.556884765625 + ], + "size": [ + 327.5999755859375, + 106 + ], + "flags": {}, + "order": 1, + "mode": 0, + "inputs": [ + { + "name": "e_img", + "type": "IMAGE", + "link": 1 + } + ], + "outputs": [ + { + "name": "Cubemap Image", + "type": "IMAGE", + "links": [ + 2 + ] + } + ], + "properties": { + "Node name for S&R": "Equirectangular to Cubemap" + }, + "widgets_values": [ + -1, + "bilinear", + "stack" + ] + }, + { + "id": 11, + "type": "Cubemap to Equirectangular", + "pos": [ + 4153.66259765625, + 1105.524169921875 + ], + "size": [ + 327.5999755859375, + 130 + ], + "flags": {}, + "order": 10, + "mode": 0, + "inputs": [ + { + "name": "e_img", + "type": "IMAGE", + "link": 15 + } + ], + "outputs": [ + { + "name": "Equirectangular Image", + "type": "IMAGE", + "links": [ + 16 + ] + } + ], + "properties": { + "Node name for S&R": "Cubemap to Equirectangular" + }, + "widgets_values": [ + -1, + -1, + "bilinear", + "stack" + ] + } + ], + "links": [ + [ + 1, + 1, + 0, + 2, + 0, + "IMAGE" + ], + [ + 2, + 2, + 0, + 3, + 0, + "IMAGE" + ], + [ + 3, + 3, + 4, + 4, + 0, + "IMAGE" + ], + [ + 4, + 3, + 1, + 5, + 0, + "IMAGE" + ], + [ + 5, + 3, + 2, + 6, + 0, + "IMAGE" + ], + [ + 6, + 3, + 3, + 7, + 0, + "IMAGE" + ], + [ + 7, + 3, + 0, + 8, + 0, + "IMAGE" + ], + [ + 8, + 3, + 5, + 9, + 0, + "IMAGE" + ], + [ + 9, + 3, + 0, + 10, + 0, + "IMAGE" + ], + [ + 10, + 3, + 1, + 10, + 1, + "IMAGE" + ], + [ + 11, + 3, + 2, + 10, + 2, + "IMAGE" + ], + [ + 12, + 3, + 3, + 10, + 3, + "IMAGE" + ], + [ + 13, + 3, + 4, + 10, + 4, + "IMAGE" + ], + [ + 14, + 3, + 5, + 10, + 5, + "IMAGE" + ], + [ + 15, + 10, + 0, + 11, + 0, + "IMAGE" + ], + [ + 16, + 11, + 0, + 12, + 0, + "IMAGE" + ] + ], + "groups": [], + "config": {}, + "extra": { + "ds": { + "scale": 0.7936579623675859, + "offset": [ + -2381.914311318234, + -266.46021946348 + ] + }, + "node_versions": { + "comfy-core": "0.3.12" + } + }, + "version": 0.4 +} \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..292edbd --- /dev/null +++ b/nodes.py @@ -0,0 +1,454 @@ +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": ("STRING", {"default": "bilinear"}), + "cube_format": ("STRING", {"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": ("STRING", {"default": "bilinear"}), + "cube_format": ("STRING", {"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": ("STRING", {"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": ("STRING", {"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}), + }, + } + + 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 + ) -> Tuple[torch.Tensor]: + assert image.dim() == 4, f"image should have 4 dimensions, got {image.dim()}" + 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": ("STRING", {"default": "bilinear"}), + "cube_format": ("STRING", {"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,) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e624728 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +pytorch360convert \ No newline at end of file