Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c732ce7cb | ||
|
|
e7d124d786 | ||
|
|
c976f3d057 | ||
|
|
c74e859aaa | ||
|
|
684d3fcb6b | ||
|
|
0f0157ed4f | ||
|
|
b306bd37a7 | ||
|
|
ea94753216 | ||
|
|
3844dd20b5 | ||
|
|
79f1003873 | ||
|
|
e159265648 | ||
|
|
aa8dc7b2ce | ||
|
|
684afdf5e6 | ||
|
|
314c6bf609 | ||
|
|
529756d415 | ||
|
|
79b14305d5 | ||
|
|
968859fc5a | ||
|
|
f05c36f520 | ||
|
|
d99c165f3d | ||
|
|
8134be8032 | ||
|
|
8d587bcab4 | ||
|
|
2ddd61ee87 | ||
|
|
87ee4aebb7 | ||
|
|
d95ef9758d | ||
|
|
01945ade86 | ||
|
|
015f8be8c9 | ||
|
|
644a2fa7f4 | ||
|
|
0d3c321ae3 | ||
|
|
9a565f31b0 | ||
|
|
214bee12d9 | ||
|
|
a091559e25 | ||
|
|
bb890e2027 | ||
|
|
d989087f08 | ||
|
|
13be95db99 | ||
|
|
018f62fed0 | ||
|
|
d443749537 | ||
|
|
35f1f1b772 | ||
|
|
624e311edf | ||
|
|
f08e90801e | ||
|
|
4948f6c7d4 | ||
|
|
a992716243 |
@@ -196,3 +196,4 @@ cython_debug/
|
||||
*.dst
|
||||
*.epr
|
||||
.*~
|
||||
sync.sh
|
||||
|
||||
+16
-1
@@ -51,7 +51,7 @@ repos:
|
||||
# The command to execute. It's expected to be found in the PATH
|
||||
# of the environment where you run `git commit`.
|
||||
entry: mypy
|
||||
args: ["--explicit-package-bases", "source/"]
|
||||
args: ["--explicit-package-bases", "src/"]
|
||||
# 'system' tells pre-commit to find the command in the current environment
|
||||
# instead of building an isolated one.
|
||||
language: system
|
||||
@@ -64,3 +64,18 @@ repos:
|
||||
# whole project as configured in mypy.ini, rather than just the changed files.
|
||||
# Try without it first.
|
||||
# pass_filenames: false
|
||||
|
||||
# --- Version checking ---
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: version-check
|
||||
name: check for version consistency
|
||||
# The command to execute. It's a Python script.
|
||||
entry: python tools/check_versions.py
|
||||
# Use 'system' to run it with the current environment's Python
|
||||
language: system
|
||||
# This hook doesn't need to run on every file.
|
||||
# It should run if either of the version files change.
|
||||
# This makes it very fast.
|
||||
files: ^(pyproject\.toml|src/nodes/__init__\.py)$
|
||||
# The regex `^...$` ensures it matches the full path from the repo root.
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
# ComfyUI Image Miscellaneous Nodes 🎨
|
||||
|
||||
This repository provides a set of custom nodes for ComfyUI focused on image manipulation.
|
||||
Currently we just have an image downloader node, but I didn't wanted to put it with
|
||||
my audio nodes.
|
||||
Currently we just have a few nodes used by other nodes I maintain.
|
||||
|
||||
|
||||
## ⚙️ Main features
|
||||
|
||||
✅ No extra dependencies, we use the same modules as ComfyUI
|
||||
✅ No bizarre extra dependencies, we use the same modules as ComfyUI
|
||||
|
||||
✅ Warnings and errors visible in the browser, configurable debug information in the console
|
||||
|
||||
@@ -18,7 +17,24 @@ my audio nodes.
|
||||
- 📦 [Dependencies](#-dependencies)
|
||||
- 🖼️ [Examples](#️-examples)
|
||||
- ✨ [Nodes](#-extra-nodes)
|
||||
- [1. Image Download and Load](#1-image-download-and-load)
|
||||
- Foreground: (Foreground estimation)
|
||||
- [Estimate foreground (AFFCE)](docs/nodes_img.md#8-estimate-foreground-affce)
|
||||
- [Estimate foreground (FMLFE)](docs/nodes_img.md#9-estimate-foreground-fmlfe)
|
||||
- Generation:
|
||||
- [Create Empty Image](docs/nodes_img.md#10-create-empty-image)
|
||||
- Input/Output:
|
||||
- [Image Download and Load](docs/nodes_img.md#1-image-download-and-load)
|
||||
- Manipulation:
|
||||
- [Face Composite](docs/nodes_img.md#2-face-composite)
|
||||
- [Face Composite (frame by frame)](docs/nodes_img.md#3-face-composite-frame-by-frame)
|
||||
- [Apply Mask using AFFCE](docs/nodes_img.md#7-apply-mask-using-affce)
|
||||
- [Pad Image](docs/nodes_img.md#11-pad-image-kjset)
|
||||
- [Resize Image](docs/nodes_img.md#12-resize-image-kjset)
|
||||
- [Resize Mask](docs/nodes_img.md#13-resize-mask-kjset)
|
||||
- Normalization:
|
||||
- [Normalize Image to ImageNet](docs/nodes_img.md#4-normalize-image-to-imagenet)
|
||||
- [Normalize Image to [-0.5, 0.5]](docs/nodes_img.md#5-normalize-image-to-05-05)
|
||||
- [Normalize Image to [-1, 1]](docs/nodes_img.md#6-normalize-image-to-1-1)
|
||||
- 📝 [Usage Notes](#-usage-notes)
|
||||
- 📜 [Project History](#-project-history)
|
||||
- ⚖️ [License](#️-license)
|
||||
@@ -26,23 +42,7 @@ my audio nodes.
|
||||
|
||||
## ✨ Nodes
|
||||
|
||||
### 1. Image Download and Load
|
||||
- **Display Name:** `Image Download and Load`
|
||||
- **Internal Name:** `SET_ImageDownload`
|
||||
- **Category:** `image/io`
|
||||
- **Description:** Downloads an image file from a URL into the `ComfyUI/input/` directory if it's not already there, and then loads it as an image and mask. This is perfect for creating self-contained, shareable workflows with example image.
|
||||
- **Inputs:**
|
||||
- `image_bypass` (IMAGE, Optional): If an image is provided here it will be used for the output. You can connect a `Load Image` node here, if the connected node is muted (bypassed) we download the file, otherwise we use the image from the `Load Image` node.
|
||||
- `mask_bypass` (MASK, Optional): This input complements `image_bypass`.
|
||||
- `base_url` (STRING): The URL of the directory containing the image file.
|
||||
- `filename` (STRING): The name of the image file to download (e.g., photo.jpg, art.png).
|
||||
- **Output:**
|
||||
- `image` (IMAGE): The loaded image.
|
||||
- `alpha_mask` (MASK): The alpha mask for the loaded image.
|
||||
- **Behavior Details:**
|
||||
- **Caching:** The node checks the `ComfyUI/input/` folder first. If the file with the specified `filename` already exists, the download is skipped.
|
||||
- **Bypass:** If only one of `image_bypass` and `mask_bypass` is connected the other will be assumed to be empty. You should connect both or avoid using the output corresponding to the unconnected input.
|
||||
|
||||
The nodes are documented [here](docs/nodes_img.md). Use the above ToC to access them by category.
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
@@ -53,14 +53,18 @@ You can install the nodes from the ComfyUI nodes manager, the name is *Image Mis
|
||||
cd ComfyUI/custom_nodes/
|
||||
git clone https://github.com/set-soft/ComfyUI-ImageMisc ComfyUI-ImageMisc
|
||||
```
|
||||
2. Restart ComfyUI.
|
||||
2. Install dependencies: `pip install -r ComfyUI/custom_nodes/ComfyUI-ImageMisc/requirements.txt`
|
||||
3. Restart ComfyUI.
|
||||
|
||||
The nodes should then appear under the "image/io" category in the "Add Node" menu.
|
||||
The nodes should then appear under the "image/io", "image/manipulation", "image/normalization" and "image/foreground" categories in the "Add Node" menu.
|
||||
|
||||
|
||||
## 📦 Dependencies
|
||||
|
||||
- SeCoNoHe (seconohe): This is just some functionality I wrote shared by my nodes, only depends on ComfyUI.
|
||||
- PyTorch: Installed by ComfyUI
|
||||
- NumPy: Installed by ComfyUI
|
||||
- Pillow: Installed by ComfyUI
|
||||
- Requests (optional): Usually an indirect ComfyUI dependency. If installed it will be used for downloads, it should be more robust than then built-in `urllib`, used as fallback.
|
||||
- Colorama (optional): Might help to get colored log messages on some terminals. We use ANSI escape sequences when it isn't installed.
|
||||
|
||||
@@ -69,7 +73,18 @@ The nodes should then appear under the "image/io" category in the "Add Node" men
|
||||
|
||||
Once installed the examples are available in the ComfyUI workflow templates, in the *Image Misc* section (or ComfyUI-ImageMisc).
|
||||
|
||||
- [image_download.json](example_workflows/image_download.json): Shows how to use the image downloader node.
|
||||
### [image_download.json](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/image_download.json)
|
||||
|
||||
Shows how to use the image downloader node.
|
||||
|
||||
[](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/image_download.json)
|
||||
|
||||
|
||||
### [resize.json](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/resize.json)
|
||||
|
||||
Shows the most common uses of the resize and padding nodes. You can see how various modes and options affects the result and how the different interpolation methods works. It shows how masks and RGBA images are handled.
|
||||
|
||||
[](https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/refs/heads/main/example_workflows/resize.json)
|
||||
|
||||
|
||||
## 📝 Usage Notes
|
||||
@@ -84,6 +99,16 @@ Once installed the examples are available in the ComfyUI workflow templates, in
|
||||
|
||||
- 1.0.0 2025-07-21: Initial release
|
||||
- Just the download image.
|
||||
- 1.1.0 2025-07-25: Adapted to use SeCoNoHe
|
||||
- 1.1.1 2025-07-25: Just small details for ComfyUI registry
|
||||
- 1.2.0 2025-10-05:
|
||||
- Face composite nodes
|
||||
- Normalization nodes
|
||||
- Apply mask nodes, including support for:
|
||||
- "Fast Multi-Level Foreground Estimation" (from [PyMatting](https://github.com/pymatting/pymatting))
|
||||
- "Approximate Fast Foreground Colour Estimation" (from [Photoroom](https://github.com/Photoroom/fast-foreground-estimation))
|
||||
- Create empty image node, from reference image
|
||||
- 1.3.0 2025-10-14: Pad Image, Resize Image and Resize Mask nodes
|
||||
|
||||
|
||||
## ⚖️ License
|
||||
@@ -93,3 +118,4 @@ Once installed the examples are available in the ComfyUI workflow templates, in
|
||||
## 🙏 Attributions
|
||||
|
||||
- Good part of the initial code and this README was generated using Gemini 2.5 Pro.
|
||||
- The *Pad Image*, *Resize Image* and *Resize Mask* are from [KJNodes](https://github.com/kijai/ComfyUI-KJNodes) by [Kijai](https://github.com/kijai) (Jukka Seppänen)
|
||||
|
||||
+6
-19
@@ -3,26 +3,13 @@
|
||||
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
|
||||
# License: GPL-3.0
|
||||
# Project: ComfyUI-ImageMisc
|
||||
from .source.nodes import nodes_img
|
||||
import inspect
|
||||
import logging
|
||||
from .source.nodes.utils.misc import NODES_NAME
|
||||
|
||||
init_logger = logging.getLogger(NODES_NAME + ".__init__")
|
||||
# This is our first import so we initialize SeCoNoHe
|
||||
from .src.nodes import nodes_img, main_logger
|
||||
from seconohe.register_nodes import register_nodes
|
||||
from seconohe import JS_PATH
|
||||
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
|
||||
for name, obj in inspect.getmembers(nodes_img):
|
||||
# We skip nodes imported from the ComfyUI main nodes
|
||||
if not inspect.isclass(obj) or not hasattr(obj, "INPUT_TYPES") or obj.__module__ == "nodes":
|
||||
continue
|
||||
assert hasattr(obj, "UNIQUE_NAME"), f"No name for {obj.__name__}"
|
||||
NODE_CLASS_MAPPINGS[obj.UNIQUE_NAME] = obj
|
||||
NODE_DISPLAY_NAME_MAPPINGS[obj.UNIQUE_NAME] = obj.DISPLAY_NAME
|
||||
|
||||
init_logger.info(f"Registering {len(NODE_CLASS_MAPPINGS)} node(s).")
|
||||
init_logger.debug(f"{list(NODE_DISPLAY_NAME_MAPPINGS.values())}")
|
||||
|
||||
WEB_DIRECTORY = "./js"
|
||||
NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS = register_nodes(main_logger, [nodes_img])
|
||||
WEB_DIRECTORY = JS_PATH
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# 1. Image Download and Load
|
||||
- **Display Name:** `Image Download and Load`
|
||||
- **Internal Name:** `SET_ImageDownload`
|
||||
- **Category:** `image/io`
|
||||
- **Description:** Downloads an image file from a URL into the `ComfyUI/input/` directory if it's not already there, and then loads it as an image and mask. This is perfect for creating self-contained, shareable workflows with example image.
|
||||
- **Inputs:**
|
||||
- `image_bypass` (IMAGE, Optional): If an image is provided here it will be used for the output. You can connect a `Load Image` node here, if the connected node is muted (bypassed) we download the file, otherwise we use the image from the `Load Image` node.
|
||||
- `mask_bypass` (MASK, Optional): This input complements `image_bypass`.
|
||||
- `base_url` (STRING): The URL of the directory containing the image file.
|
||||
- `filename` (STRING): The name of the image file to download (e.g., photo.jpg, art.png).
|
||||
- `local_name` (STRING, optional): The name used locally. Leave blank to use `filename`.
|
||||
- `embed_transparency` (BOOLEAN, optional): Create RGBA images when they have transparency. Not all nodes supports them.
|
||||
- **Output:**
|
||||
- `image` (IMAGE): The loaded image.
|
||||
- `alpha_mask` (MASK): The alpha mask for the loaded image.
|
||||
- **Behavior Details:**
|
||||
- **Caching:** The node checks the `ComfyUI/input/` folder first. If the file with the specified `filename` already exists, the download is skipped.
|
||||
- **Bypass:** If only one of `image_bypass` and `mask_bypass` is connected the other will be assumed to be empty. You should connect both or avoid using the output corresponding to the unconnected input.
|
||||
|
||||
# 2. Face Composite
|
||||
- **Display Name:** `Face Composite`
|
||||
- **Internal Name:** `SET_CompositeFace`
|
||||
- **Category:** `image/manipulation`
|
||||
- **Description:** This node is designed for complex batch operations where a single reference image generates multiple animated or processed frames. It handles an **M-to-N** relationship.
|
||||
- **Purpose:** The primary use case is for animation workflows (e.g., using AnimateDiff or SVD) where you:
|
||||
1. Extract a face from **one** source image.
|
||||
2. Generate **N** animated frames of that face.
|
||||
3. Paste each of the **N** animated frames back onto the original source image to create an animated sequence.
|
||||
- **Inputs:**
|
||||
- `animated` (`IMAGE`): A batch of `M*N` cropped/processed images (e.g., the animated faces).
|
||||
- `reference` (`IMAGE`): A batch of `M` original context images that the faces were extracted from.
|
||||
- `bboxes` (`BBOX`): A list of `M` bounding boxes. Each box in the list must correspond to a `reference` image, defining the location for the paste operation. Format: (X, Y, W, H)
|
||||
- **Output:**
|
||||
- `images` (`IMAGE`): The final output batch of `M*N` full-size images. Each image consists of a `reference` image with one of the `animated` frames pasted onto it.
|
||||
- **How it Works:**
|
||||
- For each of the `M` reference images and its corresponding bounding box, the node takes the next `N` frames from the `animated` batch. It then pastes each of these `N` frames onto a *copy* of the reference image, generating `N` final output frames. This process is repeated for all `M` reference images.
|
||||
- The animated frame is automatically resized to fit the bounding box dimensions before pasting. The pasting logic safely handles cases where the bbox is partially outside the image boundaries.
|
||||
|
||||
# 3. Face Composite (frame by frame)
|
||||
- **Display Name:** `"Face Composite (frame by frame)`
|
||||
- **Internal Name:** `SET_CompositeFaceFrameByFrame`
|
||||
- **Category:** `image/manipulation`
|
||||
- **Description:** This node is a simplified variant for **1-to-1** compositing. It is perfect for video processing workflows where you need to paste a sequence of processed frames back into the original video sequence at a static location.
|
||||
- **Purpose:** Ideal for tasks like video face restoration, stylization, or simple lip-sync, where:
|
||||
1. A video is loaded as a sequence of `N` frames.
|
||||
2. A corresponding sequence of `N` processed/animated frames is generated.
|
||||
3. Each processed frame needs to be pasted back into its corresponding original frame at the same location.
|
||||
- **Inputs:**
|
||||
- `animated` (`IMAGE`): A batch of `N` processed frames.
|
||||
- `reference` (`IMAGE`): A batch of `N` original frames. **Must have the same batch size as `animated`**.
|
||||
- `bboxes` (`BBOX`): A list of bounding boxes. **Only the first bbox in the list is used** as the static paste location for all frames.
|
||||
- **Output:**
|
||||
- `images` (`IMAGE`): The final batch of `N` composited frames.
|
||||
- **How it Works:** The node iterates from frame 0 to N-1. In each step, it takes the i-th `animated` frame and the i-th `reference` frame. It then pastes the animated frame onto the reference frame using the coordinates from the single, static bounding box.
|
||||
|
||||
|
||||
# 4. Normalize Image to ImageNet
|
||||
- **Display Name:** `"Normalize Image to ImageNet`
|
||||
- **Internal Name:** `SET_NormalizeToImageNetDataset`
|
||||
- **Category:** `image/normalization`
|
||||
- **Description:** Normalizes an image tensor using the mean and standard deviation of the ImageNet dataset.
|
||||
- **Purpose:** Essential for pre-processing images before feeding them into models that were pre-trained on ImageNet (e.g., most ResNet, VGG, EfficientNet models).
|
||||
- **Inputs:**
|
||||
- `image` (`IMAGE`): A standard ComfyUI image tensor in the `[0, 1]` range.
|
||||
- **Output:**
|
||||
- `image` (`IMAGE`): The normalized image tensor. The value range will be altered significantly.
|
||||
- **How it Works:** For each channel, it performs the operation `output = (input - mean) / std`, using the standard ImageNet values:
|
||||
- **Mean:** `[0.485, 0.456, 0.406]`
|
||||
- **Std Dev:** `[0.229, 0.224, 0.225]`
|
||||
|
||||
# 5. Normalize Image to [-0.5, 0.5]
|
||||
|
||||
- **Display Name:** `Normalize Image to [-0.5, 0.5]`
|
||||
- **Internal Name:** `SET_NormalizeToMinus05_05`
|
||||
- **Category:** `image/normalization`
|
||||
- **Description:** Normalizes an image tensor by centering its values around zero.
|
||||
- **Purpose:** Useful for models trained from scratch or those that expect input data in the `[-0.5, 0.5]` range. This can help stabilize training.
|
||||
- **Inputs:**
|
||||
- `image` (`IMAGE`): A standard ComfyUI image tensor in the `[0, 1]` range.
|
||||
- **Output:**
|
||||
- `image` (`IMAGE`): The normalized image tensor, with values in the `[-0.5, 0.5]` range.
|
||||
- **How it Works:** For each channel, it performs the operation `output = (input - mean) / std`, using:
|
||||
- **Mean:** `[0.5, 0.5, 0.5]`
|
||||
- **Std Dev:** `[1.0, 1.0, 1.0]`
|
||||
|
||||
# 6. Normalize Image to [-1, 1]
|
||||
|
||||
- **Display Name:** `Normalize Image to [-1, 1]`
|
||||
- **Internal Name:** `SET_NormalizeToMinus1_1`
|
||||
- **Category:** `image/normalization`
|
||||
- **Description:** Normalizes an image tensor to the `[-1, 1]` range.
|
||||
- **Purpose:** A common requirement for certain model architectures, particularly Generative Adversarial Networks (GANs) and models using the `tanh` activation function in their output layer.
|
||||
- **Inputs:**
|
||||
- `image` (`IMAGE`): A standard ComfyUI image tensor in the `[0, 1]` range.
|
||||
- **Output:**
|
||||
- `image` (`IMAGE`): The normalized image tensor, with values in the `[-1, 1]` range.
|
||||
- **How it Works:** For each channel, it performs the operation `output = (input - mean) / std`, using:
|
||||
- **Mean:** `[0.5, 0.5, 0.5]`
|
||||
- **Std Dev:** `[0.5, 0.5, 0.5]`
|
||||
|
||||
# 7. Apply Mask using AFFCE
|
||||
|
||||
- **Display Name:** `Apply Mask using AFFCE`
|
||||
- **Internal Name:** `SET_ApplyMaskAFFCE`
|
||||
- **Category:** `image/manipulation`
|
||||
- **Description:** Applies a mask to an image using [Approximate Fast Foreground Colour Estimation](https://github.com/Photoroom/fast-foreground-estimation). This blends the image contour in a better way.
|
||||
- **Purpose:** Used to apply the mask of a background removal model.
|
||||
- **Inputs:**
|
||||
- `images` (`IMAGE`): One ore more ComfyUI images
|
||||
- `masks` (`MASK`): Masks to apply
|
||||
- `blur_size` (`INT`): Diameter for the coarse gaussian blur
|
||||
- `blur_size_two` (`INT`): Diameter for the fine gaussian blur
|
||||
- `fill_color` (`BOOLEAN`): When enabled the removed image is replaced by a color, the output is an RGB image. Otherwise the removed part becomes transparent and the output is an RGBA image.
|
||||
- `color` (`STRING`): A string representing a color to be used when `fill_color` is enabled. Can be an hexadecimal RGB (i.e. `#AABBCC`) or comma separated RGB components. The components can be in the [0-255] or [0-1.0] range.
|
||||
- `batched` (`BOOLEAN`): Process all the images at once. Otherwise do it one at a time.
|
||||
- **Output:**
|
||||
- `image` (`IMAGE`): The image after applying the mask.
|
||||
- `mask` (`MASK`): The input mask
|
||||
|
||||
|
||||
# 8. Estimate foreground (AFFCE)
|
||||
|
||||
- **Display Name:** `Estimate foreground (AFFCE)`
|
||||
- **Internal Name:** `SET_AFFCE`
|
||||
- **Category:** `image/foreground`
|
||||
- **Description:** Estimates the foreground of an image using [Approximate Fast Foreground Colour Estimation](https://github.com/Photoroom/fast-foreground-estimation). The result is suitable for background replacement.
|
||||
- **Purpose:** Used to get a better foreground for background removal.
|
||||
- **Inputs:**
|
||||
- `images` (`IMAGE`): One ore more ComfyUI images
|
||||
- `masks` (`MASK`): Masks to apply
|
||||
- `blur_size` (`INT`): Diameter for the coarse gaussian blur
|
||||
- `blur_size_two` (`INT`): Diameter for the fine gaussian blur
|
||||
- `batched` (`BOOLEAN`): Process all the images at once. Otherwise do it one at a time.
|
||||
- **Output:**
|
||||
- `image` (`IMAGE`): The image after foreground estimation.
|
||||
- `mask` (`MASK`): The input mask
|
||||
|
||||
|
||||
# 9. Estimate foreground (FMLFE)
|
||||
|
||||
- **Display Name:** `Estimate foreground (FMLFE)`
|
||||
- **Internal Name:** `SET_FMLFE`
|
||||
- **Category:** `image/foreground`
|
||||
- **Description:** Estimates the foreground of an image using [Fast Multi-Level Foreground Estimation](https://arxiv.org/abs/2006.14970). The result is suitable for background replacement.
|
||||
- **Purpose:** Used to get a better foreground for background removal.
|
||||
- **Inputs:**
|
||||
- `images` (`IMAGE`): The source image(s) from which to estimate the foreground and background.
|
||||
- `masks` (`MASK`): The alpha matte that guides the estimation. White areas are treated as known foreground, black as known background, and gray areas are the semi-transparent regions the algorithm will solve for.
|
||||
- `implementation" (`auto`, `cupy`, `opencl`, `numba`, `torch`): Which implementation to use. The `auto` will use the fastest available. The `torch` implementation is slow and approximated, but doesn't need extra dependencies. The `numba` implementation is good and just needs [Numba](https://numba.pydata.org/). The `cupy` implementation is the fastest, but needs [CuPy](https://cupy.dev/) and a full CUDA environment.
|
||||
- `regularization` (`FLOAT`): The regularization strength (epsilon). This acts as a smoothness prior. Higher values result in smoother, more blended foreground and background colors, but may lose very fine details. Lower values preserve more detail but can be noisier.
|
||||
- `n_small_iterations` (`INT`): The number of solver iterations to perform on the lower-resolution levels of the image pyramid. More iterations can improve quality at the cost of speed.
|
||||
- `n_big_iterations` (`INT`): The number of solver iterations to perform on the higher-resolution (larger) levels of the image pyramid. Fewer iterations are typically needed at high resolution as the details are propagated up from the smaller levels.
|
||||
- `small_size` (`INT`): The pixel dimension threshold. Image pyramid levels smaller than this size will use the higher 'n_small_iterations' count, while larger levels will use 'n_big_iterations'.
|
||||
- `gradient_weight` (`FLOAT`): Controls how strongly the edges in the alpha matte influence color blending. A higher value makes the algorithm respect the mask's edges more, leading to sharper color boundaries. A lower value allows more color bleeding, an effect similar to increasing regularization.
|
||||
- **Output:**
|
||||
- `image` (`IMAGE`): The estimated foreground image (F). This is a full-color image where the algorithm has estimated the true, un-blended color of the foreground object.
|
||||
- `mask` (`MASK`): The estimated background image (B). The algorithm has effectively "inpainted" the area behind the foreground object, creating a clean background plate.
|
||||
|
||||
|
||||
# 10. Create Empty Image
|
||||
|
||||
- **Display Name:** `Create Empty Image`
|
||||
- **Internal Name:** `SET_CreateEmptyImage`
|
||||
- **Category:** `image/generation`
|
||||
- **Description:** Creates an image filled with a solid color. Similar to standard `EmptyImage`, but you can use an image as reference and you have more options to select the color.
|
||||
- **Purpose:** Create an empty image
|
||||
- **Inputs:**
|
||||
- `width` (`INT`): The width of the new image in pixels. This value is ignored if a `reference` is provided.
|
||||
- `height` (`INT`): The height of the new image in pixels. This value is ignored if a `reference` is provided.
|
||||
- `batch_size` (`INT`): The number of images to create in the batch. This value is ignored if a `reference` is provided.
|
||||
- `color` (`STRING`): The solid color to fill the image with. Can be a named color (e.g., "black", "red"), a hex string (e.g., "#FF0000", "00ff00"), or comma-separated components in the [0, 255] or [0.0, 1.0] range.
|
||||
- `reference` (`IMAGE`, optional): If an image is connected here, its dimensions (batch size, height, and width) will be used for the new image, overriding the manual width, height, and batch_size inputs.
|
||||
- **Output:**
|
||||
- `image` (`IMAGE`): A new image tensor of the specified size and color.
|
||||
|
||||
|
||||
# 11. Pad Image (KJ/SET)
|
||||
|
||||
- **Display Name:** `Pad Image (KJ/SET)`
|
||||
- **Internal Name:** `SET_ImagePad`
|
||||
- **Category:** `image/manipulation`
|
||||
- **Description:** Pads the input image and an optional mask with specified padding on each side. It offers several modes to fill the padded area.
|
||||
- **Purpose:** To add borders or expand the canvas of an image using various fill methods, such as solid color, edge pixel replication, or a blurred background effect.
|
||||
- **Inputs:**
|
||||
- `image` (`IMAGE`): The image to be padded.
|
||||
- `left` (`INT`): The number of pixels of padding to add to the left side.
|
||||
- `right` (`INT`): The number of pixels of padding to add to the right side.
|
||||
- `top` (`INT`): The number of pixels of padding to add to the top side.
|
||||
- `bottom` (`INT`): The number of pixels of padding to add to the bottom side.
|
||||
- `extra_padding` (`INT`): Additional padding applied to all sides. Note: If `target_width` and `target_height` are used, this value will shrink the source image before padding.
|
||||
- `pad_mode` (`STRING`): The method used to fill the padded area.
|
||||
- `edge`: Fills with the average color of the nearest edge.
|
||||
- `edge_pixel`: Repeats the outermost line of pixels.
|
||||
- `color`: Fills with the specified solid `color`.
|
||||
- `pillarbox_blur`: Fills with a blurred, desaturated, and dimmed version of the original image, scaled to fit the new dimensions.
|
||||
- `color` (`STRING`): The solid color to use when `pad_mode` is "color". Accepts hex codes, color names, and RGB values. This is more flexible than in KJNodes.
|
||||
- `mask` (`MASK`, optional): An optional mask to be padded along with the image.
|
||||
- `target_width` (`INT`, optional): If provided with `target_height`, pads the image to this exact width, centering the original content. This overrides the `left` and `right` inputs. If you set it to 0 the width of the image is used. Note that you must also connect `target_height`.
|
||||
- `target_height` (`INT`, optional): If provided with `target_width`, pads the image to this exact height, centering the original content. This overrides the `top` and `bottom` inputs. If you set it to 0 the heigth of the image is used. Note that you must also connect `target_width`.
|
||||
- `pad_transparency` (`FLOAT`, optional): Controls the opacity of the padded area in the output mask. 1.0 is fully transparent (no mask), and 0.0 is fully opaque (masked). Not in KJNodes.
|
||||
- **Outputs:**
|
||||
- `images` (`IMAGE`): The padded image tensor.
|
||||
- `masks` (`MASK`): The padded mask tensor.
|
||||
|
||||
|
||||
# 12. Resize Image (KJ/SET)
|
||||
|
||||
- **Display Name:** `Resize Image (KJ/SET)`
|
||||
- **Internal Name:** `SET_ImageResize`
|
||||
- **Category:** `image/manipulation`
|
||||
- **Description:** A comprehensive node to resize, crop, and pad images. It provides multiple methods for handling aspect ratio and can optionally process large batches in smaller chunks to conserve memory. This node uses [Pad Image](#11-pad-image-kjset) so please also read its documentation.
|
||||
- **Purpose:** To change the dimensions of an image with fine-grained control over how the aspect ratio is preserved, including stretching, cropping, or padding to fit the target size.
|
||||
- **Inputs:**
|
||||
- `image` (`IMAGE`): The source image to resize.
|
||||
- `width` (`INT`): The target width in pixels. If set to 0, it uses the original width or the width from `get_image_size`.
|
||||
- `height` (`INT`): The target height in pixels. If set to 0, it uses the original height or the height from `get_image_size`.
|
||||
- `upscale_method` (`STRING`): The interpolation algorithm to use for resizing (e.g., `bicubic`, `lanczos`, `nearest-exact`).
|
||||
- `keep_proportion` (`STRING`): Defines how to handle the aspect ratio when resizing.
|
||||
- `stretch`: Ignores aspect ratio and forces the image to the exact target dimensions.
|
||||
- `resize`: Maintains aspect ratio, fitting the image within the target dimensions. The final output size may be smaller than the target on one axis.
|
||||
- `pad`, `pad_edge`, `pad_edge_pixel`: Resizes the image to fit within the target dimensions while maintaining aspect ratio, then pads the remaining space to meet the exact target dimensions.
|
||||
- `crop`: Resizes and crops the image to match the target aspect ratio and size.
|
||||
- `pillarbox_blur`: Resizes the image to fit and fills the remaining space with a blurred background.
|
||||
- `pad_color` (`STRING`): The solid color to use for padding when `keep_proportion` is set to `pad`. Accepts hex codes, color names, and RGB values. This is more flexible than in KJNodes.
|
||||
- `crop_position` (`STRING`): The anchor point (`center`, `top`, `left`, etc.) for `crop` and `pad` operations.
|
||||
- `divisible_by` (`INT`): Ensures the final width and height are multiples of this number, which is often required for models like VAEs.
|
||||
- `mask` (`MASK`, optional): An optional mask to be transformed along with the image.
|
||||
- `device` (`STRING`, optional): The computation device (`cpu` or `gpu`) to use for the operation. Note that `lanczos` is not supported on the GPU.
|
||||
- `get_image_size` (`IMAGE`, optional): If an image is connected, its dimensions will be used as the target width and height, overriding the manual inputs. Not in KJNodes.
|
||||
- `per_batch` (`INT`, optional): If greater than 0, processes the input batch in smaller sub-batches of this size to reduce memory usage.
|
||||
- `pad_transparency` (`FLOAT`, optional): Controls the opacity of the padded area in the output mask. Not in KJNodes.
|
||||
- **Outputs:**
|
||||
- `IMAGE` (`IMAGE`): The transformed image.
|
||||
- `width` (`INT`): The final width of the output image.
|
||||
- `height` (`INT`): The final height of the output image.
|
||||
- `mask` (`MASK`): The transformed mask.
|
||||
|
||||
|
||||
# 13. Resize Mask (KJ/SET)
|
||||
|
||||
- **Display Name:** `Resize Mask (KJ/SET)`
|
||||
- **Internal Name:** `SET_ResizeMask`
|
||||
- **Category:** `image/manipulation`
|
||||
- **Description:** Resizes a mask or a batch of masks to the specified width and height, with options to preserve proportions.
|
||||
- **Purpose:** A dedicated and simplified node for resizing masks, which often require different interpolation methods (like `nearest-exact`) than images.
|
||||
- **Inputs:**
|
||||
- `mask` (`MASK`): The mask to be resized.
|
||||
- `width` (`INT`): The target width for the new mask.
|
||||
- `height` (`INT`): The target height for the new mask.
|
||||
- `keep_proportions` (`BOOLEAN`): If `True`, maintains the original aspect ratio, fitting the mask within the target dimensions.
|
||||
- `upscale_method` (`STRING`): The interpolation algorithm to use for resizing. Defaults to `nearest-exact`, which is ideal for masks to avoid creating grayscale values.
|
||||
- `crop` (`STRING`): If and how to crop the mask (`disabled` or `center`).
|
||||
- `get_image_size` (`IMAGE`, optional): If an image is connected here, its dimensions will be used as the target width and height.
|
||||
- **Outputs:**
|
||||
- `mask` (`MASK`): The resized mask tensor.
|
||||
- `width` (`INT`): The final width of the output mask.
|
||||
- `height` (`INT`): The final height of the output mask.
|
||||
@@ -1 +1 @@
|
||||
{"id":"63a02d67-42e7-4217-9d52-28b9c456c35b","revision":0,"last_node_id":5,"last_link_id":4,"nodes":[{"id":4,"type":"MaskPreview","pos":[1457.7564697265625,1385.4490966796875],"size":[273.6515197753906,258],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":4}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"MaskPreview"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":3,"type":"PreviewImage","pos":[1461.320068359375,1076.2701416015625],"size":[273.6515197753906,258],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":3}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"PreviewImage"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":1,"type":"SET_ImageDownload","pos":[1077.0606689453125,1075.701416015625],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image_bypass","name":"image_bypass","shape":7,"type":"IMAGE","link":1},{"localized_name":"mask_bypass","name":"mask_bypass","shape":7,"type":"MASK","link":2},{"localized_name":"base_url","name":"base_url","type":"STRING","widget":{"name":"base_url"},"link":null},{"localized_name":"filename","name":"filename","type":"STRING","widget":{"name":"filename"},"link":null}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[3]},{"localized_name":"alpha_mask","name":"alpha_mask","type":"MASK","links":[4]}],"properties":{"aux_id":"set-soft/ComfyUI-ImageMisc","ver":"3ab5664ee7057737c3215db19b078afbcead7a08","Node name for S&R":"SET_ImageDownload"},"widgets_values":["https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/","audioseparation_logo.jpg"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":2,"type":"LoadImage","pos":[727.1275024414062,1075.3787841796875],"size":[274.080078125,314],"flags":{},"order":0,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"LoadImage"},"widgets_values":["example.png","image"],"color":"#222","bgcolor":"#000"},{"id":5,"type":"MarkdownNote","pos":[733.544921875,821.4869384765625],"size":[989.1290283203125,186.43255615234375],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# If you \"unbypass\" the *Load Image* node you'll get the image and mask from it\n# When the inputs are not connected or come from a bypassed node you get the downloaded image\n# Images are downloaded to the ComfyUI's *input* directory, so they become available to be selected in the *Load Image* node. Just press R to refresh the nodes.\n# To \"bypass\" a node just select it and choose the icon with an arrow around a circle.\n"],"color":"#432","bgcolor":"#653"}],"links":[[1,2,0,1,0,"IMAGE"],[2,2,1,1,1,"MASK"],[3,1,0,3,0,"IMAGE"],[4,1,1,4,0,"MASK"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8432169278238246,"offset":[-188.01505304476098,-782.3508645860205]}},"version":0.4}
|
||||
{"id":"63a02d67-42e7-4217-9d52-28b9c456c35b","revision":0,"last_node_id":5,"last_link_id":4,"nodes":[{"id":4,"type":"MaskPreview","pos":[1457.7564697265625,1385.4490966796875],"size":[273.6515197753906,258],"flags":{},"order":4,"mode":0,"inputs":[{"localized_name":"mask","name":"mask","type":"MASK","link":4}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"MaskPreview"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":3,"type":"PreviewImage","pos":[1461.320068359375,1076.2701416015625],"size":[273.6515197753906,258],"flags":{},"order":3,"mode":0,"inputs":[{"localized_name":"images","name":"images","type":"IMAGE","link":3}],"outputs":[],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"PreviewImage"},"widgets_values":[],"color":"#222","bgcolor":"#000"},{"id":1,"type":"SET_ImageDownload","pos":[1077.0606689453125,1075.701416015625],"size":[270,314],"flags":{},"order":2,"mode":0,"inputs":[{"localized_name":"image_bypass","name":"image_bypass","shape":7,"type":"IMAGE","link":1},{"localized_name":"mask_bypass","name":"mask_bypass","shape":7,"type":"MASK","link":2},{"localized_name":"base_url","name":"base_url","type":"STRING","widget":{"name":"base_url"},"link":null},{"localized_name":"filename","name":"filename","type":"STRING","widget":{"name":"filename"},"link":null}],"outputs":[{"localized_name":"image","name":"image","type":"IMAGE","links":[3]},{"localized_name":"alpha_mask","name":"alpha_mask","type":"MASK","links":[4]}],"properties":{"aux_id":"set-soft/ComfyUI-ImageMisc","ver":"3ab5664ee7057737c3215db19b078afbcead7a08","Node name for S&R":"SET_ImageDownload"},"widgets_values":["https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/","audioseparation_logo.jpg"],"color":"#2a363b","bgcolor":"#3f5159"},{"id":2,"type":"LoadImage","pos":[727.1275024414062,1075.3787841796875],"size":[274.080078125,314],"flags":{},"order":0,"mode":4,"inputs":[{"localized_name":"image","name":"image","type":"COMBO","widget":{"name":"image"},"link":null},{"localized_name":"choose file to upload","name":"upload","type":"IMAGEUPLOAD","widget":{"name":"upload"},"link":null}],"outputs":[{"localized_name":"IMAGE","name":"IMAGE","type":"IMAGE","links":[1]},{"localized_name":"MASK","name":"MASK","type":"MASK","links":[2]}],"properties":{"cnr_id":"comfy-core","ver":"0.3.43","Node name for S&R":"LoadImage"},"widgets_values":["example.png","image"],"color":"#222","bgcolor":"#000"},{"id":5,"type":"MarkdownNote","pos":[733.544921875,821.4869384765625],"size":[989.1290283203125,186.43255615234375],"flags":{},"order":1,"mode":0,"inputs":[],"outputs":[],"properties":{},"widgets_values":["# If you \"unbypass\" the *Load Image* node you'll get the image and mask from it\n# When the inputs are not connected or come from a bypassed node you get the downloaded image\n# Images are downloaded to the ComfyUI's *input* directory, so they become available to be selected in the *Load Image* node. Just press R to refresh the nodes.\n# To \"bypass\" a node just select it and choose the icon with an arrow around a circle.\n"],"color":"#432","bgcolor":"#653"}],"links":[[1,2,0,1,0,"IMAGE"],[2,2,1,1,1,"MASK"],[3,1,0,3,0,"IMAGE"],[4,1,1,4,0,"MASK"]],"groups":[],"config":{},"extra":{"ds":{"scale":0.8432169278238246,"offset":[-188.01505304476098,-782.3508645860205]}},"version":0.4}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File diff suppressed because one or more lines are too long
@@ -1,32 +0,0 @@
|
||||
// Copyright (c) 2025 Salvador E. Tropea
|
||||
// Copyright (c) 2025 Instituto Nacional de Tecnología Industrial
|
||||
// License: GPLv3
|
||||
// Project: ComfyUI-ImageMisc
|
||||
|
||||
// This script adds an event named "set-imagemisc-toast"
|
||||
// Used to notify the user in the GUI using the Toast API
|
||||
|
||||
import { app } from "/scripts/app.js";
|
||||
|
||||
// Register a new extension
|
||||
app.registerExtension({
|
||||
name: "SET.ImageMisc.ToastHandler", // Unique name
|
||||
|
||||
// The setup function is executed when the extension is loaded
|
||||
setup() {
|
||||
// Add a listener for our custom event
|
||||
app.api.addEventListener("set-imagemisc-toast", (event) => {
|
||||
// The data from Python is in event.detail
|
||||
const { message, summary, severity } = event.detail;
|
||||
|
||||
// Use the ComfyUI toast API to show the message
|
||||
// app.ui.toast.addMessage is the modern way to do this
|
||||
app.extensionManager.toast.add({
|
||||
severity: severity,
|
||||
summary: summary,
|
||||
detail: message,
|
||||
life: 6000
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
+48
-3
@@ -1,9 +1,18 @@
|
||||
[project]
|
||||
name = "image-misc"
|
||||
description = "Miscellaneous nodes for image manipulation. Currently just download image with bypass, so you can create workflows including image examples. No extra dependencies."
|
||||
version = "1.0.0"
|
||||
description = """
|
||||
Miscellaneous nodes for image manipulation.
|
||||
Currently just download image with bypass, so you can create workflows including image examples.
|
||||
No extra dependencies, just an internal module.
|
||||
"""
|
||||
# This is the modern Python mechanism, but not supported
|
||||
# dynamic = ["version"]
|
||||
# license = "GPL-3.0-or-later"
|
||||
# Only this is supported :-(
|
||||
version = "1.3.0"
|
||||
license = { file = "LICENSE" }
|
||||
dependencies = []
|
||||
# The following is just ignored
|
||||
dependencies = ["seconohe"]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://github.com/set-soft/ComfyUI-ImageMisc"
|
||||
@@ -11,3 +20,39 @@ Repository = "https://github.com/set-soft/ComfyUI-ImageMisc"
|
||||
[tool.comfy]
|
||||
PublisherId = "set-soft"
|
||||
DisplayName = "Image Misc"
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = {attr = "nodes.__version__"}
|
||||
|
||||
# #######################################
|
||||
# MyPy (well, yours, useless hinting)
|
||||
# #######################################
|
||||
[tool.mypy]
|
||||
# Basic configuration
|
||||
python_version = "3.11"
|
||||
warn_return_any = true
|
||||
warn_unused_ignores = true
|
||||
mypy_path = "src/"
|
||||
|
||||
# This is often needed when starting out, especially with libraries that lack stubs
|
||||
ignore_missing_imports = true
|
||||
|
||||
# You can get stricter over time by removing ignore_missing_imports
|
||||
# and setting flags like these:
|
||||
# disallow_untyped_defs = true
|
||||
# disallow_any_unimported = true
|
||||
|
||||
# Tell mypy to ignore errors from third-party libraries if they are not typed
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"torch.*",
|
||||
"torchaudio.*",
|
||||
"pytest.*",
|
||||
"cv2.*",
|
||||
"colorama.*",
|
||||
"tqdm.*",
|
||||
"requests.*",
|
||||
"comfy.*", # For comfy.utils
|
||||
"server.*" # For server.PromptServer
|
||||
]
|
||||
ignore_missing_imports = true
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Pillow
|
||||
numpy
|
||||
seconohe
|
||||
@@ -1,129 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright (c) 2025 Salvador E. Tropea
|
||||
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
|
||||
# License: GPL-3.0
|
||||
# Project: ComfyUI-ImageMisc
|
||||
# From code generated by Gemini 2.5 Pro
|
||||
import os
|
||||
import torch
|
||||
from typing import Optional
|
||||
from .utils.logger import main_logger
|
||||
from .utils.downloader import download_model
|
||||
from .utils.comfy_notification import send_toast_notification
|
||||
try:
|
||||
from folder_paths import get_input_directory # To get the ComfyUI input directory
|
||||
except ModuleNotFoundError:
|
||||
# No ComfyUI, this is a test environment
|
||||
def get_input_directory():
|
||||
return ""
|
||||
try:
|
||||
# We need to import the built-in LoadImage class for ImageDownload
|
||||
from nodes import LoadImage # type: ignore[attr-defined]
|
||||
has_load_image = True
|
||||
except Exception:
|
||||
has_load_image = False
|
||||
|
||||
logger = main_logger
|
||||
BASE_CATEGORY = "image"
|
||||
IO_CATEGORY = "io"
|
||||
|
||||
|
||||
if has_load_image:
|
||||
class ImageDownload:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"base_url": ("STRING", {
|
||||
"default":
|
||||
"https://raw.githubusercontent.com/set-soft/AudioSeparation/refs/heads/main/example_workflows/",
|
||||
"tooltip": "The base URL where the image file is located."
|
||||
}),
|
||||
"filename": ("STRING", {
|
||||
"default": "audioseparation_logo.jpg",
|
||||
"tooltip": "The name of the image file to download (e.g., photo.jpg, art.png)."
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"image_bypass": ("IMAGE", {
|
||||
"tooltip": "If this image is present will be used instead of the downloaded one"
|
||||
}),
|
||||
"mask_bypass": ("MASK", {"tooltip": "If this mask is present will be used instead of the downloaded one"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("image", "alpha_mask")
|
||||
FUNCTION = "load_or_download_image"
|
||||
CATEGORY = BASE_CATEGORY + "/" + IO_CATEGORY
|
||||
DESCRIPTION = ("Downloads an image to ComfyUI's 'input' directory if it doesn't exist, then loads it using the "
|
||||
"built-in LoadImage logic.")
|
||||
UNIQUE_NAME = "SET_ImageDownload"
|
||||
DISPLAY_NAME = "Image Download and Load"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def load_or_download_image(self, base_url: str, filename: str, image_bypass: Optional[torch.Tensor] = None,
|
||||
mask_bypass: Optional[torch.Tensor] = None):
|
||||
# If we have something at the bypass inputs use it
|
||||
if image_bypass is not None or mask_bypass is not None:
|
||||
if image_bypass is None:
|
||||
# Just a mask
|
||||
assert mask_bypass is not None, "This should not be possible if image_bypass is None" # For mypy
|
||||
image_bypass = torch.zeros(mask_bypass.shape + (3,), dtype=torch.float32, device="cpu")
|
||||
logger.warning("ImageDownload: Returning an empty image")
|
||||
elif mask_bypass is None:
|
||||
# This is ComfyUI behavior when we don't have transparency
|
||||
mask_bypass = torch.zeros((64, 64), dtype=torch.float32, device="cpu").unsqueeze(0)
|
||||
logger.warning("ImageDownload: Returning an empty mask")
|
||||
return (image_bypass, mask_bypass)
|
||||
|
||||
save_dir = get_input_directory()
|
||||
local_filepath = os.path.join(save_dir, filename)
|
||||
|
||||
if not os.path.exists(local_filepath):
|
||||
logger.info(f"File '{filename}' not found locally. Attempting to download.")
|
||||
|
||||
if not base_url.endswith('/'):
|
||||
base_url += '/'
|
||||
download_url = base_url + filename
|
||||
send_toast_notification(f"Downloading `{filename}`", "Download")
|
||||
|
||||
try:
|
||||
download_model(url=download_url, save_dir=save_dir, file_name=filename, kind="image")
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed for {download_url}: {e}", exc_info=True)
|
||||
raise
|
||||
send_toast_notification("Finished downloading", "Download", 'success')
|
||||
else:
|
||||
logger.info(f"Found existing file, skipping download: '{local_filepath}'")
|
||||
|
||||
# --- REUSE ComfyUI's LoadImage LOGIC ---
|
||||
try:
|
||||
# Instantiate the built-in LoadImage node
|
||||
loader_instance = LoadImage()
|
||||
|
||||
# The LoadImage node's `load_image` method expects the filename as passed
|
||||
# by the ComfyUI widget, which is just the filename. It internally
|
||||
# resolves the path using folder_paths.
|
||||
|
||||
logger.debug(f"Calling built-in LoadImage.load_image() with filename: '{filename}'")
|
||||
|
||||
# Call the method and return its result directly
|
||||
result = loader_instance.load_image(filename)
|
||||
downloaded_file = {
|
||||
"images": [{
|
||||
"filename": filename,
|
||||
"subfolder": "",
|
||||
"type": "input" # We stored the file in the "input" folder
|
||||
}]
|
||||
}
|
||||
return {"ui": downloaded_file, "result": result}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load image '{filename}' using built-in LoadImage node: {e}", exc_info=True)
|
||||
# Re-raise to make the error visible in ComfyUI
|
||||
raise IOError(f"Could not load the image file '{filename}' using the standard loader. "
|
||||
"It may be corrupt or in an unsupported format.") from e
|
||||
else:
|
||||
logger.error("Failed to import ComfyUI `LoadImage`, please fill an issue here: "
|
||||
"https://github.com/set-soft/ComfyUI-ImageMisc/issues")
|
||||
@@ -1,113 +0,0 @@
|
||||
# Copyright Jonathan Hartley 2013. BSD 3-Clause license, see LICENSE file.
|
||||
'''
|
||||
This module generates ANSI character codes to printing colors to terminals.
|
||||
See: http://en.wikipedia.org/wiki/ANSI_escape_code
|
||||
'''
|
||||
import sys
|
||||
import os
|
||||
|
||||
CSI = '\033['
|
||||
OSC = '\033]'
|
||||
BEL = '\a'
|
||||
is_a_tty = sys.stderr.isatty() and os.name == 'posix'
|
||||
|
||||
|
||||
def code_to_chars(code):
|
||||
return CSI + str(code) + 'm' if is_a_tty else ''
|
||||
|
||||
|
||||
def set_title(title):
|
||||
return OSC + '2;' + title + BEL
|
||||
|
||||
|
||||
def clear_screen(mode=2):
|
||||
return CSI + str(mode) + 'J'
|
||||
|
||||
|
||||
def clear_line(mode=2):
|
||||
return CSI + str(mode) + 'K'
|
||||
|
||||
|
||||
class AnsiCodes(object):
|
||||
def __init__(self):
|
||||
# the subclasses declare class attributes which are numbers.
|
||||
# Upon instantiation we define instance attributes, which are the same
|
||||
# as the class attributes but wrapped with the ANSI escape sequence
|
||||
for name in dir(self):
|
||||
if not name.startswith('_'):
|
||||
value = getattr(self, name)
|
||||
setattr(self, name, code_to_chars(value))
|
||||
|
||||
|
||||
class AnsiCursor(object):
|
||||
def UP(self, n=1):
|
||||
return CSI + str(n) + 'A'
|
||||
|
||||
def DOWN(self, n=1):
|
||||
return CSI + str(n) + 'B'
|
||||
|
||||
def FORWARD(self, n=1):
|
||||
return CSI + str(n) + 'C'
|
||||
|
||||
def BACK(self, n=1):
|
||||
return CSI + str(n) + 'D'
|
||||
|
||||
def POS(self, x=1, y=1):
|
||||
return CSI + str(y) + ';' + str(x) + 'H'
|
||||
|
||||
|
||||
class AnsiFore(AnsiCodes):
|
||||
BLACK = 30
|
||||
RED = 31
|
||||
GREEN = 32
|
||||
YELLOW = 33
|
||||
BLUE = 34
|
||||
MAGENTA = 35
|
||||
CYAN = 36
|
||||
WHITE = 37
|
||||
RESET = 39
|
||||
|
||||
# These are fairly well supported, but not part of the standard.
|
||||
LIGHTBLACK_EX = 90
|
||||
LIGHTRED_EX = 91
|
||||
LIGHTGREEN_EX = 92
|
||||
LIGHTYELLOW_EX = 93
|
||||
LIGHTBLUE_EX = 94
|
||||
LIGHTMAGENTA_EX = 95
|
||||
LIGHTCYAN_EX = 96
|
||||
LIGHTWHITE_EX = 97
|
||||
|
||||
|
||||
class AnsiBack(AnsiCodes):
|
||||
BLACK = 40
|
||||
RED = 41
|
||||
GREEN = 42
|
||||
YELLOW = 43
|
||||
BLUE = 44
|
||||
MAGENTA = 45
|
||||
CYAN = 46
|
||||
WHITE = 47
|
||||
RESET = 49
|
||||
|
||||
# These are fairly well supported, but not part of the standard.
|
||||
LIGHTBLACK_EX = 100
|
||||
LIGHTRED_EX = 101
|
||||
LIGHTGREEN_EX = 102
|
||||
LIGHTYELLOW_EX = 103
|
||||
LIGHTBLUE_EX = 104
|
||||
LIGHTMAGENTA_EX = 105
|
||||
LIGHTCYAN_EX = 106
|
||||
LIGHTWHITE_EX = 107
|
||||
|
||||
|
||||
class AnsiStyle(AnsiCodes):
|
||||
BRIGHT = 1
|
||||
DIM = 2
|
||||
NORMAL = 22
|
||||
RESET_ALL = 0
|
||||
|
||||
|
||||
Fore = AnsiFore()
|
||||
Back = AnsiBack()
|
||||
Style = AnsiStyle()
|
||||
Cursor = AnsiCursor()
|
||||
@@ -1,47 +0,0 @@
|
||||
# Copyright (c) 2025 Salvador E. Tropea
|
||||
# Copyright (c) 2025 Instituto Nacional de Tecnología Industrial
|
||||
# License: GPLv3
|
||||
# Project: ComfyUI-ImageMisc
|
||||
#
|
||||
# ComfyUI Toast API messages
|
||||
# Original code from Gemini 2.5 Pro, which was really outdated
|
||||
# Took ideas from Easy Use nodes and looking at ComfyUI code
|
||||
import logging
|
||||
from typing import Optional
|
||||
# ComfyUI imports
|
||||
try:
|
||||
from server import PromptServer
|
||||
with_comfy = True
|
||||
except Exception:
|
||||
with_comfy = False
|
||||
# Local imports
|
||||
from .misc import NODES_NAME
|
||||
|
||||
logger = logging.getLogger(f"{NODES_NAME}.comfy_notification")
|
||||
|
||||
|
||||
def send_toast_notification(message: str, summary: str = "Warning", severity: str = "warn", sid: Optional[str] = None):
|
||||
"""
|
||||
Sends a toast notification event to the ComfyUI client.
|
||||
|
||||
Args:
|
||||
message (str): The message content of the toast.
|
||||
severity (str): The type of toast. Can be 'success' | 'info' | 'warn' | 'error' | 'secondary' | 'contrast'
|
||||
summary (str): Short explanation
|
||||
sid (str, optional): The session ID of the client to send to.
|
||||
If None, broadcasts to all clients. Defaults to None.
|
||||
"""
|
||||
if not with_comfy:
|
||||
return
|
||||
try:
|
||||
PromptServer.instance.send_sync(
|
||||
"set-imagemisc-toast", # This is our custom event name
|
||||
{
|
||||
'message': message,
|
||||
'summary': summary,
|
||||
'severity': severity
|
||||
},
|
||||
sid
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"when trying to use ComfyUI PromptServer: {e}")
|
||||
@@ -1,206 +0,0 @@
|
||||
# Copyright (c) 2025 Salvador E. Tropea
|
||||
# Copyright (c) 2025 Instituto Nacional de Tecnología Industrial
|
||||
# License: GPLv3
|
||||
# Project: ComfyUI-AudioSeparation, ComfyUI-AudioBatch and ComfyUI-ImageMisc
|
||||
#
|
||||
# Model downloader w/TQDM and ComfyUI progress
|
||||
# Original code from Gemini 2.5 Pro
|
||||
import logging
|
||||
import os
|
||||
# Requests is better than the core Python urllib, and is a really common package
|
||||
# But we don't really need it. Lets make it optional:
|
||||
try:
|
||||
import requests
|
||||
with_requests = True
|
||||
except Exception:
|
||||
with_requests = False
|
||||
import urllib
|
||||
from tqdm import tqdm
|
||||
# ComfyUI imports
|
||||
try:
|
||||
import comfy.utils
|
||||
with_comfy = True
|
||||
except Exception:
|
||||
with_comfy = False
|
||||
# Local imports
|
||||
from .misc import NODES_NAME
|
||||
|
||||
logger = logging.getLogger(f"{NODES_NAME}.downloader")
|
||||
|
||||
|
||||
def download_model_requests(url: str, save_dir: str, file_name: str):
|
||||
"""
|
||||
Downloads a file from a URL with progress bars for both console and ComfyUI.
|
||||
|
||||
Args:
|
||||
url (str): The direct download URL for the file.
|
||||
save_dir (str): The directory where the file will be saved.
|
||||
file_name (str): The name of the file to be saved on disk.
|
||||
"""
|
||||
full_path = os.path.join(save_dir, file_name)
|
||||
|
||||
# Ensure the save directory exists
|
||||
os.makedirs(save_dir, exist_ok=True)
|
||||
try:
|
||||
# Use a streaming request to handle large files and get content length
|
||||
with requests.get(url, stream=True, timeout=10) as r:
|
||||
r.raise_for_status() # Raise an exception for bad status codes (4xx or 5xx)
|
||||
|
||||
# Get total file size from headers
|
||||
total_size_in_bytes = int(r.headers.get('content-length', 0))
|
||||
block_size = 1024 # 1 Kibibyte
|
||||
|
||||
# --- Setup Progress Bars ---
|
||||
# Console progress bar using tqdm
|
||||
progress_bar_console = tqdm(
|
||||
total=total_size_in_bytes,
|
||||
unit='iB',
|
||||
unit_scale=True,
|
||||
desc=f"Downloading {file_name}"
|
||||
)
|
||||
|
||||
# ComfyUI progress bar
|
||||
progress_bar_ui = comfy.utils.ProgressBar(total_size_in_bytes) if with_comfy else None
|
||||
|
||||
# --- Download Loop ---
|
||||
downloaded_size = 0
|
||||
with open(full_path, 'wb') as f:
|
||||
for chunk in r.iter_content(chunk_size=block_size):
|
||||
if chunk: # filter out keep-alive new chunks
|
||||
chunk_size = len(chunk)
|
||||
|
||||
# Update console progress bar
|
||||
progress_bar_console.update(chunk_size)
|
||||
|
||||
# Update ComfyUI progress bar
|
||||
downloaded_size += chunk_size
|
||||
if progress_bar_ui:
|
||||
progress_bar_ui.update(chunk_size) # ProgressBar takes absolute value, but update is incremental
|
||||
|
||||
# Write chunk to file
|
||||
f.write(chunk)
|
||||
|
||||
# --- Cleanup ---
|
||||
progress_bar_console.close()
|
||||
|
||||
# Final check to see if download was complete
|
||||
if total_size_in_bytes != 0 and progress_bar_console.n != total_size_in_bytes:
|
||||
logger.error("Download failed: Size mismatch.")
|
||||
# Optional: remove partial file
|
||||
# os.remove(full_path)
|
||||
raise IOError(f"Download failed for {file_name}. Expected {total_size_in_bytes} but got "
|
||||
f"{progress_bar_console.n}")
|
||||
|
||||
return full_path
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.error(f"Network error while downloading {file_name}: {e}")
|
||||
# Clean up partial file if it exists
|
||||
if os.path.exists(full_path):
|
||||
try:
|
||||
os.remove(full_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred during download: {e}")
|
||||
if os.path.exists(full_path):
|
||||
try:
|
||||
os.remove(full_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
|
||||
# A simple version implemented using the Python urllib
|
||||
class Downloader:
|
||||
def __init__(self, model_path, model_name):
|
||||
self.model_path = model_path
|
||||
self.model_name = model_name
|
||||
self.model_full_name = os.path.join(self.model_path, self.model_name)
|
||||
# Ensure the directory for the model_path exists before __init__ if used elsewhere
|
||||
# or create it at the start of download_model
|
||||
|
||||
# A TQDM helper class for urlretrieve reporthook
|
||||
# This is a common pattern for this use case.
|
||||
class TqdmUpTo(tqdm):
|
||||
"""
|
||||
Provides `update_to(block_num, block_size, total_size)`
|
||||
and updates the TQDM bar.
|
||||
"""
|
||||
def __init__(self, unit, unit_scale, unit_divisor, miniters, desc):
|
||||
super().__init__(unit=unit, unit_scale=unit_scale, unit_divisor=unit_divisor, miniters=miniters, desc=desc)
|
||||
self.ui_bar = None
|
||||
self.total = None
|
||||
|
||||
def update_to(self, block_num=1, block_size=1, total_size=None):
|
||||
"""
|
||||
block_num : int, optional
|
||||
Number of blocks transferred so far [default: 1].
|
||||
block_size : int, optional
|
||||
Size of each block (in tqdm units) [default: 1].
|
||||
total_size : int, optional
|
||||
Total size (in tqdm units). If [default: None] remains unchanged.
|
||||
"""
|
||||
if total_size is not None and self.total is None:
|
||||
self.total = total_size
|
||||
# ComfyUI progress bar
|
||||
if self.ui_bar is None and with_comfy:
|
||||
self.ui_bar = comfy.utils.ProgressBar(total_size)
|
||||
# self.update() will take the *difference* from the last call.
|
||||
# So we pass the number of new blocks * block_size.
|
||||
# Since block_num is cumulative, we calculate the new amount.
|
||||
chunk_size = block_num * block_size - self.n
|
||||
self.update(chunk_size) # self.n is current progress
|
||||
if self.ui_bar:
|
||||
self.ui_bar.update(chunk_size) # ProgressBar takes absolute value, but update is incremental
|
||||
|
||||
def download_model(self, url: str):
|
||||
try:
|
||||
# Ensure the directory exists
|
||||
# Use or '.' for current dir if dirname is empty
|
||||
os.makedirs(self.model_path or '.', exist_ok=True)
|
||||
|
||||
# Get filename for tqdm description
|
||||
filename = self.model_name
|
||||
|
||||
# Use TqdmUpTo as a context manager
|
||||
with self.TqdmUpTo(unit='iB', unit_scale=True, unit_divisor=1024, miniters=1,
|
||||
desc=f"Downloading {filename}") as t:
|
||||
# urlretrieve(url, filename=None, reporthook=None, data=None)
|
||||
# reporthook is called with (block_num, block_size, total_size)
|
||||
urllib.request.urlretrieve(url, self.model_full_name, reporthook=t.update_to)
|
||||
# The 'with' statement ensures t.close() is called.
|
||||
|
||||
return filename
|
||||
|
||||
except urllib.error.URLError as e: # More specific exception for network issues
|
||||
# Clean up partially downloaded file if an error occurs
|
||||
if os.path.exists(self.model_full_name):
|
||||
os.remove(self.model_full_name)
|
||||
raise Exception(f"An error occurred while downloading the model (URL Error): {e.reason} from {url}")
|
||||
|
||||
except Exception as e:
|
||||
# Clean up partially downloaded file if an error occurs
|
||||
if os.path.exists(self.model_full_name):
|
||||
os.remove(self.model_full_name)
|
||||
raise Exception(f"An unexpected error occurred while downloading the model: {e}")
|
||||
|
||||
|
||||
def download_model_urllib(url: str, save_dir: str, file_name: str):
|
||||
return Downloader(save_dir, file_name).download_model(url)
|
||||
|
||||
|
||||
def download_model(url: str, save_dir: str, file_name: str, force_urllib: bool = False, kind: str = "model"):
|
||||
logger.info(f"Downloading {kind}: {file_name}")
|
||||
logger.info(f"Source URL: {url}")
|
||||
full_name = os.path.join(save_dir, file_name)
|
||||
logger.info(f"Destination: {full_name}")
|
||||
|
||||
if with_requests and not force_urllib:
|
||||
download_model_requests(url, save_dir, file_name)
|
||||
else:
|
||||
download_model_urllib(url, save_dir, file_name)
|
||||
|
||||
logger.info(f"Successfully downloaded {full_name}")
|
||||
return full_name
|
||||
@@ -1,136 +0,0 @@
|
||||
# Copyright (c) 2025 Salvador E. Tropea
|
||||
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
|
||||
# License: GPL-3.0
|
||||
# Project: ComfyUI-ImageMisc
|
||||
from __future__ import annotations # Good practice
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, Callable
|
||||
from .misc import NODES_NAME, NODES_DEBUG_VAR
|
||||
from .comfy_notification import send_toast_notification
|
||||
|
||||
|
||||
# 1. Initialize variables with the `Any` type.
|
||||
# This tells mypy not to make assumptions about their specific class.
|
||||
Fore: Any
|
||||
Back: Any
|
||||
Style: Any
|
||||
|
||||
# 2. Perform the runtime import logic as before.
|
||||
try:
|
||||
from colorama import init as colorama_init, Fore, Back, Style
|
||||
colorama_init()
|
||||
except ImportError:
|
||||
# If colorama is not available, import our fallback.
|
||||
# mypy will now allow this assignment because the variables were declared as Any.
|
||||
from .ansi import Fore, Back, Style
|
||||
|
||||
|
||||
class CustomFormatter(logging.Formatter):
|
||||
"""Logging Formatter to add colors"""
|
||||
|
||||
def __init__(self):
|
||||
super(logging.Formatter, self).__init__()
|
||||
white = Fore.WHITE + Style.BRIGHT
|
||||
yellow = Fore.YELLOW + Style.BRIGHT
|
||||
red = Fore.RED + Style.BRIGHT
|
||||
red_alarm = Fore.RED + Back.WHITE + Style.BRIGHT
|
||||
cyan = Fore.CYAN + Style.BRIGHT
|
||||
reset = Style.RESET_ALL
|
||||
# format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s "
|
||||
# "(%(filename)s:%(lineno)d)"
|
||||
format = f"[{NODES_NAME} %(levelname)s] %(message)s (%(name)s - %(filename)s:%(lineno)d)"
|
||||
format_simple = f"[{NODES_NAME}] %(message)s"
|
||||
|
||||
self.FORMATS = {
|
||||
logging.DEBUG: cyan + format + reset,
|
||||
logging.INFO: white + format_simple + reset,
|
||||
logging.WARNING: yellow + format + reset,
|
||||
logging.ERROR: red + format + reset,
|
||||
logging.CRITICAL: red_alarm + format + reset
|
||||
}
|
||||
|
||||
def format(self, record):
|
||||
log_fmt = self.FORMATS.get(record.levelno)
|
||||
formatter = logging.Formatter(log_fmt)
|
||||
return formatter.format(record)
|
||||
|
||||
|
||||
def on_log_error_or_warning(record: logging.LogRecord) -> None:
|
||||
"""
|
||||
This function is called whenever a log with level WARNING or higher is emitted.
|
||||
The 'record' object contains all information about the log event.
|
||||
"""
|
||||
if record.levelno == logging.WARNING:
|
||||
summary = "Warning"
|
||||
severity = "warn"
|
||||
else:
|
||||
summary = "Error"
|
||||
severity = "error"
|
||||
send_toast_notification(record.getMessage(), summary=summary, severity=severity)
|
||||
|
||||
|
||||
class WarningAndErrorFilter(logging.Filter):
|
||||
"""
|
||||
A custom log filter that intercepts logs of a certain level.
|
||||
"""
|
||||
def __init__(self, callback: Callable, level: int = logging.WARNING):
|
||||
"""
|
||||
Initializes the filter.
|
||||
|
||||
Args:
|
||||
callback: The function to call when a log record meets the level criteria.
|
||||
level: The minimum level to trigger the callback.
|
||||
"""
|
||||
super().__init__()
|
||||
self._callback = callback
|
||||
self._level = level
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
"""
|
||||
This method is called for every log record.
|
||||
"""
|
||||
# Check if the log level is WARNING or higher
|
||||
if record.levelno >= self._level:
|
||||
self._callback(record)
|
||||
|
||||
# Always return True to ensure the log is always processed
|
||||
# by the handlers after this filter.
|
||||
return True
|
||||
|
||||
|
||||
# Create a new logger
|
||||
logger = logging.getLogger(NODES_NAME)
|
||||
logger.propagate = False
|
||||
|
||||
# Add the custom filter to the logger.
|
||||
logger.addFilter(WarningAndErrorFilter(callback=on_log_error_or_warning))
|
||||
|
||||
# Add handler if we don't have one.
|
||||
if not logger.handlers:
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setFormatter(CustomFormatter())
|
||||
logger.addHandler(handler)
|
||||
|
||||
# ######################
|
||||
# Logger setup
|
||||
# ######################
|
||||
# 1. Determine the ComfyUI global log level (influenced by --verbose)
|
||||
main_logger = logger
|
||||
comfy_root_logger = logging.getLogger('comfy')
|
||||
effective_comfy_level = logging.getLogger().getEffectiveLevel()
|
||||
# 2. Check our custom environment variable for more verbosity
|
||||
try:
|
||||
nodes_debug_env = int(os.environ.get(NODES_DEBUG_VAR, "0"))
|
||||
except ValueError:
|
||||
nodes_debug_env = 0
|
||||
# 3. Set node's logger level
|
||||
if nodes_debug_env:
|
||||
main_logger.setLevel(logging.DEBUG - (nodes_debug_env - 1))
|
||||
final_level_str = f"DEBUG (due to {NODES_DEBUG_VAR}={nodes_debug_env})"
|
||||
else:
|
||||
main_logger.setLevel(effective_comfy_level)
|
||||
final_level_str = logging.getLevelName(effective_comfy_level) + " (matching ComfyUI global)"
|
||||
_initial_setup_logger = logging.getLogger(NODES_NAME + ".setup") # A temporary logger for this message
|
||||
_initial_setup_logger.debug(f"{NODES_NAME} logger level set to: {final_level_str}")
|
||||
@@ -2,5 +2,8 @@
|
||||
# Copyright (c) 2025 Instituto Nacional de Tecnologïa Industrial
|
||||
# License: GPL-3.0
|
||||
# Project: ComfyUI-ImageMisc
|
||||
from seconohe.logger import initialize_logger
|
||||
|
||||
__version__ = "1.3.0"
|
||||
NODES_NAME = "ImageMisc"
|
||||
NODES_DEBUG_VAR = NODES_NAME.upper() + "_NODES_DEBUG"
|
||||
main_logger = initialize_logger(NODES_NAME)
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+80
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# --- Configuration ---
|
||||
# The path to your pyproject.toml file, relative to the project root
|
||||
PYPROJECT_PATH = Path("pyproject.toml")
|
||||
# The path to the Python file containing the __version__ string
|
||||
SOURCE_VERSION_PATH = Path("src/nodes/__init__.py")
|
||||
# --- End Configuration ---
|
||||
|
||||
|
||||
def get_version_from_pyproject(file_path: Path) -> str | None:
|
||||
"""Extracts the version string from a pyproject.toml file."""
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
# A simple regex to find `version = "..."` under the `[project]` table
|
||||
match = re.search(r'^version\s*=\s*"(.*?)"', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: {file_path} not found.", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Error reading or parsing {file_path}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def get_version_from_source(file_path: Path) -> str | None:
|
||||
"""Extracts the __version__ string from a Python source file."""
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
# A simple regex to find `__version__ = "..."`
|
||||
match = re.search(r'^__version__\s*=\s*"(.*?)"', content, re.MULTILINE)
|
||||
if match:
|
||||
return match.group(1)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: {file_path} not found.", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Error reading or parsing {file_path}: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""
|
||||
Compares version strings from pyproject.toml and the source code.
|
||||
Exits with a non-zero status code if they do not match.
|
||||
"""
|
||||
print("--- Checking version consistency ---")
|
||||
|
||||
# Get versions
|
||||
pyproject_version = get_version_from_pyproject(PYPROJECT_PATH)
|
||||
source_version = get_version_from_source(SOURCE_VERSION_PATH)
|
||||
|
||||
# Validate that we found both
|
||||
if not pyproject_version:
|
||||
print(f"Error: Could not find version in {PYPROJECT_PATH}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if not source_version:
|
||||
print(f"Error: Could not find `__version__` in {SOURCE_VERSION_PATH}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(f"Version in {PYPROJECT_PATH}: {pyproject_version}")
|
||||
print(f"Version in {SOURCE_VERSION_PATH}: {source_version}")
|
||||
|
||||
# Compare and exit
|
||||
if pyproject_version == source_version:
|
||||
print("✅ Versions are consistent.")
|
||||
return 0
|
||||
else:
|
||||
print("\n❌ Error: Version mismatch!", file=sys.stderr)
|
||||
print(f" pyproject.toml has version '{pyproject_version}'", file=sys.stderr)
|
||||
print(f" {SOURCE_VERSION_PATH} has version '{source_version}'", file=sys.stderr)
|
||||
print(" Please ensure both versions are identical.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+219
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import shutil # For file backup
|
||||
import logging
|
||||
|
||||
# Setup logging
|
||||
logger = logging.getLogger("StyleFixer")
|
||||
logger.setLevel(logging.INFO) # Default level
|
||||
# Create console handler and set level to debug
|
||||
ch = logging.StreamHandler()
|
||||
ch.setLevel(logging.DEBUG) # Let handler decide what to show based on logger's effective level
|
||||
# Create formatter
|
||||
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
# Add formatter to ch
|
||||
ch.setFormatter(formatter)
|
||||
# Add ch to logger
|
||||
logger.addHandler(ch)
|
||||
|
||||
|
||||
def expand_tabs(line, tab_size=4):
|
||||
"""Expands tabs to spaces, respecting tab stops."""
|
||||
out_line = []
|
||||
current_col = 0
|
||||
for char in line:
|
||||
if char == '\t':
|
||||
spaces_to_add = tab_size - (current_col % tab_size)
|
||||
out_line.append(' ' * spaces_to_add)
|
||||
current_col += spaces_to_add
|
||||
else:
|
||||
out_line.append(char)
|
||||
current_col += 1
|
||||
return "".join(out_line)
|
||||
|
||||
|
||||
def fix_indentation(line, tab_size=4):
|
||||
"""Rounds leading whitespace indentation to the nearest multiple of tab_size."""
|
||||
stripped_line = line.lstrip(' ')
|
||||
if not stripped_line or stripped_line == line: # No leading spaces or empty line
|
||||
return line
|
||||
|
||||
leading_spaces_count = len(line) - len(stripped_line)
|
||||
|
||||
remainder = leading_spaces_count % tab_size
|
||||
if remainder == 0:
|
||||
new_indent_count = leading_spaces_count
|
||||
elif remainder <= tab_size / 2: # Prioritize rounding down or to current if exact multiple
|
||||
new_indent_count = leading_spaces_count - remainder
|
||||
else: # Round up
|
||||
new_indent_count = leading_spaces_count + (tab_size - remainder)
|
||||
|
||||
new_indent_count = max(0, new_indent_count) # Ensure non-negative
|
||||
|
||||
if new_indent_count != leading_spaces_count:
|
||||
logger.debug(f"Adjusting indent: from {leading_spaces_count} to {new_indent_count} for line: {line.rstrip()!r}")
|
||||
|
||||
return ' ' * new_indent_count + stripped_line
|
||||
|
||||
|
||||
def process_line(line_num, line_content_with_eol, expand_tabs_enabled=True):
|
||||
original_line_for_debug = line_content_with_eol # Keep for debugging if needed
|
||||
line = line_content_with_eol
|
||||
|
||||
# 1. Expand tabs (if enabled)
|
||||
if expand_tabs_enabled:
|
||||
line = expand_tabs(line)
|
||||
if line != original_line_for_debug and not line.isspace(): # Log only if actual content changed
|
||||
logger.debug(f"L{line_num}: Tabs expanded.")
|
||||
|
||||
# 2. W291: Remove trailing whitespace (done before other checks that might rely on EOL)
|
||||
# Also handles W293 (blank line contains whitespace) implicitly if the line becomes empty.
|
||||
processed_line = line.rstrip()
|
||||
if len(processed_line) < len(line.rstrip('\r\n')): # Compare lengths without EOL
|
||||
logger.debug(f"L{line_num}: Trailing whitespace removed.")
|
||||
|
||||
# If the line became empty after rstrip, it's a truly blank line
|
||||
if not processed_line and line.strip() == "":
|
||||
# Return an empty string for a blank line, newline char will be added later if original had it
|
||||
if line.endswith(('\n', '\r\n', '\r')):
|
||||
return '\n'
|
||||
return ""
|
||||
|
||||
# 3. Fix indentation (E111) - only if line is not blank
|
||||
if processed_line:
|
||||
indented_line = fix_indentation(processed_line)
|
||||
if indented_line != processed_line: # Log if indentation changed
|
||||
# Already logged inside fix_indentation with more detail
|
||||
pass
|
||||
processed_line = indented_line
|
||||
|
||||
# 4. E261: At least two spaces before inline comment
|
||||
in_string_char = None
|
||||
temp_part = ""
|
||||
comment_starts_at = -1
|
||||
|
||||
for idx, char_val in enumerate(processed_line):
|
||||
if in_string_char:
|
||||
temp_part += char_val
|
||||
if char_val == in_string_char:
|
||||
if len(temp_part) >= 2 and temp_part[-2] == '\\':
|
||||
pass
|
||||
else:
|
||||
in_string_char = None
|
||||
elif char_val in ("'", '"'):
|
||||
temp_part += char_val
|
||||
in_string_char = char_val
|
||||
elif char_val == '#':
|
||||
comment_starts_at = idx
|
||||
break # Found the first non-string comment char
|
||||
else:
|
||||
temp_part += char_val
|
||||
|
||||
if comment_starts_at != -1:
|
||||
code_part = processed_line[:comment_starts_at]
|
||||
comment_part = processed_line[comment_starts_at:] # Includes '#'
|
||||
|
||||
rstripped_code_part = code_part.rstrip()
|
||||
# Only add spaces if there's actual code before the comment
|
||||
if rstripped_code_part:
|
||||
if not code_part.endswith(' '): # Needs fixing (less than 2 spaces)
|
||||
new_line_with_comment_spacing = rstripped_code_part + ' ' + comment_part
|
||||
if new_line_with_comment_spacing != processed_line:
|
||||
logger.debug(f"L{line_num}: Adjusted inline comment spacing.")
|
||||
processed_line = new_line_with_comment_spacing
|
||||
# If rstripped_code_part is empty, it's a comment at the start of the line (after indent), leave it.
|
||||
|
||||
# Add back the newline character that rstrip might have removed, if the original line had one
|
||||
# or if it's not an empty line that was purely whitespace
|
||||
if line_content_with_eol.endswith('\n'):
|
||||
return processed_line + '\n'
|
||||
elif line_content_with_eol.endswith('\r\n'):
|
||||
return processed_line + '\r\n'
|
||||
elif line_content_with_eol.endswith('\r'):
|
||||
return processed_line + '\r'
|
||||
else: # Original line did not end with EOL
|
||||
return processed_line
|
||||
|
||||
|
||||
def process_file(filepath, expand_tabs_enabled=True, dry_run=False, no_backup=False):
|
||||
logger.info(f"Processing {filepath}...")
|
||||
try:
|
||||
# Read with universal newlines mode, then splitlines to preserve EOLs correctly for rejoining
|
||||
with open(filepath, 'r', encoding='utf-8', newline='') as f:
|
||||
# original_content = f.read() # Reading all at once
|
||||
# original_lines = original_content.splitlines(keepends=True) # This is better
|
||||
original_lines = f.readlines() # Simpler, usually works fine
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading file {filepath}: {e}")
|
||||
return False
|
||||
|
||||
fixed_lines = []
|
||||
changes_made = False
|
||||
for i, line_content_with_eol in enumerate(original_lines):
|
||||
fixed_line_content = process_line(i + 1, line_content_with_eol, expand_tabs_enabled)
|
||||
fixed_lines.append(fixed_line_content)
|
||||
if fixed_line_content != line_content_with_eol:
|
||||
changes_made = True
|
||||
logger.debug(f"L{i+1}: Original: {line_content_with_eol.rstrip()!r}")
|
||||
logger.debug(f"L{i+1}: Fixed : {fixed_line_content.rstrip()!r}")
|
||||
|
||||
if changes_made:
|
||||
if dry_run:
|
||||
logger.info(f"Would fix style issues in {filepath} (dry run)")
|
||||
else:
|
||||
if not no_backup:
|
||||
backup_dir = os.path.dirname(filepath)
|
||||
backup_filename = "." + os.path.basename(filepath) + "~"
|
||||
backup_path = os.path.join(backup_dir, backup_filename)
|
||||
try:
|
||||
shutil.copy2(filepath, backup_path) # copy2 preserves metadata
|
||||
logger.info(f"Backup of original file created at {backup_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create backup for {filepath}: {e}. File not modified.")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Write back lines using the original EOLs if possible, or common EOL
|
||||
with open(filepath, 'w', encoding='utf-8', newline='') as f:
|
||||
f.writelines(fixed_lines)
|
||||
logger.info(f"Fixed style issues in {filepath}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error writing to file {filepath}: {e}")
|
||||
return False
|
||||
else:
|
||||
logger.info(f"No style issues to fix in {filepath}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Fix common Python code style issues (Flake8).")
|
||||
parser.add_argument("files", metavar="FILE", type=str, nargs='+',
|
||||
help="Python files to process")
|
||||
parser.add_argument("--no-expand-tabs", action="store_false", dest="expand_tabs",
|
||||
help="Disable expansion of tabs to spaces.")
|
||||
parser.add_argument("--dry-run", action="store_true",
|
||||
help="Show what would be changed without modifying files.")
|
||||
parser.add_argument("--no-backup", action="store_true",
|
||||
help="Do not create a backup of the original file.")
|
||||
parser.add_argument("-v", "--verbose", action="store_true",
|
||||
help="Enable verbose (debug) logging.")
|
||||
parser.set_defaults(expand_tabs=True)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logger.setLevel(logging.DEBUG)
|
||||
else:
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
for filepath in args.files:
|
||||
if not os.path.isfile(filepath):
|
||||
logger.warning(f"File not found: {filepath}. Skipping.")
|
||||
continue
|
||||
process_file(filepath, args.expand_tabs, args.dry_run, args.no_backup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user