Files
2025-08-22 13:28:14 -07:00

611 lines
22 KiB
Python

# ==============================================================================
# This file contains code that has been adapted or directly copied from the
# ComfyUI-Impact-Pack package by Dr.Lt.Data ("ltdrdata").
# Original source: https://github.com/ltdrdata/ComfyUI-Impact-Pack
#
# This code is used under the terms of the original license, with modifications
# made to suit the needs of this project.
# ==============================================================================
import torch
import torch.nn.functional as F
import time, hashlib
import base64, io
from PIL import Image
class PseudoMaskBlur:
"""
Utility class for applying a Gaussian blur to image masks.
Inputs:
msk (tensor): The input image tensor. Expected shape is [1, H, W]
blur_radius (int): The radius of the Gaussian blur kernel (default: 1, min: 1, max: 31, step: 1).
sigma (float): The standard deviation of the Gaussian kernel (default: 1.0, min: 0.1, max: 10.0, step: 0.1).
Outputs:
msk (tensor): The blurred image tensor, with the same shape as the input.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"msks_list": ("MASK",),
"blur_radius": ("INT", {
"default": 25,
"min": 1,
"max": 51,
"step": 1
}),
"sigma": ("FLOAT", {
"default": 1.0,
"min": 0.1,
"max": 20.0,
"step": 0.1
}),
"invert": ("BOOLEAN", {"default": False}),
},
}
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True,)
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("msks",)
FUNCTION = "blur"
CATEGORY = "Pseudocomfy/Utils"
def blur(self, msks_list, blur_radius: int, sigma: float, invert):
"""
Apply Gaussian blur to a list of masks.
Expects each mask to be (1, H, W) or (B, H, W)
"""
# given that INPUT_IS_LIST, msks_list is a list of masks
# if inputs that are meant to be singletons are a list, use the first element
if isinstance(blur_radius, list) and len(blur_radius)>0: blur_radius = blur_radius[0]
if isinstance(sigma, list) and len(sigma)>0: sigma = sigma[0]
if isinstance(invert, list) and len(invert)>0: invert = invert[0]
print(f"[pseudocomfy] BlurMask blur_radius:{blur_radius} sigma:{sigma}")
results = []
for msk in msks_list:
print(f"\tmsk shape:{tuple(msk.shape)}")
if blur_radius == 0:
results.append(msk)
continue
device = msk.device
# Ensure batch dimension
if msk.ndim == 2:
msk = msk.unsqueeze(0) # (1, H, W)
B, H, W = msk.shape
# Add channel dimension for conv2d
msk = msk.unsqueeze(1) # (B, 1, H, W)
kernel_size = blur_radius * 2 + 1
kernel = self.gaussian_kernel(kernel_size, sigma, device=device)
kernel = kernel.expand(1, 1, kernel_size, kernel_size)
pad = blur_radius
padded_image = F.pad(msk, (pad, pad, pad, pad), mode='reflect')
blurred = F.conv2d(padded_image, kernel, padding=0, groups=1)
blurred = blurred[:, :, pad:-pad, pad:-pad] # Remove extra padding
# Remove channel dimension
result = blurred.squeeze(1) # (B, H, W)
if invert:
result = 1.0 - result
result = torch.clamp(result, 0.0, 1.0)
results.append(result)
return (results,)
def gaussian_kernel(self, kernel_size, sigma, device):
"""Create a 2D Gaussian kernel."""
coords = torch.arange(kernel_size, dtype=torch.float32, device=device) - (kernel_size - 1) / 2
grid = coords.unsqueeze(0) ** 2 + coords.unsqueeze(1) ** 2
kernel = torch.exp(-0.5 * grid / sigma ** 2)
kernel = kernel / kernel.sum()
return kernel
class PseudoMaskClamp:
"""
Utility class for clamping mask values to a specified range.
Inputs:
msk (tensor): The input mask tensor. Expected shape is [1, H, W] or [B, H, W], values in [0, 1].
min_val (float): Minimum value to clamp to (default: 0.0, min: 0.0, max: 1.0, step: 0.01).
max_val (float): Maximum value to clamp to (default: 1.0, min: 0.0, max: 1.0, step: 0.01).
Outputs:
msk (tensor): The clamped mask tensor, same shape as input.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"msks_list": ("MASK",),
"min_val": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
}),
"max_val": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
}),
},
"optional": {
}
}
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True,)
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("msks",)
FUNCTION = "clamp"
CATEGORY = "Pseudocomfy/Utils"
def clamp(self, msks_list, min_val: float, max_val: float):
"""
Clamp mask values to [min_val, max_val] for a list of masks.
"""
# given that INPUT_IS_LIST, msks_list is a list of masks
# if inputs that are meant to be singletons are a list, use the first element
if isinstance(min_val, list) and len(min_val)>0: min_val = min_val[0]
if isinstance(max_val, list) and len(max_val)>0: max_val = max_val[0]
print(f"[pseudocomfy] ClampMask")
results = []
for msk in msks_list:
print(f"\tmsk shape:{tuple(msk.shape)}")
print(f"\tclamping mask of ({msk.min():.3f} -> {msk.max():.3f}) to ({min_val:.3f} -> {max_val:.3f})")
clamped = torch.clamp(msk, min=min_val, max=max_val)
results.append(clamped)
return (results,)
class PseudoMaskRemap:
"""
Utility class for remapping mask values from a source range to a target range.
Inputs:
msk (tensor): The input mask tensor. Expected shape is [1, H, W] or [B, H, W], values in [0, 1].
src_min (float, optional): Source minimum value to remap from. If not set, uses mask min.
src_max (float, optional): Source maximum value to remap from. If not set, uses mask max.
tgt_min (float, optional): Target minimum value to remap to. If not set, uses 0.0.
tgt_max (float, optional): Target maximum value to remap to. If not set, uses 1.0.
Outputs:
msk (tensor): The remapped mask tensor, same shape as input.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"msks_list": ("MASK",),
},
"optional": {
"src_min": ("FLOAT", {"forceInput": True}), # force input b/c can't have widget for None
"src_max": ("FLOAT", {"forceInput": True}), # force input b/c can't have widget for None
"tgt_min": ("FLOAT", {
"default": 0.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
}),
"tgt_max": ("FLOAT", {
"default": 1.0,
"min": 0.0,
"max": 1.0,
"step": 0.01
}),
}
}
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True,)
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("msks",)
FUNCTION = "remap"
CATEGORY = "Pseudocomfy/Utils"
def remap(self, msks_list, src_min: float = None, src_max: float = None, tgt_min: float = 0.0, tgt_max: float = 1.0):
"""
Remap mask values from [src_min, src_max] to [tgt_min, tgt_max] for a list of masks.
"""
# given that INPUT_IS_LIST, msks_list is a list of masks
# if inputs that are meant to be singletons are a list, use the first element
if isinstance(src_min, list) and len(src_min)>0: src_min = src_min[0]
if isinstance(src_max, list) and len(src_max)>0: src_max = src_max[0]
if isinstance(tgt_min, list) and len(tgt_min)>0: tgt_min = tgt_min[0]
if isinstance(tgt_max, list) and len(tgt_max)>0: tgt_max = tgt_max[0]
print(f"[pseudocomfy] RemapMask")
results = []
for msk in msks_list:
mask_min = float(msk.min())
mask_max = float(msk.max())
from_min = src_min if src_min is not None else mask_min
from_max = src_max if src_max is not None else mask_max
to_min = tgt_min
to_max = tgt_max
print(f"\tmsk shape:{tuple(msk.shape)}")
print(f"\tremapping mask of ({mask_min:.3f} -> {mask_max:.3f}) from ({from_min:.3f} -> {from_max:.3f}) to ({to_min:.3f} -> {to_max:.3f})")
# Avoid division by zero
if from_max - from_min == 0:
remapped = torch.full_like(msk, to_min)
else:
norm = (msk - from_min) / (from_max - from_min)
remapped = norm * (to_max - to_min) + to_min
remapped = torch.clamp(remapped, min(min(to_min, to_max), 0.0), max(max(to_min, to_max), 1.0))
results.append(remapped)
return (results,)
class PseudoMaskInvert:
"""
Utility class for inverting mask values (1 - mask).
Inputs:
msk (tensor): The input mask tensor. Expected shape is [1, H, W] or [B, H, W], values in [0, 1].
Outputs:
msk (tensor): The inverted mask tensor, same shape as input.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"msks_list": ("MASK",),
}
}
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True,)
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("msks",)
FUNCTION = "invert"
CATEGORY = "Pseudocomfy/Utils"
def invert(self, msks_list):
"""
Invert mask values (1 - mask) for a list of masks.
"""
# given that INPUT_IS_LIST, msks_list is a list of masks
print(f"[pseudocomfy] MaskInvert")
results = []
for msk in msks_list:
mask_min = float(msk.min())
mask_max = float(msk.max())
print(f"\tmsk shape:{tuple(msk.shape)}")
print(f"\tinverting mask of ({mask_min:.3f} -> {mask_max:.3f}) to ({1-mask_max:.3f} -> {1-mask_min:.3f})")
inverted = 1.0 - msk
inverted = torch.clamp(inverted, 0.0, 1.0)
results.append(inverted)
return (results,)
class PseudoMaskReshape:
"""
Utility class for morphological operations on masks: Erode (shrink) and Dilate (expand) white regions.
Inputs:
msk (tensor): The input mask tensor. Expected shape is [1, H, W] or [B, H, W], values in [0, 1].
operation (str): "erode" or "dilate".
kernel_size (int): Size of the square structuring element (default: 3, min: 1, max: 31, step: 2).
iterations (int): Number of times to apply the operation (default: 1, min: 1, max: 10, step: 1).
Outputs:
msk (tensor): The processed mask tensor, same shape as input.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"msks_list": ("MASK",),
"operation": (["erode (shrink white areas)", "dilate (grow white areas)"], {"default": "dilate (grow white areas)"}),
"kernel_size": ("INT", {
"default": 3,
"min": 1,
"max": 31,
"step": 2
}),
"iterations": ("INT", {
"default": 1,
"min": 1,
"max": 10,
"step": 1
}),
"invert": ("BOOLEAN", {"default": False}),
}
}
INPUT_IS_LIST = True
OUTPUT_IS_LIST = (True,)
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("msks",)
FUNCTION = "morph"
CATEGORY = "Pseudocomfy/Utils"
def morph(self, msks_list, operation: str, kernel_size: int, iterations: int, invert):
"""
Apply morphological operation to a list of masks.
"""
# given that INPUT_IS_LIST, msks_list is a list of masks
# if inputs that are meant to be singletons are a list, use the first element
if isinstance(operation, list) and len(operation)>0: operation = operation[0]
if isinstance(kernel_size, list) and len(kernel_size)>0: kernel_size = kernel_size[0]
if isinstance(iterations, list) and len(iterations)>0: iterations = iterations[0]
if isinstance(invert, list) and len(invert)>0: invert = invert[0]
print(f"[pseudocomfy] MaskMorphology")
print(f"\toperation: {operation}, kernel_size: {kernel_size}, iterations: {iterations}, invert: {invert}")
results = []
for msk in msks_list:
print(f"\tmsk shape:{tuple(msk.shape)}")
# Ensure batch dimension
if msk.ndim == 2:
msk = msk.unsqueeze(0) # (1, H, W)
B, H, W = msk.shape
# Add channel dimension for conv2d
msk = msk.unsqueeze(1) # (B, 1, H, W)
# Create structuring element (kernel)
kernel = torch.ones((1, 1, kernel_size, kernel_size), dtype=msk.dtype, device=msk.device)
result = msk
for _ in range(iterations):
if operation[:3] == "ero":
result = torch.nn.functional.max_pool2d(
1.0 - result, kernel_size=kernel_size, stride=1, padding=kernel_size // 2
)
result = 1.0 - result
elif operation[:3] == "dil":
result = torch.nn.functional.max_pool2d(
result, kernel_size=kernel_size, stride=1, padding=kernel_size // 2
)
else:
raise ValueError(f"Unknown operation: {operation}")
# Remove channel dimension
result = result.squeeze(1) # (B, H, W)
if invert:
result = 1.0 - result
result = torch.clamp(result, 0.0, 1.0)
print(f"\tresult range: ({result.min():.3f} -> {result.max():.3f})")
results.append(result)
return (results,)
class PseudoMaskAggregate:
"""
Utility class for combining a list of masks into a single mask using various arithmetic operations.
Inputs:
msks_list (list[tensor]): List of mask tensors, each [1, H, W] or [B, H, W], values in [0, 1].
operation (str): Operation to perform. One of:
- "sum_clamped": Sum all masks, clamp to [0, 1].
- "sum_normalized": Sum all masks, then normalize result to [0, 1].
- "average": Pixelwise mean of all masks.
- "max": Pixelwise max of all masks.
- "min": Pixelwise min of all masks.
invert (bool): If True, invert the result mask (1 - mask).
Outputs:
msk (tensor): The combined mask tensor, same shape as input.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"msks_list": ("MASK", {"forceInput": True, "isList": True}),
"operation": (["sum_clamped", "sum_normalized", "average", "max", "min"], {}),
"invert": ("BOOLEAN", {"default": False}),
}
}
INPUT_IS_LIST = True
RETURN_TYPES = ("MASK",)
RETURN_NAMES = ("msk",)
FUNCTION = "combine"
CATEGORY = "Pseudocomfy/Utils"
def combine(self, msks_list, operation, invert):
# given that INPUT_IS_LIST, msks_list is a list of masks
# if inputs that are meant to be singletons are a list, use the first element
if isinstance(operation, list) and len(operation)>0: operation = operation[0]
if isinstance(invert, list) and len(invert)>0: invert = invert[0]
# Stack masks to shape (N, H, W) or (N, 1, H, W)
msks = [m.float() for m in msks_list]
stack = torch.stack(msks, dim=0)
print(f"[pseudocomfy] CombineMasks")
print(f"\toperation: {operation}")
print(f"\tinput count: {len(msks)}")
print(f"\tstack shape: {tuple(stack.shape)}")
if operation == "sum_clamped":
result = stack.sum(dim=0)
result = torch.clamp(result, 0.0, 1.0)
elif operation == "sum_normalized":
result = stack.sum(dim=0)
minv, maxv = result.min(), result.max()
if maxv - minv == 0:
result = torch.zeros_like(result)
else:
result = (result - minv) / (maxv - minv)
elif operation == "average":
result = stack.mean(dim=0)
elif operation == "max":
result = stack.max(dim=0).values
elif operation == "min":
result = stack.min(dim=0).values
else:
raise ValueError(f"Unknown operation: {operation}")
if invert:
result = 1.0 - result
result = torch.clamp(result, 0.0, 1.0)
print(f"\tresult shape: {tuple(result.shape)}")
print(f"\tresult range: ({result.min():.3f} -> {result.max():.3f})")
return (result,)
class PseudoPreviewStrings:
"""
Utility class for previewing a single string in the ComfyUI UI.
Inputs:
string (str): The string(s) to preview.
Outputs:
none
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"strings": ("STRING", {"forceInput": True}),
},
}
INPUT_IS_LIST = True
RETURN_TYPES = ()
RETURN_NAMES = ()
FUNCTION = "notify"
OUTPUT_NODE = True
CATEGORY = "Pseudocomfy/Utils"
def notify(self, strings):
# Optionally handle extra_pnginfo/unique_id if needed
return {
"ui": {"strings": strings}
}
class PseudoConcatStrings:
"""
Utility class for concatenating two strings with a selectable separator.
Inputs:
str_a (str): The first string.
str_b (str): The second string.
separator (str): The separator to use ("space", "comma", or "semicolon").
Outputs:
result (str): The concatenated string.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"str_a": ("STRING", {"forceInput": True}),
"str_b": ("STRING", {"forceInput": True}),
"separator": (["space", "comma", "semicolon"],{}),
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("str",)
FUNCTION = "concat"
CATEGORY = "Pseudocomfy/Utils"
def concat(self, str_a, str_b, separator):
sep_map = {
"space": " ",
"comma": ", ",
"semicolon": "; "
}
sep = sep_map.get(separator, " ")
result = f"{str_a}{sep}{str_b}"
return (result,)
class PseudoRemapNormalizedFloat:
"""
Utility class for remapping a float value from a source range to a target range.
Inputs:
value (float): The input float value.
src_min (float): Source minimum value (default: 0.0).
src_max (float): Source maximum value (default: 1.0).
tgt_min (float): Target minimum value (default: 0.0).
tgt_max (float): Target maximum value (default: 1.0).
Outputs:
value (float): The remapped float value.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"val": ("FLOAT", {"forceInput": True}),
"tgt_min": ("FLOAT", {"default": 0.0}),
"tgt_max": ("FLOAT", {"default": 1.0}),
},
}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("val",)
FUNCTION = "remap"
CATEGORY = "Pseudocomfy/Utils"
def remap(self, val, tgt_min, tgt_max):
print(f"[pseudocomfy] RemapFloat")
print(f"\tvalue: {val}")
print(f"\t0-1 ==> {tgt_min}-{tgt_max}")
src_min, src_max = 0.0, 1.0 # Default source range
# Ensure min <= max
if tgt_min > tgt_max:
tgt_min, tgt_max = tgt_max, tgt_min
# Clamp value to source domain
val = max(min(val, src_max), src_min)
if src_max - src_min == 0:
remapped = tgt_min
else:
norm = (val - src_min) / (src_max - src_min)
remapped = norm * (tgt_max - tgt_min) + tgt_min
return (remapped,)
class PseudoFloatToInt:
"""
Utility class for converting a float value to an integer.
Inputs:
val (float): The input float value.
rounding (str): Rounding method: "round" (default), "floor", or "ceil".
Outputs:
value (int): The converted integer value.
"""
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"val": ("FLOAT", {"forceInput": True}),
"rounding": (["round", "floor", "ceil"], {"default": "round"}),
},
}
RETURN_TYPES = ("INT",)
RETURN_NAMES = ("int",)
FUNCTION = "convert"
CATEGORY = "Pseudocomfy/Utils"
def convert(self, val, rounding="round"):
import math
if rounding == "floor":
result = int(math.floor(val))
elif rounding == "ceil":
result = int(math.ceil(val))
else:
result = int(round(val))
print(f"[pseudocomfy] FloatToInt: {val} -> {result} (method: {rounding})")
return (result,)