506 lines
18 KiB
Python
506 lines
18 KiB
Python
# (c) TenserTensor <tenser.tensor@proton.me> || Apache-2.0 (apache.org/licenses/LICENSE-2.0)
|
|
|
|
import fnmatch
|
|
import math
|
|
import os
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import override, Any
|
|
|
|
import numpy
|
|
import torch
|
|
from PIL import Image, ImageOps, ImageSequence
|
|
from spandrel import ModelLoader, ImageModelDescriptor
|
|
|
|
import folder_paths
|
|
from comfy import model_management, utils
|
|
from comfy_api.latest import ComfyExtension, io, ui
|
|
from node_helpers import conditioning_set_values, pillow
|
|
from .nodes_text_encoder import SingleCondCFGGuider
|
|
from .nodes_vae import vae_encode
|
|
|
|
CATEGORY = "TenserTensor/Image"
|
|
RESIZE_METHODS = ["nearest-exact", "bilinear", "area", "bicubic", "bislerp"]
|
|
FILENAME_FORMATS = ["name-###", "date-name-###", "name-datetime"]
|
|
FORMAT_EXT = {"PNG": ".png", "JPEG": ".jpg", "WEBP": ".webp", }
|
|
MEGAPIXELS = ["0.25 MP", "0.5 MP", "1 MP", "2 MP", "4 MP", "8 MP"]
|
|
|
|
|
|
def align_to_step(size, scale_factor, dimension_step):
|
|
return round(size * scale_factor / dimension_step) * dimension_step
|
|
|
|
|
|
# crop = "center" / "disabled"
|
|
def resize_image(timage, to_width, to_height, upscale_method, dimension_step=1, crop="disabled") -> torch.Tensor:
|
|
is_mask = timage.ndim == 3
|
|
if is_mask:
|
|
timage = timage.unsqueeze(-1)
|
|
|
|
samples = timage.movedim(-1, 1)
|
|
orig_height, orig_width = samples.shape[2:]
|
|
|
|
if crop == "disabled":
|
|
scale_factor = max(to_width / orig_width, to_height / orig_height)
|
|
final_width = align_to_step(orig_width, scale_factor, dimension_step)
|
|
final_height = align_to_step(orig_height, scale_factor, dimension_step)
|
|
else:
|
|
final_width = round(to_width / dimension_step) * dimension_step
|
|
final_height = round(to_height / dimension_step) * dimension_step
|
|
|
|
final_width, final_height = int(max(final_width, dimension_step)), int(max(final_height, dimension_step))
|
|
resized = utils.common_upscale(samples, final_width, final_height, upscale_method, crop).movedim(1, -1)
|
|
|
|
return resized.squeeze(-1) if is_mask else resized
|
|
|
|
|
|
def resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step=1, crop="disabled") -> torch.Tensor:
|
|
is_mask = timage.ndim == 3
|
|
if is_mask:
|
|
timage = timage.unsqueeze(-1)
|
|
|
|
mp_value = float(megapixels.split()[0])
|
|
total_pixels = int(mp_value * 1_000_000)
|
|
samples = timage.movedim(-1, 1)
|
|
orig_height, orig_width = samples.shape[2:]
|
|
scale_factor = math.sqrt(total_pixels / (orig_width * orig_height))
|
|
|
|
if crop == "disabled":
|
|
final_width = align_to_step(orig_width, scale_factor, dimension_step)
|
|
final_height = align_to_step(orig_height, scale_factor, dimension_step)
|
|
else:
|
|
to_width, to_height = orig_width * scale_factor, orig_height * scale_factor
|
|
final_width = round(to_width / dimension_step) * dimension_step
|
|
final_height = round(to_height / dimension_step) * dimension_step
|
|
|
|
final_width, final_height = int(max(final_width, dimension_step)), int(max(final_height, dimension_step))
|
|
resized = utils.common_upscale(samples, final_width, final_height, resize_method, crop).movedim(1, -1)
|
|
|
|
return resized.squeeze(-1) if is_mask else resized
|
|
|
|
|
|
def rotate_image(pixels, turns):
|
|
return torch.rot90(pixels, k=turns, dims=[1, 2])
|
|
|
|
|
|
def flip_image(pixels, axis):
|
|
return torch.flip(pixels, dims=[2 if axis == "x" else 1])
|
|
|
|
|
|
def get_torch_device(device=None):
|
|
return (
|
|
model_management.get_torch_device()
|
|
if device is None or device == "default"
|
|
else torch.device(device)
|
|
)
|
|
|
|
|
|
def load_image(filename, device=None) -> tuple[torch.Tensor, torch.Tensor]:
|
|
tdevice = get_torch_device(device)
|
|
path = folder_paths.get_annotated_filepath(filename)
|
|
pil_image = pillow(Image.open, path)
|
|
|
|
images, masks = [], []
|
|
width, height = None, None
|
|
|
|
for frame in ImageSequence.Iterator(pil_image):
|
|
pil_frame = pillow(ImageOps.exif_transpose, frame)
|
|
|
|
if pil_frame.mode == "I":
|
|
pil_frame = pil_frame.point(lambda i: i * (1 / 255))
|
|
|
|
if len(images) == 0:
|
|
width, height = pil_frame.size
|
|
|
|
if pil_frame.size != (width, height,):
|
|
continue
|
|
|
|
alpha = numpy.zeros((height, width,), dtype=numpy.float32)
|
|
if 'A' in pil_frame.getbands():
|
|
alpha = numpy.array(pil_frame.getchannel('A'), dtype=numpy.float32) / 255.0
|
|
elif pil_frame.mode == 'P' and 'transparency' in pil_frame.info:
|
|
alpha = numpy.array(pil_frame.convert('RGBA').getchannel('A'), dtype=numpy.float32) / 255.0
|
|
tmask = torch.from_numpy(1.0 - alpha).to(tdevice)
|
|
masks.append(tmask)
|
|
|
|
rgb_frame = pil_frame.convert("RGB")
|
|
np_image = numpy.array(rgb_frame, dtype=numpy.float32) / 255.0
|
|
timage = torch.from_numpy(np_image)[None].to(tdevice)
|
|
|
|
images.append(timage)
|
|
|
|
if pil_image.format == "MPO":
|
|
break
|
|
|
|
if len(images) == 0:
|
|
raise ValueError(f"ERROR: No frames found in {path}")
|
|
|
|
o_image = torch.cat(images, dim=0) if len(images) > 1 else images[0]
|
|
|
|
match len(masks):
|
|
case 0:
|
|
o_mask = torch.zeros((1, height, width), dtype=torch.float32)
|
|
case 1:
|
|
o_mask = masks[0].unsqueeze(0)
|
|
case _:
|
|
o_mask = torch.cat(masks, dim=0)
|
|
|
|
return o_image, o_mask,
|
|
|
|
|
|
def get_image_files():
|
|
return sorted([
|
|
f.name for f in os.scandir(folder_paths.get_input_directory())
|
|
if f.is_file() and Path(f.name).suffix.lower() in (".png", ".jpg", ".webp", ".bmp")
|
|
])
|
|
|
|
|
|
class TT_ImageLoaderResizerNode(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="TT_ImageLoaderResizerNode",
|
|
display_name="TT Image Loader / Resizer",
|
|
category=CATEGORY,
|
|
description="",
|
|
inputs=[
|
|
io.Boolean.Input("resize_image", default=False, label_on="Scale", label_off="Skip"),
|
|
io.Combo.Input("resize_method", options=RESIZE_METHODS, advanced=True),
|
|
io.Int.Input("dimension_step", default=1, min=1, max=256, advanced=True),
|
|
io.Combo.Input("megapixels", options=MEGAPIXELS, advanced=True),
|
|
io.Combo.Input("image", options=get_image_files(), upload=io.UploadType.image)
|
|
],
|
|
outputs=[
|
|
io.Image.Output("IMAGE"),
|
|
io.Mask.Output("MASK"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def fingerprint_inputs(cls, **kwargs) -> dict[str, Any]:
|
|
path = folder_paths.get_annotated_filepath(kwargs.get("image"))
|
|
resize_image = kwargs.get("resize_image")
|
|
result = {
|
|
"image": path + str(os.path.getmtime(path)),
|
|
"resize_image": resize_image,
|
|
}
|
|
|
|
if resize_image:
|
|
result["megapixels"] = kwargs.get("megapixels")
|
|
result["resize_method"] = kwargs.get("resize_method")
|
|
result["dimension_step"] = kwargs.get("dimension_step")
|
|
|
|
return result
|
|
|
|
@classmethod
|
|
def validate_inputs(cls, **kwargs) -> bool | str:
|
|
path = folder_paths.get_annotated_filepath(kwargs.get("image"))
|
|
if not os.path.isfile(path):
|
|
return f"Image file not found: {path}"
|
|
|
|
return True
|
|
|
|
@classmethod
|
|
def execute(cls, **kwargs) -> io.NodeOutput:
|
|
timage, tmask = load_image(kwargs.get("image"))
|
|
if kwargs.get("resize_image"):
|
|
resize_method, megapixels, dimension_step = (
|
|
kwargs.get("resize_method"),
|
|
kwargs.get("megapixels"),
|
|
kwargs.get("dimension_step")
|
|
)
|
|
timage = resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step)
|
|
tmask = resize_image_to_megapixels(tmask, resize_method, megapixels, dimension_step)
|
|
|
|
return io.NodeOutput(timage, tmask, ui=ui.PreviewImage(timage, cls=cls))
|
|
|
|
|
|
class InvalidSavePathError(Exception):
|
|
pass
|
|
|
|
|
|
def build_filename(save_path, filename_mask) -> str:
|
|
count = sum(1 for f in os.scandir(save_path) if fnmatch.fnmatch(f.name, filename_mask)) + 1
|
|
|
|
return filename_mask.replace("*", f"{count:05}")
|
|
|
|
|
|
def build_save_path(**kwargs) -> tuple[str, str]:
|
|
output_path, subfolder, image_format, filename_prefix = (
|
|
kwargs.get("output_path"),
|
|
kwargs.get("subfolder"),
|
|
kwargs.get("image_format"),
|
|
kwargs.get("filename_prefix"),
|
|
)
|
|
save_path = os.path.join(output_path, subfolder)
|
|
|
|
if os.path.commonpath((output_path, os.path.abspath(save_path))) != output_path:
|
|
raise InvalidSavePathError("Saving image outside the output folder is not allowed.")
|
|
|
|
os.makedirs(save_path, exist_ok=True)
|
|
|
|
ext = FORMAT_EXT[image_format]
|
|
filename = ""
|
|
match kwargs.get("filename_format"):
|
|
case "name-###":
|
|
filename = build_filename(save_path, f"{filename_prefix}-*{ext}")
|
|
case "date-name-###":
|
|
date = datetime.now().strftime("%Y-%m-%d")
|
|
filename = build_filename(save_path, f"{date}-{filename_prefix}-*{ext}")
|
|
case "name-datetime":
|
|
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
filename = f"{filename_prefix}-{timestamp}{ext}"
|
|
case _:
|
|
filename = f"{filename_prefix}{ext}"
|
|
|
|
return save_path, filename
|
|
|
|
|
|
def save_image(**kwargs) -> None:
|
|
kwargs["output_path"] = folder_paths.get_output_directory()
|
|
image = kwargs.get("image")
|
|
for frame in image:
|
|
np_frame = frame.cpu().numpy() * 255.0
|
|
tframe = Image.fromarray(numpy.clip(np_frame, 0, 255).astype(numpy.uint8))
|
|
save_path, filename = build_save_path(**kwargs)
|
|
image_format = kwargs.get("image_format")
|
|
params = (
|
|
{"compress_level": kwargs.get("compression_level")}
|
|
if kwargs.get("image_format") == "PNG"
|
|
else {"quality": kwargs.get("image_quality")}
|
|
)
|
|
tframe.save(os.path.join(save_path, filename), format=image_format, **params)
|
|
|
|
|
|
class TT_ImagePreviewSaveNode(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="TT_ImagePreviewSaveNode",
|
|
display_name="TT Image Preview / Save",
|
|
category=CATEGORY,
|
|
description="",
|
|
is_output_node=True,
|
|
inputs=[
|
|
io.Image.Input("image"),
|
|
io.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"),
|
|
io.String.Input("filename_prefix", default="TT"),
|
|
io.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True),
|
|
io.String.Input("subfolder", default="", advanced=True),
|
|
io.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True),
|
|
io.Int.Input("image_quality", default=75, min=1, max=100, advanced=True),
|
|
io.Int.Input("compression_level", default=6, min=0, max=9, advanced=True),
|
|
],
|
|
outputs=[]
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, **kwargs) -> io.NodeOutput:
|
|
timage = kwargs.get("image")
|
|
|
|
if kwargs.get("save_image"):
|
|
save_image(**kwargs)
|
|
|
|
return io.NodeOutput(ui=ui.PreviewImage(timage, cls=cls))
|
|
|
|
|
|
DEVICES = ["default", "cpu"]
|
|
|
|
|
|
def get_upscale_models() -> list[str]:
|
|
return folder_paths.get_filename_list("upscale_models")
|
|
|
|
|
|
def load_upscale_model(upscale_model) -> ImageModelDescriptor:
|
|
model_path = folder_paths.get_full_path_or_raise("upscale_models", upscale_model)
|
|
|
|
try:
|
|
sd = utils.load_torch_file(model_path, safe_load=True)
|
|
except Exception as e:
|
|
raise RuntimeError(f"Failed to load model file {model_path}: {e}")
|
|
|
|
sd = utils.state_dict_prefix_replace(sd, {"module.": ""})
|
|
upscale_model = ModelLoader().load_from_state_dict(sd).eval()
|
|
|
|
if not isinstance(upscale_model, ImageModelDescriptor):
|
|
raise TypeError(f"Expected ImageModelDescriptor, got {type(upscale_model).__name__}")
|
|
|
|
return upscale_model
|
|
|
|
|
|
BUFFER_FACTOR = 384.0
|
|
|
|
|
|
def upscale(**kwargs):
|
|
image, device_name, upscale_tile, upscale_overlap = (
|
|
kwargs.get("image"),
|
|
kwargs.get("upscaler_device"),
|
|
kwargs.get("upscale_tile"),
|
|
kwargs.get("upscale_overlap")
|
|
)
|
|
|
|
timage = image.clone()
|
|
device = get_torch_device(device_name)
|
|
model = load_upscale_model(kwargs.get("upscale_model"))
|
|
i_image = timage.movedim(-1, 1).to(device)
|
|
batch, _, height, width = i_image.shape
|
|
m_model = model_management.module_size(model.model)
|
|
m_upscale_tile = (upscale_tile * upscale_tile * 3) * i_image.element_size() * max(model.scale, 1) * BUFFER_FACTOR
|
|
m_total = m_model + m_upscale_tile + i_image.nelement() * i_image.element_size()
|
|
model_management.free_memory(m_total, device)
|
|
model.model.to(device)
|
|
|
|
progress_bar = None
|
|
try:
|
|
while True:
|
|
try:
|
|
steps = batch * utils.get_tiled_scale_steps(
|
|
width, height,
|
|
tile_x=upscale_tile,
|
|
tile_y=upscale_tile,
|
|
overlap=upscale_overlap
|
|
)
|
|
progress_bar = utils.ProgressBar(steps)
|
|
scaled = utils.tiled_scale(
|
|
i_image,
|
|
model,
|
|
tile_x=upscale_tile,
|
|
tile_y=upscale_tile,
|
|
overlap=upscale_overlap,
|
|
upscale_amount=model.scale,
|
|
pbar=progress_bar
|
|
)
|
|
break
|
|
except model_management.OOM_EXCEPTION as e:
|
|
progress_bar = None
|
|
upscale_tile //= 2
|
|
if upscale_tile < 128:
|
|
raise e
|
|
finally:
|
|
if device.type != "cpu":
|
|
model.model.to("cpu")
|
|
|
|
return torch.clamp(scaled.movedim(1, -1), min=0.0, max=1.0)
|
|
|
|
|
|
class TT_ImagePreviewUpscaleSaveNode(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="TT_ImagePreviewUpscaleSaveNode",
|
|
display_name="TT Image Preview / Upscale / Save",
|
|
category=CATEGORY,
|
|
description="",
|
|
is_output_node=True,
|
|
inputs=[
|
|
io.Image.Input("image"),
|
|
io.Boolean.Input("save_image", default=True, label_on="Save image", label_off="Only preview"),
|
|
io.Boolean.Input("upscale_image", default=True, label_on="Upscale image", label_off="Keep size"),
|
|
io.String.Input("filename_prefix", default="TT"),
|
|
io.Combo.Input("filename_format", options=FILENAME_FORMATS, default="name-datetime", advanced=True),
|
|
io.String.Input("subfolder", default="", advanced=True),
|
|
io.Combo.Input("image_format", options=list(FORMAT_EXT.keys()), default="PNG", advanced=True),
|
|
io.Int.Input("image_quality", default=75, min=1, max=100, advanced=True),
|
|
io.Int.Input("compression_level", default=6, min=0, max=9, advanced=True),
|
|
io.Combo.Input("upscaler_device", options=DEVICES, advanced=True),
|
|
io.Combo.Input("upscale_model", options=get_upscale_models(), advanced=True),
|
|
io.Int.Input("upscale_tile", default=512, min=128, max=4096, step=64, advanced=True),
|
|
io.Int.Input("upscale_overlap", default=64, min=8, max=256, step=8, advanced=True),
|
|
],
|
|
outputs=[]
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, **kwargs) -> io.NodeOutput:
|
|
image = kwargs.get("image")
|
|
timage = image.clone()
|
|
|
|
if kwargs.get("upscale_image"):
|
|
timage = upscale(**kwargs)
|
|
|
|
if kwargs.get("save_image"):
|
|
kwargs["image"] = timage
|
|
save_image(**kwargs)
|
|
|
|
return io.NodeOutput(ui=ui.PreviewImage(timage, cls=cls))
|
|
|
|
|
|
class TT_GuiderImageReferenceNode(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id="TT_GuiderImageReferenceNode",
|
|
display_name="TT Guider Image Reference",
|
|
category=CATEGORY,
|
|
description="",
|
|
inputs=[
|
|
io.Vae.Input("vae"),
|
|
io.Guider.Input("guider"),
|
|
io.Combo.Input("megapixels", options=MEGAPIXELS),
|
|
io.Combo.Input("resize_method", options=RESIZE_METHODS, advanced=True),
|
|
io.Int.Input("dimension_step", default=1, min=1, max=256, advanced=True),
|
|
io.Combo.Input("image", options=get_image_files(), upload=io.UploadType.image)
|
|
],
|
|
outputs=[
|
|
io.Vae.Output("VAE"),
|
|
io.Guider.Output("GUIDER"),
|
|
]
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, **kwargs) -> io.NodeOutput:
|
|
timage, _ = load_image(kwargs.get("image"))
|
|
vae, guider, resize_method, megapixels, dimension_step = (
|
|
kwargs.get("vae"),
|
|
kwargs.get("guider"),
|
|
kwargs.get("resize_method"),
|
|
kwargs.get("megapixels"),
|
|
kwargs.get("dimension_step")
|
|
)
|
|
|
|
if not isinstance(guider, SingleCondCFGGuider):
|
|
guider = SingleCondCFGGuider.from_cfg_guider(guider)
|
|
|
|
resized = resize_image_to_megapixels(timage, resize_method, megapixels, dimension_step)
|
|
latent = vae_encode(resized, vae)
|
|
|
|
conditioning = guider.get_conds()
|
|
|
|
if conditioning is not None:
|
|
conditioning = conditioning_set_values(conditioning, {"reference_latents": [latent["samples"]]}, append=True)
|
|
guider.set_conds(conditioning)
|
|
else:
|
|
raise ValueError("ERROR: Guider has no conditioning")
|
|
|
|
return io.NodeOutput(vae, guider, ui=ui.PreviewImage(timage, cls=cls))
|
|
|
|
|
|
# ==============================================================================
|
|
# V3 entrypoint — registers context nodes with ComfyUI
|
|
# ==============================================================================
|
|
|
|
class ImageNodesExtension(ComfyExtension):
|
|
@override
|
|
async def get_node_list(self) -> list[type[io.ComfyNode]]:
|
|
return [
|
|
TT_ImageLoaderResizerNode,
|
|
TT_ImagePreviewSaveNode,
|
|
TT_ImagePreviewUpscaleSaveNode,
|
|
TT_GuiderImageReferenceNode,
|
|
]
|
|
|
|
|
|
async def comfy_entrypoint() -> ImageNodesExtension:
|
|
return ImageNodesExtension()
|
|
|
|
|
|
# ==============================================================================
|
|
# Re-exports for backward compatibility
|
|
# ==============================================================================
|
|
|
|
__all__ = [
|
|
"TT_ImageLoaderResizerNode",
|
|
"TT_ImagePreviewSaveNode",
|
|
"TT_ImagePreviewUpscaleSaveNode",
|
|
"TT_GuiderImageReferenceNode",
|
|
]
|