Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8134be8032 | ||
|
|
8d587bcab4 | ||
|
|
2ddd61ee87 | ||
|
|
87ee4aebb7 | ||
|
|
d95ef9758d | ||
|
|
01945ade86 | ||
|
|
644a2fa7f4 | ||
|
|
0d3c321ae3 | ||
|
|
9a565f31b0 | ||
|
|
214bee12d9 | ||
|
|
a091559e25 | ||
|
|
bb890e2027 | ||
|
|
d989087f08 | ||
|
|
13be95db99 |
@@ -196,3 +196,4 @@ cython_debug/
|
||||
*.dst
|
||||
*.epr
|
||||
.*~
|
||||
sync.sh
|
||||
|
||||
@@ -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,16 @@ my audio nodes.
|
||||
- 📦 [Dependencies](#-dependencies)
|
||||
- 🖼️ [Examples](#️-examples)
|
||||
- ✨ [Nodes](#-extra-nodes)
|
||||
- [1. Image Download and Load](#1-image-download-and-load)
|
||||
- [Image Download and Load](#1-image-download-and-load)
|
||||
- [Face Composite](#2-face-composite)
|
||||
- [Face Composite (frame by frame)](#3-face-composite-frame-by-frame)
|
||||
- [Normalize Image to ImageNet](#4-normalize-image-to-imagenet)
|
||||
- [Normalize Image to [-0.5, 0.5]](#5-normalize-image-to-05-05)
|
||||
- [Normalize Image to [-1, 1]](#6-normalize-image-to-1-1)
|
||||
- [Apply Mask using AFFCE](#7-apply-mask-using-affce)
|
||||
- [Estimate foreground (AFFCE)](#8-estimate-foreground-affce)
|
||||
- [Estimate foreground (FMLFE)](#9-estimate-foreground-fmlfe)
|
||||
- [Create Empty Image](#10-create-empty-image)
|
||||
- 📝 [Usage Notes](#-usage-notes)
|
||||
- 📜 [Project History](#-project-history)
|
||||
- ⚖️ [License](#️-license)
|
||||
@@ -36,6 +44,7 @@ my audio nodes.
|
||||
- `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`.
|
||||
- **Output:**
|
||||
- `image` (IMAGE): The loaded image.
|
||||
- `alpha_mask` (MASK): The alpha mask for the loaded image.
|
||||
@@ -43,6 +52,162 @@ my audio nodes.
|
||||
- **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.
|
||||
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
@@ -53,14 +218,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.
|
||||
|
||||
|
||||
## 📦 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.
|
||||
|
||||
@@ -84,6 +253,15 @@ 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, with reference image
|
||||
|
||||
|
||||
## ⚖️ License
|
||||
|
||||
+2
-1
@@ -9,8 +9,9 @@ No extra dependencies, just an internal module.
|
||||
# dynamic = ["version"]
|
||||
# license = "GPL-3.0-or-later"
|
||||
# Only this is supported :-(
|
||||
version = "1.1.0"
|
||||
version = "1.2.0"
|
||||
license = { file = "LICENSE" }
|
||||
# The following is just ignored
|
||||
dependencies = ["seconohe"]
|
||||
|
||||
[project.urls]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Pillow
|
||||
numpy
|
||||
seconohe
|
||||
@@ -4,6 +4,6 @@
|
||||
# Project: ComfyUI-ImageMisc
|
||||
from seconohe.logger import initialize_logger
|
||||
|
||||
__version__ = "1.1.0"
|
||||
__version__ = "1.2.0"
|
||||
NODES_NAME = "ImageMisc"
|
||||
main_logger = initialize_logger(NODES_NAME)
|
||||
|
||||
+539
-6
@@ -4,14 +4,22 @@
|
||||
# License: GPL-3.0
|
||||
# Project: ComfyUI-ImageMisc
|
||||
# From code generated by Gemini 2.5 Pro
|
||||
import numpy as np
|
||||
import os
|
||||
from PIL import Image # Import the Python Imaging Library
|
||||
from seconohe.apply_mask import apply_mask
|
||||
from seconohe.foreground_estimation.affce import affce
|
||||
from seconohe.foreground_estimation.fmlfe import fmlfe, IMPL_PRIORITY
|
||||
from seconohe.color import color_to_rgb_float
|
||||
from seconohe.downloader import download_file
|
||||
# We are the main source, so we use the main_logger
|
||||
from . import main_logger
|
||||
import torch
|
||||
import torchvision.transforms.functional as TF
|
||||
from typing import Optional
|
||||
try:
|
||||
from folder_paths import get_input_directory # To get the ComfyUI input directory
|
||||
from comfy import model_management
|
||||
except ModuleNotFoundError:
|
||||
# No ComfyUI, this is a test environment
|
||||
def get_input_directory():
|
||||
@@ -26,6 +34,28 @@ except Exception:
|
||||
logger = main_logger
|
||||
BASE_CATEGORY = "image"
|
||||
IO_CATEGORY = "io"
|
||||
MANIPULATION_CATEGORY = "manipulation"
|
||||
NORMALIZATION = "normalization"
|
||||
FOREGROUND = "foreground"
|
||||
BLUR_SIZE_OPT = ("INT", {"default": 90, "min": 1, "max": 255, "step": 1, })
|
||||
BLUR_SIZE_TWO_OPT = ("INT", {"default": 6, "min": 1, "max": 255, "step": 1, })
|
||||
COLOR_OPT = ("STRING", {
|
||||
"default": "#000000",
|
||||
"tooltip": "Color for fill.\n"
|
||||
"Can be an hexadecimal (#RRGGBB).\n"
|
||||
"Can comma separated RGB values in [0-255] or [0-1.0] range."})
|
||||
|
||||
|
||||
def tensor_to_pil(tensor: torch.Tensor) -> Image.Image:
|
||||
"""Converts a single image tensor (H, W, C) [0, 1] to a Pillow Image."""
|
||||
np_image = (tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
return Image.fromarray(np_image)
|
||||
|
||||
|
||||
def pil_to_tensor(pil_image: Image.Image) -> torch.Tensor:
|
||||
"""Converts a Pillow Image to a tensor (H, W, C) [0, 1]."""
|
||||
np_image = np.array(pil_image).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(np_image)
|
||||
|
||||
|
||||
if has_load_image:
|
||||
@@ -49,6 +79,10 @@ if has_load_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"}),
|
||||
"local_name": ("STRING", {
|
||||
"default": "",
|
||||
"tooltip": "The name used locally. Leave empty to use `filename`"
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +100,7 @@ if has_load_image:
|
||||
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):
|
||||
mask_bypass: Optional[torch.Tensor] = None, local_name: str = 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:
|
||||
@@ -81,7 +115,8 @@ if has_load_image:
|
||||
return (image_bypass, mask_bypass)
|
||||
|
||||
save_dir = get_input_directory()
|
||||
local_filepath = os.path.join(save_dir, filename)
|
||||
dest_fname = local_name or filename
|
||||
local_filepath = os.path.join(save_dir, dest_fname)
|
||||
|
||||
if not os.path.exists(local_filepath):
|
||||
logger.info(f"File '{filename}' not found locally. Attempting to download.")
|
||||
@@ -91,7 +126,7 @@ if has_load_image:
|
||||
download_url = base_url + filename
|
||||
|
||||
try:
|
||||
download_file(logger, url=download_url, save_dir=save_dir, file_name=filename, kind="image")
|
||||
download_file(logger, url=download_url, save_dir=save_dir, file_name=dest_fname, kind="image")
|
||||
except Exception as e:
|
||||
logger.error(f"Download failed for {download_url}: {e}", exc_info=True)
|
||||
raise
|
||||
@@ -107,15 +142,15 @@ if has_load_image:
|
||||
# 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}'")
|
||||
logger.debug(f"Calling built-in LoadImage.load_image() with filename: '{dest_fname}'")
|
||||
|
||||
# Call the method and return its result directly
|
||||
result = loader_instance.load_image(filename)
|
||||
result = loader_instance.load_image(dest_fname)
|
||||
# This information is for the preview, as we are an output node and we return images
|
||||
# they will be displayed in our node. Quite simple.
|
||||
downloaded_file = {
|
||||
"images": [{
|
||||
"filename": filename,
|
||||
"filename": dest_fname,
|
||||
"subfolder": "",
|
||||
"type": "input" # We stored the file in the "input" folder
|
||||
}]
|
||||
@@ -130,3 +165,501 @@ if has_load_image:
|
||||
else:
|
||||
logger.error("Failed to import ComfyUI `LoadImage`, please fill an issue here: "
|
||||
"https://github.com/set-soft/ComfyUI-ImageMisc/issues")
|
||||
|
||||
|
||||
class CompositeFace:
|
||||
"""
|
||||
A ComfyUI node to composite (paste) animated face crops back onto reference images.
|
||||
It handles a M-to-N relationship, where M reference images and bboxes correspond
|
||||
to M*N animated face images.
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"animated": ("IMAGE",), # The M*N batch of cropped faces
|
||||
"reference": ("IMAGE",), # The M batch of original context images
|
||||
"bboxes": ("BBOX",), # The M list of (x, y, w, h) tuples
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
FUNCTION = "composite"
|
||||
|
||||
CATEGORY = BASE_CATEGORY + "/" + MANIPULATION_CATEGORY
|
||||
DESCRIPTION = ("Inserts the `animated` face in the `reference` images at the `bboxes` coordinates.")
|
||||
UNIQUE_NAME = "SET_CompositeFace"
|
||||
DISPLAY_NAME = "Face Composite"
|
||||
|
||||
def composite(self, animated: torch.Tensor, reference: torch.Tensor, bboxes: list):
|
||||
# 1. Get batch sizes and validate the M vs M*N relationship
|
||||
ref_count = reference.shape[0]
|
||||
anim_count = animated.shape[0]
|
||||
bbox_count = len(bboxes)
|
||||
|
||||
if ref_count == 0 or anim_count == 0:
|
||||
logger.info("Warning: One of the input image batches is empty. Returning empty tensor.")
|
||||
return (torch.zeros((0, 1, 1, 3)),)
|
||||
|
||||
if ref_count != bbox_count:
|
||||
raise ValueError(f"Mismatch: Received {ref_count} reference images but {bbox_count} bboxes. "
|
||||
"These must be equal.")
|
||||
|
||||
if anim_count % ref_count != 0:
|
||||
raise ValueError(f"Batch size mismatch: The 'animated' batch ({anim_count}) is not a multiple of the "
|
||||
f"'reference' batch ({ref_count}).")
|
||||
|
||||
# N: Number of animated frames per reference image
|
||||
n_frames_per_ref = anim_count // ref_count
|
||||
logger.info(f"Processing {ref_count} reference images, each with {n_frames_per_ref} animated frames.")
|
||||
|
||||
output_images = []
|
||||
|
||||
# 2. Iterate through each reference image and its corresponding bbox
|
||||
for i in range(ref_count):
|
||||
ref_tensor = reference[i]
|
||||
bbox = bboxes[i]
|
||||
|
||||
# The bbox from your code is the area to be replaced.
|
||||
# Assuming it's in the format (x, y, width, height)
|
||||
try:
|
||||
x, y, w, h = map(int, bbox)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise TypeError(f"Bbox item {i} has an invalid format: {bbox}. Expected (x, y, w, h). Error: {e}")
|
||||
|
||||
# Convert the reference image to Pillow Image ONCE before the inner loop
|
||||
ref_pil = tensor_to_pil(ref_tensor)
|
||||
|
||||
# 3. For this one reference, loop through its N animated frames
|
||||
for j in range(n_frames_per_ref):
|
||||
anim_index = i * n_frames_per_ref + j
|
||||
anim_tensor = animated[anim_index]
|
||||
|
||||
# Convert the small animated face to Pillow Image
|
||||
anim_pil = tensor_to_pil(anim_tensor)
|
||||
|
||||
# 4. Resize the animated face to fit the target bbox
|
||||
# Image.Resampling.LANCZOS is a high-quality resampling filter.
|
||||
resized_anim_face = anim_pil.resize((w, h), Image.Resampling.LANCZOS)
|
||||
|
||||
# 5. Perform the paste operation.
|
||||
# It's CRITICAL to work on a copy of the reference image for each frame.
|
||||
pasted_image_pil = ref_pil.copy()
|
||||
pasted_image_pil.paste(resized_anim_face, (x, y))
|
||||
|
||||
# 6. Convert the final image back to a tensor and add to the output list
|
||||
final_tensor = pil_to_tensor(pasted_image_pil)
|
||||
output_images.append(final_tensor)
|
||||
|
||||
# 7. Stack all the generated images into a single batch tensor
|
||||
if not output_images:
|
||||
return (torch.zeros_like(reference),) # Return something if all pastes failed
|
||||
|
||||
final_batch = torch.stack(output_images)
|
||||
|
||||
return (final_batch,)
|
||||
|
||||
|
||||
class CompositeFaceFrameByFrame(CompositeFace):
|
||||
"""
|
||||
A ComfyUI node to composite animated frames onto reference frames on a 1-to-1 basis.
|
||||
It expects the 'animated' and 'reference' batches to have the same number of frames.
|
||||
It uses the *first* bounding box from the 'bboxes' input for all frames.
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"animated": ("IMAGE",), # The batch of cropped/processed frames
|
||||
"reference": ("IMAGE",), # The batch of original frames
|
||||
"bboxes": ("BBOX",), # A list of bboxes; only the first is used
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY = BASE_CATEGORY + "/" + MANIPULATION_CATEGORY
|
||||
DESCRIPTION = ("Inserts the `animated` face in the `reference` video at the `bboxes` coordinates.")
|
||||
UNIQUE_NAME = "SET_CompositeFaceFrameByFrame"
|
||||
DISPLAY_NAME = "Face Composite (frame by frame)"
|
||||
|
||||
def composite(self, animated: torch.Tensor, reference: torch.Tensor, bboxes: list):
|
||||
# 1. Validate inputs
|
||||
anim_count = animated.shape[0]
|
||||
ref_count = reference.shape[0]
|
||||
|
||||
if anim_count != ref_count:
|
||||
raise ValueError(f"Batch size mismatch: Received {anim_count} animated frames and {ref_count} reference "
|
||||
"frames. They must be equal.")
|
||||
|
||||
if not bboxes:
|
||||
raise ValueError("Bboxes input is empty. A bounding box is required.")
|
||||
|
||||
# 2. Extract the single bounding box to be used for all frames
|
||||
if len(bboxes) > 1:
|
||||
logger.info(f"Warning: Received {len(bboxes)} bboxes. Using only the first one for all frames.")
|
||||
|
||||
try:
|
||||
# Use the first bbox from the list
|
||||
x, y, w, h = map(int, bboxes[0])
|
||||
bbox_to_use = (x, y, w, h)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise TypeError(f"The first bbox has an invalid format: {bboxes[0]}. Expected (x, y, w, h). Error: {e}")
|
||||
|
||||
logger.info(f"Compositing {anim_count} frames using static bbox: {bbox_to_use}")
|
||||
|
||||
output_images = []
|
||||
|
||||
# 3. Loop through each frame in a 1-to-1 fashion
|
||||
for i in range(anim_count):
|
||||
ref_tensor = reference[i]
|
||||
anim_tensor = animated[i]
|
||||
|
||||
# Convert tensors to Pillow Images
|
||||
ref_pil = tensor_to_pil(ref_tensor)
|
||||
anim_pil = tensor_to_pil(anim_tensor)
|
||||
|
||||
# 4. Resize the animated face to fit the bbox
|
||||
# Image.Resampling.LANCZOS is a high-quality filter comparable to OpenCV's INTER_CUBIC/LANCZOS4
|
||||
resized_anim_face = anim_pil.resize((w, h), Image.Resampling.LANCZOS)
|
||||
|
||||
# 5. Perform the paste operation
|
||||
# Pillow's paste is simpler. It handles coordinates and requires a copy.
|
||||
pasted_image_pil = ref_pil.copy()
|
||||
pasted_image_pil.paste(resized_anim_face, (x, y))
|
||||
|
||||
# 6. Convert back to tensor and add to output list
|
||||
final_tensor = pil_to_tensor(pasted_image_pil)
|
||||
output_images.append(final_tensor)
|
||||
|
||||
# 7. Stack all images into the final output batch
|
||||
final_batch = torch.stack(output_images)
|
||||
|
||||
return (final_batch,)
|
||||
|
||||
|
||||
class NormalizeToImageNetDataset():
|
||||
"""
|
||||
A ComfyUI node to normalize the values to the mean/std of the ImageNet dataset
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "normalize"
|
||||
CATEGORY = BASE_CATEGORY + "/" + NORMALIZATION
|
||||
DESCRIPTION = ("Normalize the image to the ImageNet dataset")
|
||||
UNIQUE_NAME = "SET_NormalizeToImageNetDataset"
|
||||
DISPLAY_NAME = "Normalize Image to ImageNet"
|
||||
imagenet_normalize = None
|
||||
|
||||
def normalize(self, image: torch.Tensor):
|
||||
return (TF.normalize(image.permute(0, 3, 1, 2), # BHWC -> BCHW
|
||||
mean=[0.485, 0.456, 0.406],
|
||||
std=[0.229, 0.224, 0.225]).permute(0, 2, 3, 1),) # BCHW -> BHWC
|
||||
|
||||
|
||||
class NormalizeToRangeMinus05to05():
|
||||
"""
|
||||
A ComfyUI node to normalize the values to the [-0.5, 0.5] range
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"image": ("IMAGE",), }, }
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "normalize"
|
||||
CATEGORY = BASE_CATEGORY + "/" + NORMALIZATION
|
||||
DESCRIPTION = ("Normalize the image to [-0.5, 0.5]")
|
||||
UNIQUE_NAME = "SET_NormalizeToRangeMinus05to05"
|
||||
DISPLAY_NAME = "Normalize Image to [-0.5, 0.5]"
|
||||
imagenet_normalize = None
|
||||
|
||||
def normalize(self, image: torch.Tensor):
|
||||
return (TF.normalize(image.permute(0, 3, 1, 2), # BHWC -> BCHW
|
||||
mean=[0.5, 0.5, 0.5],
|
||||
std=[1.0, 1.0, 1.0]).permute(0, 2, 3, 1),) # BCHW -> BHWC
|
||||
|
||||
|
||||
class NormalizeToRangeMinus1to1():
|
||||
"""
|
||||
A ComfyUI node to normalize the values to the [-1, 1] range
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"image": ("IMAGE",), }, }
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "normalize"
|
||||
CATEGORY = BASE_CATEGORY + "/" + NORMALIZATION
|
||||
DESCRIPTION = ("Normalize the image to [-1, 1]")
|
||||
UNIQUE_NAME = "SET_NormalizeToRangeMinus1to1"
|
||||
DISPLAY_NAME = "Normalize Image to [-1, 1] (i.e. GAN)"
|
||||
imagenet_normalize = None
|
||||
|
||||
def normalize(self, image: torch.Tensor):
|
||||
return (TF.normalize(image.permute(0, 3, 1, 2), # BHWC -> BCHW
|
||||
mean=[0.5, 0.5, 0.5],
|
||||
std=[0.5, 0.5, 0.5]).permute(0, 2, 3, 1),) # BCHW -> BHWC
|
||||
|
||||
|
||||
class ApplyMaskAFFCE:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"masks": ("MASK",),
|
||||
"blur_size": BLUR_SIZE_OPT,
|
||||
"blur_size_two": BLUR_SIZE_TWO_OPT,
|
||||
"fill_color": ("BOOLEAN", {
|
||||
"default": False,
|
||||
"tooltip": ("Fill the background using a color.\n"
|
||||
"Returns an RGB image, otherwise an RGBA.")
|
||||
}),
|
||||
"color": COLOR_OPT,
|
||||
"batched": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": ("Process the images at once.\n"
|
||||
"Faster, needs more memory")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK",)
|
||||
RETURN_NAMES = ("image", "mask",)
|
||||
FUNCTION = "get_foreground"
|
||||
CATEGORY = BASE_CATEGORY + "/" + MANIPULATION_CATEGORY
|
||||
DESCRIPTION = ("Apply a mask to an image using\n"
|
||||
"Approximate Fast Foreground Colour Estimation.\n"
|
||||
"https://github.com/Photoroom/fast-foreground-estimation")
|
||||
UNIQUE_NAME = "SET_ApplyMaskAFFCE"
|
||||
DISPLAY_NAME = "Apply Mask using AFFCE"
|
||||
|
||||
def get_foreground(self, images, masks, blur_size=91, blur_size_two=7, fill_color=False, color=None, batched=True):
|
||||
out_images = apply_mask(logger, images, masks, model_management.get_torch_device(), blur_size, blur_size_two,
|
||||
fill_color, color, batched)
|
||||
return out_images.cpu(), masks.cpu()
|
||||
|
||||
|
||||
class AFFCE:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"masks": ("MASK",),
|
||||
"blur_size": BLUR_SIZE_OPT,
|
||||
"blur_size_two": BLUR_SIZE_TWO_OPT,
|
||||
"batched": ("BOOLEAN", {
|
||||
"default": True,
|
||||
"tooltip": ("Process the images at once.\n"
|
||||
"Faster, needs more memory")
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK",)
|
||||
RETURN_NAMES = ("foreground", "mask",)
|
||||
FUNCTION = "get_foreground"
|
||||
CATEGORY = BASE_CATEGORY + "/" + FOREGROUND
|
||||
DESCRIPTION = ("Estimate the foreground image using\n"
|
||||
"Approximate Fast Foreground Colour Estimation.\n"
|
||||
"https://github.com/Photoroom/fast-foreground-estimation")
|
||||
UNIQUE_NAME = "SET_AFFCE"
|
||||
DISPLAY_NAME = "Estimate foreground (AFFCE)"
|
||||
|
||||
def get_foreground(self, images, masks, blur_size=91, blur_size_two=7, batched=True):
|
||||
device = model_management.get_torch_device()
|
||||
images_on_device = images.to(device)
|
||||
masks_on_device = masks.to(device)
|
||||
|
||||
out_images = affce(images_on_device, masks_on_device, r1=blur_size, r2=blur_size_two, batched=batched)
|
||||
|
||||
return out_images.cpu(), masks.cpu()
|
||||
|
||||
|
||||
class FMLFE:
|
||||
"""
|
||||
A ComfyUI node that uses the Fast Multi-Level Foreground Estimation algorithm
|
||||
to produce a high-quality foreground and background separation. It can
|
||||
intelligently select the best available backend (CuPy, OpenCL, Numba, or PyTorch).
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
# Create the dropdown list for the implementation choice
|
||||
impl_list = ['auto'] + IMPL_PRIORITY
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", {
|
||||
"tooltip": "The source image(s) from which to estimate the foreground and background."
|
||||
}),
|
||||
"masks": ("MASK", {
|
||||
"tooltip": "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": (impl_list, {
|
||||
"default": "auto",
|
||||
"tootip": "Select the computation backend. 'auto' mode will automatically try to use the "
|
||||
"fastest available implementation, in order of priority: CuPy (NVIDIA GPU), "
|
||||
"OpenCL (GPU), Numba (CPU/GPU), and finally the pure PyTorch version."
|
||||
}),
|
||||
},
|
||||
"optional": {
|
||||
"regularization": ("FLOAT", {
|
||||
"default": 1e-5,
|
||||
"min": 0.0,
|
||||
"max": 0.1,
|
||||
"step": 1e-5,
|
||||
"display": "number",
|
||||
"tooltip": "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", {
|
||||
"default": 10,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"tooltip": "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", {
|
||||
"default": 2,
|
||||
"min": 1,
|
||||
"max": 100,
|
||||
"tooltip": "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", {
|
||||
"default": 32,
|
||||
"min": 8,
|
||||
"max": 256,
|
||||
"tooltip": "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", {
|
||||
"default": 1.0,
|
||||
"min": 0.0,
|
||||
"max": 10.0,
|
||||
"step": 0.1,
|
||||
"tooltip": "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."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "IMAGE", "MASK",)
|
||||
RETURN_NAMES = ("foreground", "background", "mask")
|
||||
FUNCTION = "estimate"
|
||||
CATEGORY = BASE_CATEGORY + "/" + FOREGROUND
|
||||
DESCRIPTION = ("Estimate the foreground image using\n"
|
||||
"Fast Multi-Level Foreground Estimation.")
|
||||
UNIQUE_NAME = "SET_FMLFE"
|
||||
DISPLAY_NAME = "Estimate foreground (FMLFE)"
|
||||
|
||||
def estimate(self, images: torch.Tensor, masks: torch.Tensor, implementation: str,
|
||||
regularization: float, n_small_iterations: int, n_big_iterations: int,
|
||||
small_size: int, gradient_weight: float):
|
||||
try:
|
||||
foregrounds, backgrounds = fmlfe(
|
||||
images=images,
|
||||
masks=masks,
|
||||
logger=logger,
|
||||
implementation=implementation,
|
||||
regularization=regularization,
|
||||
n_small_iterations=n_small_iterations,
|
||||
n_big_iterations=n_big_iterations,
|
||||
small_size=small_size,
|
||||
gradient_weight=gradient_weight
|
||||
)
|
||||
|
||||
return (foregrounds, backgrounds, masks,)
|
||||
|
||||
except Exception as e:
|
||||
# This ensures that if all backends fail, the error is clearly visible in the ComfyUI console.
|
||||
logger.error("Failed to execute ML Foreground Estimation. All backends failed.")
|
||||
logger.error(f"Last error: {e}")
|
||||
# Raising the exception will stop the workflow and show the error to the user.
|
||||
raise e
|
||||
|
||||
|
||||
class CreateEmptyImage:
|
||||
"""
|
||||
A ComfyUI node to create a solid-color image tensor.
|
||||
The output dimensions can be specified manually or inherited from an optional input image.
|
||||
"""
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"tooltip": "The width of the new image in pixels. This value is ignored if a `reference` is provided."
|
||||
}),
|
||||
"height": ("INT", {
|
||||
"default": 1024,
|
||||
"min": 1,
|
||||
"max": 8192,
|
||||
"step": 8,
|
||||
"tooltip": "The height of the new image in pixels. This value is ignored if a `reference` is provided."
|
||||
}),
|
||||
"batch_size": ("INT", {
|
||||
"default": 1,
|
||||
"min": 1,
|
||||
"max": 64,
|
||||
"tooltip": "The number of images to create in the batch. This value is ignored if a `reference` "
|
||||
"is provided."
|
||||
}),
|
||||
"color": COLOR_OPT,
|
||||
},
|
||||
"optional": {
|
||||
"reference": ("IMAGE", {
|
||||
"tooltip": "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."
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "create_image"
|
||||
CATEGORY = BASE_CATEGORY + "/generation"
|
||||
DESCRIPTION = ("Create a solid-color image.\n"
|
||||
"If the optional image is provides uses its shape.")
|
||||
UNIQUE_NAME = "SET_CreateEmptyImage"
|
||||
DISPLAY_NAME = "Create Empty Image"
|
||||
|
||||
def create_image(self, width: int, height: int, batch_size: int, color: str,
|
||||
reference: Optional[torch.Tensor] = None):
|
||||
# --- 1. Determine the final shape of the output tensor ---
|
||||
if reference is not None:
|
||||
# If an image is provided, its shape overrides the manual inputs
|
||||
b, h, w, _ = reference.shape
|
||||
else:
|
||||
b, h, w = batch_size, height, width
|
||||
|
||||
# --- 2. Parse the color string ---
|
||||
# The function returns a tuple of floats in the [0, 1] range
|
||||
rgb_color = color_to_rgb_float(logger, color)
|
||||
|
||||
# --- 3. Create the tensor efficiently ---
|
||||
# Create a small color tensor and then expand it to the final size.
|
||||
# This is highly memory-efficient as it creates a view, not a full-size copy.
|
||||
# Tensors should be created on the CPU by default in generator nodes.
|
||||
color_tensor = torch.tensor(rgb_color, dtype=torch.float32, device="cpu").view(1, 1, 1, 3)
|
||||
final_image = color_tensor.expand(b, h, w, 3)
|
||||
|
||||
return (final_image,)
|
||||
|
||||
@@ -10,6 +10,7 @@ PYPROJECT_PATH = Path("pyproject.toml")
|
||||
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:
|
||||
@@ -74,5 +75,6 @@ def main() -> int:
|
||||
print(" Please ensure both versions are identical.", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(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