[object Object]

← back to Wallco Ai

reroll-2: DTD verdict A — drop canny for the 2 glassware-entangled roots (text-led seamless txt2img) + add Gemini/local-llava subject-recovery gate to the retry loop; add free local-llava vision backend (Gemini over spend-cap 429); add gen_textled_seamless to redo module

95d401a6a9f885aa6cf6278e4dcc7555cc66745e · 2026-06-11 16:57:37 -0700 · Steve Abrams

Files touched

Diff

commit 95d401a6a9f885aa6cf6278e4dcc7555cc66745e
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 16:57:37 2026 -0700

    reroll-2: DTD verdict A — drop canny for the 2 glassware-entangled roots (text-led seamless txt2img) + add Gemini/local-llava subject-recovery gate to the retry loop; add free local-llava vision backend (Gemini over spend-cap 429); add gen_textled_seamless to redo module
---
 scripts/controlnet-anchored-redo.py     |  36 ++++++++
 scripts/controlnet-anchored-reroll-2.py | 148 +++++++++++++++++++++++---------
 scripts/vision-contains-local.py        |  40 +++++++++
 3 files changed, 184 insertions(+), 40 deletions(-)

diff --git a/scripts/controlnet-anchored-redo.py b/scripts/controlnet-anchored-redo.py
index f0cd50c..8d8003f 100644
--- a/scripts/controlnet-anchored-redo.py
+++ b/scripts/controlnet-anchored-redo.py
@@ -261,6 +261,12 @@ def gen_controlnet_seamless(positive, negative, seed, hint_local, cn_strength,
         '8':  {'class_type': 'VAEDecode', 'inputs': {'samples': ['3', 0], 'vae': ['2000', 0]}},
         '9':  {'class_type': 'SaveImage', 'inputs': {'filename_prefix': f'wallco_cnar_{seed}', 'images': ['8', 0]}},
     }
+    return _submit_and_pull(workflow, out_path)
+
+
+def _submit_and_pull(workflow, out_path):
+    """Submit a ComfyUI workflow, poll /history until node '9' SaveImage yields,
+    pull the bytes to out_path. Shared by the canny + text-led generators."""
     submit = comfy_post('/prompt', {'prompt': workflow, 'client_id': 'wallco-cnar'})
     pid = json.loads(submit).get('prompt_id')
     if not pid:
@@ -299,6 +305,36 @@ def gen_controlnet_seamless(positive, negative, seed, hint_local, cn_strength,
     return out_path
 
 
+def gen_textled_seamless(positive, negative, seed, out_path):
+    """txt2img native-seamless with NO ControlNet — pure text-led. Used for roots
+    whose source structure (glassware) entangles the animal so badly that a canny
+    edge-map re-imports the wrong geometry no matter how it is cropped (DTD verdict
+    A, 2026-06-11). Same zero-smear circular-pad construction (SeamlessTile +
+    MakeCircularVAE, denoise 1.0) so the tile wraps by construction; the open
+    sole-focal-animal prompt drives a clean single subject with no glassware prior.
+
+    Graph: CheckpointLoader -> SeamlessTile(model) + MakeCircularVAE(vae);
+    CLIPTextEncode(pos/neg); EmptyLatentImage; KSampler(denoise 1.0) ->
+    VAEDecode(circular vae) -> SaveImage. Identical to the canny path minus the
+    LoadImage/Canny/ControlNet nodes."""
+    workflow = {
+        '4':  {'class_type': 'CheckpointLoaderSimple', 'inputs': {'ckpt_name': MODEL}},
+        '1000': {'class_type': 'SeamlessTile', 'inputs': {'model': ['4', 0], 'tiling': 'enable', 'copy_model': 'Make a copy'}},
+        '2000': {'class_type': 'MakeCircularVAE', 'inputs': {'vae': ['4', 2], 'tiling': 'enable', 'copy_vae': 'Make a copy'}},
+        '6':  {'class_type': 'CLIPTextEncode', 'inputs': {'text': positive, 'clip': ['4', 1]}},
+        '7':  {'class_type': 'CLIPTextEncode', 'inputs': {'text': negative, 'clip': ['4', 1]}},
+        '5':  {'class_type': 'EmptyLatentImage', 'inputs': {'width': RENDER, 'height': RENDER, 'batch_size': 1}},
+        '3':  {'class_type': 'KSampler', 'inputs': {
+            'seed': seed, 'steps': 30, 'cfg': 7.0, 'sampler_name': SAMPLER,
+            'scheduler': SCHEDULER, 'denoise': 1.0,
+            'model': ['1000', 0], 'positive': ['6', 0], 'negative': ['7', 0],
+            'latent_image': ['5', 0]}},
+        '8':  {'class_type': 'VAEDecode', 'inputs': {'samples': ['3', 0], 'vae': ['2000', 0]}},
+        '9':  {'class_type': 'SaveImage', 'inputs': {'filename_prefix': f'wallco_cnar_txt_{seed}', 'images': ['8', 0]}},
+    }
+    return _submit_and_pull(workflow, out_path)
+
+
 # ---------- composite ----------
 def dominant_hex(png_path):
     im = Image.open(png_path).convert('RGB').resize((128, 128))
diff --git a/scripts/controlnet-anchored-reroll-2.py b/scripts/controlnet-anchored-reroll-2.py
index fd69de4..b449b98 100644
--- a/scripts/controlnet-anchored-reroll-2.py
+++ b/scripts/controlnet-anchored-reroll-2.py
@@ -73,22 +73,27 @@ ALLOWED_ROOTS = {54076, 54266}
 # directly addresses its specific WARN. Tighter crop_frac + smaller motif_px =
 # figure sits well inside the boundary = no boundary-touching structure to break
 # the wrap, and more open negative space around it.
+#
+# DTD VERDICT A (3/3, 2026-06-11): for THESE TWO roots the canny anchor is the
+# proven cause of subject-loss — the animal is the MINORITY structure entangled
+# with glassware (wine-glass rim / champagne coupes), so any canny crop (auto or
+# hand-targeted) re-imports the glass geometry and the animal vanishes. We DROP
+# canny for both and go text-led (no_canny=True): pure circular-pad seamless
+# txt2img driven by the strong sole-focal-animal prompt, still gated by the
+# seam-retry-until-PASS-both-axes loop (zero-smear + seam guarantee unchanged).
 ROOT_CFG = {
-    54076: {  # orangutan: kill the white horizontal seam + stop the glass-rim slice
+    54076: {  # orangutan: text-led single orangutan, no glass-rim slice, no seam
         'ground': 'natural linen',
-        'crop_frac': 0.26,     # tighter than the sample's 0.32 -> a single focal blob
-        'motif_px': 150,       # smaller than 175 -> motif lands well inside the frame
-        'cn_strength': 0.40,   # lower than 0.5 -> the open prompt wins layout vs. glass
-        'cn_end': 0.45,        # release control earlier so surround opens up
-        'cov_lo': 8.0, 'cov_hi': 28.0,   # accept an open, non-busy motif tile
+        'no_canny': True,
+        'cov_lo': 8.0, 'cov_hi': 30.0,   # accept an open, non-busy motif tile
+        # canny params retained only as fallback if --use-canny is forced
+        'crop_frac': 0.26, 'motif_px': 150, 'cn_strength': 0.40, 'cn_end': 0.45,
     },
-    54266: {  # frog: drop the champagne-tower clutter, single focal frog, low coverage
+    54266: {  # frog: text-led single tree-frog, drop the champagne-tower clutter
         'ground': 'raffia',
-        'crop_frac': 0.24,     # very tight -> grab the frog, not the coupe tower
-        'motif_px': 150,
-        'cn_strength': 0.38,   # weak anchor: the tower structure must NOT come back
-        'cn_end': 0.42,
-        'cov_lo': 8.0, 'cov_hi': 30.0,   # Steve's ~20-30% coverage target, generous space
+        'no_canny': True,
+        'cov_lo': 8.0, 'cov_hi': 30.0,   # Steve's ~20-30% coverage target, open space
+        'crop_frac': 0.24, 'motif_px': 150, 'cn_strength': 0.38, 'cn_end': 0.42,
     },
 }
 
@@ -101,7 +106,46 @@ def _verify_array(arr, pass_tol=edgeverify.PASS_TOL, warn_tol=edgeverify.WARN_TO
     return edgeverify.verify(np.asarray(arr)[:, :, :3], pass_tol, warn_tol)
 
 
-def reroll_one(root, cfg, max_retries, pass_tol, warn_tol):
+def _vision_has_animal(png_path, animal):
+    """Subject-recovery gate (yes/no vision). The 5-root sample WARNed on edges;
+    this re-roll ALSO failed 'subject recovered' (the canny re-imported glassware
+    and the animal vanished). So we add a hard vision gate: the motif tile must
+    visibly contain a recognizable <animal>.
+
+    Primary backend = FREE LOCAL Ollama llava (Mac2 localhost, unmetered) because
+    the Gemini API is currently over its monthly spend cap (HTTP 429 — raising it
+    is a Steve-gated money action). Falls back to Gemini only if the local model
+    is unreachable (exit 2). Verified locally: elephant->yes, subjectless
+    glassware->no.
+
+    Keep the question simple and inclusive ("is a recognizable <animal>
+    present?"). An exclusion-clause phrasing ("not just glassware...") made the
+    model answer NO whenever ANY glassware co-existed, false-rejecting a
+    genuinely-present elephant and risking an infinite loop. This gate's only job
+    is to reject tiles where the animal is ABSENT.
+
+    On total failure of both backends -> False (fail-closed; never ship an
+    unconfirmed subject)."""
+    import subprocess as _sp
+    q = f"Is there a recognizable {animal} visible in this wallpaper image?"
+    # 1) local llava (free) — exit 0 = clean answer, exit 2 = unreachable
+    try:
+        r = _sp.run(['python3', os.path.join(ROOT, 'scripts', 'vision-contains-local.py'),
+                     png_path, q], capture_output=True, text=True, timeout=140)
+        if r.returncode == 0:
+            return (r.stdout or '').strip().lower().startswith('yes')
+    except Exception:
+        pass
+    # 2) Gemini fallback (metered; may be 429-capped — then returns 'no')
+    try:
+        r = _sp.run(['python3', os.path.join(ROOT, 'scripts', 'vision-contains.py'),
+                     png_path, q], capture_output=True, text=True, timeout=40)
+        return (r.stdout or '').strip().lower().startswith('yes')
+    except Exception:
+        return False
+
+
+def reroll_one(root, cfg, max_retries, pass_tol, warn_tol, force_canny=False):
     """Image/file-level retry loop for ONE root. Returns the result dict for the
     FINAL passing candidate (and inserts exactly one DB row), or a dict with
     ok=False and the per-attempt edge history if it never clears."""
@@ -115,31 +159,37 @@ def reroll_one(root, cfg, max_retries, pass_tol, warn_tol):
     subject = cnar.extract_subject(prompt)
     animal = cnar.short_animal(prompt)
     positive, negative = cnar.build_prompt(subject, animal)
-
-    root_img = Image.open(src).convert('RGB')
-    # The focal crop is deterministic per root, so re-rolling the crop position
-    # would just return the same window. We re-roll the SEED (and, on later
-    # attempts, gently jitter the canny strength down) to get a different motif
-    # while keeping the same open prompt + circular-pad seamless construction.
-    focal, crop_box = cnar.find_focal_crop(root_img, cfg['crop_frac'])
-    hint = cnar.build_canny_hint(focal, cfg['motif_px'])
-    hint_png = os.path.join(GENDIR, f'{int(time.time()*1000)}_{root}_reroll2_hint.png')
-    hint.save(hint_png, 'PNG')
+    no_canny = bool(cfg.get('no_canny')) and not force_canny
+
+    # For the canny fallback path only: the focal crop is deterministic per root,
+    # so re-rolling the crop position returns the same window; we re-roll the SEED
+    # (and jitter canny strength down on later attempts) for a different motif.
+    hint_png = None
+    crop_box = None
+    if not no_canny:
+        root_img = Image.open(src).convert('RGB')
+        focal, crop_box = cnar.find_focal_crop(root_img, cfg['crop_frac'])
+        hint = cnar.build_canny_hint(focal, cfg['motif_px'])
+        hint_png = os.path.join(GENDIR, f'{int(time.time()*1000)}_{root}_reroll2_hint.png')
+        hint.save(hint_png, 'PNG')
 
     attempts = []
     ground = cfg['ground']
+    mode = 'text-led' if no_canny else 'canny'
     for attempt in range(1, max_retries + 1):
         seed = int.from_bytes(os.urandom(4), 'big') % (2**31 - 1)
-        # Late attempts: nudge the canny strength further down so a stubborn
-        # boundary-crossing structure (glass rim / tower strut) is given even
-        # less authority and the model is freer to lay out an open, wrapping tile.
+        # canny-fallback only: nudge strength down on later attempts so a stubborn
+        # boundary-crossing structure is given even less authority.
         cn_strength = max(0.30, cfg['cn_strength'] - 0.03 * (attempt - 1))
         cn_end = cfg['cn_end']
         ts = int(time.time() * 1000)
         motif_png = os.path.join(GENDIR, f'{ts}_{seed}_reroll2_motif.png')
         try:
-            cnar.gen_controlnet_seamless(positive, negative, seed, hint_png,
-                                         cn_strength, cn_end, motif_png)
+            if no_canny:
+                cnar.gen_textled_seamless(positive, negative, seed, motif_png)
+            else:
+                cnar.gen_controlnet_seamless(positive, negative, seed, hint_png,
+                                             cn_strength, cn_end, motif_png)
         except Exception as e:
             attempts.append({'attempt': attempt, 'seed': seed, 'error': str(e)[:200]})
             continue
@@ -153,19 +203,29 @@ def reroll_one(root, cfg, max_retries, pass_tol, warn_tol):
         v_v = ev['axes']['vertical']['verdict']
         cov_ok = cfg['cov_lo'] <= cov <= cfg['cov_hi']
         motif_pass = (ev['verdict'] == 'PASS' and h_v == 'PASS' and v_v == 'PASS')
-        attempts.append({
-            'attempt': attempt, 'seed': seed, 'cn_strength': round(cn_strength, 2),
+        rec = {
+            'attempt': attempt, 'seed': seed, 'mode': mode,
+            'cn_strength': round(cn_strength, 2),
             'motif_edge': ev['verdict'], 'h': h_v, 'v': v_v,
             'h_tiled': ev['axes']['horizontal']['tiled_cross_seam_max'],
             'v_tiled': ev['axes']['vertical']['tiled_cross_seam_max'],
             'coverage_pct': round(cov, 1), 'cov_ok': cov_ok,
             'isolated_motif': ev['isolated_motif'], 'motif': motif_png,
-        })
-        print(f"  root {root} attempt {attempt}/{max_retries}: motif edge={ev['verdict']} "
+        }
+        attempts.append(rec)
+        print(f"  root {root} attempt {attempt}/{max_retries} [{mode}]: motif edge={ev['verdict']} "
               f"h={h_v} v={v_v} cov={cov:.1f}% cov_ok={cov_ok} seed={seed}", file=sys.stderr)
 
         if not (motif_pass and cov_ok):
-            continue   # re-roll; do NOT composite, do NOT insert
+            continue   # re-roll; do NOT spend a vision call, composite, or insert
+
+        # --- SUBJECT-RECOVERY gate (only after edges+coverage pass, to save cost) ---
+        subject_ok = _vision_has_animal(motif_png, animal)
+        rec['subject_recovered'] = subject_ok
+        print(f"  root {root} attempt {attempt}: subject_recovered({animal})={subject_ok}",
+              file=sys.stderr)
+        if not subject_ok:
+            continue   # animal not recognizable -> re-roll; do NOT composite/insert
 
         # --- motif cleared BOTH axes + coverage OK: finalize this candidate ---
         final_png = os.path.join(GENDIR, f'{ts}_{seed}_reroll2.png')
@@ -192,12 +252,15 @@ def reroll_one(root, cfg, max_retries, pass_tol, warn_tol):
             'category': meta.get('category'), 'subject': subject, 'animal': animal,
             'ground': ground, 'seed': seed, 'base_hex': base_hex,
             'cn_strength': round(cn_strength, 2), 'cn_end': cn_end,
+            'mode': mode, 'subject_recovered': True,
             'crop_box': crop_box, 'coverage_pct': round(cov, 1),
             'attempts_used': attempt, 'attempts': attempts,
-            'technique': ('canny-SDXL-CN-anchored native-seamless txt2img '
-                          '(SeamlessTile+MakeCircularVAE, denoise 1.0, tighter focal '
-                          'canny crop) + SEAM-RETRY-until-motif-PASS-both-axes + '
-                          'deterministic real-fibre composite — NO smear'),
+            'technique': (
+                ('text-led native-seamless txt2img (SeamlessTile+MakeCircularVAE, '
+                 'denoise 1.0, NO canny — DTD verdict A) ' if mode == 'text-led'
+                 else 'canny-SDXL-CN-anchored native-seamless txt2img (tighter focal crop) ')
+                + '+ SEAM-RETRY-until-motif-PASS-both-axes + Gemini subject-recovery gate '
+                + '+ deterministic real-fibre composite — NO smear'),
             'edge_verdict': fev['verdict'], 'edge_seamless': bool(fev['ok']),
             'edge_axes': {'h': fev['axes']['horizontal'], 'v': fev['axes']['vertical']},
             'motif_edge_verdict': ev['verdict'],
@@ -207,8 +270,10 @@ def reroll_one(root, cfg, max_retries, pass_tol, warn_tol):
 
     # Never cleared within the cap.
     return {
-        'root_id': root, 'ok': False,
-        'error': f'no motif cleared PASS-both-axes + coverage in {max_retries} retries',
+        'root_id': root, 'ok': False, 'mode': mode,
+        'error': (f'no motif cleared all gates (edge PASS-both-axes + coverage '
+                  f'{cfg["cov_lo"]}-{cfg["cov_hi"]}% + Gemini subject-recovery) in '
+                  f'{max_retries} retries'),
         'ground': ground, 'attempts': attempts,
     }
 
@@ -220,6 +285,8 @@ def main():
     ap.add_argument('--max-retries', type=int, default=6)
     ap.add_argument('--pass-tol', type=float, default=edgeverify.PASS_TOL)
     ap.add_argument('--warn-tol', type=float, default=edgeverify.WARN_TOL)
+    ap.add_argument('--use-canny', action='store_true',
+                    help='force the canny anchor fallback (default: text-led per DTD verdict A)')
     args = ap.parse_args()
 
     ids = [int(x) for x in args.ids.split(',') if x.strip()]
@@ -233,7 +300,8 @@ def main():
     for rid in ids:
         cfg = ROOT_CFG[rid]
         try:
-            r = reroll_one(rid, cfg, args.max_retries, args.pass_tol, args.warn_tol)
+            r = reroll_one(rid, cfg, args.max_retries, args.pass_tol, args.warn_tol,
+                           force_canny=args.use_canny)
         except Exception as e:
             r = {'root_id': rid, 'ok': False, 'error': str(e), 'ground': cfg['ground']}
         results.append(r)
diff --git a/scripts/vision-contains-local.py b/scripts/vision-contains-local.py
new file mode 100644
index 0000000..75809a8
--- /dev/null
+++ b/scripts/vision-contains-local.py
@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+"""vision-contains-local.py — FREE local yes/no vision gate via Ollama llava
+(Mac2 localhost, unmetered). Drop-in replacement for vision-contains.py when the
+Gemini API is over its spend cap (HTTP 429). Used by controlnet-anchored-reroll-2
+as the subject-recovery gate.
+
+Usage: vision-contains-local.py <png_path> <concept-question>
+Prints: yes|no   (exit 0 on a clean answer, 2 on unreachable/error -> caller
+fails closed)
+"""
+import base64, json, os, sys, urllib.request
+
+OLLAMA = os.environ.get('OLLAMA_URL', 'http://127.0.0.1:11434')
+MODEL = os.environ.get('OLLAMA_VISION_MODEL', 'llava:latest')
+
+png, question = sys.argv[1], sys.argv[2]
+try:
+    img_b64 = base64.b64encode(open(png, 'rb').read()).decode()
+except Exception as e:
+    print('no', file=sys.stderr); print('no'); sys.exit(2)
+
+body = {
+    'model': MODEL,
+    'prompt': question + ' Answer with exactly one word: yes or no.',
+    'images': [img_b64],
+    'stream': False,
+    'options': {'temperature': 0},
+}
+req = urllib.request.Request(f'{OLLAMA}/api/generate',
+                             data=json.dumps(body).encode(),
+                             headers={'Content-Type': 'application/json'})
+try:
+    with urllib.request.urlopen(req, timeout=120) as r:
+        j = json.load(r)
+    txt = (j.get('response') or '').strip().lower()
+    print('yes' if txt.startswith('yes') else 'no')
+    sys.exit(0)
+except Exception as e:
+    print(f'local-vision error: {e}', file=sys.stderr)
+    print('no'); sys.exit(2)

← 9ec758e Add controlnet-anchored-reroll-2: seam-retry loop for the tw  ·  back to Wallco Ai  ·  reroll-2: empirical correction — text-led alone fails edges+ fb6e03f →