483 lines
17 KiB
Python
483 lines
17 KiB
Python
import os
|
|
import logging
|
|
import types
|
|
from typing import TypedDict, Callable
|
|
import torch
|
|
import numpy as np
|
|
import folder_paths
|
|
import comfy
|
|
import comfy.model_management as model_management
|
|
from comfy.sd import VAE
|
|
from comfy.utils import load_torch_file
|
|
from comfy.diffusers_convert import convert_unet_state_dict
|
|
from comfy.ldm.models.autoencoder import AutoencoderKL
|
|
from comfy.model_base import BaseModel
|
|
from comfy.model_patcher import ModelPatcher
|
|
from nodes import MAX_RESOLUTION
|
|
from .utils.image import generate_gradient_image, LightPosition, resize_and_center_crop
|
|
from .utils.patches import calculate_weight_adjust_channel
|
|
|
|
# logger
|
|
logging.basicConfig(level=logging.INFO)
|
|
LOGGER = logging.getLogger(__name__)
|
|
|
|
# contents
|
|
MODEL_TYPE_ICLIGHT = "iclight"
|
|
|
|
# set the models directory
|
|
if MODEL_TYPE_ICLIGHT not in folder_paths.folder_names_and_paths:
|
|
current_paths = [os.path.join(folder_paths.models_dir, MODEL_TYPE_ICLIGHT)]
|
|
else:
|
|
current_paths, _ = folder_paths.folder_names_and_paths[MODEL_TYPE_ICLIGHT]
|
|
folder_paths.folder_names_and_paths[MODEL_TYPE_ICLIGHT] = (current_paths, folder_paths.supported_pt_extensions)
|
|
|
|
|
|
class UnetParams(TypedDict):
|
|
input: torch.Tensor
|
|
timestep: torch.Tensor
|
|
c: dict
|
|
cond_or_uncond: torch.Tensor
|
|
|
|
|
|
class ICLight:
|
|
def extra_conds(self, **kwargs):
|
|
out = {}
|
|
|
|
image = kwargs.get("concat_latent_image", None)
|
|
noise = kwargs.get("noise", None)
|
|
device = kwargs["device"]
|
|
|
|
if image is None:
|
|
image = torch.zeros_like(noise)
|
|
|
|
if image.shape[1:] != noise.shape[1:]:
|
|
image = comfy.utils.common_upscale(image.to(device), noise.shape[-1], noise.shape[-2], "bilinear", "center")
|
|
|
|
image = comfy.utils.resize_to_batch_size(image, noise.shape[0])
|
|
|
|
process_image_in = lambda image: image
|
|
out['c_concat'] = comfy.conds.CONDNoiseShape(process_image_in(image))
|
|
|
|
adm = self.encode_adm(**kwargs)
|
|
if adm is not None:
|
|
out['y'] = comfy.conds.CONDRegular(adm)
|
|
return out
|
|
|
|
|
|
class ICLightModelLoader:
|
|
"""
|
|
Class for loading and managing the weights of ICLight models.
|
|
"""
|
|
|
|
def __init__(self):
|
|
"""
|
|
Initializes the ICLight model loader with default state.
|
|
"""
|
|
self.iclight = {"model": None, "sd_dict": None, "fbc": False}
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
"""
|
|
Class method defining the required input types for model loading.
|
|
|
|
Returns:
|
|
dict: A dictionary specifying the required input types for the model load operation.
|
|
"""
|
|
return {"required": {"iclight_name": (folder_paths.get_filename_list(MODEL_TYPE_ICLIGHT),)}}
|
|
|
|
RETURN_TYPES = ("ICLIGHT",)
|
|
RETURN_NAMES = ("iclight",)
|
|
FUNCTION = "load_iclight_model"
|
|
CATEGORY = "gaffer"
|
|
|
|
def load_iclight_model(self, iclight_name):
|
|
"""
|
|
Loads the weights of an ICLight model.
|
|
|
|
Parameters:
|
|
iclight_name (str): The name of the ICLight model to load.
|
|
|
|
Returns:
|
|
tuple: A tuple containing the loaded ICLight model information.
|
|
|
|
Raises:
|
|
Exception: If the specified ICLight model file is not found.
|
|
"""
|
|
LOGGER.info("Loading ICLight Model weights")
|
|
|
|
# Safely retrieves the full path of the model and checks for its existence
|
|
ckpt_path = folder_paths.get_full_path(MODEL_TYPE_ICLIGHT, iclight_name)
|
|
if not os.path.exists(ckpt_path):
|
|
error_message = f"Invalid ICLight model: {iclight_name}. File not found."
|
|
LOGGER.error(error_message)
|
|
raise Exception(error_message)
|
|
|
|
# Loads the model securely
|
|
model = load_torch_file(ckpt_path, safe_load=True)
|
|
|
|
# converts it to a hardware-optimized precision
|
|
sd_dict = convert_unet_state_dict(model)
|
|
|
|
# Optimizes performance by converting the model's precision on demand
|
|
sd_dict = {key: sd_dict[key].half() for key in sd_dict.keys()}
|
|
|
|
# Updates the internal state to reflect the loaded model details
|
|
self.iclight['model'] = model
|
|
self.iclight['sd_dict'] = sd_dict
|
|
self.iclight['fbc'] = 'fbc' in iclight_name.lower()
|
|
|
|
LOGGER.info(f"ICLight model loaded from {iclight_name}")
|
|
return (self.iclight,)
|
|
|
|
|
|
class ICLightVAEEncoder:
|
|
def __init__(self, vae: VAE):
|
|
assert isinstance(vae.first_stage_model, AutoencoderKL), "vae only supported for AutoencoderKL"
|
|
self.vae = vae
|
|
|
|
def encode(self, pixels):
|
|
original_sample_mode = self.vae.first_stage_model.regularization.sample
|
|
self.vae.first_stage_model.regularization.sample = False
|
|
out_samples = self.vae.encode(pixels)
|
|
self.vae.first_stage_model.regularization.sample = original_sample_mode
|
|
return out_samples
|
|
|
|
|
|
class ApplyICLight:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"model": ("MODEL",),
|
|
"vae": ("VAE",),
|
|
"iclight": ("ICLIGHT",),
|
|
"positive": ("CONDITIONING",),
|
|
"negative": ("CONDITIONING",),
|
|
"fg_pixels": ("IMAGE",),
|
|
"multiplier": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 10.0, "step": 0.001}),
|
|
},
|
|
"optional": {
|
|
"bg_pixels": ("IMAGE",),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("MODEL", "CONDITIONING", "CONDITIONING", "LATENT")
|
|
RETURN_NAMES = ("model", "positive", "negative", "empty_latent")
|
|
FUNCTION = "apply"
|
|
CATEGORY = "gaffer"
|
|
DESCRIPTION = """"""
|
|
|
|
def apply(self, model, vae: VAE, iclight, positive, negative, fg_pixels, multiplier, bg_pixels=None):
|
|
device = model_management.get_torch_device()
|
|
dtype = model_management.unet_dtype()
|
|
if dtype not in [torch.float32, torch.float16, torch.bfloat16]:
|
|
dtype = torch.float16 if model_management.should_use_fp16() else torch.float32
|
|
work_model = model.clone()
|
|
|
|
vae_encode = ICLightVAEEncoder(vae)
|
|
|
|
fg_samples = vae_encode.encode(fg_pixels)
|
|
|
|
if iclight.get("fbc", False):
|
|
if bg_pixels is None:
|
|
raise ValueError("When using background-conditioned Model, 'bg_pixel' is required")
|
|
print(fg_pixels.shape)
|
|
bg_pixels = resize_and_center_crop(bg_pixels, fg_pixels.shape[2], fg_pixels.shape[1])
|
|
bg_samples = vae_encode.encode(bg_pixels)
|
|
concat_samples = torch.cat((fg_samples, bg_samples), dim=1)
|
|
else:
|
|
concat_samples = fg_samples
|
|
|
|
base_model: BaseModel = work_model.model
|
|
scale_factor = base_model.model_config.latent_format.scale_factor
|
|
concat_conds = concat_samples * scale_factor * multiplier
|
|
concat_conds = torch.cat([c[None, ...] for c in concat_conds], dim=1)
|
|
|
|
out_latent = torch.zeros_like(fg_samples)
|
|
|
|
self._patch_accept_multi_channel_inputs()
|
|
self._work_model_add_patches_iclight(work_model, iclight, device, dtype)
|
|
|
|
self._work_model_set_model_unet_function_wrapper(work_model, concat_conds)
|
|
|
|
# positive, negative = self._create_conditionings(positive, negative, concat_conds)
|
|
# self._work_model_bound_extra_conds(work_model)
|
|
|
|
return (work_model, positive, negative, {"samples": out_latent})
|
|
|
|
@staticmethod
|
|
def _patch_accept_multi_channel_inputs():
|
|
"""Patch ComfyUI's LoRA weight application to accept multi-channel inputs."""
|
|
try:
|
|
ModelPatcher.calculate_weight = calculate_weight_adjust_channel(ModelPatcher.calculate_weight)
|
|
except:
|
|
raise Exception("Could not patch calculate_weight")
|
|
|
|
@staticmethod
|
|
def _work_model_add_patches_iclight(work_model, iclight, device, dtype):
|
|
ic_model_state_dict = iclight.get("sd_dict", {})
|
|
work_model.add_patches(
|
|
patches={
|
|
("diffusion_model." + key): (value.to(dtype=dtype, device=device),)
|
|
for key, value in ic_model_state_dict.items()
|
|
}
|
|
)
|
|
|
|
@staticmethod
|
|
def _work_model_set_model_unet_function_wrapper(work_model, concat_conds):
|
|
def apply_c_concat(params: UnetParams) -> UnetParams:
|
|
"""Apply c_concat on unet call."""
|
|
sample = params["input"]
|
|
params["c"]["c_concat"] = torch.cat(
|
|
([concat_conds.to(sample.device)] * (sample.shape[0] // concat_conds.shape[0])),
|
|
dim=0,
|
|
)
|
|
return params
|
|
|
|
def unet_dummy_apply(unet_apply: Callable, params: UnetParams):
|
|
"""A dummy unet apply wrapper serving as the endpoint of wrapper chain."""
|
|
return unet_apply(x=params["input"], t=params["timestep"], **params["c"])
|
|
|
|
# Compose on existing `model_function_wrapper`.
|
|
existing_wrapper = work_model.model_options.get("model_function_wrapper", unet_dummy_apply)
|
|
|
|
def wrapper_func(unet_apply: Callable, params: UnetParams):
|
|
return existing_wrapper(unet_apply, params=apply_c_concat(params))
|
|
|
|
work_model.set_model_unet_function_wrapper(wrapper_func)
|
|
|
|
@staticmethod
|
|
def _create_conditionings(positive, negative, concat_conds: torch.Tensor):
|
|
out_conds = []
|
|
for conditioning in [positive, negative]:
|
|
c = []
|
|
for t in conditioning:
|
|
d = t[1].copy()
|
|
d["concat_latent_image"] = concat_conds
|
|
n = [t[0], d]
|
|
c.append(n)
|
|
out_conds.append(c)
|
|
return out_conds
|
|
|
|
@staticmethod
|
|
def _work_model_bound_extra_conds(work_model):
|
|
# Mimic the existing IP2P class to enable extra_conds
|
|
def bound_extra_conds(self, **kwargs):
|
|
return ICLight.extra_conds(self, **kwargs)
|
|
|
|
new_extra_conds = types.MethodType(bound_extra_conds, work_model.model)
|
|
work_model.add_object_patch("extra_conds", new_extra_conds)
|
|
|
|
|
|
class LightSource:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"light_position": ([member.value for member in LightPosition],),
|
|
"multiplier": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 100.0, "step": 0.001}),
|
|
"start_color": ("STRING", {"default": "#FFFFFF"}),
|
|
"end_color": ("STRING", {"default": "#000000"}),
|
|
"width": ("INT", {"default": 512, "min": 0, "max": MAX_RESOLUTION, "step": 8, }),
|
|
"height": ("INT", {"default": 512, "min": 0, "max": MAX_RESOLUTION, "step": 8, })
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("IMAGE",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "gaffer"
|
|
DESCRIPTION = """
|
|
Generates a gradient image that can be used
|
|
as a simple light source. The color can be
|
|
specified in RGB or hex format.
|
|
"""
|
|
|
|
def execute(self, light_position, multiplier, start_color, end_color, width, height):
|
|
def toRgb(color):
|
|
if color.startswith('#') and len(color) == 7: # e.g. "#RRGGBB"
|
|
color_rgb = tuple(int(color[i:i + 2], 16) for i in (1, 3, 5))
|
|
else: # e.g. "255,255,255"
|
|
color_rgb = tuple(int(i) for i in color.split(','))
|
|
return color_rgb
|
|
|
|
lightPosition = LightPosition(light_position)
|
|
start_color_rgb = toRgb(start_color)
|
|
end_color_rgb = toRgb(end_color)
|
|
image = generate_gradient_image(width, height, start_color_rgb, end_color_rgb, multiplier, lightPosition)
|
|
|
|
image = image.astype(np.float32) / 255.0
|
|
image = torch.from_numpy(image)[None,]
|
|
return (image,)
|
|
|
|
|
|
class CalculateNormalMap:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"images": ("IMAGE",),
|
|
"sigma": ("FLOAT", {"default": 10.0, "min": 0.01, "max": 100.0, "step": 0.01, }),
|
|
"center_input_range": ("BOOLEAN", {"default": False, }),
|
|
},
|
|
"optional": {
|
|
"mask": ("MASK",),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("normal",)
|
|
FUNCTION = "execute"
|
|
CATEGORY = "gaffer"
|
|
DESCRIPTION = """
|
|
Calculates normal map from different directional exposures.
|
|
Takes in 4 images as a batch:
|
|
left, right, bottom, top
|
|
"""
|
|
|
|
def execute(self, images, sigma, center_input_range, mask=None):
|
|
"""
|
|
Executes an image processing algorithm to calculate and return a normal map.
|
|
|
|
Parameters:
|
|
images - A sequence of input images.
|
|
sigma - The standard deviation for Gaussian blur, controlling the smoothness of the normal calculation.
|
|
center_input_range - The range used to center the input images.
|
|
mask - Optional, a mask image to specify the computation region.
|
|
|
|
Returns:
|
|
normal - The calculated normal map.
|
|
"""
|
|
|
|
# Input validation
|
|
self.validate_inputs(images, sigma, center_input_range, mask)
|
|
|
|
# Centering images to adjust the brightness levels to have a mean closer to zero
|
|
images = self.center_images(images, center_input_range)
|
|
|
|
# Compute the normal based on image intensities and Gaussian blur
|
|
normal = self.calculate_normal(images, sigma, mask)
|
|
|
|
# Normalize and clamp the normal values to ensure they stay within valid ranges
|
|
normal = self.normalize_and_clamp(normal)
|
|
|
|
return (normal,)
|
|
|
|
def validate_inputs(self, images, sigma, center_input_range, mask):
|
|
if not isinstance(images, torch.Tensor) or images.ndim != 4:
|
|
raise ValueError("images must be a 4-dimensional torch.Tensor")
|
|
if not (0.01 <= sigma <= 100.0):
|
|
raise ValueError("sigma must be between 0.01 and 100.0")
|
|
if not isinstance(center_input_range, bool):
|
|
raise ValueError("center_input_range must be a boolean")
|
|
if mask is not None and (not isinstance(mask, torch.Tensor) or mask.ndim != 3):
|
|
raise ValueError("mask must be a 3-dimensional torch.Tensor")
|
|
|
|
def center_images(self, images, center_input_range):
|
|
if center_input_range:
|
|
images = images * 0.5 + 0.5
|
|
return images
|
|
|
|
def calculate_normal(self, images, sigma, mask):
|
|
images_np = images.numpy().astype(np.float32)
|
|
left = images_np[0]
|
|
right = images_np[1]
|
|
bottom = images_np[2]
|
|
top = images_np[3]
|
|
ambient = (left + right + bottom + top) / 4.0
|
|
height, width, _ = ambient.shape
|
|
|
|
def safe_divide(a, b):
|
|
epsilon = 1e-5
|
|
return ((a + epsilon) / (b + epsilon)) - 1.0
|
|
|
|
left = safe_divide(left, ambient)
|
|
right = safe_divide(right, ambient)
|
|
bottom = safe_divide(bottom, ambient)
|
|
top = safe_divide(top, ambient)
|
|
|
|
u = (right - left) * 0.5
|
|
v = (top - bottom) * 0.5
|
|
|
|
u = np.mean(u, axis=2)
|
|
v = np.mean(v, axis=2)
|
|
h = (1.0 - u ** 2.0 - v ** 2.0).clip(0, 1e5) ** (0.5 * sigma)
|
|
z = np.zeros_like(h)
|
|
|
|
normal = np.stack([u, v, h], axis=2)
|
|
normal /= np.sum(normal ** 2.0, axis=2, keepdims=True) ** 0.5
|
|
|
|
if mask is not None:
|
|
matting = mask.numpy().astype(np.float32)
|
|
matting = matting[..., np.newaxis]
|
|
normal = normal * matting + np.stack([z, z, 1 - z], axis=2) * (1 - matting)
|
|
normal = torch.from_numpy(normal)
|
|
else:
|
|
normal += np.stack([z, z, 1 - z], axis=2)
|
|
normal = torch.from_numpy(normal).unsqueeze(0)
|
|
|
|
return normal
|
|
|
|
def normalize_and_clamp(self, normal):
|
|
normal = (normal + 1.0) / 2.0
|
|
normal = torch.clamp(normal, 0, 1)
|
|
return normal
|
|
|
|
class GrayScaler:
|
|
"""Class to scale image regions to grey based on provided masks."""
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
"mask": ("MASK",),
|
|
}
|
|
}
|
|
|
|
CATEGORY = "gaffer"
|
|
RETURN_TYPES = ("IMAGE",)
|
|
FUNCTION = "apply"
|
|
|
|
def apply(self, image: torch.Tensor, mask: torch.Tensor):
|
|
"""
|
|
Applies grey scaling to image regions as indicated by the mask.
|
|
|
|
Args:
|
|
image: torch.Tensor: The input image tensor.
|
|
mask: torch.Tensor: A mask tensor where 1 indicates areas to be converted to grey.
|
|
|
|
Returns:
|
|
tuple: A tuple containing the image with masked regions turned grey.
|
|
"""
|
|
# Validate inputs
|
|
if not isinstance(image, torch.Tensor) or not isinstance(mask, torch.Tensor):
|
|
raise ValueError("image and mask must be torch.Tensor types.")
|
|
if image.ndim != 4 or mask.ndim not in [3, 4]:
|
|
raise ValueError("image must be a 4D tensor, and mask must be a 3D or 4D tensor.")
|
|
|
|
# Adjust mask dimensions if necessary
|
|
if mask.ndim == 3:
|
|
# [B, H, W] => [B, H, W, C=1]
|
|
mask = mask.unsqueeze(-1)
|
|
|
|
# Apply the mask to the image to turn specified regions grey.
|
|
image = image * mask + (1 - mask) * 0.5
|
|
|
|
return (image,)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"ICLightModelLoader": ICLightModelLoader,
|
|
"ApplyICLight": ApplyICLight,
|
|
"LightSource": LightSource,
|
|
"CalculateNormalMap": CalculateNormalMap,
|
|
"GrayScaler": GrayScaler
|
|
}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"ICLightModelLoader": "Load ICLight Model",
|
|
"ApplyICLight": "Apply ICLight",
|
|
"LightSource": "Simple Light Source",
|
|
"CalculateNormalMap": "Calculate Normal Map",
|
|
"GrayScaler": "Gray Scaler",
|
|
}
|