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
This commit is contained in:
+134
@@ -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/
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user