diff --git a/README.md b/README.md index 08b56a5..2e68221 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,11 @@ # ComfyUI KSampler Matrix Lab -ComfyUI KSampler Matrix Lab is a custom node for comparing multiple sampler and scheduler combinations in one labeled image grid. +ComfyUI KSampler Matrix Lab provides two custom nodes for visual benchmark grids: -The node runs each selected sampler/scheduler pair sequentially with the same model, conditioning, latent, VAE, seed, steps, CFG, and denoise settings. It then decodes the results and returns one final `IMAGE` output that can be connected to `Preview Image` or `Save Image`. +- `KSampler Matrix Lab` compares sampler and scheduler combinations with one model. +- `Model Matrix Lab` compares multiple installed models with one sampler and scheduler. + +Both nodes run tests sequentially and return one labeled `IMAGE` output that can be connected to `Preview Image` or `Save Image`. ## Features @@ -17,6 +20,10 @@ The node runs each selected sampler/scheduler pair sequentially with the same mo - Top run header with model, VAE, CLIP, steps, CFG, and denoise metadata. - Error placeholder cells when `continue_on_error` is enabled. - Safety limit for maximum combinations. +- Compare up to 20 checkpoints or standalone diffusion models. +- Automatically populated model dropdowns from local ComfyUI model folders. +- Shared prompt, sampler, scheduler, seed, steps, CFG, denoise, and latent for model comparisons. +- Configurable model grid column count. ## Screenshots @@ -43,12 +50,13 @@ git clone https://github.com/btitkin/ComfyUI-KSampler-Matrix-Lab.git Restart ComfyUI after installation. -## Node +## Nodes -The node appears as: +The package adds: ```text KSampler Matrix Lab +Model Matrix Lab ``` Category: @@ -63,7 +71,17 @@ Output: IMAGE ``` -## Basic Workflow +## Included Workflow + +The example workflow contains ready-to-use setups for both nodes: + +```text +Workflows/KSamplerMatrixLab_Workflow.json +``` + +Drag the JSON file into ComfyUI or use `Workflow > Open` to load it. + +## KSampler Matrix Lab Workflow Use the node with a standard ComfyUI generation workflow: @@ -84,6 +102,53 @@ Connect: - latent to `latent_image` - `VAE` to `vae` +## Model Matrix Lab + +`Model Matrix Lab` compares installed models using the same generation settings and prompt text. + +The node scans: + +```text +ComfyUI/models/checkpoints +ComfyUI/models/unet +ComfyUI/models/diffusion_models +``` + +It provides model slots: + +```text +model_01 ... model_20 +``` + +Set unused slots to `None`. Model choices are labeled as either: + +```text +checkpoint | filename +diffusion | filename +``` + +Checkpoint selections use their embedded CLIP and VAE when available. Standalone diffusion models require compatible external `CLIP` and `VAE` connections. These optional connections are also used as fallbacks when a checkpoint does not contain its own CLIP or VAE. + +Use the text fields inside the node for the positive and negative prompts. This allows every checkpoint to encode the same text with its own text encoder. + +Connect: + +- `LATENT` to `latent_image` +- a compatible external `CLIP` to the optional `clip` input when required +- a compatible external `VAE` to the optional `vae` input when required + +All selected models use the same: + +- latent, +- seed, +- sampler, +- scheduler, +- steps, +- CFG, +- denoise. + +The output is a configurable model comparison grid with the model source and filename above each cell. The optional top header shows the shared sampler, scheduler, steps, CFG, and denoise settings. + ## Sampler and Scheduler Selection The node provides dropdown slots: @@ -141,9 +206,9 @@ Uses `seed + cell_index` for each cell. This is useful for quick variation previ ## Error Handling -When `continue_on_error` is enabled, a failed sampler/scheduler combination produces an error placeholder cell and the rest of the matrix continues. +When `continue_on_error` is enabled, a failed sampler/scheduler combination or model produces an error placeholder cell and the rest of the matrix continues. -When disabled, the node stops on the first failed combination. +When disabled, the node stops on the first failed test. ## Notes diff --git a/Workflows/KSamplerMatrixLab_Workflow.json b/Workflows/KSamplerMatrixLab_Workflow.json index 717bcc1..a811e79 100644 --- a/Workflows/KSamplerMatrixLab_Workflow.json +++ b/Workflows/KSamplerMatrixLab_Workflow.json @@ -1,139 +1,9 @@ { "id": "8d8165c3-8da4-445d-b617-c8c245189557", "revision": 0, - "last_node_id": 11, - "last_link_id": 19, + "last_node_id": 17, + "last_link_id": 23, "nodes": [ - { - "id": 2, - "type": "CLIPLoader", - "pos": [ - -1244.9397992099694, - 1276.267068793239 - ], - "size": [ - 390, - 120 - ], - "flags": {}, - "order": 0, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "CLIP", - "type": "CLIP", - "links": [ - 18, - 19 - ] - } - ], - "properties": { - "Node name for S&R": "CLIPLoader", - "cnr_id": "comfy-core", - "ver": "0.8.2", - "models": [ - { - "name": "qwen_3_8b_fp8mixed.safetensors", - "url": "https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors", - "directory": "text_encoders" - } - ], - "enableTabs": false, - "tabWidth": 65, - "tabXOffset": 10, - "hasSecondTab": false, - "secondTabText": "Send Back", - "secondTabOffset": 80, - "secondTabWidth": 65 - }, - "widgets_values": [ - "qwen_3_8b_fp8mixed.safetensors", - "flux2", - "default" - ] - }, - { - "id": 6, - "type": "EmptyLatentImage", - "pos": [ - 156.51598811120022, - 1738.4127867386487 - ], - "size": [ - 270, - 106 - ], - "flags": {}, - "order": 1, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "LATENT", - "type": "LATENT", - "links": [ - 10 - ] - } - ], - "properties": { - "Node name for S&R": "EmptyLatentImage" - }, - "widgets_values": [ - 832, - 1216, - 1 - ] - }, - { - "id": 3, - "type": "VAELoader", - "pos": [ - 31.763390481797494, - 1905.7833895787114 - ], - "size": [ - 390, - 84 - ], - "flags": {}, - "order": 2, - "mode": 0, - "inputs": [], - "outputs": [ - { - "name": "VAE", - "type": "VAE", - "links": [ - 11 - ] - } - ], - "properties": { - "Node name for S&R": "VAELoader", - "cnr_id": "comfy-core", - "ver": "0.8.2", - "models": [ - { - "name": "flux2-vae.safetensors", - "url": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors", - "directory": "vae" - } - ], - "enableTabs": false, - "tabWidth": 65, - "tabXOffset": 10, - "hasSecondTab": false, - "secondTabText": "Send Back", - "secondTabOffset": 80, - "secondTabWidth": 65 - }, - "widgets_values": [ - "flux2-vae.safetensors" - ] - }, { "id": 1, "type": "UNETLoader", @@ -146,8 +16,8 @@ 90 ], "flags": {}, - "order": 3, - "mode": 0, + "order": 0, + "mode": 4, "inputs": [], "outputs": [ { @@ -194,8 +64,8 @@ 200 ], "flags": {}, - "order": 5, - "mode": 0, + "order": 8, + "mode": 4, "inputs": [ { "name": "clip", @@ -232,8 +102,8 @@ 200 ], "flags": {}, - "order": 4, - "mode": 0, + "order": 7, + "mode": 4, "inputs": [ { "name": "clip", @@ -262,16 +132,16 @@ "id": 7, "type": "KSamplerMatrixLab", "pos": [ - 711.8805408941868, - 1100.288026089966 + 730, + 1120 ], "size": [ 270, 1166 ], "flags": {}, - "order": 6, - "mode": 0, + "order": 10, + "mode": 4, "inputs": [ { "name": "model", @@ -312,7 +182,7 @@ "Node name for S&R": "KSamplerMatrixLab" }, "widgets_values": [ - 29887734876397, + 489677761671689, "randomize", 8, 1, @@ -349,20 +219,246 @@ 100 ] }, + { + "id": 6, + "type": "EmptyLatentImage", + "pos": [ + 156.51598811120022, + 1738.4127867386487 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 1, + "mode": 4, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 10 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 832, + 1216, + 1 + ] + }, + { + "id": 3, + "type": "VAELoader", + "pos": [ + 31.763390481797494, + 1905.7833895787114 + ], + "size": [ + 390, + 84 + ], + "flags": {}, + "order": 2, + "mode": 4, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 11 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.8.2", + "models": [ + { + "name": "flux2-vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors", + "directory": "vae" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "flux2-vae.safetensors" + ] + }, + { + "id": 2, + "type": "CLIPLoader", + "pos": [ + -1244.9397992099694, + 1276.267068793239 + ], + "size": [ + 390, + 120 + ], + "flags": {}, + "order": 3, + "mode": 4, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 18, + 19 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.8.2", + "models": [ + { + "name": "qwen_3_8b_fp8mixed.safetensors", + "url": "https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors", + "directory": "text_encoders" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "qwen_3_8b_fp8mixed.safetensors", + "flux2", + "default" + ] + }, + { + "id": 14, + "type": "VAELoader", + "pos": [ + -43.766305877719745, + 2714.740117789898 + ], + "size": [ + 390, + 84 + ], + "flags": {}, + "order": 4, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "VAE", + "type": "VAE", + "links": [ + 21 + ] + } + ], + "properties": { + "Node name for S&R": "VAELoader", + "cnr_id": "comfy-core", + "ver": "0.8.2", + "models": [ + { + "name": "flux2-vae.safetensors", + "url": "https://huggingface.co/Comfy-Org/flux2-dev/resolve/main/split_files/vae/flux2-vae.safetensors", + "directory": "vae" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "ae.safetensors" + ] + }, + { + "id": 15, + "type": "CLIPLoader", + "pos": [ + -45.2585223629422, + 2534.460362293897 + ], + "size": [ + 390, + 120 + ], + "flags": {}, + "order": 5, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "CLIP", + "type": "CLIP", + "links": [ + 22 + ] + } + ], + "properties": { + "Node name for S&R": "CLIPLoader", + "cnr_id": "comfy-core", + "ver": "0.8.2", + "models": [ + { + "name": "qwen_3_8b_fp8mixed.safetensors", + "url": "https://huggingface.co/Comfy-Org/flux2-klein-9B/resolve/main/split_files/text_encoders/qwen_3_8b_fp8mixed.safetensors", + "directory": "text_encoders" + } + ], + "enableTabs": false, + "tabWidth": 65, + "tabXOffset": 10, + "hasSecondTab": false, + "secondTabText": "Send Back", + "secondTabOffset": 80, + "secondTabWidth": 65 + }, + "widgets_values": [ + "Z-Image_qwen_3_4b.safetensors", + "qwen_image", + "default" + ] + }, { "id": 11, "type": "PreviewImage", "pos": [ - 1148.2866581416445, - 1099.2675050435573 + 1235.533669486074, + 1121.475835203959 ], "size": [ 586.4129170279989, 686.0355896418848 ], "flags": {}, - "order": 7, - "mode": 0, + "order": 12, + "mode": 4, "inputs": [ { "name": "images", @@ -371,10 +467,159 @@ } ], "outputs": [], + "title": "Sampler Scheduler Matrix", "properties": { "Node name for S&R": "PreviewImage" }, "widgets_values": [] + }, + { + "id": 16, + "type": "PreviewImage", + "pos": [ + 1317.8212566020425, + 2422.2894985420116 + ], + "size": [ + 586.4129170279989, + 686.0355896418848 + ], + "flags": {}, + "order": 11, + "mode": 0, + "inputs": [ + { + "name": "images", + "type": "IMAGE", + "link": 23 + } + ], + "outputs": [], + "title": "Model Matrix", + "properties": { + "Node name for S&R": "PreviewImage" + }, + "widgets_values": [] + }, + { + "id": 13, + "type": "EmptyLatentImage", + "pos": [ + 73.83538582904669, + 2361.445960961291 + ], + "size": [ + 270, + 106 + ], + "flags": {}, + "order": 6, + "mode": 0, + "inputs": [], + "outputs": [ + { + "name": "LATENT", + "type": "LATENT", + "links": [ + 20 + ] + } + ], + "properties": { + "Node name for S&R": "EmptyLatentImage" + }, + "widgets_values": [ + 896, + 1152, + 1 + ] + }, + { + "id": 12, + "type": "ModelMatrixLab", + "pos": [ + 645.1103132010634, + 2421.681847892186 + ], + "size": [ + 462.5216843315013, + 1809.1883183159807 + ], + "flags": {}, + "order": 9, + "mode": 0, + "inputs": [ + { + "name": "latent_image", + "type": "LATENT", + "link": 20 + }, + { + "name": "clip", + "shape": 7, + "type": "CLIP", + "link": 22 + }, + { + "name": "vae", + "shape": 7, + "type": "VAE", + "link": 21 + } + ], + "outputs": [ + { + "name": "grid_image", + "type": "IMAGE", + "links": [ + 23 + ] + } + ], + "properties": { + "Node name for S&R": "ModelMatrixLab" + }, + "widgets_values": [ + "A raw phone photo of an 25 years old Colombian-Japanese woman with bobcut dark hair, medium warm tan skin with a natural matte texture, and a small thin hoop nose ring in her right nostril. She has a curvy Instagram-model body with an athletic waist, wide hips, full thighs, sculpted proportions, and large breasts.\n\nShe is sitting beside a window, one knee raised, wearing an elegant black silk halter wrap dress inspired by a luxury evening gown: deep plunging neckline, soft draped fabric crossing around the waist, glossy black satin-silk texture, fitted torso, flowing skirt, and a high side slit revealing one thigh. The dress looks expensive, smooth, slightly reflective, and naturally wrinkled where the fabric folds around her body.\n\nHer expression is seductive, intense, and confident, with a direct composed gaze into the camera. The scene is set in a tower room with red curtains, an old wooden desk, scattered spellbooks, candles, and antique details. The lighting is warm red lamp light with a dark night window behind her, creating dramatic contrast on the black silk dress.\n\nNatural skin texture, realistic uneven lighting, imperfect details, candid handheld framing, raw phone photo, no beauty retouching, no glamour studio look.", + "", + 568148328078253, + "randomize", + 10, + 1, + 1, + "euler_flow", + "zimage_turbo", + "checkpoint | zitZEUSONE_v10.safetensors", + "checkpoint | zImageTurbo_v10.safetensors", + "checkpoint | zitBadmilkFocus_v10.safetensors", + "checkpoint | wkbZIT_fp8V10.safetensors", + "checkpoint | whiplashNSFW_v50FilmicFisheyeZIT.safetensors", + "checkpoint | fixedZturbo_tertium.safetensors", + "checkpoint | sophosZit_v11.safetensors", + "checkpoint | cielbleuZIT_v1.safetensors", + "checkpoint | sickOllie_v1.safetensors", + "checkpoint | reazit_v10FP8.safetensors", + "checkpoint | bordella_v20.safetensors", + "checkpoint | dafZIT_gamma.safetensors", + "checkpoint | bigLoveZ_zt1.safetensors", + "checkpoint | zEpicrealism_turboV1Fp8.safetensors", + "checkpoint | zimageTurboNSFWBy_2602NSFWBF16.safetensors", + "checkpoint | moodyProMix_zitV13FP8.safetensors", + "checkpoint | divingZImageTurbo_v60Fp16.safetensors", + "checkpoint | pornmasterZImage_turboV35Fp8.safetensors", + "checkpoint | zImageTurboNSFW_82BF16FP8.safetensors", + "checkpoint | darkBeast_dbzit9DIMRclaw.safetensors", + "default", + 3, + 1, + 22, + 10, + "white", + true, + true, + true, + true + ] } ], "links": [ @@ -441,19 +686,51 @@ 9, 0, "CLIP" + ], + [ + 20, + 13, + 0, + 12, + 0, + "LATENT" + ], + [ + 21, + 14, + 0, + 12, + 2, + "VAE" + ], + [ + 22, + 15, + 0, + 12, + 1, + "CLIP" + ], + [ + 23, + 12, + 0, + 16, + 0, + "IMAGE" ] ], "groups": [], "config": {}, "extra": { "ds": { - "scale": 0.9229599817706413, + "scale": 1.015255979947709, "offset": [ - 1028.9801016231872, - -895.579296297689 + 125.38375815942595, + -2096.0447889369275 ] }, - "frontendVersion": "1.41.21" + "frontendVersion": "1.45.15" }, "version": 0.4 } \ No newline at end of file diff --git a/ksampler_matrix_lab.py b/ksampler_matrix_lab.py index 4c35b14..502d99f 100644 --- a/ksampler_matrix_lab.py +++ b/ksampler_matrix_lab.py @@ -1,5 +1,6 @@ import copy import gc +import math import textwrap import numpy as np @@ -9,7 +10,8 @@ from PIL import Image, ImageDraw, ImageFont import comfy.model_management import comfy.samplers import comfy.utils -from nodes import common_ksampler +import folder_paths +from nodes import CheckpointLoaderSimple, UNETLoader, common_ksampler MAX_SEED = 0xFFFFFFFFFFFFFFFF @@ -17,7 +19,10 @@ DEFAULT_MAX_COMBINATIONS = 100 NONE_OPTION = "None" SAMPLER_SLOT_COUNT = 9 SCHEDULER_SLOT_COUNT = 9 +MODEL_SLOT_COUNT = 20 UNKNOWN_LABEL = "unknown" +CHECKPOINT_PREFIX = "checkpoint | " +DIFFUSION_PREFIX = "diffusion | " def collect_selected_slots(values): @@ -42,6 +47,80 @@ def get_available_schedulers(): return list(comfy.samplers.KSampler.SCHEDULERS) +def get_available_model_choices(): + checkpoints = [f"{CHECKPOINT_PREFIX}{name}" for name in folder_paths.get_filename_list("checkpoints")] + diffusion_models = [ + f"{DIFFUSION_PREFIX}{name}" + for name in folder_paths.get_filename_list("diffusion_models") + ] + return checkpoints + diffusion_models + + +def parse_model_selection(selection): + if selection.startswith(CHECKPOINT_PREFIX): + return "checkpoint", selection[len(CHECKPOINT_PREFIX):] + if selection.startswith(DIFFUSION_PREFIX): + return "diffusion", selection[len(DIFFUSION_PREFIX):] + raise ValueError(f"Unsupported model selection: {selection}") + + +def load_model_selection(selection, diffusion_weight_dtype): + source_type, model_name = parse_model_selection(selection) + if source_type == "checkpoint": + model, clip, vae = CheckpointLoaderSimple().load_checkpoint(model_name) + return model, clip, vae, source_type, model_name + + model = UNETLoader().load_unet(model_name, diffusion_weight_dtype)[0] + return model, None, None, source_type, model_name + + +def encode_prompt_text(clip, text): + if not is_valid_clip(clip): + raise RuntimeError( + "No compatible CLIP is available. Connect the optional CLIP input " + "when comparing standalone diffusion models." + ) + tokens = clip.tokenize(text) + return clip.encode_from_tokens_scheduled(tokens) + + +def is_valid_clip(clip): + if clip is None: + return False + return callable(getattr(clip, "tokenize", None)) and callable( + getattr(clip, "encode_from_tokens_scheduled", None) + ) + + +def is_valid_vae(vae): + if vae is None or not callable(getattr(vae, "decode", None)): + return False + + validator = getattr(vae, "throw_exception_if_invalid", None) + if callable(validator): + try: + validator() + except RuntimeError: + return False + return True + + +def select_clip(checkpoint_clip, external_clip): + if is_valid_clip(checkpoint_clip): + return checkpoint_clip + if is_valid_clip(external_clip): + return external_clip + return None + + +def select_vae(checkpoint_vae, external_vae): + if is_valid_vae(checkpoint_vae): + return checkpoint_vae + if is_valid_vae(external_vae): + return external_vae + return None + + def validate_selected_names(selected, available, label): if not selected: raise ValueError(f"No {label} selected. Choose at least one {label} slot that is not None.") @@ -496,6 +575,162 @@ def compose_grid( return grid +def build_model_run_header_text(sampler_name, scheduler, steps, cfg, denoise): + return ( + "Model Matrix Lab Benchmark\n" + f"Sampler: {sampler_name} | Scheduler: {scheduler}\n" + f"Steps: {steps} | CFG: {float(cfg):g} | Denoise: {float(denoise):g}" + ) + + +def compose_model_error_cell(size, model_label, error_message, background, font): + cell = Image.new("RGB", size, resolve_background(background)) + draw = ImageDraw.Draw(cell) + fill = (210, 76, 68) if background == "white" else (255, 146, 132) + short_error = str(error_message).replace("\n", " ")[:180] + draw_centered_text( + draw, + (8, 8, size[0] - 16, size[1] - 16), + f"ERROR\n{model_label}\n{short_error}", + font, + fill, + ) + return cell + + +def compose_model_grid( + entries, + latent_image, + sampler_name, + scheduler, + steps, + cfg, + denoise, + grid_columns, + cell_scale, + font_size, + padding, + background, + show_grid_lines, + show_cell_labels, + show_run_header, +): + font = load_font(font_size) + label_font = load_font(max(9, int(font_size * 0.82))) + first_image = next( + (entry["image"] for entry in entries if entry.get("image") is not None), + None, + ) + base_width, base_height = ( + first_image.size + if first_image is not None + else placeholder_size_from_latent(latent_image) + ) + + cell_width = max(1, int(round(base_width * cell_scale))) + cell_height = max(1, int(round(base_height * cell_scale))) + cell_label_height = max(42, int(font_size * 2.7)) if show_cell_labels else 0 + cell_block_height = cell_height + cell_label_height + + columns = max(1, min(int(grid_columns), len(entries))) + rows = math.ceil(len(entries) / columns) + background_rgb = resolve_background(background) + label_background_rgb = label_background_for_background(background) + text_rgb = text_color_for_background(background) + line_rgb = line_color_for_background(background) + + total_width = columns * cell_width + (columns + 1) * padding + header_text = build_model_run_header_text( + sampler_name, + scheduler, + steps, + cfg, + denoise, + ) + if show_run_header: + measure = ImageDraw.Draw(Image.new("RGB", (1, 1))) + header_text_height = wrapped_text_height( + measure, + header_text, + font, + max(1, total_width - 2 * padding), + ) + run_header_height = max(70, header_text_height + 2 * padding) + else: + run_header_height = 0 + + total_height = run_header_height + rows * cell_block_height + (rows + 1) * padding + grid = Image.new("RGB", (total_width, total_height), background_rgb) + draw = ImageDraw.Draw(grid) + + if show_run_header: + draw.rectangle( + (0, 0, total_width - 1, run_header_height - 1), + fill=label_background_rgb, + ) + draw_centered_text( + draw, + (padding, padding, total_width - 2 * padding, run_header_height - 2 * padding), + header_text, + font, + text_rgb, + ) + if show_grid_lines: + draw.line( + (0, run_header_height - 1, total_width, run_header_height - 1), + fill=line_rgb, + width=1, + ) + + for index, entry in enumerate(entries): + row = index // columns + column = index % columns + x = padding + column * (cell_width + padding) + y = run_header_height + padding + row * (cell_block_height + padding) + + if show_cell_labels: + draw.rectangle( + (x, y, x + cell_width - 1, y + cell_label_height - 1), + fill=label_background_rgb, + ) + draw_centered_text( + draw, + (x + 4, y + 3, cell_width - 8, cell_label_height - 6), + f"Model: {entry['label']}", + label_font, + text_rgb, + ) + if show_grid_lines: + draw.line( + (x, y + cell_label_height - 1, x + cell_width - 1, y + cell_label_height - 1), + fill=line_rgb, + width=1, + ) + + if entry.get("image") is not None: + image = entry["image"] + if image.size != (cell_width, cell_height): + image = image.resize((cell_width, cell_height), Image.Resampling.LANCZOS) + else: + image = compose_model_error_cell( + (cell_width, cell_height), + entry["label"], + entry.get("error", "Unknown error"), + background, + label_font, + ) + + grid.paste(image, (x, y + cell_label_height)) + if show_grid_lines: + draw.rectangle( + (x, y, x + cell_width - 1, y + cell_block_height - 1), + outline=line_rgb, + width=1, + ) + + return grid + + class KSamplerMatrixLab: @classmethod def INPUT_TYPES(cls): @@ -696,10 +931,266 @@ class KSamplerMatrixLab: return (pil_to_tensor(grid),) +class ModelMatrixLab: + @classmethod + def INPUT_TYPES(cls): + model_choices = [NONE_OPTION] + get_available_model_choices() + available_models = get_available_model_choices() + samplers = get_available_samplers() + schedulers = get_available_schedulers() + + required = { + "latent_image": ("LATENT",), + "positive_text": ( + "STRING", + { + "default": "", + "multiline": True, + "dynamicPrompts": True, + "tooltip": "The same positive prompt is encoded for every selected model.", + }, + ), + "negative_text": ( + "STRING", + { + "default": "", + "multiline": True, + "dynamicPrompts": True, + "tooltip": "The same negative prompt is encoded for every selected model.", + }, + ), + "seed": ( + "INT", + { + "default": 0, + "min": 0, + "max": MAX_SEED, + "control_after_generate": True, + }, + ), + "steps": ("INT", {"default": 20, "min": 1, "max": 10000}), + "cfg": ("FLOAT", {"default": 8.0, "min": 0.0, "max": 100.0, "step": 0.1}), + "denoise": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), + "sampler_name": ( + samplers, + {"tooltip": "The sampler used for every selected model."}, + ), + "scheduler": ( + schedulers, + {"tooltip": "The scheduler used for every selected model."}, + ), + } + + for index in range(1, MODEL_SLOT_COUNT + 1): + required[f"model_{index:02d}"] = ( + model_choices, + { + "default": available_models[0] if index == 1 and available_models else NONE_OPTION, + "tooltip": "Select a checkpoint or standalone diffusion model, or None to ignore this slot.", + }, + ) + + required.update( + { + "diffusion_weight_dtype": ( + ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2"], + { + "default": "default", + "advanced": True, + "tooltip": "Weight dtype used only for standalone diffusion models.", + }, + ), + "grid_columns": ("INT", {"default": 3, "min": 1, "max": 20}), + "cell_scale": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 4.0, "step": 0.05}), + "font_size": ("INT", {"default": 22, "min": 8, "max": 96}), + "padding": ("INT", {"default": 10, "min": 0, "max": 96}), + "background": (["white", "gray", "black"], {"default": "white"}), + "show_grid_lines": ("BOOLEAN", {"default": True}), + "show_cell_labels": ( + "BOOLEAN", + { + "default": True, + "tooltip": "Show the model source and filename above every grid cell.", + }, + ), + "show_run_header": ( + "BOOLEAN", + { + "default": True, + "tooltip": "Show sampler, scheduler, steps, CFG, and denoise above the grid.", + }, + ), + "continue_on_error": ("BOOLEAN", {"default": True}), + } + ) + + return { + "required": required, + "optional": { + "clip": ( + "CLIP", + { + "tooltip": "Required for standalone diffusion models or checkpoints without an embedded CLIP.", + }, + ), + "vae": ( + "VAE", + { + "tooltip": "Required for standalone diffusion models or checkpoints without an embedded VAE.", + }, + ), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("grid_image",) + FUNCTION = "generate_model_matrix" + CATEGORY = "ComfyUI-KSampler-Matrix-Lab" + DESCRIPTION = "Compares selected checkpoints and diffusion models with one shared sampler and scheduler, then returns a labeled image grid." + + def generate_model_matrix( + self, + latent_image, + positive_text, + negative_text, + seed, + steps, + cfg, + denoise, + sampler_name, + scheduler, + diffusion_weight_dtype, + grid_columns, + cell_scale, + font_size, + padding, + background, + show_grid_lines, + show_cell_labels, + show_run_header, + continue_on_error, + clip=None, + vae=None, + **model_slots, + ): + selected_models = collect_selected_slots( + model_slots.get(f"model_{index:02d}") + for index in range(1, MODEL_SLOT_COUNT + 1) + ) + validate_selected_names( + selected_models, + get_available_model_choices(), + "model", + ) + + progress = comfy.utils.ProgressBar(len(selected_models)) + entries = [] + + for selection in selected_models: + loaded_model = None + loaded_clip = None + loaded_vae = None + effective_clip = None + effective_vae = None + positive = None + negative = None + sampled_latent = None + decoded_images = None + source_type, model_name = parse_model_selection(selection) + source_label = "Checkpoint" if source_type == "checkpoint" else "Diffusion" + display_label = f"{source_label}: {model_name}" + + try: + ( + loaded_model, + loaded_clip, + loaded_vae, + _, + _, + ) = load_model_selection(selection, diffusion_weight_dtype) + + effective_clip = select_clip(loaded_clip, clip) + effective_vae = select_vae(loaded_vae, vae) + if not is_valid_vae(effective_vae): + raise RuntimeError( + "No compatible VAE is available. Connect the optional VAE input " + "when a selected checkpoint does not contain a valid VAE or when " + "comparing standalone diffusion models." + ) + + positive = encode_prompt_text(effective_clip, positive_text) + negative = encode_prompt_text(effective_clip, negative_text) + sampled_latent = run_single_sample( + loaded_model, + seed, + steps, + cfg, + sampler_name, + scheduler, + positive, + negative, + latent_image, + denoise, + ) + decoded_images = decode_latent_to_image(effective_vae, sampled_latent) + entries.append( + { + "label": display_label, + "image": tensor_to_pil(decoded_images), + } + ) + except Exception as exc: + if not continue_on_error: + raise RuntimeError( + f"Model Matrix Lab failed for '{display_label}': {exc}" + ) from exc + entries.append( + { + "label": display_label, + "image": None, + "error": str(exc), + } + ) + finally: + loaded_model = None + loaded_clip = None + loaded_vae = None + effective_clip = None + effective_vae = None + positive = None + negative = None + sampled_latent = None + decoded_images = None + progress.update(1) + gc.collect() + comfy.model_management.soft_empty_cache() + + grid = compose_model_grid( + entries, + latent_image, + sampler_name, + scheduler, + steps, + cfg, + denoise, + grid_columns, + cell_scale, + font_size, + padding, + background, + show_grid_lines, + show_cell_labels, + show_run_header, + ) + return (pil_to_tensor(grid),) + + NODE_CLASS_MAPPINGS = { "KSamplerMatrixLab": KSamplerMatrixLab, + "ModelMatrixLab": ModelMatrixLab, } NODE_DISPLAY_NAME_MAPPINGS = { "KSamplerMatrixLab": "KSampler Matrix Lab", + "ModelMatrixLab": "Model Matrix Lab", }