14 Commits
Author SHA1 Message Date
Salvador E. Tropea 8134be8032 Bumped version to 1.2.0 2025-10-05 11:24:35 -03:00
Salvador E. Tropea 8d587bcab4 [Fixed][Downloader] Display image with the upstream name
Not the one we downloaded
2025-10-04 18:31:08 -03:00
Salvador E. Tropea 2ddd61ee87 [Added] Foreground estimation (2 nodes) and Empty Image (from ref) 2025-09-30 10:05:32 -03:00
Salvador E. Tropea 87ee4aebb7 [Apply Mask][Added] Option to process images one by one 2025-09-17 13:37:25 -03:00
Salvador E. Tropea d95ef9758d [Added][Downloader] Name for the local image 2025-09-17 09:20:21 -03:00
Salvador E. Tropea 01945ade86 [Added] Node to apply a mask to an image
Using Approximate Fast Foreground Colour Estimation
2025-09-16 13:50:05 -03:00
Salvador E. Tropea 644a2fa7f4 [Added] Nodes for normalization
Useful for testing models or using low level models
2025-09-16 08:35:49 -03:00
Salvador E. Tropea 0d3c321ae3 [Added][Tools] Style fixer 2025-08-22 11:17:31 -03:00
Salvador E. Tropea 9a565f31b0 [Added] Ignore the sync script 2025-08-22 11:16:57 -03:00
Salvador E. Tropea 214bee12d9 [Added] Nodes to insert a video in a reference/s 2025-08-22 11:16:03 -03:00
Salvador E. Tropea a091559e25 [Fixed] Flake8 style details 2025-08-22 09:59:45 -03:00
Salvador E. Tropea bb890e2027 Bumped version to 1.1.1 2025-07-25 13:12:53 -03:00
Salvador E. Tropea d989087f08 Added (deprecated) requirements.txt
comfy-cli is really wrong
2025-07-25 13:11:58 -03:00
Salvador E. Tropea 13be95db99 [README][Added] SeCoNoHe as dependency 2025-07-25 12:58:58 -03:00
8 changed files with 951 additions and 14 deletions
+1
View File
@@ -196,3 +196,4 @@ cython_debug/
*.dst
*.epr
.*~
sync.sh
+183 -5
View File
@@ -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
View File
@@ -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]
+3
View File
@@ -0,0 +1,3 @@
Pillow
numpy
seconohe
+1 -1
View File
@@ -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
View File
@@ -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,)
+3 -1
View File
@@ -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())
+219
View File
@@ -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()