import comfy from comfy_extras.nodes_custom_sampler import SamplerCustom import folder_paths from nodes import LoraLoader, PreviewImage, KSampler, KSamplerAdvanced from ... import ROOT_NAME 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" class LoraLoaderModelOnlyXY(LoraLoader): @classmethod def INPUT_TYPES(s): return { "required": { "model": ("MODEL",), "lora_name": (folder_paths.get_filename_list("loras"), ), "strength_list": ("STRING", {"multiline": True}), } } RETURN_TYPES = ("XY_MODEL","XY_LIST", ) FUNCTION = "load_lora_model_only_xy" CATEGORY = CATEGORY_NAME def load_lora_model_only_xy(self, model, lora_name, strength_list): models = [] xy_list = [] weights = [float(x.strip()) for x in strength_list.strip().strip(",").split(",")] for value in weights: models.append(self.load_lora(model, None, lora_name, value, 0)[0]) xy_list.append(f"{lora_name.split('.')[0]}:{value}") return (models, xy_list) class SamplerCustomXY(SamplerCustom): @classmethod def INPUT_TYPES(s): return {"required": {"model_xy": ("XY_MODEL",), "add_noise": ("BOOLEAN", {"default": True}), "noise_seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01}), "positive": ("CONDITIONING", ), "negative": ("CONDITIONING", ), "sampler": ("SAMPLER", ), "sigmas": ("SIGMAS", ), "latent_image": ("LATENT", ), } } FUNCTION = "sample_xy" CATEGORY = CATEGORY_NAME def sample_xy(self, model_xy, **kwargs): outputs = [] denoised_outputs = [] for model in model_xy: output, denoised_output = self.sample(model, **kwargs) outputs.append(output["samples"]) denoised_outputs.append(denoised_output["samples"]) return ({"samples":torch.cat(outputs)}, {"samples":torch.cat(denoised_outputs)}) class KSamplerXY(KSampler): @classmethod def INPUT_TYPES(s): return {"required": {"model_xy": ("XY_MODEL",), "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), "steps": ("INT", {"default": 20, "min": 1, "max": 10000}), "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01}), "sampler_name": (comfy.samplers.KSampler.SAMPLERS, ), "scheduler": (comfy.samplers.KSampler.SCHEDULERS, ), "positive": ("CONDITIONING", ), "negative": ("CONDITIONING", ), "latent_image": ("LATENT", ), "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), } } FUNCTION = "sample_xy" CATEGORY = CATEGORY_NAME def sample_xy(self, model_xy, **kwargs): outputs = [] for model in model_xy: output = self.sample(model, **kwargs)[0] outputs.append(output["samples"]) return ({"samples":torch.cat(outputs)},) class KSamplerAdvancedXY(KSamplerAdvanced): @classmethod def INPUT_TYPES(s): return {"required": {"model_xy": ("XY_MODEL",), "add_noise": (["enable", "disable"], ), "noise_seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), "steps": ("INT", {"default": 20, "min": 1, "max": 10000}), "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step":0.1, "round": 0.01}), "sampler_name": (comfy.samplers.KSampler.SAMPLERS, ), "scheduler": (comfy.samplers.KSampler.SCHEDULERS, ), "positive": ("CONDITIONING", ), "negative": ("CONDITIONING", ), "latent_image": ("LATENT", ), "start_at_step": ("INT", {"default": 0, "min": 0, "max": 10000}), "end_at_step": ("INT", {"default": 10000, "min": 0, "max": 10000}), "return_with_leftover_noise": (["disable", "enable"], ), } } FUNCTION = "sample_xy" CATEGORY = CATEGORY_NAME def sample_xy(self, model_xy, **kwargs): outputs = [] for model in model_xy: output = self.sample(model, **kwargs)[0] outputs.append(output["samples"]) return ({"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} class PreviewXY(PreviewImage): @classmethod def INPUT_TYPES(s): return { "required":{"images": ("IMAGE", ), "xy_list": ("XY_LIST", )}, "hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"}, } CATEGORY_NAME = ROOT_NAME def save_images(self, images, xy_list, filename_prefix="ComfyUI", prompt=None, extra_pnginfo=None): filename_prefix += self.prefix_append full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]) results = 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) img = self.xy_plot(pil_images, xy_list) file = "lora_xy_.png" img.save(os.path.join(full_output_folder, file), compress_level=self.compress_level) results.append({ "filename": file, "subfolder": subfolder, "type": self.type }) counter += 1 return { "ui": { "images": results } } def xy_plot(self, 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 = [self.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 def text_to_image(self, 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