Text-to-Image
Diffusers
image-generation
comfyui
quantization
int8
int4
svdquant
krea2
krea
diffusion
transformer
lowvram
Instructions to use AlperKTS/Krea-2-SVDQuant-ComfyUI with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Diffusers
How to use AlperKTS/Krea-2-SVDQuant-ComfyUI with Diffusers:
pip install -U diffusers transformers accelerate
import torch from diffusers import DiffusionPipeline # switch to "mps" for apple devices pipe = DiffusionPipeline.from_pretrained("AlperKTS/Krea-2-SVDQuant-ComfyUI", torch_dtype=torch.bfloat16, device_map="cuda") prompt = "Astronaut in a jungle, cold color palette, muted colors, detailed, 8k" image = pipe(prompt).images[0] - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- Draw Things
- DiffusionBee
| """Multi-seed, paired fidelity benchmark against a BF16 reference -- with and without a LoRA. | |
| python tools/fidelity_bench.py generate --output-dir <ComfyUI/output> [--variant base] [--seeds 4] | |
| python tools/fidelity_bench.py score --output-dir <ComfyUI/output> | |
| Why this exists. The measurement it replaced scored one seed per prompt and reported each | |
| checkpoint's *marginal* mean. Two things go wrong with that: | |
| * **One seed cannot resolve the differences we care about.** Re-analysing its own 130-pair | |
| output as paired differences: `rank256 vs nolowrank` is a clean t=-4.73 (10/10 prompts), | |
| but `rank256 vs rank64` -- a *fourfold* rank increase -- is t=-1.29 and wins only 5 of 10 | |
| prompts. Anything subtler than the branch itself is invisible at n=10, which is how a rank | |
| reallocation (`--rank-alloc gqa`, t=0.55) can look like a null result without that being | |
| provable either way. | |
| * **Marginal means hide the effect behind prompt difficulty.** Prompt LPIPS ranges 0.23-0.42 | |
| in this set; between-prompt spread swamps between-checkpoint spread. The same prompt scored | |
| under two checkpoints cancels that -- so this reports `mean(A_i - B_i)` over shared cells | |
| with its own standard error, never two independent means eyeballed side by side. | |
| So: several seeds per prompt, paired statistics, and a reseed noise floor printed next to | |
| every result. A difference smaller than the floor is a difference you cannot claim. | |
| The LoRA arm exists because the repo's headline use case is unmeasured. `BENCHMARKS.md` | |
| Test 2 scored LoRA output with a vision judge that had already saturated (71 of 110 cells a | |
| flat 10/10 in the run that preceded it), and it never compared quantized+LoRA against | |
| *BF16+LoRA* -- only against each other. The question that arm answers: does a LoRA amplify | |
| quantization error, i.e. is LPIPS(quant+LoRA vs BF16+LoRA) worse than LPIPS(quant vs BF16)? | |
| If it is, low-rank branch size matters more for LoRA users than the plain t2i sweep suggests. | |
| Both arms therefore run the *same* prompts and seeds, so the two are directly subtractable. | |
| Generation is idempotent: a cell whose PNG already exists is skipped, so the dataset can be | |
| grown one `--seeds` or `--checkpoints` at a time without redoing work. | |
| Needs `lpips` (`pip install lpips`) for the score stage; the generate stage needs only a | |
| running ComfyUI on `--server`. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import csv | |
| import itertools | |
| import json | |
| import math | |
| import os | |
| import statistics | |
| import sys | |
| import time | |
| import urllib.error | |
| import urllib.request | |
| HERE = os.path.dirname(os.path.abspath(__file__)) | |
| sys.path.insert(0, HERE) | |
| # The metric implementations are shared rather than reimplemented -- two copies of SSIM in | |
| # one repo is two chances to have a different window or a different epsilon. | |
| import torch # noqa: E402 | |
| import pixel_metrics # noqa: E402 | |
| SUBDIR = "fb" | |
| REFERENCE = "bf16" | |
| # 8 steps / cfg 1.0 / euler+simple is what Krea 2 Turbo is distilled for; the base model is | |
| # not distilled and wants 50 steps at cfg 3.5 (SAMPLER_HINTS in quantize_krea2.py). Changing | |
| # either set changes what "the same image" means and invalidates comparison with earlier runs, | |
| # so they are picked by `--variant` rather than edited. | |
| STEPS = 8 | |
| CFG = 1.0 | |
| SAMPLER = "euler" | |
| SCHEDULER = "simple" | |
| WIDTH = 1024 | |
| HEIGHT = 1024 | |
| CLIP_NAME = "qwen3vl_4b_fp8_scaled.safetensors" | |
| VAE_NAME = "qwen_image_vae.safetensors" | |
| LORA_STRENGTH = 1.0 | |
| # An "arm" is one LoRA configuration, `base` being none. Each is scored against its *own* | |
| # BF16 reference, because a LoRA changes how seed-sensitive the model is -- the reseed floor | |
| # is 0.547 without one and 0.519 with -- so raw LPIPS is not comparable across arms and only | |
| # the within-arm paired numbers mean anything. | |
| # | |
| # Both LoRAs here cover the same layers (448 block + 64 txtfusion keys) and differ in rank, | |
| # which is the point: a second LoRA is what separates "this LoRA behaves like that" from | |
| # "LoRAs behave like that". A txtfusion-only adapter would be useless here -- it never | |
| # touches the 224 quantized blocks, so it cannot exercise the branch at all. | |
| # | |
| # The names must match what ComfyUI puts in the node's dropdown, which for a LoRA in a | |
| # subfolder is a *native* relative path -- `Krea2\canon_krea2.safetensors` on Windows. A | |
| # forward slash there fails validation with "value_not_in_list", so the subfolder is joined | |
| # rather than written in. `LORA_SUBDIR = ""` for a flat `models/loras`. | |
| LORA_SUBDIR = "Krea2" | |
| def _lora(name: str) -> str: | |
| return os.path.join(LORA_SUBDIR, name) if LORA_SUBDIR else name | |
| ARM_LORA = { | |
| "base": None, | |
| "lora": _lora("canon_krea2.safetensors"), # rank 16, photographic style | |
| "lora2": _lora("bloomgirls-ultrarealism-krea2_4k.safetensors"), # rank 32, realism style | |
| "lora3": _lora("lenovo_krea2.safetensors"), # rank 16 | |
| "lora4": _lora("nicegirls_krea2.safetensors"), # rank 16 | |
| } | |
| # Loader choice is a property of the file, so it lives with the file rather than being | |
| # guessed from the name at call time. `quantized` drives the *LoRA* node choice: applying a | |
| # LoRA to a quantized weight through the stock path means dequantize -> add -> requantize, | |
| # which re-quantizes the LoRA delta to 4 bits, so quantized files go through this repo's node. | |
| CHECKPOINTS_TURBO = { | |
| "bf16": {"file": "turbo.safetensors", "loader": "unet", "quantized": False}, | |
| "nolowrank": {"file": "Krea2-Turbo-W4A4-noLowRank.safetensors", "loader": "unet", "quantized": True}, | |
| "r16": {"file": "Krea2-Turbo-SVDQuant-W4A4-rank16.safetensors", "loader": "svdq", "quantized": True}, | |
| "r64": {"file": "Krea2-Turbo-SVDQuant-W4A4-rank64.safetensors", "loader": "svdq", "quantized": True}, | |
| "r128": {"file": "Krea2-Turbo-SVDQuant-W4A4-rank128.safetensors","loader": "svdq", "quantized": True}, | |
| "r256": {"file": "Krea2-Turbo-SVDQuant-W4A4-rank256.safetensors","loader": "svdq", "quantized": True}, | |
| # Same rank, same size, same kernel -- built with --act-stats so the low-rank split is | |
| # weighted by measured activation RMS instead of plain weight magnitude. | |
| "r256aa": {"file": "Krea2-Turbo-SVDQuant-W4A4-rank256-actaware.safetensors", "loader": "svdq", "quantized": True}, | |
| # Same build as r256aa except the activation statistics were captured with `lora2` | |
| # loaded. BENCHMARKS.md's open question is whether act-aware goes null under a LoRA | |
| # because the calibration saw no adapter; this arm is what answers it, and it is only | |
| # meaningful when scored on the `lora2` arm it was calibrated for. | |
| "r256aal2": {"file": "Krea2-Turbo-SVDQuant-W4A4-rank256-actaware-lora2.safetensors", "loader": "svdq", "quantized": True}, | |
| } | |
| CHECKPOINTS_BASE = { | |
| "bf16": {"file": "raw.safetensors", "loader": "unet", "quantized": False}, | |
| "nolowrank": {"file": "Krea2-Base-W4A4-convrot.safetensors", "loader": "unet", "quantized": True}, | |
| "r16": {"file": "Krea2-Base-SVDQuant-W4A4-rank16.safetensors", "loader": "svdq", "quantized": True}, | |
| "r64": {"file": "Krea2-Base-SVDQuant-W4A4-rank64.safetensors", "loader": "svdq", "quantized": True}, | |
| "r128": {"file": "Krea2-Base-SVDQuant-W4A4-rank128.safetensors","loader": "svdq", "quantized": True}, | |
| "r256": {"file": "Krea2-Base-SVDQuant-W4A4-rank256.safetensors","loader": "svdq", "quantized": True}, | |
| "r256aa": {"file": "Krea2-Base-SVDQuant-W4A4-rank256-actaware.safetensors", "loader": "svdq", "quantized": True}, | |
| } | |
| # Renders go to a per-variant subfolder so a base run cannot be scored against turbo images: | |
| # the scorer indexes a directory, and two variants in one directory would silently pair | |
| # 8-step turbo renders with 50-step base ones. | |
| # The negative matters only above cfg 1.0. Turbo runs at cfg 1.0, where the negative branch | |
| # is never evaluated, so a zeroed-out conditioning costs nothing there. The base model runs at | |
| # cfg 3.5 and CFG then pushes *away* from whatever the negative is -- away from a zeroed | |
| # conditioning means away from nothing, which comes out as grainy, over-contrasted garbage. | |
| # `None` = ConditioningZeroOut; a string = a real encoded negative, as the shipped base | |
| # workflow uses. | |
| NEGATIVE_BASE = "blurry, low resolution, jpeg artifacts, watermark, deformed, extra limbs" | |
| VARIANTS = { | |
| "turbo": {"steps": 8, "cfg": 1.0, "subdir": "fb", "checkpoints": CHECKPOINTS_TURBO, | |
| "negative": None}, | |
| "base": {"steps": 50, "cfg": 3.5, "subdir": "fb_base", "checkpoints": CHECKPOINTS_BASE, | |
| "negative": NEGATIVE_BASE}, | |
| } | |
| CHECKPOINTS = CHECKPOINTS_TURBO | |
| NEGATIVE = None | |
| def select_variant(name: str) -> None: | |
| """Point the module's sampler settings and checkpoint table at one model variant.""" | |
| global STEPS, CFG, SUBDIR, CHECKPOINTS, NEGATIVE | |
| spec = VARIANTS[name] | |
| STEPS, CFG = spec["steps"], spec["cfg"] | |
| SUBDIR, CHECKPOINTS = spec["subdir"], spec["checkpoints"] | |
| NEGATIVE = spec["negative"] | |
| # The repo's established prompt set (the same ids BENCHMARKS.md uses), kept verbatim so new | |
| # numbers stay comparable with the old ones. | |
| PROMPTS_OBJECTS = [ | |
| ("01_dense_text", "A rain-soaked neon diner sign at night, below it a handwritten chalkboard menu with three lines of text reading 'SOUP $4 / PIE $6 / COFFEE $2', reflections on wet asphalt, cinematic"), | |
| ("02_curved_text", "Close-up of a person holding a paper coffee cup with large bold curved text 'STAY WARM' printed around the cup, soft morning light, shallow depth of field"), | |
| ("03_hands_detail", "A violinist's hands mid-performance, fingers pressed on the strings, bow in motion with visible blur, studio lighting, extreme close-up, photorealistic"), | |
| ("04_crowd_faces", "A busy Tokyo street crossing at dusk, dozens of pedestrians with distinct faces and expressions, neon signage in the background, wide angle, high detail"), | |
| ("05_symmetry_pattern", "A perfectly symmetrical Islamic geometric tile mosaic, intricate repeating star and polygon pattern, deep blue and gold, overhead flat lighting, ultra sharp"), | |
| ("06_multi_subject", "Two chefs in white uniforms plating a dish together in a busy kitchen, one holding tweezers placing a garnish, the other pouring sauce, steam rising, low angle shot"), | |
| ("07_reflections_glass", "A glass of iced whiskey on a dark wood bar, condensation droplets, warm bokeh lights reflected in the glass and the liquid, macro photography"), | |
| ("08_logo_typography", "A vintage motorcycle fuel tank with a hand-painted logo reading 'IRON WOLF GARAGE' in bold serif letters, chrome and scratched paint texture, studio product shot"), | |
| ("09_counting_objects", "A wooden table from directly above with exactly seven red apples arranged in a neat row next to three green pears, soft natural light, flat lay photography"), | |
| ("10_complex_scene", "A fantasy marketplace street at golden hour, merchant stalls with hanging fabrics and baskets of spices, a dragon perched on a rooftop in the background, dense crowd, painterly digital art"), | |
| ] | |
| # People, deliberately. The set above is mostly objects and scenes, which under-tests the | |
| # kind of LoRA most people actually install: `canon_krea2` is a photographic style adapter, | |
| # and a style adapter shows its hand on skin, hair and fabric far more than on a tile mosaic. | |
| # Faces are also the failure mode viewers are most sensitive to, so a quantization difference | |
| # that is invisible on a marketplace scene can be obvious on a portrait. | |
| PROMPTS_PEOPLE = [ | |
| ("11_portrait_woman", "Close-up portrait of a woman with freckles and green eyes, windswept auburn hair, wearing a chunky knitted wool sweater, standing on a rainy city street at blue hour, shallow depth of field, natural skin texture with visible pores, catchlights in both eyes, 85mm lens, photorealistic"), | |
| ("12_person_holding_sign", "A young woman in a bright yellow raincoat standing at the end of a wooden pier, holding up a handwritten cardboard sign that reads 'BACK IN 5 MIN' in thick black marker, seagulls circling behind her, overcast diffused light, full body shot, 35mm documentary photography"), | |
| ("13_two_people_interaction", "A barista with tattooed forearms handing a paper cup across a marble counter to a bearded man in a denim jacket, both mid-conversation and smiling, morning light through a tall window, steam rising from the cup, candid documentary photography, natural skin tones"), | |
| ("14_hands_and_face", "A woman applying red lipstick while looking into a small round handheld mirror, her fingers wrapped around the mirror rim, the reflection of one eye visible in the glass, warm bathroom lighting, extreme close-up on face and hands, photorealistic detail"), | |
| ("15_full_body_fashion", "Full body editorial fashion photograph of a woman in a flowing emerald green silk dress mid-stride on a marble staircase, one hand trailing on the brass railing, dramatic hard side lighting, sharp fabric texture and folds, 50mm, high fashion magazine style"), | |
| ("16_group_selfie", "Four friends of different ethnicities crowded around a restaurant table taking a group selfie, one holding the phone at arm's length, another leaning in making a peace sign, plates of pasta and half-full wine glasses on the table, warm indoor lighting, natural candid expressions"), | |
| ] | |
| PROMPTS = PROMPTS_OBJECTS + PROMPTS_PEOPLE | |
| # Fixed rather than random so a re-run reproduces the same dataset. The first is the seed the | |
| # older single-seed benchmarks used, which keeps those results inside this one. | |
| SEEDS = [987654321, 424242424, 12345, 777] | |
| # --------------------------------------------------------------------------- generation | |
| def cell_name(arm: str, ckpt: str, prompt_id: str, seed: int) -> str: | |
| """Filename stem for one cell. `__` separates fields because prompt ids contain `_`.""" | |
| return "{}__{}__{}__s{}".format(arm, ckpt, prompt_id, seed) | |
| def build_graph(ckpt: str, prompt: str, seed: int, arm: str, prefix: str) -> dict: | |
| spec = CHECKPOINTS[ckpt] | |
| if spec["loader"] == "svdq": | |
| loader = {"class_type": "Krea2SVDQuantW4A4Loader", | |
| "inputs": {"model_name": spec["file"]}} | |
| else: | |
| loader = {"class_type": "UNETLoader", | |
| "inputs": {"unet_name": spec["file"], "weight_dtype": "default"}} | |
| g = { | |
| "1": loader, | |
| "2": {"class_type": "CLIPLoader", | |
| "inputs": {"clip_name": CLIP_NAME, "type": "krea2", "device": "default"}}, | |
| "3": {"class_type": "VAELoader", "inputs": {"vae_name": VAE_NAME}}, | |
| "4": {"class_type": "CLIPTextEncode", "inputs": {"text": prompt, "clip": ["2", 0]}}, | |
| "5": ({"class_type": "ConditioningZeroOut", "inputs": {"conditioning": ["4", 0]}} | |
| if NEGATIVE is None else | |
| {"class_type": "CLIPTextEncode", "inputs": {"text": NEGATIVE, "clip": ["2", 0]}}), | |
| "6": {"class_type": "EmptySD3LatentImage", | |
| "inputs": {"width": WIDTH, "height": HEIGHT, "batch_size": 1}}, | |
| "7": {"class_type": "KSampler", | |
| "inputs": {"seed": seed, "steps": STEPS, "cfg": CFG, "sampler_name": SAMPLER, | |
| "scheduler": SCHEDULER, "denoise": 1.0, | |
| "model": ["1", 0], "positive": ["4", 0], "negative": ["5", 0], | |
| "latent_image": ["6", 0]}}, | |
| "8": {"class_type": "VAEDecode", "inputs": {"samples": ["7", 0], "vae": ["3", 0]}}, | |
| "9": {"class_type": "SaveImage", | |
| "inputs": {"images": ["8", 0], "filename_prefix": prefix}}, | |
| } | |
| lora = ARM_LORA[arm] | |
| if lora: | |
| # A quantized checkpoint needs this repo's LoRA node whether or not it carries a | |
| # low-rank branch -- `nolowrank` included. ComfyUI's own loader would report success | |
| # and patch nothing on the 224 quantized blocks. | |
| if spec["quantized"]: | |
| g["10"] = {"class_type": "Krea2SVDQuantLoraLoader", | |
| "inputs": {"model": ["1", 0], "lora_name": lora, | |
| "strength": LORA_STRENGTH}} | |
| else: | |
| g["10"] = {"class_type": "LoraLoaderModelOnly", | |
| "inputs": {"model": ["1", 0], "lora_name": lora, | |
| "strength_model": LORA_STRENGTH}} | |
| g["7"]["inputs"]["model"] = ["10", 0] | |
| return g | |
| def _post(server: str, path: str, payload: dict) -> dict: | |
| req = urllib.request.Request(server + path, data=json.dumps(payload).encode("utf-8"), | |
| headers={"Content-Type": "application/json"}) | |
| with urllib.request.urlopen(req, timeout=60) as resp: | |
| return json.loads(resp.read()) | |
| def _get(server: str, path: str) -> dict: | |
| with urllib.request.urlopen(server + path, timeout=60) as resp: | |
| return json.loads(resp.read()) | |
| def run_cell(server: str, graph: dict, timeout: int) -> tuple[bool, float, str]: | |
| t0 = time.time() | |
| try: | |
| result = _post(server, "/prompt", {"prompt": graph}) | |
| except urllib.error.HTTPError as exc: | |
| return False, 0.0, "submit failed: {}".format(exc.read().decode("utf-8", "replace")[:400]) | |
| if "error" in result: | |
| return False, 0.0, json.dumps(result["error"])[:400] | |
| pid = result["prompt_id"] | |
| while time.time() - t0 < timeout: | |
| hist = _get(server, "/history/" + pid) | |
| entry = hist.get(pid) | |
| if entry: | |
| status = entry.get("status", {}) | |
| if status.get("completed") or status.get("status_str") == "success": | |
| return True, time.time() - t0, "" | |
| if status.get("status_str") == "error": | |
| return False, time.time() - t0, json.dumps(status)[:400] | |
| time.sleep(2) | |
| return False, time.time() - t0, "timeout" | |
| def generate(args) -> int: | |
| out_root = os.path.join(args.output_dir, SUBDIR) | |
| os.makedirs(out_root, exist_ok=True) | |
| seeds = SEEDS[: args.seeds] | |
| if args.prompt_ids: | |
| wanted = set(args.prompt_ids) | |
| prompts = [p for p in PROMPTS if p[0] in wanted] | |
| else: | |
| prompts = PROMPTS[: args.prompts] | |
| todo = [] | |
| for arm, ckpt, (pid, text), seed in itertools.product( | |
| args.arms, args.checkpoints, prompts, seeds): | |
| stem = cell_name(arm, ckpt, pid, seed) | |
| # ComfyUI's SaveImage bumps the counter when a name is taken, so an existing file | |
| # would silently become _00002_ and never be found by the scorer. Skipping is both | |
| # the resume mechanism and the guard against that. | |
| if os.path.exists(os.path.join(out_root, stem + "_00001_.png")): | |
| continue | |
| todo.append((arm, ckpt, pid, text, seed, stem)) | |
| print("{} cells to generate ({} already present)".format( | |
| len(todo), len(args.arms) * len(args.checkpoints) * len(prompts) * len(seeds) - len(todo)), | |
| flush=True) | |
| failures = [] | |
| for i, (arm, ckpt, pid, text, seed, stem) in enumerate(todo, start=1): | |
| graph = build_graph(ckpt, text, seed, arm, "{}/{}".format(SUBDIR, stem)) | |
| ok, elapsed, err = run_cell(args.server, graph, args.timeout) | |
| print("[{}/{}] {} {:.1f}s{}".format(i, len(todo), stem, elapsed, | |
| "" if ok else " FAILED: " + err), flush=True) | |
| if not ok: | |
| failures.append((stem, err)) | |
| if failures: | |
| print("\n{} cells failed:".format(len(failures))) | |
| for stem, err in failures: | |
| print(" {}: {}".format(stem, err)) | |
| return 1 | |
| return 0 | |
| # --------------------------------------------------------------------------- scoring | |
| def index(out_root: str) -> dict[tuple[str, str], dict[tuple[str, int], str]]: | |
| """{(arm, checkpoint): {(prompt_id, seed): path}} from the files actually on disk.""" | |
| found: dict[tuple[str, str], dict[tuple[str, int], str]] = {} | |
| if not os.path.isdir(out_root): | |
| return found | |
| for name in sorted(os.listdir(out_root)): | |
| if not name.endswith("_00001_.png"): | |
| continue | |
| stem = name[: -len("_00001_.png")] | |
| parts = stem.split("__") | |
| if len(parts) != 4 or not parts[3].startswith("s"): | |
| continue | |
| arm, ckpt, pid, seed_s = parts | |
| try: | |
| seed = int(seed_s[1:]) | |
| except ValueError: | |
| continue | |
| found.setdefault((arm, ckpt), {})[(pid, seed)] = os.path.join(out_root, name) | |
| return found | |
| def paired(a_scores: dict, b_scores: dict) -> dict | None: | |
| """Paired difference A-B over the cells both have. Negative means A is closer to BF16.""" | |
| cells = sorted(set(a_scores) & set(b_scores)) | |
| if len(cells) < 2: | |
| return None | |
| diffs = [a_scores[c] - b_scores[c] for c in cells] | |
| mean = statistics.mean(diffs) | |
| sd = statistics.stdev(diffs) | |
| se = sd / math.sqrt(len(diffs)) | |
| return {"mean": mean, "sd": sd, "se": se, | |
| "t": mean / se if se else float("inf"), | |
| "a_wins": sum(1 for d in diffs if d < 0), "n": len(diffs)} | |
| def score(args) -> int: | |
| try: | |
| import lpips | |
| except ImportError: | |
| raise SystemExit("pip install lpips") | |
| out_root = os.path.join(args.output_dir, SUBDIR) | |
| images = index(out_root) | |
| if not images: | |
| raise SystemExit("no fidelity_bench renders in {}; run the generate stage first" | |
| .format(out_root)) | |
| # A partially-finished generate leaves some cells with more seeds than others, which | |
| # would weight the reseed floor unevenly across prompts. Restricting to a seed prefix | |
| # keeps the analysed subset rectangular while the dataset keeps growing. | |
| if args.seeds: | |
| keep = set(SEEDS[: args.seeds]) | |
| images = {k: {c: p for c, p in cells.items() if c[1] in keep} | |
| for k, cells in images.items()} | |
| images = {k: cells for k, cells in images.items() if cells} | |
| # The two prompt groups answer different questions -- a photographic style LoRA barely | |
| # touches a tile mosaic but rewrites skin and hair -- so they are separable. | |
| if args.group: | |
| wanted = {p for p, _ in (PROMPTS_PEOPLE if args.group == "people" | |
| else PROMPTS_OBJECTS)} | |
| images = {k: {c: p for c, p in cells.items() if c[0] in wanted} | |
| for k, cells in images.items()} | |
| images = {k: cells for k, cells in images.items() if cells} | |
| device = torch.device(args.device) | |
| net = lpips.LPIPS(net="alex").to(device).eval() | |
| # Only the reference renders are cached. They are read once per candidate checkpoint and | |
| # again for every reseed pair, so caching them is most of the win; candidates are read | |
| # exactly once. Caching everything costs ~12 MB of VRAM per 1024x1024 image, which on a | |
| # full 400-cell run is ~5 GB sitting next to LPIPS for no benefit. | |
| ref_paths = {p for (_, ck), cells in images.items() if ck == REFERENCE | |
| for p in cells.values()} | |
| cache: dict[str, "torch.Tensor"] = {} | |
| def load(path): | |
| if path in cache: | |
| return cache[path] | |
| tensor = pixel_metrics.load_image(path, device) | |
| if path in ref_paths: | |
| cache[path] = tensor | |
| return tensor | |
| rows = [] | |
| # {(arm, ckpt): {(prompt, seed): lpips}} -- the shape `paired` consumes. | |
| lpips_by = {} | |
| with torch.no_grad(): | |
| for (arm, ckpt), cells in sorted(images.items()): | |
| if ckpt == REFERENCE: | |
| continue | |
| refs = images.get((arm, REFERENCE)) | |
| if not refs: | |
| print("skipping arm {!r}: no {} reference renders".format(arm, REFERENCE)) | |
| continue | |
| for cell, path in sorted(cells.items()): | |
| ref_path = refs.get(cell) | |
| if ref_path is None: | |
| continue | |
| a, b = load(ref_path), load(path) | |
| if a.shape != b.shape: | |
| print(" skipping {} {} {}: shape mismatch".format(arm, ckpt, cell)) | |
| continue | |
| value = net(a, b).item() | |
| rows.append({"arm": arm, "checkpoint": ckpt, "prompt_id": cell[0], | |
| "seed": cell[1], "lpips": round(value, 5), | |
| "psnr": round(pixel_metrics.psnr(a, b), 3), | |
| "ssim": round(pixel_metrics.ssim(a, b), 5)}) | |
| lpips_by.setdefault((arm, ckpt), {})[cell] = value | |
| # Reseed floor: the same checkpoint at two seeds on the same prompt. Anything below | |
| # this is a difference the sampler produces on its own. | |
| floors = {} | |
| for arm in sorted({a for a, _ in images}): | |
| refs = images.get((arm, REFERENCE), {}) | |
| per_prompt = {} | |
| for (pid, seed), path in refs.items(): | |
| per_prompt.setdefault(pid, []).append(path) | |
| dists = [] | |
| for pid, paths in per_prompt.items(): | |
| for p, q in itertools.combinations(sorted(paths), 2): | |
| dists.append(net(load(p), load(q)).item()) | |
| if dists: | |
| floors[arm] = {"mean": statistics.mean(dists), | |
| "sd": statistics.stdev(dists) if len(dists) > 1 else 0.0, | |
| "n": len(dists)} | |
| # Sanity: the instrument must return 0 comparing an image with itself. | |
| any_path = next(iter(next(iter(images.values())).values())) | |
| self_lpips = net(load(any_path), load(any_path).clone()).item() | |
| out_csv = args.out or os.path.join(out_root, "fidelity_bench.csv") | |
| with open(out_csv, "w", encoding="utf-8", newline="") as fh: | |
| w = csv.DictWriter(fh, fieldnames=["arm", "checkpoint", "prompt_id", "seed", | |
| "lpips", "psnr", "ssim"]) | |
| w.writeheader() | |
| w.writerows(rows) | |
| print("\nsanity: reference vs itself LPIPS = {:.6f} (must be ~0)".format(self_lpips)) | |
| for arm, f in sorted(floors.items()): | |
| print("reseed floor [{}]: LPIPS {:.4f} +-{:.4f} over {} same-prompt seed pairs" | |
| .format(arm, f["mean"], f["sd"], f["n"])) | |
| print("\n{:<6} {:<12} {:>8} {:>8} {:>8} {:>5}".format( | |
| "arm", "checkpoint", "LPIPS", "PSNR", "SSIM", "n")) | |
| for (arm, ckpt) in sorted(lpips_by): | |
| sub = [r for r in rows if r["arm"] == arm and r["checkpoint"] == ckpt] | |
| print("{:<6} {:<12} {:>8.4f} {:>8.2f} {:>8.4f} {:>5}".format( | |
| arm, ckpt, statistics.mean(r["lpips"] for r in sub), | |
| statistics.mean(r["psnr"] for r in sub), | |
| statistics.mean(r["ssim"] for r in sub), len(sub))) | |
| print("\npaired differences within each arm (negative = first is closer to BF16)") | |
| print("{:<40} {:>9} {:>8} {:>7} {:>9}".format("A vs B", "mean", "se", "t", "A wins")) | |
| summary = [] | |
| for arm in sorted({a for a, _ in lpips_by}): | |
| ckpts = sorted(c for a, c in lpips_by if a == arm) | |
| for a_ck, b_ck in itertools.combinations(ckpts, 2): | |
| st = paired(lpips_by[(arm, a_ck)], lpips_by[(arm, b_ck)]) | |
| if st is None: | |
| continue | |
| mark = " ***" if abs(st["t"]) > 3 else (" *" if abs(st["t"]) > 2 else "") | |
| label = "[{}] {} vs {}".format(arm, a_ck, b_ck) | |
| print("{:<40} {:>+9.4f} {:>8.4f} {:>7.2f} {:>5}/{}{}".format( | |
| label, st["mean"], st["se"], st["t"], st["a_wins"], st["n"], mark)) | |
| summary.append(dict(arm=arm, a=a_ck, b=b_ck, **st)) | |
| # The reason the LoRA arms exist: same checkpoint, same cells, LoRA vs no LoRA. A | |
| # positive mean means the LoRA made that checkpoint drift further from its own | |
| # reference, i.e. LoRAs amplify quantization error. One block per LoRA arm present -- | |
| # this used to be hardcoded to "lora", so extra arms were generated and then silently | |
| # left out of the analysis. | |
| base_ckpts = {c for a, c in lpips_by if a == "base"} | |
| for arm in sorted({a for a, _ in lpips_by} - {"base"}): | |
| both = sorted({c for a, c in lpips_by if a == arm} & base_ckpts) | |
| if not both: | |
| continue | |
| print("\nLoRA amplification ({} - base, same checkpoint/prompt/seed)".format(arm)) | |
| print("{:<40} {:>9} {:>8} {:>7} {:>9}".format("checkpoint", "mean", "se", "t", "worse")) | |
| for ckpt in both: | |
| st = paired(lpips_by[(arm, ckpt)], lpips_by[("base", ckpt)]) | |
| if st is None: | |
| continue | |
| mark = " ***" if abs(st["t"]) > 3 else (" *" if abs(st["t"]) > 2 else "") | |
| print("{:<40} {:>+9.4f} {:>8.4f} {:>7.2f} {:>5}/{}{}".format( | |
| ckpt, st["mean"], st["se"], st["t"], st["n"] - st["a_wins"], st["n"], mark)) | |
| summary.append(dict(arm="amplification:" + arm, a=ckpt, b=ckpt, **st)) | |
| json_path = os.path.splitext(out_csv)[0] + "_summary.json" | |
| with open(json_path, "w", encoding="utf-8") as fh: | |
| json.dump({"reference": REFERENCE, "variant": args.variant, | |
| "reference_self_lpips": self_lpips, | |
| "reseed_floor": floors, "paired": summary, | |
| "settings": {"steps": STEPS, "cfg": CFG, "negative": NEGATIVE, | |
| "sampler": SAMPLER, | |
| "scheduler": SCHEDULER, "width": WIDTH, "height": HEIGHT, | |
| "loras": ARM_LORA, "lora_strength": LORA_STRENGTH}}, | |
| fh, indent=2) | |
| print("\nwrote {}\n {}".format(out_csv, json_path)) | |
| return 0 | |
| # --------------------------------------------------------------------------- cli | |
| def main() -> int: | |
| ap = argparse.ArgumentParser(description=__doc__, | |
| formatter_class=argparse.RawDescriptionHelpFormatter) | |
| sub = ap.add_subparsers(dest="stage", required=True) | |
| for name in ("generate", "score"): | |
| p = sub.add_parser(name) | |
| p.add_argument("--output-dir", required=True, | |
| help="the ComfyUI output directory (renders land in a subfolder of it)") | |
| p.add_argument("--variant", default="turbo", choices=sorted(VARIANTS), | |
| help="which model family to bench: turbo (8 steps, cfg 1.0) or base " | |
| "(50 steps, cfg 3.5). Must match between generate and score") | |
| gen = sub.choices["generate"] | |
| gen.add_argument("--server", default="http://127.0.0.1:8188") | |
| gen.add_argument("--seeds", type=int, default=len(SEEDS), | |
| help="how many of the fixed seeds to use (max {})".format(len(SEEDS))) | |
| gen.add_argument("--prompts", type=int, default=len(PROMPTS), | |
| help="how many of the fixed prompts to use (max {})".format(len(PROMPTS))) | |
| gen.add_argument("--prompt-ids", nargs="+", default=None, | |
| metavar="ID", choices=[p for p, _ in PROMPTS], | |
| help="explicit prompt ids, overriding --prompts. Use this rather than a " | |
| "count when the set has grown: a count silently changes meaning " | |
| "when prompts are appended") | |
| gen.add_argument("--arms", nargs="+", default=["base"], choices=sorted(ARM_LORA)) | |
| # No `choices=` here: the legal set depends on --variant, which argparse has not read | |
| # yet. Validated against the selected variant below instead. | |
| gen.add_argument("--checkpoints", nargs="+", default=None) | |
| gen.add_argument("--timeout", type=int, default=900) | |
| sc = sub.choices["score"] | |
| sc.add_argument("--out", default=None) | |
| # Scoring is LPIPS over already-rendered PNGs -- it has no GPU requirement of its own, | |
| # so it must not fail on a machine that only has the images. | |
| sc.add_argument("--device", default="cuda" if torch.cuda.is_available() else "cpu") | |
| sc.add_argument("--seeds", type=int, default=None, | |
| help="analyse only the first N fixed seeds, so a half-finished generate " | |
| "does not make the cell grid ragged") | |
| sc.add_argument("--group", default=None, choices=["objects", "people"], | |
| help="restrict to one prompt group (01-10 vs 11-16)") | |
| args = ap.parse_args() | |
| select_variant(args.variant) | |
| if args.stage == "generate": | |
| if args.checkpoints is None: | |
| args.checkpoints = sorted(CHECKPOINTS) | |
| unknown = [c for c in args.checkpoints if c not in CHECKPOINTS] | |
| if unknown: | |
| raise SystemExit("unknown checkpoint(s) for variant {}: {} (have: {})".format( | |
| args.variant, ", ".join(unknown), ", ".join(sorted(CHECKPOINTS)))) | |
| if REFERENCE not in args.checkpoints: | |
| print("note: {!r} is not in --checkpoints, so nothing will be scorable until it " | |
| "is generated".format(REFERENCE)) | |
| return generate(args) | |
| return score(args) | |
| if __name__ == "__main__": | |
| sys.exit(main()) | |