Add model comparison matrix node

This commit is contained in:
btitkin
2026-06-17 19:48:14 +02:00
parent 5239213920
commit b2b82f09df
3 changed files with 992 additions and 159 deletions
+72 -7
View File
@@ -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
+428 -151
View File
@@ -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
}
+492 -1
View File
@@ -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",
}