# (c) TenserTensor || 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", ]