[object Object]

← back to Wallco Ai

Add native-seamless-sample.py: retire post-hoc strip-smear, generate native-seamless candidates on textured-natural grounds with open composition (sanity sample, no bulk)

12c62e911af8d2301ee6dfb421ef23cf7be1c439 · 2026-06-11 14:36:22 -0700 · Steve Abrams

Files touched

Diff

commit 12c62e911af8d2301ee6dfb421ef23cf7be1c439
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 14:36:22 2026 -0700

    Add native-seamless-sample.py: retire post-hoc strip-smear, generate native-seamless candidates on textured-natural grounds with open composition (sanity sample, no bulk)
---
 scripts/native-seamless-sample.py | 309 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 309 insertions(+)

diff --git a/scripts/native-seamless-sample.py b/scripts/native-seamless-sample.py
new file mode 100644
index 0000000..f81e994
--- /dev/null
+++ b/scripts/native-seamless-sample.py
@@ -0,0 +1,309 @@
+#!/usr/bin/env python3
+"""
+native-seamless-sample.py — BOUNDED sanity sample for Steve's 2026-06-11 brief:
+  "its adding weird smudges into the design, remember to use the textured
+   naturals as backgrounds and clean up. not so busy.. more open designs"
+
+WHAT THIS IS (and why a NEW standalone script — DTD verdict A, 2026-06-11):
+  The seam-fix path (force-edge-seamless.py / seam-fix-variants.py v1/v2) gets a
+  clean wrap by MIRROR-AVERAGING / feathering a boundary strip. That smearing IS
+  the "weird smudges" Steve sees — a translucent ghost band of the opposite
+  edge's motifs bleeding inward. We RETIRE post-hoc strip-smearing as a
+  visible-output technique.
+
+  The correct way to get a clean seamless edge with ZERO smudge is NATIVE
+  seamless generation: ComfyUI SeamlessTile (UNet Conv2D → circular pad) +
+  MakeCircularVAE (VAE decoder → circular pad). The tile wraps by construction;
+  no post-processing touches the pixels, so there is no smear band at all.
+  Mac1 ComfyUI (spinagon/ComfyUI-seamless-tiling) already has both nodes — this
+  is the SAME mechanism generate_designs.js uses for WALLCO_FORCE_TILEABLE, but
+  driven directly here so we can also rewrite the prompt for the aesthetic brief
+  WITHOUT touching generate_designs.js (which 79 other roots depend on).
+
+THE AESTHETIC BRIEF (built into PROMPT below):
+  - Textured natural ground: the background reads as a real DW natural-material
+    SKU (grasscloth / raffia / linen / sisal / paperweave) — FLAT, woven,
+    matte, printed-on — NOT embossed/3D/relief (the old "embossed raffia relief"
+    suffix that caused the depth/bleed regression is explicitly NEGATIVE-prompted
+    away here).
+  - Cleaner / less busy: ONE isolated motif, large open repeat, low element
+    density.
+  - More open: generous negative space, the motif breathes, NOT edge-to-edge.
+
+SACRED-ROOT GUARANTEE (identical to force-edge-seamless.py):
+  - Never overwrites a root. Each candidate → NEW file + NEW design id with
+    is_published=FALSE, user_removed=FALSE, parent_design_id=<root>.
+  - The NEW prompt is stored on the child (so provenance is honest — this is a
+    re-generation on a new brief, not a pixel-fix of the root).
+  - Every candidate is verified with verify-edge-seamless.py on the WRITTEN file;
+    the real verdict is recorded — a candidate that still WARN/FAILs is recorded
+    as such, never a fake PASS.
+  - Appends to data/seam-fix-queue.jsonl with the same-ish schema so the curator
+    surfaces these candidates (variant='native-seamless').
+
+NOT a bulk run. Caps at the --ids you pass (sanity sample). Scale to the rest
+only after Steve approves the direction.
+
+Usage:
+  python3 scripts/native-seamless-sample.py --ids 2662,54266,54076,54610,53896
+  python3 scripts/native-seamless-sample.py --ids 2662 --dry-run   # prompt only, no gen
+  python3 scripts/native-seamless-sample.py --ids 2662 --ground grasscloth
+
+DATABASE_URL self-resolves from env or .env (never echoed).
+"""
+import argparse, datetime, json, os, re, subprocess, sys, time
+from pathlib import Path
+from urllib.parse import quote
+
+import numpy as np
+from PIL import Image
+
+ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
+OUTDIR = os.path.join(ROOT, 'data', 'seam-fix')
+GENDIR = os.path.join(ROOT, 'data', 'generated')   # where the real designs live
+QUEUE = os.path.join(ROOT, 'data', 'seam-fix-queue.jsonl')
+
+sys.path.insert(0, os.path.join(ROOT, 'scripts'))
+from importlib import import_module
+sfv = import_module('seam-fix-variants')            # reuse psql / root_meta
+edgeverify = import_module('verify-edge-seamless')  # true-toroidal edge gate
+
+COMFY = os.environ.get('COMFY_URL', 'http://192.168.1.133:8188')
+MODEL = os.environ.get('COMFY_MODEL', 'sd_xl_base_1.0.safetensors')
+WIDTH = int(os.environ.get('COMFY_WIDTH', '1024'))
+HEIGHT = int(os.environ.get('COMFY_HEIGHT', '1024'))
+SAMPLER = os.environ.get('COMFY_SAMPLER', 'dpmpp_2m_sde')
+SCHEDULER = os.environ.get('COMFY_SCHEDULER', 'karras')
+POLL_TIMEOUT = int(os.environ.get('COMFY_POLL_TIMEOUT_SEC', '900'))
+
+# Rotate the natural ground across the sample so Steve sees range, not one note.
+GROUNDS = ['grasscloth', 'raffia', 'natural linen', 'sisal', 'paperweave']
+
+# Real DW natural-material substrates, described as FLAT printed-on grounds.
+GROUND_PHRASE = {
+    'grasscloth':    'a real woven grasscloth wallpaper ground — fine horizontal natural-fibre weave, matte unbleached, the pattern is screen-printed flat onto it',
+    'raffia':        'a real raffia wallpaper ground — coarse hand-woven palm-fibre texture, warm natural tan, the pattern is screen-printed flat onto it',
+    'natural linen': 'a real natural-linen wallpaper ground — soft slubby woven flax texture, oatmeal undyed, the pattern is screen-printed flat onto it',
+    'sisal':         'a real sisal wallpaper ground — tight even natural-fibre weave, pale wheat, the pattern is screen-printed flat onto it',
+    'paperweave':    'a real paperweave wallpaper ground — rolled-paper natural weave, soft beige, the pattern is screen-printed flat onto it',
+}
+
+
+def extract_subject(prompt: str) -> str:
+    """Pull the animal + drink subject from a drunk-animals root prompt.
+    These prompts open with '<animal description>, <drink description>, ...';
+    take the first two comma clauses as the subject, drop the rest (which is
+    where the busy 'sunflowers / foliage / Met-PD reference' bloat lives)."""
+    if not prompt:
+        return 'a single tipsy animal holding one cocktail'
+    parts = [p.strip() for p in prompt.split(',')]
+    subject = ', '.join(parts[:2]) if len(parts) >= 2 else parts[0]
+    # Strip any trailing tone-on-tone / texture boilerplate that leaked in.
+    subject = re.split(r'\.\s', subject)[0]
+    return subject.strip().rstrip('.')
+
+
+def build_prompt(subject: str, ground: str):
+    """The aesthetic brief, encoded. ONE isolated motif on a flat textured
+    natural ground, lots of open space, native-seamless (the wrap is done by
+    the workflow, not the prompt)."""
+    gp = GROUND_PHRASE.get(ground, GROUND_PHRASE['grasscloth'])
+    positive = (
+        f"A single {subject}, rendered as ONE clean flat-colour silhouette motif, "
+        f"sitting alone with lots of breathing room on {gp}. "
+        "Open airy wallcovering repeat — the motif is widely spaced, isolated, "
+        "surrounded by generous empty negative space of bare natural ground; "
+        "this is a sparse minimal pattern, NOT busy, NOT edge-to-edge, NOT a "
+        "dense all-over print. One animal motif per repeat unit, calm and "
+        "uncluttered. The motif is a flat solid-colour figure in two or three "
+        "muted archival ink tones (oyster, cocoa, sage, charcoal — low "
+        "saturation, tone-on-tone), printed flat onto the woven natural ground "
+        "with hard clean edges. The NATURAL FIBRE TEXTURE OF THE GROUND is the "
+        "only texture in the piece — the printed motif itself is perfectly flat "
+        "with no shading, no depth, no relief. Luxury designer wallcovering, "
+        "Phillip Jeffries / Maya Romanoff natural-textile aesthetic, calm and "
+        "elegant."
+    )
+    negative = (
+        "busy, cluttered, dense, edge-to-edge, all-over print, tessellated "
+        "densely, packed motifs, many animals, repeating crowded figures, "
+        "sunflowers, foliage filling the background, leaves filling the "
+        "background, vines everywhere, jungle scene, flat ivory paper, plain "
+        "flat paper ground, smooth flat background, no texture, glossy ground, "
+        "embossed, relief, raised texture, 3D, three-dimensional, dimensional, "
+        "depth, drop shadow, ambient occlusion, ghost layer, ghosted, faded "
+        "background copies, secondary motifs behind the figure, atmospheric "
+        "perspective, gradient fill, halftone, ben-day dots, crosshatching, "
+        "outline only, hollow shape, see-through interior, neon, fluorescent, "
+        "saturated, rainbow, more than 4 colours, seam visible, hard seam, "
+        "frame, border, picture frame, pasted-on rectangle, plate behind motif, "
+        "signature, watermark, text, blurry, low quality"
+    )
+    return positive, negative
+
+
+def comfy_post(path, data=None, binary=False, timeout=30):
+    url = f'{COMFY}{path}'
+    if data is not None:
+        r = subprocess.run(
+            ['curl', '-sf', '-m', str(timeout), '-H', 'Content-Type: application/json',
+             '-X', 'POST', url, '-d', '@-'],
+            input=json.dumps(data), capture_output=True, text=True)
+    else:
+        r = subprocess.run(['curl', '-sf', '-m', str(timeout), url],
+                           capture_output=True, text=True)
+    if r.returncode != 0:
+        raise RuntimeError(f'comfy {path} failed rc={r.returncode}: {r.stderr[:200]}')
+    return r.stdout
+
+
+def gen_native_seamless(positive, negative, seed, out_path):
+    """Drive ComfyUI with the SeamlessTile + MakeCircularVAE circular-pad
+    workflow (base-only, mirrors generate_designs.js genComfy tileable branch).
+    Native seamless == zero post-hoc smear."""
+    workflow = {
+        '3': {'class_type': 'KSampler', 'inputs': {
+            'seed': seed, 'steps': 28, 'cfg': 8.5, 'sampler_name': SAMPLER,
+            'scheduler': SCHEDULER, 'denoise': 1.0,
+            'model': ['1000', 0], 'positive': ['6', 0], 'negative': ['7', 0],
+            'latent_image': ['5', 0]}},
+        '4': {'class_type': 'CheckpointLoaderSimple', 'inputs': {'ckpt_name': MODEL}},
+        '5': {'class_type': 'EmptyLatentImage', 'inputs': {'width': WIDTH, 'height': HEIGHT, 'batch_size': 1}},
+        '6': {'class_type': 'CLIPTextEncode', 'inputs': {'text': positive, 'clip': ['4', 1]}},
+        '7': {'class_type': 'CLIPTextEncode', 'inputs': {'text': negative, 'clip': ['4', 1]}},
+        '8': {'class_type': 'VAEDecode', 'inputs': {'samples': ['3', 0], 'vae': ['2000', 0]}},
+        '9': {'class_type': 'SaveImage', 'inputs': {'filename_prefix': f'wallco_ns_{seed}', 'images': ['8', 0]}},
+        # circular-pad nodes — the native-seamless mechanism
+        '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'}},
+    }
+    submit = comfy_post('/prompt', {'prompt': workflow, 'client_id': 'wallco-ns-sample'})
+    pid = json.loads(submit).get('prompt_id')
+    if not pid:
+        raise RuntimeError('ComfyUI did not return prompt_id')
+    start = time.time()
+    images = None
+    while time.time() - start < POLL_TIMEOUT:
+        time.sleep(2)
+        try:
+            hist = json.loads(comfy_post(f'/history/{pid}', timeout=5))
+        except Exception:
+            continue
+        entry = hist.get(pid)
+        if entry and entry.get('outputs', {}).get('9', {}).get('images'):
+            images = entry['outputs']['9']['images']
+            break
+    if not images:
+        raise RuntimeError(f'ComfyUI timed out after {POLL_TIMEOUT}s (pid={pid})')
+    im = images[0]
+    view = (f"/view?filename={quote(im['filename'])}"
+            f"&subfolder={quote(im.get('subfolder',''))}"
+            f"&type={quote(im.get('type','output'))}")
+    raw = subprocess.run(['curl', '-sf', '-m', '60', f'{COMFY}{view}'],
+                         capture_output=True)
+    if raw.returncode != 0 or len(raw.stdout) < 1000:
+        raise RuntimeError(f'ComfyUI image pull failed ({view})')
+    with open(out_path, 'wb') as f:
+        f.write(raw.stdout)
+    return out_path
+
+
+def insert_child_with_prompt(root, out_path, positive, negative, ground):
+    """Like sfv.insert_child but stores the NEW prompt on the child (honest
+    provenance — this is a re-gen on a new brief, not a pixel-fix)."""
+    pos = positive.replace("'", "''")
+    neg = negative.replace("'", "''")
+    new_id = int(sfv.psql(
+        "INSERT INTO all_designs "
+        "(category, kind, prompt, negative_prompt, local_path, dominant_hex, "
+        " width_in, height_in, seed, tags, is_published, user_removed, generator, "
+        " parent_design_id, source_dw_sku) "
+        f"SELECT category, kind, '{pos}', '{neg}', '{out_path}', dominant_hex, "
+        "  width_in, height_in, seed, "
+        f"  (COALESCE(tags, ARRAY[]::text[])) || "
+        f"  ARRAY['native-seamless','natural-ground-{ground.replace(' ','-')}','open-composition','sample-from-{root}']::text[], "
+        f"  FALSE, FALSE, 'native-seamless-sample', {root}, source_dw_sku "
+        f"FROM all_designs WHERE id={root} RETURNING id;"))
+    sfv.psql(f"UPDATE all_designs SET image_url='/designs/img/by-id/' || id WHERE id={new_id};")
+    return new_id
+
+
+def process(root, ground, dry_run=False):
+    os.makedirs(OUTDIR, exist_ok=True)
+    meta = sfv.root_meta(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'}
+
+    subject = extract_subject(meta.get('prompt') or
+                              sfv.psql(f"SELECT prompt FROM all_designs WHERE id={root};"))
+    positive, negative = build_prompt(subject, ground)
+
+    if dry_run:
+        return {'root_id': root, 'ok': True, 'dry_run': True, 'subject': subject,
+                'ground': ground, 'positive': positive}
+
+    seed = int.from_bytes(os.urandom(4), 'big') % (2**31 - 1)
+    out_path = os.path.join(GENDIR, f'{int(time.time()*1000)}_{seed}_ns.png')
+    gen_native_seamless(positive, negative, seed, out_path)
+
+    # True-toroidal edge gate on the WRITTEN file (real verdict, no fake PASS).
+    ev = edgeverify.verify_path(Path(out_path))
+
+    new_id = insert_child_with_prompt(root, out_path, positive, negative, ground)
+
+    entry = {
+        'ts': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),
+        'root_id': root,
+        'variant': 'native-seamless',
+        'generator': 'native-seamless-sample',
+        'category': meta.get('category'),
+        'subject': subject,
+        'ground': ground,
+        'seed': seed,
+        'technique': 'native-seamless (ComfyUI SeamlessTile + MakeCircularVAE circular-pad) — NO post-hoc smear',
+        'edge_verdict': ev.get('verdict'),
+        'edge_seamless': bool(ev.get('ok')),
+        'edge_axes': {
+            'h': ev.get('axes', {}).get('horizontal', {}),
+            'v': ev.get('axes', {}).get('vertical', {}),
+        },
+        'new_id': new_id,
+        'out': out_path,
+        'ok': True,
+    }
+    return entry
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--ids', required=True)
+    ap.add_argument('--ground', help='force one ground for all (else rotates GROUNDS)')
+    ap.add_argument('--dry-run', action='store_true')
+    args = ap.parse_args()
+    ids = [int(x) for x in args.ids.split(',') if x.strip()]
+
+    results = []
+    for i, rid in enumerate(ids):
+        ground = args.ground or GROUNDS[i % len(GROUNDS)]
+        try:
+            r = process(rid, ground, dry_run=args.dry_run)
+        except Exception as e:
+            r = {'root_id': rid, 'ok': False, 'error': str(e), 'ground': ground}
+        results.append(r)
+        if r.get('ok') and not r.get('dry_run'):
+            with open(QUEUE, 'a') as q:
+                q.write(json.dumps(r) + '\n')
+        v = r.get('edge_verdict', r.get('error', '?'))
+        ax = r.get('edge_axes', {})
+        h = (ax.get('h') or {}).get('verdict'); vv = (ax.get('v') or {}).get('verdict')
+        print(f"root {rid} ground={r.get('ground')}: edge={v} h={h} v={vv} "
+              f"new_id={r.get('new_id')} {'(dry)' if r.get('dry_run') else ''}",
+              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()

← b9ad50c Force true-toroidal edge-wrap on both axes for the 6 WARN ro  ·  back to Wallco Ai  ·  TODO: native-seamless sanity sample results (smear killed, 2 3555103 →