← back to Wallco Ai

scripts/controlnet-anchored-reroll-2.py

332 lines

#!/usr/bin/env python3
"""controlnet-anchored-reroll-2.py — TARGETED re-roll of EXACTLY two roots that
missed the brief in the 5-root ControlNet sample (Steve-approved 2026-06-11):

  54076 orangutan — WARN: a real visible white horizontal seam + the subject was
                    sliced inside a glass rim. (current child 10000013)
  54266 tree frog — WARN: ~50% coverage, busy/abstract; a champagne-tower
                    structure dominated and the frog was lost. (child 10000012)

This is a SURGICAL superset of controlnet-anchored-redo.py. It reuses that
script's proven machinery (cropped-focal canny hint, SeamlessTile +
MakeCircularVAE circular-pad txt2img at denoise 1.0 = zero smear by
construction, deterministic real-fibre composite) and adds the THREE things the
sample exposed as missing:

  1. SEAM-RETRY LOOP (the core fix). The 5-root sample WARNed because the
     circular-pad txt2img sometimes lands a motif whose structure crosses a tile
     boundary badly — verified: both failing motif-only tiles scored WARN with
     tiled_cross_seam_max 37-41 (>> warn_tol). So we now re-roll the SEED and
     regenerate the MOTIF-ONLY tile, re-running verify-edge-seamless IN MEMORY on
     the raw motif array, and only proceed to composite + DB-insert once the
     motif clears cross-seam PASS on BOTH axes. Cap MAX_RETRIES (default 6). If a
     root never clears, we STOP and report it — we do NOT ship a WARN and do NOT
     expand scope.

  2. MINIMIZE ORPHANS. all_designs lives in publication dw_unified_pub with NO
     replica identity / PK, so dw_admin can only INSERT (never UPDATE/DELETE) —
     a failed attempt's row can never be cleaned up. Therefore the ENTIRE retry
     loop runs at the IMAGE/FILE level; we INSERT the DB row ONLY for the final
     passing candidate. Exactly one new id per root, max. (Per Steve's CRITICAL.)

  3. TIGHTER CROP / SMALLER MOTIF so motif copies do not touch the tile boundary
     (a boundary-touching motif is what WARNs the line-art). Smaller crop_frac +
     motif_px keeps the figure well inside, leaving open ground all the way to
     the wrap line. Per-root overrides below also drop the clutter:
       - frog: lower coverage target (reject too-busy rolls), single focal frog;
       - orangutan: smaller motif + lower canny strength so the figure is NOT
         redrawn inside a glass rim, and the open prompt drives layout.

SACRED ROOTS: never overwrites a root. Final candidate -> NEW file + NEW id,
is_published=FALSE, user_removed=FALSE, parent_design_id=<root>. Never published
live. The settlement gate is untouched (these are children of already-passed
roots; no new prompt class). DATABASE_URL self-resolves, never echoed. Does NOT
use make_seamless.py / force-edge-seamless.py (smear sources).

ONLY operates on 54076 and 54266. Refuses any other id.

Usage:
  python3 scripts/controlnet-anchored-reroll-2.py
  python3 scripts/controlnet-anchored-reroll-2.py --max-retries 6
  python3 scripts/controlnet-anchored-reroll-2.py --ids 54076   # one at a time
"""
import argparse, datetime, json, os, sys, time
from pathlib import Path

import numpy as np
from PIL import Image

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
GENDIR = os.path.join(ROOT, 'data', 'generated')
QUEUE = os.path.join(ROOT, 'data', 'seam-fix-queue.jsonl')

sys.path.insert(0, os.path.join(ROOT, 'scripts'))
from importlib import import_module
cnar = import_module('controlnet-anchored-redo')    # reuse the proven machinery
sfv = import_module('seam-fix-variants')            # psql
edgeverify = import_module('verify-edge-seamless')  # true-toroidal edge gate (in-mem)

# HARD SCOPE: only these two roots may be touched.
ALLOWED_ROOTS = {54076, 54266}

# Per-root config. Each root gets its own ground + crop/motif/canny tuning that
# 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: weak-canny + subject-gate, no glass-rim slice, no seam
        'ground': 'natural linen',
        'no_canny': False,
        'cov_lo': 8.0, 'cov_hi': 34.0,   # open-ish; PASS-edge tiles landed ~31%
        'crop_frac': 0.26, 'motif_px': 150, 'cn_strength': 0.40, 'cn_end': 0.45,
    },
    54266: {  # frog: weak-canny + subject-gate, drop the champagne-tower clutter
        'ground': 'raffia',
        'no_canny': False,
        'cov_lo': 8.0, 'cov_hi': 34.0,   # ~20-34% open coverage
        'crop_frac': 0.24, 'motif_px': 150, 'cn_strength': 0.38, 'cn_end': 0.42,
    },
}
# EMPIRICAL UPDATE (2026-06-11, after the text-led trial): DTD verdict A said
# drop canny because it re-imported glassware. True — BUT removing canny also
# removed the structural prior that kept the tile OPEN and WRAPPING: pure
# text-led produced busy, high-coverage (40-63%), edge-FAILING tiles on both
# roots (0/12 attempts cleared). The canny path, by contrast, cleared
# PASS-both-axes reliably in the first run; its only failure was subject-loss.
# SYNTHESIS: keep the weak canny anchor (openness + wrap) AND add the
# subject-recovery vision gate (now wired into the loop) so a glassware-only
# roll is REJECTED and re-rolled until canny lands a tile that ALSO contains the
# animal. Coverage cap nudged to 34% (PASS-edge tiles measured ~31%). This
# honors the DTD's real concern (don't ship a subjectless tile) via the gate,
# while keeping the mechanism that actually produces clean seamless tiles.

# Coverage gate widened a touch — the real discriminator is the edge verdict;
# coverage just rejects an over-busy roll (too high) or an empty one (too low).


def _verify_array(arr, pass_tol=edgeverify.PASS_TOL, warn_tol=edgeverify.WARN_TOL):
    """In-memory true-toroidal verify of a motif array. No file round-trip."""
    return edgeverify.verify(np.asarray(arr)[:, :, :3], 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."""
    os.makedirs(GENDIR, exist_ok=True)
    meta = cnar.root_meta_one(root)
    src = meta.get('local_path')
    if not src or not os.path.exists(src):
        return {'root_id': root, 'ok': False, 'error': 'no source file'}

    prompt = meta.get('prompt') or ''
    subject = cnar.extract_subject(prompt)
    animal = cnar.short_animal(prompt)
    positive, negative = cnar.build_prompt(subject, animal)
    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)
        # 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:
            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

        # --- in-memory edge verify on the RAW MOTIF tile (the real gate) ---
        motif_arr = np.asarray(Image.open(motif_png).convert('RGB'))
        ev = _verify_array(motif_arr, pass_tol, warn_tol)
        base_hex = cnar.dominant_hex(motif_png)
        cov = cnar.coverage_pct(motif_png, base_hex)
        h_v = ev['axes']['horizontal']['verdict']
        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')
        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,
        }
        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 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')
        cnar.composite_real_fibre(motif_png, ground, base_hex, final_png)
        # Re-verify the FINAL composite (the bg-swap provably wraps, but verify
        # the written file so the recorded verdict is on the shipped pixels).
        fev = edgeverify.verify_path(Path(final_png), pass_tol, warn_tol)
        fh = fev['axes']['horizontal']['verdict']
        fv = fev['axes']['vertical']['verdict']
        if not (fev['verdict'] == 'PASS' and fh == 'PASS' and fv == 'PASS'):
            # Composite somehow regressed the wrap — record + keep rerolling.
            attempts[-1]['composite_edge'] = fev['verdict']
            attempts[-1]['composite_regressed'] = True
            print(f"  root {root} attempt {attempt}: composite regressed to "
                  f"{fev['verdict']} (h={fh} v={fv}) — rerolling", file=sys.stderr)
            continue

        # INSERT exactly one DB row — only now, only for this passing candidate.
        new_id = cnar.insert_child(root, final_png, positive, negative, ground, cn_strength)
        return {
            'ts': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),
            'root_id': root, 'variant': 'controlnet-anchored-reroll-2',
            'generator': 'controlnet-anchored-reroll-2',
            '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': (
                ('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'],
            'new_id': new_id, 'hint': hint_png, 'motif': motif_png, 'out': final_png,
            'ok': True,
        }

    # Never cleared within the cap.
    return {
        '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,
    }


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--ids', default='54076,54266',
                    help='subset of the two allowed roots (default both)')
    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()]
    bad = [i for i in ids if i not in ALLOWED_ROOTS]
    if bad:
        print(json.dumps({'error': f'roots {bad} are OUT OF SCOPE — only '
                          f'{sorted(ALLOWED_ROOTS)} are permitted'}), file=sys.stderr)
        sys.exit(2)

    results = []
    for rid in ids:
        cfg = ROOT_CFG[rid]
        try:
            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)
        if r.get('ok'):
            with open(QUEUE, 'a') as q:
                q.write(json.dumps(r) + '\n')
        v = r.get('edge_verdict', r.get('error', '?'))
        print(f"=> root {rid}: {'OK' if r.get('ok') else 'FAIL'} edge={v} "
              f"new_id={r.get('new_id')} attempts={r.get('attempts_used')}", file=sys.stderr)

    print(json.dumps(results, indent=2))
    sys.exit(0 if all(r.get('ok') for r in results) else 1)


if __name__ == '__main__':
    main()