diff --git a/docs/krea-v10-technical-paper.md b/docs/krea-v10-technical-paper.md index 42ee071..ba9d763 100644 --- a/docs/krea-v10-technical-paper.md +++ b/docs/krea-v10-technical-paper.md @@ -374,7 +374,7 @@ Values are the 2026-07 render-retuned set. | `keep the same subject` | identity | normal | 1.0 | 1.0 | stack | stack | preserve | 1.0 | 1.0 | — | 1.0 | 1.0 | even | | `copy pose and layout` | composition | grayscale blur | 0.0 | 0.25 | stack | stack | avoid | 1.2 | 0.2 | 1.25 | 1.3 | 0.3 | even | | `copy lighting and mood` | lighting | palette wash | 0.85 | 0.15 | 256 | stack | avoid | 1.0 | 0.55 | 1.25 | 0.8 | 1.3 | lighting | -| `suggest the visual style` | style | palette wash | 0.85 | 0.0 | 256 | stack | avoid | 0.85 | 0.85 | 0.9 | 0.8 | 1.85 | style | +| `suggest the visual style` | style | strong blur | 1.0 | 0.3 | 384 | stack | avoid | 0.85 | 0.85 | 0.65 | 0.85 | 1.85 | style-transfer | | `suggest material or texture` | material | palette wash | 0.8 | 0.1 | 256 | stack | avoid | 0.5 | 0.75 | 0.95 | 1.0 | 1.55 | material | | `copy big shapes only` | shape only | shape wash | 0.0 | 0.0 | 256 | stack | avoid | 1.1 | 0.0 | 1.0 | 1.2 | 0.05 | even | | `avoid copying text/logos` | text/logo safe | shape wash | 0.0 | 0.0 | 256 | stack | avoid | 0.75 | 0.0 | **0.03** | 0.08 | 0.0 | flat 0.15 | @@ -396,7 +396,9 @@ text/logo safe 0.08/0.0. ``` even: [1.0] × 12 # balanced, identity, composition, framing, shape only -style: [0.25, 0.35, 0.45, 0.6, 0.8, 1.0, 1.0, 2.5, 5.0, 1.1, 4.0, 1.2] # style, environment, loose +style: [0.25, 0.35, 0.45, 0.6, 0.8, 1.0, 1.0, 2.5, 5.0, 1.1, 4.0, 1.2] # environment, loose +style-transfer: + [0.062,0.087,0.113,0.15, 0.2, 0.25,1.25,3.438, 6.875, 1.375, 5.5, 1.5] # visual style palette: [0.15, 0.2, 0.3, 0.45, 0.7, 1.0, 1.0, 2.8, 5.5, 1.3, 4.5, 1.2] material: [0.2, 0.3, 0.45, 0.65, 0.85, 1.0, 1.0, 2.0, 4.0, 1.2, 3.0, 1.1] lighting: [0.2, 0.25, 0.35, 0.5, 0.8, 1.0, 1.0, 2.2, 4.5, 1.4, 4.0, 1.2] diff --git a/docs/recipe-lab/README.md b/docs/recipe-lab/README.md index bac28a6..8e85edd 100644 --- a/docs/recipe-lab/README.md +++ b/docs/recipe-lab/README.md @@ -11,6 +11,9 @@ number, render it, and get a scored verdict from images." | File | Role | Who runs it | | --- | --- | --- | | [tweak_test.py](tweak_test.py) | Render engine: given a recipe (built-in label or a full tweak bundle) + reference + prompt + strength + seed, renders on the ComfyUI box and prints `{name, image, metrics}` as JSON. Pure mechanical work, no judgment. | a cheap agent (Haiku), or you directly | +| [style_transfer_stack_test.py](style_transfer_stack_test.py) | Multi-card V10 style-transfer harness: content anchor + style reference + optional away-layout guard. Used to test the repeatable style-transfer method in [v10-style-transfer-method.md](v10-style-transfer-method.md). | maintainer | +| [style_transfer_reliability_suite.py](style_transfer_reliability_suite.py) | Fixed V10 style-transfer regression matrix: six content/style stress cases across the default and leak-control methods, with JSONL metrics and a contact sheet. | maintainer | +| [style_transfer_seed_sweep.py](style_transfer_seed_sweep.py) | Seed-stability check for the recommended V10 style-transfer methods: three stress cases, two methods, three seeds each, with JSONL metrics and a contact sheet. | maintainer | | the `recipe-tweak-test` workflow | Orchestrates: for each recipe, Haiku agents render the current settings + candidate tweaks in parallel, then a Fable agent **reads the images and scores** each against the recipe's stated intent, names the best, and recommends a change. | the Workflow tool | | [generate_guide_demos.py](generate_guide_demos.py) | Renders the V10 user-guide demo set (every recipe + the journeys) with the matching drag-in V10 workflow embedded in each PNG, and rebuilds the guide manifest + recipe-gallery contact sheet in `docs/assets/krea-v10/demos/`. `--only ` re-renders a subset; `--skip-render` rebuilds manifest/sheet only. | maintainer | | refs/ | Local copies of reference images so the judge agent can see them. | - | diff --git a/docs/recipe-lab/style_transfer_reliability_suite.py b/docs/recipe-lab/style_transfer_reliability_suite.py new file mode 100644 index 0000000..5835236 --- /dev/null +++ b/docs/recipe-lab/style_transfer_reliability_suite.py @@ -0,0 +1,226 @@ +"""Run the V10 style-transfer method reliability suite. + +The suite renders a fixed matrix of content/style stress cases and method +variants, writes JSONL metrics, and builds a contact sheet. It intentionally +uses the real V10 ComfyUI nodes through style_transfer_stack_test.py. + +Examples: + python docs/recipe-lab/style_transfer_reliability_suite.py --render --skip-existing + python docs/recipe-lab/style_transfer_reliability_suite.py --sheet-only +""" +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont, ImageOps + + +REPO = Path(__file__).resolve().parents[2] +RUNS = REPO / "docs" / "recipe-lab" / "runs" +LOCAL_REFS = Path(r"C:\Users\kgilp\AppData\Local\Temp\krea-style-variety-refs-20260705") +STACK_TEST = REPO / "docs" / "recipe-lab" / "style_transfer_stack_test.py" +STYLE_RECIPE = REPO / "docs" / "recipe-lab" / "runs" / "codex-style-transfer-final.json" + +SUITE_ID = "20260705" +METHODS = ["content-only", "two-card", "final-style", "final-style-guarded"] + +CASES = [ + { + "key": "hare-starry", + "label": "Hare -> Starry", + "content_ref": "codex-style-variety-20260705/durer_hare.jpg", + "content_local": LOCAL_REFS / "durer_hare.jpg", + "style_ref": "codex-style-variety-20260705/starry_night.jpg", + "style_local": LOCAL_REFS / "starry_night.jpg", + "prompt": "the same hare from the content reference sitting on a simple stone plinth in a clean studio scene, no readable text", + "seed": 771201, + }, + { + "key": "bootprint-hokusai", + "label": "Bootprint -> Hokusai", + "content_ref": "codex-style-variety-20260705/apollo_bootprint.jpg", + "content_local": LOCAL_REFS / "apollo_bootprint.jpg", + "style_ref": "codex-style-variety-20260705/hokusai_great_wave.jpg", + "style_local": LOCAL_REFS / "hokusai_great_wave.jpg", + "prompt": "the same lunar bootprint impression from the content reference in a clean square composition, no readable text", + "seed": 771202, + }, + { + "key": "figure-mondrian", + "label": "Figure -> Mondrian", + "content_ref": "codex-style-variety-20260705/vitruvian.jpg", + "content_local": LOCAL_REFS / "vitruvian.jpg", + "style_ref": "codex-style-variety-20260705/mondrian_color_fields.jpg", + "style_local": LOCAL_REFS / "mondrian_color_fields.jpg", + "prompt": "the same standing human figure study from the content reference, centered in a clean studio composition, no readable text", + "seed": 771203, + }, + { + "key": "portrait-starry", + "label": "Portrait -> Starry", + "content_ref": "codex-style-variety-20260705/migrant_mother.jpg", + "content_local": LOCAL_REFS / "migrant_mother.jpg", + "style_ref": "codex-style-variety-20260705/starry_night.jpg", + "style_local": LOCAL_REFS / "starry_night.jpg", + "prompt": "the same adult woman from the content reference in a simple seated studio portrait, no readable text", + "seed": 771204, + }, + { + "key": "nebula-mondrian", + "label": "Nebula -> Mondrian", + "content_ref": "codex-style-variety-20260705/pillars_creation.jpg", + "content_local": LOCAL_REFS / "pillars_creation.jpg", + "style_ref": "codex-style-variety-20260705/mondrian_color_fields.jpg", + "style_local": LOCAL_REFS / "mondrian_color_fields.jpg", + "prompt": "the same pillar-shaped nebula forms from the content reference in a clean square space image, no readable text", + "seed": 771205, + }, + { + "key": "hare-hokusai", + "label": "Hare -> Hokusai", + "content_ref": "codex-style-variety-20260705/durer_hare.jpg", + "content_local": LOCAL_REFS / "durer_hare.jpg", + "style_ref": "codex-style-variety-20260705/hokusai_great_wave.jpg", + "style_local": LOCAL_REFS / "hokusai_great_wave.jpg", + "prompt": "the same hare from the content reference sitting on a simple stone plinth in a clean studio scene, no readable text", + "seed": 771206, + }, +] + + +def output_name(case_key, method): + return f"codex-suite-{case_key}-{method}-{SUITE_ID}" + + +def output_path(case_key, method): + return RUNS / f"{output_name(case_key, method)}.png" + + +def run_render(case, method, skip_existing): + image = output_path(case["key"], method) + if skip_existing and image.exists(): + return None + cmd = [ + sys.executable, + str(STACK_TEST), + "--method", + method, + "--content-ref", + case["content_ref"], + "--style-ref", + case["style_ref"], + "--style-recipe-json", + str(STYLE_RECIPE), + "--prompt", + case["prompt"], + "--seed", + str(case["seed"]), + "--name", + output_name(case["key"], method), + "--balance", + ] + proc = subprocess.run(cmd, cwd=REPO, check=True, capture_output=True, text=True) + lines = [line for line in proc.stdout.splitlines() if line.strip()] + result = json.loads(lines[-1]) + result["case"] = case["key"] + return result + + +def write_metrics(records): + out = RUNS / f"codex-style-transfer-reliability-suite-{SUITE_ID}.jsonl" + with out.open("w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record, sort_keys=True) + "\n") + return out + + +def build_sheet(): + out = RUNS / f"codex-style-transfer-reliability-suite-{SUITE_ID}.png" + cols = [("Content ref", "content"), ("Style ref", "style")] + [(m, m) for m in METHODS] + + try: + title_font = ImageFont.truetype(r"C:\Windows\Fonts\arialbd.ttf", 21) + header_font = ImageFont.truetype(r"C:\Windows\Fonts\arialbd.ttf", 13) + label_font = ImageFont.truetype(r"C:\Windows\Fonts\arial.ttf", 12) + except Exception: + title_font = header_font = label_font = ImageFont.load_default() + + thumb = 146 + label_h = 38 + header_h = 104 + margin = 14 + gap = 9 + sheet_w = margin * 2 + len(cols) * thumb + (len(cols) - 1) * gap + sheet_h = header_h + margin + len(CASES) * (thumb + label_h) + (len(CASES) - 1) * gap + margin + sheet = Image.new("RGB", (sheet_w, sheet_h), "#f4f1ea") + draw = ImageDraw.Draw(sheet) + draw.text((margin, 12), "V10 style-transfer reliability suite", fill="#171411", font=title_font) + draw.text( + (margin, 42), + "Fixed public-domain content/style stress cases. Compare default transfer against safer final-detail variants.", + fill="#4a443d", + font=label_font, + ) + draw.text( + (margin, 62), + "Methods: content-only baseline, two-card default, final-style leak control, final-style-guarded composition guard.", + fill="#4a443d", + font=label_font, + ) + + for col_index, (heading, _kind) in enumerate(cols): + x = margin + col_index * (thumb + gap) + draw.text((x, header_h - 26), heading, fill="#171411", font=header_font) + + for row_index, case in enumerate(CASES): + y = header_h + margin + row_index * (thumb + label_h + gap) + for col_index, (_heading, kind) in enumerate(cols): + x = margin + col_index * (thumb + gap) + if kind == "content": + path = case["content_local"] + caption = case["label"] + elif kind == "style": + path = case["style_local"] + caption = "style source" + else: + path = output_path(case["key"], kind) + caption = kind + img = Image.open(path).convert("RGB") + img = ImageOps.contain(img, (thumb, thumb), Image.Resampling.LANCZOS) + frame = Image.new("RGB", (thumb, thumb), "#ded7cb") + frame.paste(img, ((thumb - img.width) // 2, (thumb - img.height) // 2)) + sheet.paste(frame, (x, y)) + draw.rectangle((x, y, x + thumb - 1, y + thumb - 1), outline="#b7aea1", width=1) + draw.text((x, y + thumb + 7), caption, fill="#28231e", font=label_font) + sheet.save(out, quality=94) + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--render", action="store_true", help="render every case/method image") + ap.add_argument("--skip-existing", action="store_true", help="do not rerender existing PNGs") + ap.add_argument("--sheet-only", action="store_true", help="only rebuild the contact sheet") + args = ap.parse_args() + + RUNS.mkdir(parents=True, exist_ok=True) + records = [] + if args.render and not args.sheet_only: + for case in CASES: + for method in METHODS: + print(f"Rendering {case['key']} / {method}", flush=True) + record = run_render(case, method, args.skip_existing) + if record is not None: + records.append(record) + if records: + metrics_path = write_metrics(records) + print(metrics_path) + sheet_path = build_sheet() + print(sheet_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/recipe-lab/style_transfer_seed_sweep.py b/docs/recipe-lab/style_transfer_seed_sweep.py new file mode 100644 index 0000000..46c840d --- /dev/null +++ b/docs/recipe-lab/style_transfer_seed_sweep.py @@ -0,0 +1,174 @@ +"""Run a seed-sweep check for the V10 style-transfer method. + +The broad reliability suite varies content/style cases. This one varies seeds +for the recommended methods so we can see whether the method is stable across +sampling noise, not just lucky on one seed. + +Examples: + python docs/recipe-lab/style_transfer_seed_sweep.py --render --skip-existing + python docs/recipe-lab/style_transfer_seed_sweep.py --sheet-only +""" +import argparse +import json +import subprocess +import sys +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont, ImageOps + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import style_transfer_reliability_suite as suite + + +RUNS = suite.RUNS +REPO = suite.REPO +STACK_TEST = suite.STACK_TEST +STYLE_RECIPE = suite.STYLE_RECIPE +SUITE_ID = suite.SUITE_ID + +CASE_KEYS = ["hare-starry", "bootprint-hokusai", "figure-mondrian"] +METHODS = ["two-card", "final-style"] +SEEDS = [771301, 771302, 771303] +CASES = [case for case in suite.CASES if case["key"] in CASE_KEYS] + + +def output_name(case_key, method, seed): + return f"codex-seedsweep-{case_key}-{method}-seed{seed}-{SUITE_ID}" + + +def output_path(case_key, method, seed): + return RUNS / f"{output_name(case_key, method, seed)}.png" + + +def run_render(case, method, seed, skip_existing): + image = output_path(case["key"], method, seed) + if skip_existing and image.exists(): + return None + cmd = [ + sys.executable, + str(STACK_TEST), + "--method", + method, + "--content-ref", + case["content_ref"], + "--style-ref", + case["style_ref"], + "--style-recipe-json", + str(STYLE_RECIPE), + "--prompt", + case["prompt"], + "--seed", + str(seed), + "--name", + output_name(case["key"], method, seed), + "--balance", + ] + proc = subprocess.run(cmd, cwd=REPO, check=True, capture_output=True, text=True) + lines = [line for line in proc.stdout.splitlines() if line.strip()] + result = json.loads(lines[-1]) + result["case"] = case["key"] + result["seed"] = seed + return result + + +def write_metrics(records): + out = RUNS / f"codex-style-transfer-seed-sweep-{SUITE_ID}.jsonl" + with out.open("w", encoding="utf-8") as f: + for record in records: + f.write(json.dumps(record, sort_keys=True) + "\n") + return out + + +def build_sheet(): + out = RUNS / f"codex-style-transfer-seed-sweep-{SUITE_ID}.png" + columns = [("Content ref", "content", None), ("Style ref", "style", None)] + for method in METHODS: + for seed in SEEDS: + columns.append((f"{method}\n{seed}", method, seed)) + + try: + title_font = ImageFont.truetype(r"C:\Windows\Fonts\arialbd.ttf", 21) + header_font = ImageFont.truetype(r"C:\Windows\Fonts\arialbd.ttf", 12) + label_font = ImageFont.truetype(r"C:\Windows\Fonts\arial.ttf", 12) + except Exception: + title_font = header_font = label_font = ImageFont.load_default() + + thumb = 132 + label_h = 38 + header_h = 112 + margin = 14 + gap = 8 + sheet_w = margin * 2 + len(columns) * thumb + (len(columns) - 1) * gap + sheet_h = header_h + margin + len(CASES) * (thumb + label_h) + (len(CASES) - 1) * gap + margin + sheet = Image.new("RGB", (sheet_w, sheet_h), "#f4f1ea") + draw = ImageDraw.Draw(sheet) + draw.text((margin, 12), "V10 style-transfer seed sweep", fill="#171411", font=title_font) + draw.text( + (margin, 42), + "Three seeds per recommended method. Failures here mean the method is seed-lucky rather than reliable.", + fill="#4a443d", + font=label_font, + ) + draw.text( + (margin, 62), + "two-card = strongest visible transfer; final-style = leak-control fallback.", + fill="#4a443d", + font=label_font, + ) + + for col_index, (heading, _method, _seed) in enumerate(columns): + x = margin + col_index * (thumb + gap) + for line_index, line in enumerate(heading.splitlines()): + draw.text((x, header_h - 40 + line_index * 16), line, fill="#171411", font=header_font) + + for row_index, case in enumerate(CASES): + y = header_h + margin + row_index * (thumb + label_h + gap) + for col_index, (_heading, method, seed) in enumerate(columns): + x = margin + col_index * (thumb + gap) + if method == "content": + path = case["content_local"] + caption = case["label"] + elif method == "style": + path = case["style_local"] + caption = "style source" + else: + path = output_path(case["key"], method, seed) + caption = f"{method} {seed}" + img = Image.open(path).convert("RGB") + img = ImageOps.contain(img, (thumb, thumb), Image.Resampling.LANCZOS) + frame = Image.new("RGB", (thumb, thumb), "#ded7cb") + frame.paste(img, ((thumb - img.width) // 2, (thumb - img.height) // 2)) + sheet.paste(frame, (x, y)) + draw.rectangle((x, y, x + thumb - 1, y + thumb - 1), outline="#b7aea1", width=1) + draw.text((x, y + thumb + 7), caption, fill="#28231e", font=label_font) + sheet.save(out, quality=94) + return out + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--render", action="store_true", help="render every case/method/seed image") + ap.add_argument("--skip-existing", action="store_true", help="do not rerender existing PNGs") + ap.add_argument("--sheet-only", action="store_true", help="only rebuild the contact sheet") + args = ap.parse_args() + + RUNS.mkdir(parents=True, exist_ok=True) + records = [] + if args.render and not args.sheet_only: + for case in CASES: + for method in METHODS: + for seed in SEEDS: + print(f"Rendering {case['key']} / {method} / {seed}", flush=True) + record = run_render(case, method, seed, args.skip_existing) + if record is not None: + records.append(record) + if records: + metrics_path = write_metrics(records) + print(metrics_path) + sheet_path = build_sheet() + print(sheet_path) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/recipe-lab/style_transfer_stack_test.py b/docs/recipe-lab/style_transfer_stack_test.py new file mode 100644 index 0000000..4b73197 --- /dev/null +++ b/docs/recipe-lab/style_transfer_stack_test.py @@ -0,0 +1,254 @@ +"""Render V10 multi-card style-transfer stack experiments. + +This complements tweak_test.py, which is intentionally one-card. The goal here +is to test a *method*: keep a content reference stable while applying a style +reference through V10 guide cards. + +Examples: + python docs/recipe-lab/style_transfer_stack_test.py --method two-card \ + --content-ref codex-style-variety-20260705/durer_hare.jpg \ + --style-ref codex-style-variety-20260705/starry_night.jpg \ + --style-recipe-json docs/recipe-lab/runs/codex-style-transfer-final.json \ + --prompt "the same hare sitting on a stone plinth, no readable text" \ + --seed 771201 --name hare-starry-two-card + +Prints one JSON line with local output path and metrics against both refs. +""" +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import tweak_test as tt + + +def stack_inputs(prompt, prompt_strength, balance): + return { + "Krea CLIP": ["clip", 0], + "Final image prompt": prompt, + "Written prompt strength": prompt_strength, + "Image slider feel": "artist friendly - soft at low values", + "Image detail level": "medium - balanced default (384)", + "Image framing": "keep full image shape", + "When images guide": "smart per-card timing", + "Early-to-final handoff": 0.4, + "Text/logo guard prompt handling": "gentle guard - keep my prompt words", + "Balance strong cards": "gentle balance" if balance else "off - use my values", + "Reuse image studies": "always re-study", + } + + +def method_cards(method, style_label): + """Return card specs for one V10 style-transfer method.""" + if method == "content-only": + return [ + {"ref": "content", "recipe": "keep the same subject", "strength": 0.9}, + ] + if method == "style-only": + return [ + {"ref": "style", "recipe": style_label, "strength": 0.65}, + ] + if method == "two-card": + return [ + {"ref": "content", "recipe": "keep the same subject", "strength": 0.9}, + {"ref": "style", "recipe": style_label, "strength": 0.55}, + ] + if method == "final-style": + return [ + {"ref": "content", "recipe": "keep the same subject", "strength": 0.9}, + { + "ref": "style", + "recipe": style_label, + "strength": 0.65, + "timing": "final details only", + }, + ] + if method == "final-style-guarded": + return [ + {"ref": "content", "recipe": "keep the same subject", "strength": 0.9}, + { + "ref": "style", + "recipe": style_label, + "strength": 0.65, + "timing": "final details only", + }, + { + "ref": "style", + "recipe": "copy pose and layout", + "strength": 0.1, + "direction": "away from this image", + "timing": "early layout only", + }, + ] + if method == "stable-style": + return [ + {"ref": "content", "recipe": "keep the same subject", "strength": 0.9}, + { + "ref": "style", + "recipe": style_label, + "strength": 0.6, + "timing": "final details only", + }, + { + "ref": "style", + "recipe": "copy pose and layout", + "strength": 0.2, + "direction": "away from this image", + "timing": "early layout only", + }, + ] + raise ValueError(f"unknown method: {method}") + + +def build_graph(method, content_ref, style_ref, style_label, prompt, prompt_strength, seed, name, balance): + graph = { + "unet": {"class_type": "UNETLoader", "inputs": {"unet_name": tt.MODEL, "weight_dtype": "default"}}, + "sampling": {"class_type": "ModelSamplingAuraFlow", "inputs": {"shift": 3.14, "model": ["unet", 0]}}, + "clip": {"class_type": "CLIPLoader", "inputs": {"clip_name": tt.CLIP, "type": "krea2", "device": "default"}}, + "vae": {"class_type": "VAELoader", "inputs": {"vae_name": tt.VAE}}, + "neg": tt.stack(tt.NEG), + "latent": {"class_type": "EmptyLatentImage", "inputs": {"width": 512, "height": 512, "batch_size": 1}}, + "decode": {"class_type": "VAEDecode", "inputs": {"samples": ["sampler", 0], "vae": ["vae", 0]}}, + "save": { + "class_type": "SaveImage", + "inputs": {"images": ["decode", 0], "filename_prefix": "claude-generations/recipe-lab/" + name}, + }, + } + ref_paths = {"content": content_ref, "style": style_ref} + pos = stack_inputs(prompt, prompt_strength, balance) + + for i, spec in enumerate(method_cards(method, style_label), start=1): + load_key = f"load{i}" + card_key = f"card{i}" + graph[load_key] = { + "class_type": "LoadImage", + "inputs": {"image": ref_paths[spec["ref"]], "upload": "image"}, + } + card_inputs = dict(tt.CARD_DEFAULTS) + card_inputs.update( + { + "Reference image": [load_key, 0], + "How strongly this image guides": spec["strength"], + "Use image for": spec["recipe"], + "Guide direction": spec.get("direction", "toward this image"), + "When this card guides": spec.get("timing", "recipe decides"), + } + ) + graph[card_key] = {"class_type": "KGKrea2ImageGuideCardV10", "inputs": card_inputs} + pos[f"Reference {i} guide card"] = [card_key, 0] + + graph["pos"] = {"class_type": "KGTextEncodeKreaImageReferencesV10", "inputs": pos} + graph["sampler"] = { + "class_type": "KSampler", + "inputs": { + "seed": seed, + "steps": 8, + "cfg": 1.0, + "sampler_name": "euler", + "scheduler": "simple", + "denoise": 1.0, + "model": ["sampling", 0], + "positive": ["pos", 0], + "negative": ["neg", 0], + "latent_image": ["latent", 0], + }, + } + return graph + + +def input_ref(server, path): + return tt.fetch( + server, + { + "filename": Path(path).name, + "subfolder": str(Path(path).parent).replace("\\", "/"), + "type": "input", + }, + ) + + +def metrics(out, content_img, style_img): + return { + "content_palette_dist": round(sum(abs(a - b) for a, b in zip(tt.hist(out), tt.hist(content_img))), 3), + "content_struct_sim": round(tt.struct_sim(tt.gray(out), tt.gray(content_img)), 3), + "style_palette_dist": round(sum(abs(a - b) for a, b in zip(tt.hist(out), tt.hist(style_img))), 3), + "style_struct_sim": round(tt.struct_sim(tt.gray(out), tt.gray(style_img)), 3), + "texture_energy": round(tt.lapvar(out), 0), + } + + +def main(): + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument( + "--method", + choices=[ + "content-only", + "style-only", + "two-card", + "final-style", + "final-style-guarded", + "stable-style", + ], + required=True, + ) + ap.add_argument("--content-ref", required=True) + ap.add_argument("--style-ref", required=True) + ap.add_argument("--style-recipe-json") + ap.add_argument("--style-label", default="suggest the visual style") + ap.add_argument("--prompt", required=True) + ap.add_argument("--prompt-strength", type=float, default=1.1) + ap.add_argument("--seed", type=int, default=771201) + ap.add_argument("--name", required=True) + ap.add_argument("--server", default=tt.DEFAULT_SERVER) + ap.add_argument("--balance", action="store_true") + args = ap.parse_args() + + server = args.server.rstrip("/") + style_label = args.style_label + if args.style_recipe_json: + bundle = json.loads(Path(args.style_recipe_json).read_text(encoding="utf-8")) + style_label = bundle["label"] + tt.OLGA_RECIPES.mkdir(parents=True, exist_ok=True) + (tt.OLGA_RECIPES / ("lab-" + style_label.replace(" ", "-") + ".json")).write_text( + json.dumps(bundle), + encoding="utf-8", + ) + tt.refresh_card_info(server) + + info = tt.submit( + server, + build_graph( + args.method, + args.content_ref, + args.style_ref, + style_label, + args.prompt, + args.prompt_strength, + args.seed, + args.name, + args.balance, + ), + ) + out = tt.fetch(server, info) + tt.OUTDIR.mkdir(parents=True, exist_ok=True) + local = tt.OUTDIR / (args.name + ".png") + out.save(local) + content_img = input_ref(server, args.content_ref) + style_img = input_ref(server, args.style_ref) + print( + json.dumps( + { + "name": args.name, + "method": args.method, + "image": str(local), + "style_label": style_label, + "metrics": metrics(out, content_img, style_img), + } + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/recipe-lab/tweak_test.py b/docs/recipe-lab/tweak_test.py index 62ee8ea..064bcc4 100644 --- a/docs/recipe-lab/tweak_test.py +++ b/docs/recipe-lab/tweak_test.py @@ -19,7 +19,7 @@ from pathlib import Path from PIL import Image, ImageFilter DEFAULT_SERVER = "http://10.0.0.35:8188" -OLGA_RECIPES = Path("//Olga/d/ComfyUI/custom_nodes/krea-reference/custom_recipes") +OLGA_RECIPES = Path("//Olga/d/ComfyUI/custom_nodes/comfyui-krea-reference/custom_recipes") OUTDIR = Path(__file__).resolve().parent / "runs" MODEL = "krea2\\krea2_turbo_nvfp4.safetensors" CLIP = "krea2\\qwen3vl_4b_fp8_scaled.safetensors" @@ -86,6 +86,12 @@ def fetch(server, info): return Image.open(io.BytesIO(r.read())).convert("RGB") +def refresh_card_info(server): + """Force ComfyUI to rescan V10 card inputs after dropping a lab recipe.""" + with urllib.request.urlopen(server + "/object_info/KGKrea2ImageGuideCardV10", timeout=30) as r: + r.read() + + def hist(img, bins=8): img = img.resize((128, 128)) h = [0.0] * (bins * 3) @@ -142,6 +148,8 @@ def main(): return 2 server = args.server.rstrip("/") + if args.recipe_json: + refresh_card_info(server) info = submit(server, build_graph(label, args.ref, args.prompt, args.strength, args.seed, args.name)) out = fetch(server, info) local = OUTDIR / (args.name + ".png") diff --git a/docs/recipe-lab/v10-style-transfer-method.md b/docs/recipe-lab/v10-style-transfer-method.md new file mode 100644 index 0000000..39341ed --- /dev/null +++ b/docs/recipe-lab/v10-style-transfer-method.md @@ -0,0 +1,154 @@ +# V10 Style Transfer Method + +Goal: make style transfer reliable inside the V10 Krea guide-card stack while +preserving the requested content subject. This is a stack method, not only a +single recipe. + +## Default Method + +Use two guide cards: + +| Slot | Reference | Use image for | Direction | Card timing | Strength | +| --- | --- | --- | --- | --- | --- | +| 1 | content image | `keep the same subject` | toward | recipe decides | `0.9` | +| 2 | style image | `suggest the visual style` | toward | recipe decides | `0.55` | + +Stack settings: + +| Setting | Value | +| --- | --- | +| `When images guide` | `smart per-card timing` | +| `Balance strong cards` | `gentle balance` | +| `Image slider feel` | `artist friendly - soft at low values` | +| `Written prompt strength` | start around `1.1` | + +This is the best first pass when the user wants obvious style transfer. The +content card anchors the subject; the style card brings palette, medium, and +finish. It is stronger than the single-card style recipe and much more stable +than trying to style-transfer from a prompt alone. + +## Input Hygiene + +The content reference must be clean. `keep the same subject` preserves the +visible content subject, and if the image contains several people or objects it +may preserve several of them. For reliable style transfer, crop or choose a +content reference where the intended subject is isolated, centered, and free of +extra people, text, logos, or distracting props. + +The style reference can be messier, but source-heavy style images need the +leak-control method below. Strong subjects, famous scenes, portraits, big +foreground objects, and hard composition lines are the risky cases. + +## Leak-Control Method + +If the style source starts bringing its scene/layout along with the look, +change only the style card timing: + +| Slot | Reference | Use image for | Direction | Card timing | Strength | +| --- | --- | --- | --- | --- | --- | +| 1 | content image | `keep the same subject` | toward | recipe decides | `0.9` | +| 2 | style image | `suggest the visual style` | toward | `final details only` | `0.65` | + +This gives the content image and prompt first claim on layout, then applies the +style in the late/detail phase. It is safer for source-heavy artwork, photos, +and drawings, but the style can be quieter. + +Use this as the first choice when content fidelity matters more than obvious +style intensity, or when the style reference has a strong subject/scene. + +## Optional Layout Guard + +Use this only when the style image still imposes its composition: + +| Slot | Reference | Use image for | Direction | Card timing | Strength | +| --- | --- | --- | --- | --- | --- | +| 3 | style image | `copy pose and layout` | away | `early layout only` | `0.1` | + +The `0.1` guard is intentionally small. A heavier `0.2` away-layout card was +render-tested and proved too conservative: it protected content but often +muted the useful style signal. + +## Decision Tree + +1. Use the default two-card method when the style source is abstract, + texture-like, graphic, or when the user wants obvious visual transformation. +2. Use leak-control (`final details only`) when the style source has a strong + scene/subject or the first render starts importing the source layout. +3. Add the optional `0.1` layout guard only after leak-control still shows + source composition leakage. +4. Do not start with the guard. It is a correction, not the default. +5. If all safe variants look too quiet, return to the default two-card method + and accept some background/style-source presence as the tradeoff for a + visible transfer. + +## Evidence + +Rendered on the real V10 ComfyUI server using public-domain references: + +- `codex-style-transfer-variety-proof-20260705.png`: one-card style recipe + across paintings, drawings, photos, and different prompts. +- `codex-style-transfer-method-stack-expanded-proof-20260705.png`: content + anchor + style-stack comparisons across animal, object/terrain, and figure + cases. +- `codex-style-transfer-reliability-suite-20260705.png`: 24-render regression + matrix across six content/style stress cases and four methods. +- `codex-style-transfer-reliability-suite-20260705.jsonl`: matching metrics + against content and style references. +- `codex-style-transfer-seed-sweep-20260705.png`: 18-render seed-stability + check across the two recommended methods. +- `codex-style-transfer-seed-sweep-20260705.jsonl`: matching seed-sweep + metrics. + +Key observations from the expanded method proof: + +- `style-only` transfers style but does not reliably preserve the intended + content subject. +- `content + style` gives the strongest visible style transfer while keeping + the content subject recognizable. +- `final-style` reduces source-scene takeover and is the safer fallback. +- The seed sweep confirms this tradeoff is stable: `two-card` is consistently + visible across seeds, while `final-style` consistently preserves layout + better but can be too quiet on some style sources. +- `final-style-guarded` is useful when composition leakage remains, but should + not be the default. +- Heavy layout-away guarding (`stable-style`, away layout `0.2`) is too muted + for a general style-transfer method. +- Multi-subject content references are a known failure mode: the content card + correctly anchors what it sees, so the suite portrait case preserves multiple + figures. Crop/clean the content reference before treating this as a style + failure. +- Highly self-styled content references, especially space/nebula imagery, can + resist unrelated graphic styles. In those cases the method stays stable, but + the transfer may be subtle unless the default two-card method is used. + +## Lab Command + +Use `style_transfer_stack_test.py` for repeatable checks: + +```bash +python docs/recipe-lab/style_transfer_stack_test.py \ + --method two-card \ + --content-ref codex-style-variety-20260705/durer_hare.jpg \ + --style-ref codex-style-variety-20260705/starry_night.jpg \ + --style-recipe-json docs/recipe-lab/runs/codex-style-transfer-final.json \ + --prompt "the same hare from the content reference sitting on a simple stone plinth in a clean studio scene, no readable text" \ + --seed 771201 \ + --name hare-starry-two-card \ + --balance +``` + +The `--style-recipe-json` argument exists because the render server may not +have reloaded local Python changes. It injects a lab recipe with the same +settings as the V10 `suggest the visual style` override. + +Run the full regression suite: + +```bash +python docs/recipe-lab/style_transfer_reliability_suite.py --render --skip-existing +``` + +Run the seed-stability check: + +```bash +python docs/recipe-lab/style_transfer_seed_sweep.py --render --skip-existing +``` diff --git a/kg_krea_v10/recipes.py b/kg_krea_v10/recipes.py index 7280f9c..282e0d5 100644 --- a/kg_krea_v10/recipes.py +++ b/kg_krea_v10/recipes.py @@ -1,9 +1,10 @@ -"""V10 tuning tables: the V9 tables plus the V10 additions. +"""V10 tuning tables: the V9 tables plus the V10 additions/overrides. V10 adds quick recipes for the four roles that V9 left manual-only -(palette, environment, framing, loose), the per-card timing table, the -stack's balance budgets, and the manual structure/finish layer-dial split. -Everything else re-exports V9 so the two versions cannot drift apart. +(palette, environment, framing, loose), overrides the visual-style recipe, +the per-card timing table, the stack's balance budgets, and the manual +structure/finish layer-dial split. Shared tables still re-export V9; V10-only +behavior lives in this module so V9 remains frozen. """ from ._v9 import v9 @@ -18,6 +19,7 @@ STYLE_LAYER_PULL = _v9_recipes.STYLE_LAYER_PULL PALETTE_LAYER_PULL = _v9_recipes.PALETTE_LAYER_PULL MATERIAL_LAYER_PULL = _v9_recipes.MATERIAL_LAYER_PULL LIGHTING_LAYER_PULL = _v9_recipes.LIGHTING_LAYER_PULL +STYLE_TRANSFER_LAYER_PULL = [0.062, 0.087, 0.113, 0.15, 0.2, 0.25, 1.25, 3.438, 6.875, 1.375, 5.5, 1.5] role_pull_defaults = _v9_recipes.role_pull_defaults role_layer_pull_defaults = _v9_recipes.role_layer_pull_defaults @@ -28,6 +30,28 @@ effective_image_strength = _v9_recipes.effective_image_strength # whisper-defaults philosophy (soft caps, avoid-subject policies). QUICK_RECIPES = dict(_v9_recipes.QUICK_RECIPES) QUICK_RECIPES.update({ + "style gentle": { + "role": "style", + "treatment": "strong blur", + "color": 1.0, + # Render-tuned 2026-07-05 for V10 only: the inherited palette-wash + # style recipe had collapsed into a color-grade twin of "suggest the + # color palette". Strong blur keeps broad medium/finish cues alive + # while muting small details, and the finish-heavy table suppresses the + # source's structure layers. Palette-only safety stays with the + # separate palette recipe. + "detail": 0.3, + "study": "384", + "framing": "stack", + "subject": "avoid", + "early": 0.85, + "late": 0.85, + "guard": False, + "cap": 0.65, + "shape": 0.85, + "global": 1.85, + "layers": STYLE_TRANSFER_LAYER_PULL, + }, "palette only": { "role": "palette", "treatment": "palette wash", diff --git a/tests/test_krea_v10.py b/tests/test_krea_v10.py index a19a4ca..0b813cc 100644 --- a/tests/test_krea_v10.py +++ b/tests/test_krea_v10.py @@ -277,6 +277,58 @@ class KreaV10CardBehaviorTests(unittest.TestCase): self.assertEqual(card["v9_strength_cap"], 0.9) self.assertEqual(card["resolved_subject_policy"], "avoid") + def test_visual_style_recipe_uses_v10_style_transfer_override(self): + card = self._build(**{ + "Use image for": "suggest the visual style", + "How strongly this image guides": 1.6, + }) + + self.assertEqual(card["source_version"], "v10") + self.assertEqual(card["quick_recipe"], "style gentle") + self.assertFalse(card["manual_controls_active"]) + self.assertFalse(card["custom_recipe"]) + self.assertEqual(card["resolved_role"], "style") + self.assertEqual(card["resolved_treatment"], "strong blur") + self.assertAlmostEqual(card["resolved_color_keep"], 1.0) + self.assertAlmostEqual(card["resolved_detail"], 0.3) + self.assertEqual(card["resolved_reference_resolution"], "384") + self.assertEqual(card["resolved_reference_fit"], "stack") + self.assertEqual(card["resolved_subject_policy"], "avoid") + self.assertAlmostEqual(card["resolved_early_multiplier"], 0.85) + self.assertAlmostEqual(card["resolved_late_multiplier"], 0.85) + self.assertEqual(card["v9_strength_cap"], 0.65) + self.assertAlmostEqual(card["requested_strength"], 1.6) + self.assertAlmostEqual(card["strength"], 0.65) + self.assertAlmostEqual(card["resolved_shape_pull"], 0.85) + self.assertGreater(card["resolved_shape_pull"], 0.4) + self.assertAlmostEqual(card["resolved_global_pull"], 1.85) + self.assertEqual(card["resolved_layer_pull"], self.nodes.recipes.STYLE_TRANSFER_LAYER_PULL) + self.assertEqual(card["resolved_focus"], "") + self.assertEqual(card["resolved_direction"], "toward") + self.assertEqual(card["resolved_timing"], "recipe") + + def test_visual_style_recipe_keeps_contract_under_v10_direction_and_timing(self): + card = self._build(**{ + "Use image for": "suggest the visual style", + "Guide direction": "away from this image", + "When this card guides": "final details only", + "Structure layers pull": 0.25, + "Finish layers pull": 2.0, + }) + + self.assertEqual(card["resolved_direction"], "away") + self.assertEqual(card["direction"], "away") + self.assertEqual(card["resolved_timing"], "late") + self.assertEqual(card["resolved_subject_policy"], "avoid") + self.assertEqual(card["resolved_role"], "style") + self.assertEqual(card["resolved_treatment"], "strong blur") + self.assertAlmostEqual(card["resolved_early_multiplier"], 0.0) + self.assertAlmostEqual(card["resolved_late_multiplier"], 0.85) + # V10 layer dials are manual-mode only; built-in recipe layers stay stable. + self.assertEqual(card["resolved_layer_pull"], self.nodes.recipes.STYLE_TRANSFER_LAYER_PULL) + self.assertAlmostEqual(card["resolved_shape_pull"], 0.85) + self.assertAlmostEqual(card["resolved_global_pull"], 1.85) + def test_away_direction_forces_subject_avoid(self): card = self._build(**{ "Use image for": "keep the same subject", @@ -494,6 +546,66 @@ class KreaV10EncoderBehaviorTests(unittest.TestCase): finally: restore_encoder_machinery(cls, original) + def test_visual_style_recipe_targets_finish_layers_and_balances_predictably(self): + cls = self.nodes.KGTextEncodeKreaImageReferencesV10 + captured = {} + original = patch_encoder_machinery(cls, captured) + try: + style_card = self.nodes.KGKrea2ImageGuideCardV10().build(**{ + "Reference image": "style-ref", + "How strongly this image guides": 0.9, + "Use image for": "suggest the visual style", + })[0] + self._run( + captured, + **{ + "Image slider feel": "artist friendly - soft at low values", + "When images guide": "guide the whole image", + "Balance strong cards": "off - use my values", + "Reference 1 guide card": style_card, + }, + ) + image_entries = [e for e in captured["compose_calls"][0] if e[0] == "image-delta"] + self.assertEqual(len(image_entries), 1) + _, token_weight, pooled_weight, token_layers = image_entries[0] + effective = 0.65 ** 1.6 + token_target = effective * 0.85 + + self.assertAlmostEqual(token_weight, token_target - 1.0) + self.assertAlmostEqual(pooled_weight, effective * 1.85 - 1.0) + self.assertLess(token_layers[0], 0.0) + self.assertLess(token_layers[3], 0.0) + self.assertGreater(token_layers[8], 1.5) + self.assertGreater(token_layers[10], 1.0) + + captured["compose_calls"] = [] + self._run( + captured, + **{ + "Image slider feel": "literal slider values", + "When images guide": "guide the whole image", + "Balance strong cards": "strict balance", + "Reference 1 guide card": style_card, + "Reference 2 guide card": style_card, + }, + ) + balanced_entries = [ + e for e in captured["compose_calls"][0] if e[0] == "image-delta" + ] + self.assertEqual(len(balanced_entries), 2) + # The recipe caps the literal 0.9 slider to 0.65. Target departures: + # token: 0.65 * 0.85 - 1 = -0.4475, + # pooled: 0.65 * 1.85 - 1 = 0.2025. + # The balancer budgets by the larger axis per card, so this stack + # stays below the strict 1.5 budget and should be unchanged. + scale = 1.0 + for _, token_weight, pooled_weight, token_layers in balanced_entries: + self.assertAlmostEqual(token_weight, (0.65 * 0.85 - 1.0) * scale) + self.assertAlmostEqual(pooled_weight, (0.65 * 1.85 - 1.0) * scale) + self.assertGreater(token_layers[8], token_layers[3]) + finally: + restore_encoder_machinery(cls, original) + class KreaV10CustomRecipeTests(unittest.TestCase): @classmethod