329 lines
13 KiB
Python
329 lines
13 KiB
Python
import comfy
|
|
import comfy.samplers
|
|
import comfy.sd
|
|
import comfy.utils
|
|
from comfy_extras.nodes_custom_sampler import SamplerCustom
|
|
import nodes
|
|
import folder_paths
|
|
from ... import ROOT_NAME, NODE_SURFIX, SYMBOL
|
|
from comfy_api.v0_0_2 import io, ui
|
|
import torch
|
|
from PIL import Image, ImageFont, ImageDraw
|
|
import numpy as np
|
|
import os
|
|
import matplotlib.pyplot as plt
|
|
from PIL import Image
|
|
from io import BytesIO
|
|
|
|
def generate_image_matrix(images, xy_list):
|
|
num_images = len(images)
|
|
cols = len(xy_list) # 列数
|
|
rows = num_images // cols
|
|
|
|
fig, axes = plt.subplots(rows, cols, figsize=(cols * 2, rows * 2))
|
|
axes = axes.flatten() # 1次元配列化
|
|
|
|
for i in range(len(axes)):
|
|
if i < num_images:
|
|
axes[i].imshow(images[i])
|
|
axes[i].set_title(xy_list[i], fontsize=8)
|
|
axes[i].axis("off")
|
|
else:
|
|
axes[i].axis("off") # 余ったスペースを空白にする
|
|
|
|
plt.tight_layout()
|
|
|
|
# Figure をバイナリデータとして保存し、PIL画像に変換
|
|
buf = BytesIO()
|
|
plt.savefig(buf, format='png', bbox_inches='tight', pad_inches=0)
|
|
plt.close(fig)
|
|
buf.seek(0)
|
|
|
|
return Image.open(buf)
|
|
|
|
CATEGORY_NAME = ROOT_NAME + "lora_xy"
|
|
|
|
# module-level cache replacing the old per-instance `self.loaded_lora` state from
|
|
# nodes.py's LoraLoader (execute() is a classmethod, no `self` to cache on).
|
|
_lora_xy_cache = {"loaded_lora": None}
|
|
|
|
def _load_lora_model_only(model, lora_name, strength_model):
|
|
# Mirrors nodes.py LoraLoader.load_lora(model, clip=None, lora_name, strength_model, strength_clip=0).
|
|
if strength_model == 0:
|
|
return model
|
|
|
|
lora_path = folder_paths.get_full_path_or_raise("loras", lora_name)
|
|
lora = None
|
|
lora_metadata = None
|
|
loaded_lora = _lora_xy_cache["loaded_lora"]
|
|
if loaded_lora is not None:
|
|
if loaded_lora[0] == lora_path:
|
|
lora = loaded_lora[1]
|
|
lora_metadata = loaded_lora[2] if len(loaded_lora) > 2 else None
|
|
else:
|
|
_lora_xy_cache["loaded_lora"] = None
|
|
|
|
if lora is None:
|
|
lora, lora_metadata = comfy.utils.load_torch_file(lora_path, safe_load=True, return_metadata=True)
|
|
_lora_xy_cache["loaded_lora"] = (lora_path, lora, lora_metadata)
|
|
|
|
model_lora, _ = comfy.sd.load_lora_for_models(model, None, lora, strength_model, 0, lora_metadata=lora_metadata)
|
|
return model_lora
|
|
|
|
class LoraLoaderModelOnlyXY(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id=f"LoraLoaderModelOnlyXY{NODE_SURFIX}",
|
|
display_name=f"Lora Loader Model Only XY {SYMBOL}",
|
|
category=CATEGORY_NAME,
|
|
inputs=[
|
|
io.Model.Input("model"),
|
|
io.Combo.Input("lora_name", options=folder_paths.get_filename_list("loras")),
|
|
io.String.Input("strength_list", multiline=True),
|
|
],
|
|
outputs=[
|
|
io.Custom("XY_MODEL").Output(),
|
|
io.Custom("XY_LIST").Output(),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, model, lora_name, strength_list) -> io.NodeOutput:
|
|
models = []
|
|
xy_list = []
|
|
|
|
weights = [float(x.strip()) for x in strength_list.strip().strip(",").split(",")]
|
|
for value in weights:
|
|
models.append(_load_lora_model_only(model, lora_name, value))
|
|
xy_list.append(f"{lora_name.split('.')[0]}:{value}")
|
|
|
|
return io.NodeOutput(models, xy_list)
|
|
|
|
class SamplerCustomXY(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id=f"SamplerCustomXY{NODE_SURFIX}",
|
|
display_name=f"Sampler Custom XY {SYMBOL}",
|
|
category=CATEGORY_NAME,
|
|
inputs=[
|
|
io.Custom("XY_MODEL").Input("model_xy"),
|
|
io.Boolean.Input("add_noise", default=True),
|
|
io.Int.Input("noise_seed", default=0, min=0, max=0xffffffffffffffff),
|
|
io.Float.Input("cfg", default=8.0, min=0.0, max=100.0, step=0.1, round=0.01),
|
|
io.Conditioning.Input("positive"),
|
|
io.Conditioning.Input("negative"),
|
|
io.Sampler.Input("sampler"),
|
|
io.Sigmas.Input("sigmas"),
|
|
io.Latent.Input("latent_image"),
|
|
],
|
|
outputs=[
|
|
io.Latent.Output(display_name="output"),
|
|
io.Latent.Output(display_name="denoised_output"),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, model_xy, add_noise, noise_seed, cfg, positive, negative, sampler, sigmas, latent_image) -> io.NodeOutput:
|
|
outputs = []
|
|
denoised_outputs = []
|
|
|
|
# Composition, not inheritance: SamplerCustom is itself a V3 io.ComfyNode now, so we
|
|
# call its public `execute` classmethod per model instead of subclassing it. This keeps
|
|
# us in sync with upstream's noise/x0-output/nested-tensor handling without duplicating it.
|
|
for model in model_xy:
|
|
result = SamplerCustom.execute(
|
|
model=model,
|
|
add_noise=add_noise,
|
|
noise_seed=noise_seed,
|
|
cfg=cfg,
|
|
positive=positive,
|
|
negative=negative,
|
|
sampler=sampler,
|
|
sigmas=sigmas,
|
|
latent_image=latent_image,
|
|
)
|
|
output, denoised_output = result.result
|
|
outputs.append(output["samples"])
|
|
denoised_outputs.append(denoised_output["samples"])
|
|
|
|
return io.NodeOutput({"samples": torch.cat(outputs)}, {"samples": torch.cat(denoised_outputs)})
|
|
|
|
class KSamplerXY(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id=f"KSamplerXY{NODE_SURFIX}",
|
|
display_name=f"KSampler XY {SYMBOL}",
|
|
category=CATEGORY_NAME,
|
|
inputs=[
|
|
io.Custom("XY_MODEL").Input("model_xy"),
|
|
io.Int.Input("seed", default=0, min=0, max=0xffffffffffffffff),
|
|
io.Int.Input("steps", default=20, min=1, max=10000),
|
|
io.Float.Input("cfg", default=8.0, min=0.0, max=100.0, step=0.1, round=0.01),
|
|
io.Combo.Input("sampler_name", options=comfy.samplers.KSampler.SAMPLERS),
|
|
io.Combo.Input("scheduler", options=comfy.samplers.KSampler.SCHEDULERS),
|
|
io.Conditioning.Input("positive"),
|
|
io.Conditioning.Input("negative"),
|
|
io.Latent.Input("latent_image"),
|
|
io.Float.Input("denoise", default=1.0, min=0.0, max=1.0, step=0.01),
|
|
],
|
|
outputs=[
|
|
io.Latent.Output(),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, model_xy, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise) -> io.NodeOutput:
|
|
outputs = []
|
|
|
|
# Composition: nodes.common_ksampler is the stable module-level function that both
|
|
# KSampler and KSamplerAdvanced wrap; calling it directly avoids depending on the
|
|
# KSampler node class itself.
|
|
for model in model_xy:
|
|
output = nodes.common_ksampler(model, seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image, denoise=denoise)[0]
|
|
outputs.append(output["samples"])
|
|
|
|
return io.NodeOutput({"samples": torch.cat(outputs)})
|
|
|
|
class KSamplerAdvancedXY(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id=f"KSamplerAdvancedXY{NODE_SURFIX}",
|
|
display_name=f"KSampler Advanced XY {SYMBOL}",
|
|
category=CATEGORY_NAME,
|
|
inputs=[
|
|
io.Custom("XY_MODEL").Input("model_xy"),
|
|
io.Combo.Input("add_noise", options=["enable", "disable"]),
|
|
io.Int.Input("noise_seed", default=0, min=0, max=0xffffffffffffffff),
|
|
io.Int.Input("steps", default=20, min=1, max=10000),
|
|
io.Float.Input("cfg", default=8.0, min=0.0, max=100.0, step=0.1, round=0.01),
|
|
io.Combo.Input("sampler_name", options=comfy.samplers.KSampler.SAMPLERS),
|
|
io.Combo.Input("scheduler", options=comfy.samplers.KSampler.SCHEDULERS),
|
|
io.Conditioning.Input("positive"),
|
|
io.Conditioning.Input("negative"),
|
|
io.Latent.Input("latent_image"),
|
|
io.Int.Input("start_at_step", default=0, min=0, max=10000),
|
|
io.Int.Input("end_at_step", default=10000, min=0, max=10000),
|
|
io.Combo.Input("return_with_leftover_noise", options=["disable", "enable"]),
|
|
],
|
|
outputs=[
|
|
io.Latent.Output(),
|
|
],
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, model_xy, add_noise, noise_seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
|
|
start_at_step, end_at_step, return_with_leftover_noise) -> io.NodeOutput:
|
|
outputs = []
|
|
|
|
force_full_denoise = True
|
|
if return_with_leftover_noise == "enable":
|
|
force_full_denoise = False
|
|
disable_noise = False
|
|
if add_noise == "disable":
|
|
disable_noise = True
|
|
|
|
# Composition: same nodes.common_ksampler function that KSamplerAdvanced.sample wraps.
|
|
for model in model_xy:
|
|
output = nodes.common_ksampler(model, noise_seed, steps, cfg, sampler_name, scheduler, positive, negative, latent_image,
|
|
denoise=1.0, disable_noise=disable_noise, start_step=start_at_step, last_step=end_at_step,
|
|
force_full_denoise=force_full_denoise)[0]
|
|
outputs.append(output["samples"])
|
|
|
|
return io.NodeOutput({"samples": torch.cat(outputs)})
|
|
|
|
class XYImage:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required":{"images": ("IMAGE", ), "xy_list": ("XY_LIST", )},
|
|
}
|
|
|
|
FUNCTION = "xy_images"
|
|
CATEGORY_NAME = ROOT_NAME
|
|
|
|
def xy_images(self, images, xy_list):
|
|
pil_images = []
|
|
for (batch_number, image) in enumerate(images):
|
|
i = 255. * image.cpu().numpy()
|
|
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
|
pil_images.append(img)
|
|
|
|
imgs = generate_image_matrix(pil_images, xy_list)
|
|
img = np.array(imgs).astype(np.float32) / 255.
|
|
img = img * 2. - 1.
|
|
img = torch.from_numpy(img).permute(2, 0, 1).unsqueeze(0)
|
|
|
|
return {"images": img}
|
|
|
|
def _xy_text_to_image(text):
|
|
font = ImageFont.load_default()
|
|
img = Image.new('RGB', (256, 20), 'white')
|
|
draw = ImageDraw.Draw(img)
|
|
text_width, text_height = draw.textbbox((0,0), text, font=font)[2:]
|
|
text_x = (256 - text_width) / 2
|
|
text_y = (20 - text_height) / 2
|
|
draw.text((text_x, text_y), text, font=font, fill='black')
|
|
return img
|
|
|
|
def _xy_plot(images, xy_list, text_height=100):
|
|
n = len(xy_list)
|
|
m = len(images) // n
|
|
|
|
image_width, image_height = images[0].width, images[0].height
|
|
|
|
# キャンバスのサイズを再計算(全画像が同じサイズの場合)
|
|
canvas_width = image_width * n
|
|
canvas_height = (image_height * m) + text_height # 文字列の高さ分を追加
|
|
|
|
# キャンバスを再作成
|
|
canvas = Image.new('RGB', (canvas_width, canvas_height), 'white')
|
|
|
|
# 画像と文字列の画像をキャンバスに配置(全画像が同じサイズの場合の最適化)
|
|
for i, img in enumerate(images):
|
|
# 画像を配置する位置を計算
|
|
x_offset = (i // m) * image_width
|
|
y_offset = (i % m) * (image_height) + text_height # 文字列の高さ分をオフセットして再計算
|
|
canvas.paste(img, (x_offset, y_offset))
|
|
|
|
text_images = [_xy_text_to_image(title).resize((image_width, text_height)) for title in xy_list]
|
|
|
|
# 文字列の画像をキャンバスに配置(各列の上部に)
|
|
for i, text_img in enumerate(text_images):
|
|
canvas.paste(text_img, (i * image_width, 0))
|
|
|
|
return canvas
|
|
|
|
class PreviewXY(io.ComfyNode):
|
|
@classmethod
|
|
def define_schema(cls) -> io.Schema:
|
|
return io.Schema(
|
|
node_id=f"PreviewXY{NODE_SURFIX}",
|
|
display_name=f"Preview XY {SYMBOL}",
|
|
category=CATEGORY_NAME,
|
|
inputs=[
|
|
io.Image.Input("images"),
|
|
io.Custom("XY_LIST").Input("xy_list"),
|
|
],
|
|
outputs=[],
|
|
is_output_node=True,
|
|
)
|
|
|
|
@classmethod
|
|
def execute(cls, images, xy_list) -> io.NodeOutput:
|
|
pil_images = []
|
|
for image in images:
|
|
i = 255. * image.cpu().numpy()
|
|
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
|
pil_images.append(img)
|
|
|
|
canvas = _xy_plot(pil_images, xy_list)
|
|
|
|
canvas_np = np.array(canvas).astype(np.float32) / 255.
|
|
canvas_tensor = torch.from_numpy(canvas_np).unsqueeze(0)
|
|
|
|
return io.NodeOutput(ui=ui.PreviewImage(canvas_tensor, cls=cls))
|