Files
Gin3601-LLS-node/image/nodes.py
T
2026-05-22 15:25:59 +08:00

498 lines
17 KiB
Python

"""
LLS / Image
===========
功能域:图像处理与后处理(对应功能分类总览第 5 节)
CATEGORY = "LLS/Image"
"""
from __future__ import annotations
from datetime import datetime
import importlib
from ..utils.model_info import (
MODEL_FAMILY_CHOICES,
canonicalize_family,
get_family_defaults,
get_latent_spec,
info_to_json,
parse_jsonish_info,
resolve_model_family,
resolve_vae_name,
)
try:
comfy_core_nodes = importlib.import_module("nodes")
except Exception:
comfy_core_nodes = None
try:
import comfy.utils as comfy_utils
except Exception as exc:
comfy_utils = None
_COMFY_UTILS_ERR = exc
else:
_COMFY_UTILS_ERR = None
_RESIZE_MODES = ["keep_aspect", "crop_center", "stretch", "none"]
_SIZE_SOURCES = ["input_image", "custom", "model_recommended"]
_SAVE_OUTPUT_MODES = ["save", "preview_only"]
def _round_to_multiple(value: int, multiple: int) -> int:
return max(multiple, ((int(value) + multiple - 1) // multiple) * multiple)
def _first_non_empty(*values):
for value in values:
if value not in (None, ""):
return value
return ""
def _get_image_dimensions(image) -> tuple[int, int, int]:
shape = tuple(getattr(image, "shape", ()))
if len(shape) < 4:
raise RuntimeError(
"[LLS] IMAGE input must be a tensor shaped like [batch, height, width, channels]."
)
return int(shape[0]), int(shape[2]), int(shape[1])
def _resolve_requested_size(
size_source: str,
width: int,
height: int,
source_width: int,
source_height: int,
model_info: dict,
) -> tuple[int, int]:
if size_source == "input_image":
return source_width, source_height
if size_source == "custom":
return int(width), int(height)
if size_source == "model_recommended":
return int(model_info["default_width"]), int(model_info["default_height"])
raise RuntimeError(f"[LLS] Unsupported size_source '{size_source}'.")
def _fit_inside_box(
source_width: int,
source_height: int,
target_width: int,
target_height: int,
multiple: int,
) -> tuple[int, int]:
if source_width <= 0 or source_height <= 0:
raise RuntimeError("[LLS] IMAGE input has invalid dimensions.")
scale = min(target_width / source_width, target_height / source_height)
fitted_width = max(multiple, int(round(source_width * scale)))
fitted_height = max(multiple, int(round(source_height * scale)))
fitted_width = min(target_width, _round_to_multiple(fitted_width, multiple))
fitted_height = min(target_height, _round_to_multiple(fitted_height, multiple))
return fitted_width, fitted_height
def _resize_image_to(image, target_width: int, target_height: int, resize_mode: str):
_batch_size, source_width, source_height = _get_image_dimensions(image)
if source_width == target_width and source_height == target_height:
return image
if comfy_utils is None:
raise RuntimeError(
"[LLS] comfy.utils is not available, so IMAGE resizing cannot run outside a ComfyUI environment."
) from _COMFY_UTILS_ERR
crop_mode = "center" if resize_mode == "crop_center" else "disabled"
try:
channel_first = image.movedim(-1, 1)
resized = comfy_utils.common_upscale(
channel_first,
target_width,
target_height,
"bilinear",
crop_mode,
)
return resized.movedim(1, -1)
except Exception as exc:
raise RuntimeError(f"[LLS] Failed to resize IMAGE for VAE encode: {exc}") from exc
def _infer_family_from_latent_or_vae(samples, vae=None) -> str:
tagged_family = getattr(vae, "_lls_family", None)
if tagged_family and tagged_family not in {"Auto", "auto", ""}:
return canonicalize_family(tagged_family)
if isinstance(samples, dict):
try:
ratio = int(samples.get("downscale_ratio_spacial", 0))
if ratio >= 16:
return "FLUX_DEV"
except Exception:
pass
latent_tensor = samples.get("samples")
latent_shape = tuple(getattr(latent_tensor, "shape", ()))
if len(latent_shape) >= 2:
try:
if int(latent_shape[1]) >= 16:
return "FLUX_DEV"
except Exception:
pass
return "SD1.5"
class LLSSimpleVAEEncode:
"""
把 IMAGE 编码为 LATENT,供 img2img 直接接入现有 KSampler。
编码本身复用 ComfyUI 原生 vae.encode,只在外层补尺寸处理与元信息。
"""
CATEGORY = "LLS/Image"
FUNCTION = "encode"
RETURN_TYPES = ("LATENT", "INT", "INT", "STRING")
RETURN_NAMES = ("latent", "width", "height", "latent_info")
DESCRIPTION = "Encode an IMAGE into a LATENT tensor for img2img workflows."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"vae": ("VAE",),
"resize_mode": (_RESIZE_MODES, {"default": "keep_aspect"}),
"size_source": (_SIZE_SOURCES, {"default": "input_image"}),
"width": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"height": ("INT", {"default": 512, "min": 64, "max": 4096, "step": 8}),
"model_family": (MODEL_FAMILY_CHOICES, {"default": "Auto"}),
},
"optional": {
"model": ("MODEL",),
"clip": ("CLIP",),
},
}
def encode(
self,
image,
vae,
resize_mode: str,
size_source: str,
width: int,
height: int,
model_family: str = "Auto",
model=None,
clip=None,
):
if image is None:
raise RuntimeError("[LLS] IMAGE input is required for LLS Simple VAE Encode.")
if vae is None:
raise RuntimeError(
"[LLS] Missing VAE. Connect the Loader VAE output or choose an external VAE in the loader."
)
family = resolve_model_family(model_family, model=model, clip=clip)
defaults = get_family_defaults(family)
latent_spec = get_latent_spec(defaults)
alignment = max(8, int(latent_spec["downscale_ratio"]))
batch_size, source_width, source_height = _get_image_dimensions(image)
requested_width, requested_height = _resolve_requested_size(
size_source=size_source,
width=width,
height=height,
source_width=source_width,
source_height=source_height,
model_info={
"default_width": defaults["default_width"],
"default_height": defaults["default_height"],
},
)
requested_width = _round_to_multiple(requested_width, alignment)
requested_height = _round_to_multiple(requested_height, alignment)
if resize_mode == "none":
final_width = _round_to_multiple(source_width, alignment)
final_height = _round_to_multiple(source_height, alignment)
elif resize_mode == "keep_aspect":
final_width, final_height = _fit_inside_box(
source_width=source_width,
source_height=source_height,
target_width=requested_width,
target_height=requested_height,
multiple=alignment,
)
elif resize_mode in {"crop_center", "stretch"}:
final_width = requested_width
final_height = requested_height
else:
raise RuntimeError(f"[LLS] Unsupported resize_mode '{resize_mode}'.")
processed_image = _resize_image_to(
image=image,
target_width=final_width,
target_height=final_height,
resize_mode=resize_mode,
)
try:
latent = vae.encode(processed_image)
except Exception as exc:
raise RuntimeError(f"[LLS] VAE encode failed: {exc}") from exc
latent_channels = int(getattr(latent, "shape", [0, latent_spec["latent_channels"]])[1])
latent_payload = {
"samples": latent,
"downscale_ratio_spacial": int(latent_spec["downscale_ratio"]),
"source": "image_encode",
}
latent_info = info_to_json(
{
"model_family": family,
"task_mode": "img2img",
"latent_source": "image_encode",
"input_image_width": source_width,
"input_image_height": source_height,
"width": final_width,
"height": final_height,
"batch_size": batch_size,
"resize_mode": resize_mode,
"size_source": size_source,
"vae_name": resolve_vae_name(vae, fallback="unknown"),
"latent_channels": latent_channels,
"downscale_ratio": int(latent_spec["downscale_ratio"]),
}
)
return (latent_payload, final_width, final_height, latent_info)
class LLSSimpleVAEDecode:
"""
简化版 VAE Decode 节点。
内部复用 ComfyUI 原生 VAE.decode。
"""
CATEGORY = "LLS/Image"
FUNCTION = "decode"
RETURN_TYPES = ("IMAGE", "STRING")
RETURN_NAMES = ("image", "decode_info")
DESCRIPTION = "Decode a LATENT tensor into a pixel IMAGE using the VAE."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"samples": ("LATENT",),
"vae": ("VAE",),
},
}
def decode(self, samples, vae):
family = _infer_family_from_latent_or_vae(samples, vae)
if vae is None:
if family.startswith("FLUX"):
raise RuntimeError(
"[LLS] Missing FLUX AE/VAE. Place ae.safetensors in ComfyUI/models/vae/ "
"or set Loader.vae_source to 'external'."
)
raise RuntimeError(
"[LLS] Missing VAE. Connect the Loader VAE output or choose an external VAE in the loader."
)
if samples is None:
raise RuntimeError(
"[LLS] LATENT samples is None. Connect the output of KSampler to this node."
)
latent_tensor = samples.get("samples")
if latent_tensor is None:
raise RuntimeError(
"[LLS] LATENT dict does not contain 'samples'. Make sure the upstream node outputs a valid LATENT."
)
if getattr(latent_tensor, "is_nested", False):
latent_tensor = latent_tensor.unbind()[0]
try:
image = vae.decode(latent_tensor)
except Exception as exc:
raise RuntimeError(f"[LLS] VAE decode failed: {exc}") from exc
if len(getattr(image, "shape", ())) == 5:
image = image.reshape(-1, image.shape[-3], image.shape[-2], image.shape[-1])
defaults = get_family_defaults(family)
downscale_ratio = int(samples.get("downscale_ratio_spacial", defaults["downscale_ratio"]))
height = latent_tensor.shape[2] * downscale_ratio
width = latent_tensor.shape[3] * downscale_ratio
decode_info = info_to_json(
{
"model_family": family,
"width": width,
"height": height,
"batch_size": latent_tensor.shape[0],
"vae_name": resolve_vae_name(vae, fallback="unknown"),
"decode_stage": "vae_decode",
}
)
return (image, decode_info)
class LLSSaveImage:
"""
复用 ComfyUI 原生 SaveImage,并把 LLS 生成链路信息合并进 PNG metadata。
"""
CATEGORY = "LLS/Image"
FUNCTION = "save"
OUTPUT_NODE = True
RETURN_TYPES = ()
DESCRIPTION = "Save images with LLS generation metadata."
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"filename_prefix": ("STRING", {"default": "LLS"}),
"output_mode": (_SAVE_OUTPUT_MODES, {"default": "save"}),
"save_metadata": ("BOOLEAN", {"default": True}),
},
"optional": {
"prompt_info": ("STRING", {"forceInput": True}),
"latent_info": ("STRING", {"forceInput": True}),
"sample_info": ("STRING", {"forceInput": True}),
"decode_info": ("STRING", {"forceInput": True}),
"upscale_info": ("STRING", {"forceInput": True}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
def _build_metadata(
self,
image,
prompt_info: str | None = None,
latent_info: str | None = None,
sample_info: str | None = None,
decode_info: str | None = None,
upscale_info: str | None = None,
) -> dict:
prompt = parse_jsonish_info(prompt_info)
latent = parse_jsonish_info(latent_info)
sample = parse_jsonish_info(sample_info)
decode = parse_jsonish_info(decode_info)
upscale = parse_jsonish_info(upscale_info)
family = _first_non_empty(
prompt.get("model_family"),
latent.get("model_family"),
decode.get("model_family"),
sample.get("model_family"),
sample.get("family"),
)
checkpoint_name = _first_non_empty(
prompt.get("checkpoint_name"),
latent.get("checkpoint_name"),
decode.get("checkpoint_name"),
sample.get("checkpoint_name"),
sample.get("model_name"),
)
text_encoder_name = _first_non_empty(
prompt.get("text_encoder_name"),
prompt.get("text_encoder_name_1"),
prompt.get("text_encoder_name_2"),
latent.get("text_encoder_name"),
decode.get("text_encoder_name"),
)
vae_name = _first_non_empty(
decode.get("vae_name"),
latent.get("vae_name"),
prompt.get("vae_name"),
)
metadata = {
"positive_prompt": prompt.get("positive_prompt", ""),
"negative_prompt": prompt.get("negative_prompt", ""),
"seed": sample.get("seed"),
"model_family": family,
"checkpoint_name": checkpoint_name,
"vae_name": vae_name,
"text_encoder_name": text_encoder_name,
"steps": sample.get("steps"),
"cfg": sample.get("cfg"),
"guidance": sample.get("guidance"),
"sampler_name": sample.get("sampler_name"),
"scheduler": sample.get("scheduler"),
"denoise": sample.get("denoise"),
"width": decode.get("width") or latent.get("width") or image.shape[2],
"height": decode.get("height") or latent.get("height") or image.shape[1],
"batch_size": decode.get("batch_size") or latent.get("batch_size") or image.shape[0],
"upscale_mode": upscale.get("mode") or upscale.get("upscale_mode"),
"scale": upscale.get("scale") or upscale.get("upscale_scale"),
"timestamp": datetime.now().isoformat(timespec="seconds"),
}
return metadata
def save(
self,
image,
filename_prefix: str = "LLS",
output_mode: str = "save",
save_metadata: bool = True,
prompt_info: str | None = None,
latent_info: str | None = None,
sample_info: str | None = None,
decode_info: str | None = None,
upscale_info: str | None = None,
prompt=None,
extra_pnginfo=None,
):
if output_mode == "preview_only":
if comfy_core_nodes is None or not hasattr(comfy_core_nodes, "PreviewImage"):
raise RuntimeError("[LLS] ComfyUI core PreviewImage node is not available.")
previewer = comfy_core_nodes.PreviewImage()
return previewer.save_images(
image,
prompt=prompt,
extra_pnginfo=None,
)
if comfy_core_nodes is None or not hasattr(comfy_core_nodes, "SaveImage"):
raise RuntimeError("[LLS] ComfyUI core SaveImage node is not available.")
saver = comfy_core_nodes.SaveImage()
merged_extra_pnginfo = dict(extra_pnginfo or {})
if save_metadata:
merged_extra_pnginfo["lls_metadata"] = self._build_metadata(
image=image,
prompt_info=prompt_info,
latent_info=latent_info,
sample_info=sample_info,
decode_info=decode_info,
upscale_info=upscale_info,
)
return saver.save_images(
image,
filename_prefix=filename_prefix,
prompt=prompt,
extra_pnginfo=merged_extra_pnginfo,
)
NODE_CLASS_MAPPINGS: dict[str, type] = {
"LLSSimpleVAEEncode": LLSSimpleVAEEncode,
"LLSSimpleVAEDecode": LLSSimpleVAEDecode,
"LLSSaveImage": LLSSaveImage,
}
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {
"LLSSimpleVAEEncode": "LLS Simple VAE Encode",
"LLSSimpleVAEDecode": "LLS Simple VAE Decode",
"LLSSaveImage": "LLS Save Image",
}