From 0429b63e22b778f25cbe265e237a125cd52877aa Mon Sep 17 00:00:00 2001 From: thalismind Date: Sun, 20 Jul 2025 21:37:16 -0500 Subject: [PATCH] Add LoadImageWithFilename and LoadImageFolder nodes - Enhanced LoadImageWithFilename to output filename as STRING - Added LoadImageFolder node for batch loading entire folders - Added proper imports and error handling - Created comprehensive README documentation - Added Python-specific .gitignore file Based on ComfyUI issue #8699 for filename output functionality --- .gitignore | 134 +++++++++++++++++++++++++++++++ README.md | 57 ++++++++++++++ nodes.py | 227 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 nodes.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ba9a34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,134 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# ComfyUI specific +models/ +outputs/ +temp/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ea26e04 --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# ComfyUI LoadImageWithFilename + +This custom node extends ComfyUI's image loading functionality with filename output and folder loading capabilities. + +## Features + +### LoadImageWithFilename +- **Enhanced Load Image Node**: Based on the original ComfyUI LoadImage node +- **Filename Output**: Returns the filename of the loaded image as a STRING output +- **Compatible**: Maintains all original functionality (IMAGE and MASK outputs) + +### LoadImageFolder +- **Folder Loading**: Load all images from a selected folder +- **Batch Processing**: Returns all images concatenated as a single tensor +- **Filename Tracking**: Returns a comma-separated list of all loaded filenames +- **Error Handling**: Gracefully handles corrupted or unsupported image files + +## Installation + +1. Place `nodes.py` in your ComfyUI `custom_nodes` directory +2. Restart ComfyUI +3. The new nodes will appear in the "image" category + +## Usage + +### LoadImageWithFilename +- **Input**: Select an image file from the dropdown +- **Outputs**: + - `image`: The loaded image tensor + - `mask`: The image mask (if available) + - `filename`: The filename of the loaded image + +### LoadImageFolder +- **Input**: Select a folder from the dropdown +- **Outputs**: + - `image`: All images from the folder concatenated as a single tensor + - `mask`: All masks concatenated as a single tensor + - `filenames`: Comma-separated list of all loaded filenames + +## Requirements + +- ComfyUI +- PIL (Pillow) +- PyTorch +- NumPy + +## Notes + +- The LoadImageFolder node will skip any non-image files in the selected folder +- If no valid images are found in a folder, empty tensors will be returned +- All images in a folder must have the same dimensions for proper concatenation +- Error messages are printed to console for any files that fail to load + +## Based On + +- [ComfyUI LoadImage Node](https://github.com/comfyanonymous/ComfyUI/blob/master/nodes.py) +- [Issue #8699](https://github.com/comfyanonymous/ComfyUI/issues/8699) - Request for filename output functionality \ No newline at end of file diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..c50f3cc --- /dev/null +++ b/nodes.py @@ -0,0 +1,227 @@ +import os +import hashlib +import torch +import numpy as np +from PIL import Image, ImageOps, ImageSequence +import folder_paths +import node_helpers + +class LoadImageWithFilename: + @classmethod + def INPUT_TYPES(s): + input_dir = folder_paths.get_input_directory() + files = [f for f in os.listdir(input_dir) if os.path.isfile(os.path.join(input_dir, f))] + files = folder_paths.filter_files_content_types(files, ["image"]) + return {"required": + {"image": (sorted(files), {"image_upload": True})}, + } + + CATEGORY = "image" + + RETURN_TYPES = ("IMAGE", "MASK", "STRING") + RETURN_NAMES = ("image", "mask", "filename") + FUNCTION = "load_image" + + def load_image(self, image): + image_path = folder_paths.get_annotated_filepath(image) + + img = node_helpers.pillow(Image.open, image_path) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + image = i.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + elif i.mode == 'P' and 'transparency' in i.info: + mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and img.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + # Extract filename from the image path + filename = os.path.basename(image_path) + + return (output_image, output_mask, filename) + + @classmethod + def IS_CHANGED(s, image): + image_path = folder_paths.get_annotated_filepath(image) + m = hashlib.sha256() + with open(image_path, 'rb') as f: + m.update(f.read()) + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, image): + if not folder_paths.exists_annotated_filepath(image): + return "Invalid image file: {}".format(image) + + return True + + +class LoadImageFolder: + @classmethod + def INPUT_TYPES(s): + input_dir = folder_paths.get_input_directory() + folders = [f for f in os.listdir(input_dir) if os.path.isdir(os.path.join(input_dir, f))] + return {"required": + {"folder": (sorted(folders),)}, + } + + CATEGORY = "image" + + RETURN_TYPES = ("IMAGE", "MASK", "STRING") + RETURN_NAMES = ("image", "mask", "filenames") + FUNCTION = "load_folder" + + def load_folder(self, folder): + input_dir = folder_paths.get_input_directory() + folder_path = os.path.join(input_dir, folder) + + # Get all image files in the folder + image_files = [] + for file in os.listdir(folder_path): + file_path = os.path.join(folder_path, file) + if os.path.isfile(file_path): + # Check if it's an image file + try: + with Image.open(file_path) as img: + image_files.append(file) + except: + continue + + if not image_files: + # Return empty tensors if no images found + empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32) + empty_mask = torch.zeros((1, 64, 64), dtype=torch.float32) + return (empty_image, empty_mask, "") + + # Load all images in the folder + all_images = [] + all_masks = [] + all_filenames = [] + + for filename in sorted(image_files): + file_path = os.path.join(folder_path, filename) + + try: + img = node_helpers.pillow(Image.open, file_path) + + output_images = [] + output_masks = [] + w, h = None, None + + excluded_formats = ['MPO'] + + for i in ImageSequence.Iterator(img): + i = node_helpers.pillow(ImageOps.exif_transpose, i) + + if i.mode == 'I': + i = i.point(lambda i: i * (1 / 255)) + image = i.convert("RGB") + + if len(output_images) == 0: + w = image.size[0] + h = image.size[1] + + if image.size[0] != w or image.size[1] != h: + continue + + image = np.array(image).astype(np.float32) / 255.0 + image = torch.from_numpy(image)[None,] + if 'A' in i.getbands(): + mask = np.array(i.getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + elif i.mode == 'P' and 'transparency' in i.info: + mask = np.array(i.convert('RGBA').getchannel('A')).astype(np.float32) / 255.0 + mask = 1. - torch.from_numpy(mask) + else: + mask = torch.zeros((64,64), dtype=torch.float32, device="cpu") + output_images.append(image) + output_masks.append(mask.unsqueeze(0)) + + if len(output_images) > 1 and img.format not in excluded_formats: + output_image = torch.cat(output_images, dim=0) + output_mask = torch.cat(output_masks, dim=0) + else: + output_image = output_images[0] + output_mask = output_masks[0] + + all_images.append(output_image) + all_masks.append(output_mask) + all_filenames.append(filename) + + except Exception as e: + print(f"Error loading {filename}: {e}") + continue + + if not all_images: + # Return empty tensors if no images could be loaded + empty_image = torch.zeros((1, 64, 64, 3), dtype=torch.float32) + empty_mask = torch.zeros((1, 64, 64), dtype=torch.float32) + return (empty_image, empty_mask, "") + + # Concatenate all images and masks + combined_images = torch.cat(all_images, dim=0) + combined_masks = torch.cat(all_masks, dim=0) + filenames_str = ",".join(all_filenames) + + return (combined_images, combined_masks, filenames_str) + + @classmethod + def IS_CHANGED(s, folder): + input_dir = folder_paths.get_input_directory() + folder_path = os.path.join(input_dir, folder) + if not os.path.exists(folder_path): + return "INVALID" + + # Create a hash based on folder contents + m = hashlib.sha256() + for file in sorted(os.listdir(folder_path)): + file_path = os.path.join(folder_path, file) + if os.path.isfile(file_path): + try: + with open(file_path, 'rb') as f: + m.update(f.read()) + except: + continue + return m.digest().hex() + + @classmethod + def VALIDATE_INPUTS(s, folder): + input_dir = folder_paths.get_input_directory() + folder_path = os.path.join(input_dir, folder) + if not os.path.exists(folder_path): + return "Invalid folder: {}".format(folder) + if not os.path.isdir(folder_path): + return "Not a directory: {}".format(folder) + return True \ No newline at end of file