6 Commits
6 changed files with 350 additions and 11 deletions
+4
View File
@@ -31,10 +31,13 @@ Currently we just have a few nodes used by other nodes I maintain.
- [Pad Image](docs/nodes_img.md#11-pad-image-kjset)
- [Resize Image](docs/nodes_img.md#12-resize-image-kjset)
- [Resize Mask](docs/nodes_img.md#13-resize-mask-kjset)
- [Image with text label](docs/nodes_img.md#16-image-with-text-label)
- Normalization:
- [Normalize Image to ImageNet](docs/nodes_img.md#4-normalize-image-to-imagenet)
- [Normalize Image to [-0.5, 0.5]](docs/nodes_img.md#5-normalize-image-to-05-05)
- [Normalize Image to [-1, 1]](docs/nodes_img.md#6-normalize-image-to-1-1)
- [Arbitrary Normalize](docs/nodes_img.md#14-arbitrary-normalize)
- [Normalize Parameters](docs/nodes_img.md#15-normalize-parameters)
- 📝 [Usage Notes](#-usage-notes)
- 📜 [Project History](#-project-history)
- ⚖️ [License](#️-license)
@@ -109,6 +112,7 @@ Shows the most common uses of the resize and padding nodes. You can see how vari
- "Approximate Fast Foreground Colour Estimation" (from [Photoroom](https://github.com/Photoroom/fast-foreground-estimation))
- Create empty image node, from reference image
- 1.3.0 2025-10-14: Pad Image, Resize Image and Resize Mask nodes
- 1.4.0 2025-10-20: "Image with text label" and "Arbitrary Normalize"/"Normalize Parameters"
## ⚖️ License
Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

+52 -2
View File
@@ -37,7 +37,7 @@
- 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)`
- **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.
@@ -55,7 +55,7 @@
# 4. Normalize Image to ImageNet
- **Display Name:** `"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.
@@ -255,3 +255,53 @@
- `mask` (`MASK`): The resized mask tensor.
- `width` (`INT`): The final width of the output mask.
- `height` (`INT`): The final height of the output mask.
# 14. Arbitrary Normalize
- **Display Name:** `Arbitrary Normalize`
- **Internal Name:** `SET_NormalizeArbitrary`
- **Category:** `image/normalization`
- **Description:** Normalizes an image tensor using the mean and standard deviation provided in the `parameters` input.
- **Purpose:** Essential for pre-processing images before feeding them into models
- **Inputs:**
- `image` (`IMAGE`): A standard ComfyUI image tensor in the `[0, 1]` range.
- `parameters` (`NORM_PARAMS`): The parameters for the normalization. This is a dict with two keys, `mean` and `std`, which are lists with the R, G, B parameters.
- **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 values from NORM_PARAMS
# 15. Normalize Parameters
- **Display Name:** `Normalize Parameters`
- **Internal Name:** `SET_NormalizeParameters`
- **Category:** `image/normalization`
- **Description:** Provides the `NORM_PARAMS` for the [Arbitrary Normalize](#14-arbitrary-normalize) node
- **Purpose:** This is a user interface to the [Arbitrary Normalize](#14-arbitrary-normalize) node
- **Inputs:**
- `mean_red` (`FLOAT`): Mean value for the red channel
- `mean_green` (`FLOAT`): Mean value for the green channel
- `mean_blue` (`FLOAT`): Mean value for the blue channel
- `std_red` (`FLOAT`): Standard deviation for the red channel
- `std_green` (`FLOAT`): Standard deviation for the green channel
- `std_blue` (`FLOAT`): Standard deviation for the blue channel
- **Output:**
- `parameters` (`NORM_PARAMS`): The parameters for the normalization. This is a dict with two keys, `mean` and `std`, which are lists with the R, G, B parameters.
# 16. Image with text label
- **Display Name:** `Image with text label`
- **Internal Name:** `SET_ImageWithTextLabel`
- **Category:** `image/manipulation`
- **Description:** Adds a border and a label to an image or a mask. This can be used to create comparison grids. Use the Comfy-Core `Image Stitch` node to concatenate the images.
- **Purpose:** Intended to create labeled images
- **Inputs:**
- `image` (`IMAGE`): Image to use, leave unconnected when using a mask.
- `mask` ("MASK`): Mask to be used as image, leave unconnected when using an image.
- `text` (`STRING`): Text to use as label. Can contain multiple lines.
- `side` ("top", "bottom", "left", "right"): The side where the label will be placed.
- `label_size` (`STRING`): The width (left/right) or height (top/bottom) of the area for the label. Can be expressed as a percentage (i.e. 10%) or absolute number of pixels.
- `separation` (`STRING`): The size of the border around the image. Can be expressed as a percentage (i.e. 1%) or absolute number of pixels.
- `background_color` (`STRING`): Color to fill the border and the label area. 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. Some color names are also supported.
- `foreground_color` (`STRING`): Color the label font. 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. Some color names are also supported.
- `font_name` (`STRING`): The name of the font to use for the text.
- **Output:**
- `IMAGE` (`IMAGE`): The generated RGB image.
+7 -2
View File
@@ -2,14 +2,18 @@
name = "image-misc"
description = """
Miscellaneous nodes for image manipulation.
Currently just download image with bypass, so you can create workflows including image examples.
Download image with bypass, so you can create workflows including image examples.
Foreground estimation and mask apply.
Face composition nodes (to reinsert a processed face).
Advanced image/mask pad/resize (from KJNodes with some additions).
Normalization nodes for low level image pre-processing.
No extra dependencies, just an internal module.
"""
# This is the modern Python mechanism, but not supported
# dynamic = ["version"]
# license = "GPL-3.0-or-later"
# Only this is supported :-(
version = "1.3.0"
version = "1.4.0"
license = { file = "LICENSE" }
# The following is just ignored
dependencies = ["seconohe"]
@@ -20,6 +24,7 @@ Repository = "https://github.com/set-soft/ComfyUI-ImageMisc"
[tool.comfy]
PublisherId = "set-soft"
DisplayName = "Image Misc"
Icon = "https://raw.githubusercontent.com/set-soft/ComfyUI-ImageMisc/main/assets/Icon_400x400.jpg"
[tool.setuptools.dynamic]
version = {attr = "nodes.__version__"}
+1 -1
View File
@@ -4,6 +4,6 @@
# Project: ComfyUI-ImageMisc
from seconohe.logger import initialize_logger
__version__ = "1.3.0"
__version__ = "1.4.0"
NODES_NAME = "ImageMisc"
main_logger = initialize_logger(NODES_NAME)
+286 -6
View File
@@ -10,12 +10,12 @@
from copy import deepcopy
import numpy as np
import os
from PIL import Image # Import the Python Imaging Library
from PIL import Image, ImageDraw, ImageFont # 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.downloader import download_file
from seconohe.color import color_to_rgb_float
from seconohe.color import color_to_rgb_float, color_to_rgb_uint8
# We are the main source, so we use the main_logger
from . import main_logger
import torch
@@ -89,12 +89,24 @@ PAD_TRANS = ("FLOAT", {
"display": "number",
"tooltip": ("The transparency for the padded area for all modes except `edge_pixel`."
"1.0 is fully transparent, 0.0 is fully opaque.")})
NORM_PARAM = ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.1,
"display": "number"})
# A dictionary to cache loaded fonts
font_cache = {}
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)
if tensor.dim() == 2:
return Image.fromarray(np_image, 'L')
else:
return Image.fromarray(np_image, 'RGB')
def pil_to_tensor(pil_image: Image.Image) -> torch.Tensor:
@@ -108,6 +120,22 @@ def upscale(image, width, height, upscale_method):
return common_upscale(image, width, height, upscale_method, crop="disabled")
def parse_size(size_str, reference_dim):
"""Parses a size string which can be pixels or a percentage."""
size_str = size_str.strip()
if size_str.endswith('%'):
try:
percentage = float(size_str[:-1])
return int(reference_dim * (percentage / 100.0))
except ValueError:
return 0
else:
try:
return int(size_str)
except ValueError:
return 0
if has_load_image:
class ImageDownload:
@classmethod
@@ -418,7 +446,6 @@ class NormalizeToImageNetDataset():
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
@@ -440,7 +467,6 @@ class NormalizeToRangeMinus05to05():
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
@@ -462,7 +488,6 @@ class NormalizeToRangeMinus1to1():
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
@@ -470,6 +495,57 @@ class NormalizeToRangeMinus1to1():
std=[0.5, 0.5, 0.5]).permute(0, 2, 3, 1),) # BCHW -> BHWC
class NormalizeArbitrary():
"""
A ComfyUI node to normalize the values to arbitrary mean/std
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"parameters": ("NORM_PARAMS",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "normalize"
CATEGORY = BASE_CATEGORY + "/" + NORMALIZATION
DESCRIPTION = ("Normalize the image to the provided parameters")
UNIQUE_NAME = "SET_NormalizeArbitrary"
DISPLAY_NAME = "Arbitrary Normalize"
def normalize(self, image: torch.Tensor, parameters):
return (TF.normalize(image.movedim(-1, 1), # BHWC -> BCHW
mean=parameters["mean"],
std=parameters["std"]).movedim(1, -1),) # BCHW -> BHWC
class NormalizeParameters():
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"mean_red": NORM_PARAM,
"mean_green": NORM_PARAM,
"mean_blue": NORM_PARAM,
"std_red": NORM_PARAM,
"std_green": NORM_PARAM,
"std_blue": NORM_PARAM,
},
}
RETURN_TYPES = ("NORM_PARAMS",)
RETURN_NAMES = ("parameters",)
FUNCTION = "normalize"
CATEGORY = BASE_CATEGORY + "/" + NORMALIZATION
DESCRIPTION = ("Parameters for the arbitrary normalization")
UNIQUE_NAME = "SET_NormalizeParameters"
DISPLAY_NAME = "Normalize Parameters"
def normalize(self, mean_red, mean_green, mean_blue, std_red, std_green, std_blue):
return ({"mean": [mean_red, mean_green, mean_blue], "std": [std_red, std_green, std_blue]},)
class ApplyMaskAFFCE:
@classmethod
def INPUT_TYPES(cls):
@@ -1245,3 +1321,207 @@ class ResizeMask:
out_mask = common_upscale(mask.unsqueeze(1), width, height, upscale_method, crop=crop).squeeze(1)
return (out_mask, out_mask.shape[2], out_mask.shape[1],)
# #################################################################################################
# ImageWithTextLabel
# #################################################################################################
def load_font(font_name, font_size):
"""Loads a font from the cache or from file."""
if (font_name, font_size) in font_cache:
return font_cache[(font_name, font_size)]
try:
font = ImageFont.truetype(font_name, font_size)
except IOError:
try:
# Fallback for Linux
font_path = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
if not os.path.exists(font_path):
# Fallback to a common system font if the specified one isn't found
font_path = os.path.join("C:", os.sep, "Windows", "Fonts", f"{font_name}.ttf")
font = ImageFont.truetype(font_path, font_size)
except IOError:
# If all else fails, use the default Pillow font
logger.warning(f"Font '{font_name}' not found. Falling back to default font.")
font = ImageFont.load_default()
font_cache[(font_name, font_size)] = font
return font
class ImageWithTextLabel:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"text": ("STRING", {
"multiline": True, "default": "Your text here",
"tooltip": "Label for this image"}),
"side": (["top", "bottom", "left", "right"],),
"label_size": ("STRING", {
"default": "10%",
"tooltip": "Expressed as a percentage (i.e. 10%) or absolute number of pixels"}),
"separation": ("STRING", {
"default": "1%",
"tooltip": "Expressed as a percentage (i.e. 1%) or absolute number of pixels"}),
"background_color": ("STRING", {"default": "white"}),
"foreground_color": ("STRING", {"default": "black"}),
"font_name": ("STRING", {"default": "Arial"}),
},
"optional": {
"image": ("IMAGE", {
"tooltip": "Image, leave unconnected when using a mask"}),
"mask": ("MASK", {
"tooltip": "Mask to be used as image, leave unconnected when using an image"}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "add_label"
CATEGORY = BASE_CATEGORY + "/" + MANIPULATION_CATEGORY
DESCRIPTION = ("Adds a text label to an image")
UNIQUE_NAME = "SET_ImageWithTextLabel"
DISPLAY_NAME = "Image with text label, good for comparison grids"
def add_label(self, text: str, side: str, label_size: str, separation: int, background_color: str,
foreground_color: str, font_name: str, image: Optional[torch.Tensor] = None,
mask: Optional[torch.Tensor] = None):
if image is None and mask is None:
raise ValueError("You must provide an image or a mask")
image = image if image is not None else mask
# Process multiple images in the batch
pil_images = [tensor_to_pil(img) for img in image]
output_images = []
for pil_img in pil_images:
original_width, original_height = pil_img.size
# Parse label size
if side in ["top", "bottom"]:
label_dim = parse_size(label_size, original_height)
separation_px = parse_size(separation, original_height)
else: # left, right
label_dim = parse_size(label_size, original_width)
separation_px = parse_size(separation, original_width)
# Calculate new canvas dimensions
new_width = original_width + 2 * separation_px
new_height = original_height + 2 * separation_px
if side in ["top", "bottom"]:
new_height += label_dim
else: # left, right
new_width += label_dim
# Create new image canvas
bg_color_rgb = color_to_rgb_uint8(logger, background_color)
canvas = Image.new("RGB", (new_width, new_height), bg_color_rgb)
# Paste the original image with separation
img_paste_position = (separation_px, separation_px)
if side == "top":
img_paste_position = (separation_px, separation_px + label_dim)
elif side == "left":
img_paste_position = (separation_px + label_dim, separation_px)
canvas.paste(pil_img, img_paste_position)
draw = ImageDraw.Draw(canvas)
# Define the text area
text_area = (0, 0, 0, 0)
if side == "top":
text_area = (separation_px, separation_px, new_width - separation_px, separation_px + label_dim)
elif side == "bottom":
text_area = (separation_px, original_height + separation_px, new_width - separation_px, new_height -
separation_px)
elif side == "left":
text_area = (separation_px, separation_px, separation_px + label_dim, new_height - separation_px)
elif side == "right":
text_area = (original_width + separation_px, separation_px, new_width - separation_px, new_height -
separation_px)
text_area_width = text_area[2] - text_area[0]
text_area_height = text_area[3] - text_area[1]
fg_color_rgb = color_to_rgb_uint8(logger, foreground_color)
text_x = text_area[0] + text_area_width / 2
text_y = text_area[1] + text_area_height / 2
font = None
# --- Find best font size and render text ---
if side in ["top", "bottom"]:
# --- HORIZONTAL TEXT LOGIC (with wrapping) ---
max_font_size = text_area_height
font_size = max_font_size
wrapped_text = text
while font_size > 5:
font = load_font(font_name, font_size)
# Simple word wrapping
lines = []
words = text.split()
current_line = ""
for word in words:
if font.getlength(current_line + word + " ") < text_area_width:
current_line += word + " "
else:
lines.append(current_line.strip())
current_line = word + " "
lines.append(current_line.strip())
wrapped_text = "\n".join(lines)
# Check if wrapped text fits vertically
bbox = draw.multiline_textbbox((0, 0), wrapped_text, font=font)
text_height = bbox[3] - bbox[1]
if text_height <= text_area_height:
break # Font size is good
font_size -= 1
else:
logger.warning("Text could not fit into the designated area even at the smallest font size.")
draw.multiline_text(
(text_x, text_y),
wrapped_text,
fill=fg_color_rgb,
font=font,
anchor="mm", # middle-middle anchor
align="center"
)
else: # --- VERTICAL TEXT LOGIC ---
max_font_size = text_area_width # For vertical, font size is constrained by width
font_size = max_font_size
while font_size > 5:
font = load_font(font_name, font_size)
# Use textbbox with direction to measure final size
bbox = draw.textbbox((0, 0), text, font=font, direction="ttb")
text_width = bbox[2] - bbox[0]
text_height = bbox[3] - bbox[1]
# Check if the vertically rendered text fits in the area
if text_width <= text_area_width and text_height <= text_area_height:
break # Font size is good
font_size -= 1
else:
logger.warning("Text could not fit into the designated area even at the smallest font size.")
draw.text(
(text_x, text_y),
text,
fill=fg_color_rgb,
font=font,
anchor="mm", # middle-middle anchor
direction="ttb" # Top-to-bottom direction
)
output_images.append(pil_to_tensor(canvas).unsqueeze(0))
# Stack the processed images back into a single tensor
return (torch.cat(output_images, dim=0),)