[object Object]

← back to Wallco Ai

wallco redo: real-fibre composite path + img2img native-seamless orchestrator

3a53bd5a4ced1fabd17cf1ebeefb2fe89fd633ee · 2026-06-11 15:04:40 -0700 · Steve Abrams

- make-fibre-ground.py: deterministic SEAMLESS natural-fibre swatch generator
  (grasscloth/raffia/linen/sisal/paperweave) — wraps by construction (edge ΔE
  0.00), fills the real-fibre-ground gap (no local DW fibre swatch library).
  Validated: composite of root #54266 onto raffia reads as genuine woven fibre.
- seamless-bg-swap-file.py: file-input wrapper around seamless-bg-swap.py so the
  deterministic real-fibre composite runs on a fresh motif PNG (not just a DB id).
- native-seamless-redo.py: img2img native-seamless orchestrator (VAEEncode root +
  SeamlessTile/MakeCircularVAE circular-pad, subject-anchored by starting from the
  root latent) then real-fibre composite. Sacred roots, is_published=FALSE.

FINDING (sample BLOCKED, not shipped): proved txt2img circular-pad PASSES edges
cleanly (3.18/2.18) but has no subject anchor -> drift; img2img preserves subject
but breaks native-seamless edges to WARN AND can't open the busy root. No
ControlNet model + no IPAdapter installed on Mac1 ComfyUI = no anchor that works
at denoise-1.0 native-seamless strength. Minimum missing: an SDXL canny/lineart
ControlNet model on Mac1. Probes 58056/58057 removed (FALSE+user_removed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit 3a53bd5a4ced1fabd17cf1ebeefb2fe89fd633ee
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 15:04:40 2026 -0700

    wallco redo: real-fibre composite path + img2img native-seamless orchestrator
    
    - make-fibre-ground.py: deterministic SEAMLESS natural-fibre swatch generator
      (grasscloth/raffia/linen/sisal/paperweave) — wraps by construction (edge ΔE
      0.00), fills the real-fibre-ground gap (no local DW fibre swatch library).
      Validated: composite of root #54266 onto raffia reads as genuine woven fibre.
    - seamless-bg-swap-file.py: file-input wrapper around seamless-bg-swap.py so the
      deterministic real-fibre composite runs on a fresh motif PNG (not just a DB id).
    - native-seamless-redo.py: img2img native-seamless orchestrator (VAEEncode root +
      SeamlessTile/MakeCircularVAE circular-pad, subject-anchored by starting from the
      root latent) then real-fibre composite. Sacred roots, is_published=FALSE.
    
    FINDING (sample BLOCKED, not shipped): proved txt2img circular-pad PASSES edges
    cleanly (3.18/2.18) but has no subject anchor -> drift; img2img preserves subject
    but breaks native-seamless edges to WARN AND can't open the busy root. No
    ControlNet model + no IPAdapter installed on Mac1 ComfyUI = no anchor that works
    at denoise-1.0 native-seamless strength. Minimum missing: an SDXL canny/lineart
    ControlNet model on Mac1. Probes 58056/58057 removed (FALSE+user_removed).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 data/fibre-grounds/grasscloth.png    | Bin 0 -> 2104943 bytes
 data/fibre-grounds/natural-linen.png | Bin 0 -> 1951456 bytes
 data/fibre-grounds/paperweave.png    | Bin 0 -> 1851233 bytes
 data/fibre-grounds/raffia.png        | Bin 0 -> 2054199 bytes
 data/fibre-grounds/sisal.png         | Bin 0 -> 2217115 bytes
 data/seam-fix-queue.jsonl            |   5 +
 scripts/make-fibre-ground.py         | 181 ++++++++++++++++++++
 scripts/native-seamless-redo.py      | 321 +++++++++++++++++++++++++++++++++++
 scripts/seamless-bg-swap-file.py     |  74 ++++++++
 9 files changed, 581 insertions(+)

diff --git a/data/fibre-grounds/grasscloth.png b/data/fibre-grounds/grasscloth.png
new file mode 100644
index 0000000..afbe3b5
Binary files /dev/null and b/data/fibre-grounds/grasscloth.png differ
diff --git a/data/fibre-grounds/natural-linen.png b/data/fibre-grounds/natural-linen.png
new file mode 100644
index 0000000..461c33d
Binary files /dev/null and b/data/fibre-grounds/natural-linen.png differ
diff --git a/data/fibre-grounds/paperweave.png b/data/fibre-grounds/paperweave.png
new file mode 100644
index 0000000..0ee843e
Binary files /dev/null and b/data/fibre-grounds/paperweave.png differ
diff --git a/data/fibre-grounds/raffia.png b/data/fibre-grounds/raffia.png
new file mode 100644
index 0000000..d23a534
Binary files /dev/null and b/data/fibre-grounds/raffia.png differ
diff --git a/data/fibre-grounds/sisal.png b/data/fibre-grounds/sisal.png
new file mode 100644
index 0000000..0902290
Binary files /dev/null and b/data/fibre-grounds/sisal.png differ
diff --git a/data/seam-fix-queue.jsonl b/data/seam-fix-queue.jsonl
index cba63b5..0d5383c 100644
--- a/data/seam-fix-queue.jsonl
+++ b/data/seam-fix-queue.jsonl
@@ -174,3 +174,8 @@
 {"ts": "2026-06-11T21:25:00.583895Z", "root_id": 54123, "variant": "edgeforce", "generator": "force-edge-seamless", "category": "drunk-animals", "strip_px": 16, "before_verdict": "FAIL", "before_max": 20.59, "after_verdict": "FAIL", "after_max": 20.37, "improved": true, "root_edge_verdict": "WARN", "edge_verdict": "PASS", "edge_seamless": true, "edge_axes": {"h": "PASS", "v": "PASS"}, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/54123_edgeforce.png", "ok": true, "new_id": 58046}
 {"ts": "2026-06-11T21:25:00.776639Z", "root_id": 54266, "variant": "edgeforce", "generator": "force-edge-seamless", "category": "drunk-animals", "strip_px": 16, "before_verdict": "FAIL", "before_max": 24.56, "after_verdict": "FAIL", "after_max": 24.53, "improved": true, "root_edge_verdict": "WARN", "edge_verdict": "PASS", "edge_seamless": true, "edge_axes": {"h": "PASS", "v": "PASS"}, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/54266_edgeforce.png", "ok": true, "new_id": 58047}
 {"ts": "2026-06-11T21:25:00.982885Z", "root_id": 54507, "variant": "edgeforce", "generator": "force-edge-seamless", "category": "drunk-animals", "strip_px": 16, "before_verdict": "FAIL", "before_max": 21.13, "after_verdict": "FAIL", "after_max": 13.26, "improved": true, "root_edge_verdict": "WARN", "edge_verdict": "PASS", "edge_seamless": true, "edge_axes": {"h": "PASS", "v": "PASS"}, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/54507_edgeforce.png", "ok": true, "new_id": 58048}
+{"ts": "2026-06-11T21:38:08.261899Z", "root_id": 2662, "variant": "native-seamless", "generator": "native-seamless-sample", "category": "drunk-animals", "subject": "reticulated giraffe with crooked smile, sipping from a coupe glass of ros\u00e9", "ground": "grasscloth", "seed": 531344081, "technique": "native-seamless (ComfyUI SeamlessTile + MakeCircularVAE circular-pad) \u2014 NO post-hoc smear", "edge_verdict": "WARN", "edge_seamless": false, "edge_axes": {"h": {"verdict": "PASS", "line_diff": 0.86, "line_diff_halfdrop": 91.77, "best": 0.86, "tiled_cross_seam_max": 6.62, "edges": "column[0] vs column[W-1]"}, "v": {"verdict": "WARN", "line_diff": 2.8, "line_diff_halfdrop": 50.17, "best": 2.8, "tiled_cross_seam_max": 17.12, "edges": "row[0] vs row[H-1]"}}, "new_id": 58050, "out": "/Users/stevestudio2/Projects/wallco-ai/data/generated/1781213789161_531344081_ns.png", "ok": true}
+{"ts": "2026-06-11T21:39:53.463413Z", "root_id": 54266, "variant": "native-seamless", "generator": "native-seamless-sample", "category": "drunk-animals", "subject": "wide-eyed Amazonian tree frog, pyramid-tower of stacked champagne coupes", "ground": "raffia", "seed": 832831745, "technique": "native-seamless (ComfyUI SeamlessTile + MakeCircularVAE circular-pad) \u2014 NO post-hoc smear", "edge_verdict": "WARN", "edge_seamless": false, "edge_axes": {"h": {"verdict": "PASS", "line_diff": 2.01, "line_diff_halfdrop": 18.95, "best": 2.01, "tiled_cross_seam_max": 10.26, "edges": "column[0] vs column[W-1]"}, "v": {"verdict": "WARN", "line_diff": 4.12, "line_diff_halfdrop": 102.03, "best": 4.12, "tiled_cross_seam_max": 14.49, "edges": "row[0] vs row[H-1]"}}, "new_id": 58051, "out": "/Users/stevestudio2/Projects/wallco-ai/data/generated/1781213888310_832831745_ns.png", "ok": true}
+{"ts": "2026-06-11T21:41:38.659638Z", "root_id": 54076, "variant": "native-seamless", "generator": "native-seamless-sample", "category": "drunk-animals", "subject": "shaggy orange orangutan clutching a banana cocktail, painkiller cocktail in a wide rocks glass with grated nutmeg dusting on top", "ground": "natural linen", "seed": 36110905, "technique": "native-seamless (ComfyUI SeamlessTile + MakeCircularVAE circular-pad) \u2014 NO post-hoc smear", "edge_verdict": "FAIL", "edge_seamless": false, "edge_axes": {"h": {"verdict": "FAIL", "line_diff": 16.94, "line_diff_halfdrop": 85.32, "best": 16.94, "tiled_cross_seam_max": 41.49, "edges": "column[0] vs column[W-1]"}, "v": {"verdict": "WARN", "line_diff": 8.49, "line_diff_halfdrop": 55.26, "best": 8.49, "tiled_cross_seam_max": 39.45, "edges": "row[0] vs row[H-1]"}}, "new_id": 58052, "out": "/Users/stevestudio2/Projects/wallco-ai/data/generated/1781213993518_36110905_ns.png", "ok": true}
+{"ts": "2026-06-11T21:43:23.825010Z", "root_id": 54610, "variant": "native-seamless", "generator": "native-seamless-sample", "category": "drunk-animals", "subject": "wobbly pink champagne elephant, spraying champagne bottle held above the head like a Formula-1 podium", "ground": "sisal", "seed": 1286070489, "technique": "native-seamless (ComfyUI SeamlessTile + MakeCircularVAE circular-pad) \u2014 NO post-hoc smear", "edge_verdict": "WARN", "edge_seamless": false, "edge_axes": {"h": {"verdict": "WARN", "line_diff": 5.73, "line_diff_halfdrop": 22.29, "best": 5.73, "tiled_cross_seam_max": 19.23, "edges": "column[0] vs column[W-1]"}, "v": {"verdict": "WARN", "line_diff": 7.7, "line_diff_halfdrop": 59.48, "best": 7.7, "tiled_cross_seam_max": 47.51, "edges": "row[0] vs row[H-1]"}}, "new_id": 58053, "out": "/Users/stevestudio2/Projects/wallco-ai/data/generated/1781214098716_1286070489_ns.png", "ok": true}
+{"ts": "2026-06-11T21:45:09.029059Z", "root_id": 53896, "variant": "native-seamless", "generator": "native-seamless-sample", "category": "drunk-animals", "subject": "plastered red panda, wide-bowl cognac snifter resting on its belly", "ground": "paperweave", "seed": 1597191492, "technique": "native-seamless (ComfyUI SeamlessTile + MakeCircularVAE circular-pad) \u2014 NO post-hoc smear", "edge_verdict": "WARN", "edge_seamless": false, "edge_axes": {"h": {"verdict": "WARN", "line_diff": 1.39, "line_diff_halfdrop": 77.31, "best": 1.39, "tiled_cross_seam_max": 12.85, "edges": "column[0] vs column[W-1]"}, "v": {"verdict": "WARN", "line_diff": 1.49, "line_diff_halfdrop": 1.6, "best": 1.49, "tiled_cross_seam_max": 108.23, "edges": "row[0] vs row[H-1]"}}, "new_id": 58054, "out": "/Users/stevestudio2/Projects/wallco-ai/data/generated/1781214203876_1597191492_ns.png", "ok": true}
diff --git a/scripts/make-fibre-ground.py b/scripts/make-fibre-ground.py
new file mode 100644
index 0000000..d8f1934
--- /dev/null
+++ b/scripts/make-fibre-ground.py
@@ -0,0 +1,181 @@
+#!/usr/bin/env python3
+"""make-fibre-ground.py — DETERMINISTIC seamless natural-fibre texture generator.
+
+WHY THIS EXISTS (the real-fibre-ground gap, 2026-06-11 redo brief):
+  seamless-bg-swap.py modulates a design's flat ground by a REAL fibre-texture
+  photo's high-frequency grain, so the background reads as genuine woven
+  grasscloth/raffia/linen/sisal/paperweave. But wallco-ai has NO local
+  natural-fibre swatch library (only one cached _texture.png from an old run).
+  Prompting SDXL for "grasscloth" gives a flat dead fill (the regression Steve
+  rejected). So we need an honest fibre-texture SOURCE that:
+    - reads as a real woven natural fibre (directional warp/weft strands, slubs,
+      irregular thickness — not a flat noise field),
+    - tiles SEAMLESSLY by construction (so the bg-swap composite stays seamless),
+    - is deterministic + free (no AI, no smear).
+
+  This builds that source PROCEDURALLY. The output is a grayscale-modulation
+  swatch (the bg-swap only uses luminance grain anyway), wrapped on both axes by
+  building it from periodic functions + WRAP-padded noise. The fibre TYPE sets
+  warp/weft frequency, slub density, and strand contrast so grasscloth ≠ raffia
+  ≠ linen ≠ sisal ≠ paperweave.
+
+Usage:
+  python3 scripts/make-fibre-ground.py --type grasscloth --out /abs/grass.png
+  python3 scripts/make-fibre-ground.py --type raffia --size 1024 --seed 7
+
+Output: a seamless RGB PNG (neutral warm-grey) carrying the fibre weave. Feed it
+to seamless-bg-swap.py --texture-image <out> --no-sanitize (it's already clean).
+"""
+import argparse
+import os
+import sys
+
+import numpy as np
+from PIL import Image
+from scipy.ndimage import gaussian_filter
+
+# Per-fibre weave parameters. (warp_freq, weft_freq) = strands across the tile;
+# slub_density = irregular thick spots; strand_contrast = grain depth; aniso =
+# how much warp dominates weft (a coarse raffia is very directional, linen
+# near-isotropic); base_tint = warm-neutral ground colour the weave sits on.
+FIBRE = {
+    # very coarse, strongly horizontal palm-fibre strands, frequent slubs
+    'raffia':        dict(warp=34,  weft=22, slub=0.9, contrast=0.34, aniso=0.75, tint=(207, 188, 150)),
+    # fine even horizontal grass strands, tight, subtle slubs
+    'grasscloth':    dict(warp=70,  weft=40, slub=0.45, contrast=0.26, aniso=0.62, tint=(205, 196, 168)),
+    # near-isotropic slubby flax weave, soft, irregular thread thickness
+    'linen':         dict(warp=58,  weft=56, slub=0.7,  contrast=0.22, aniso=0.18, tint=(214, 206, 184)),
+    'natural linen': dict(warp=58,  weft=56, slub=0.7,  contrast=0.22, aniso=0.18, tint=(214, 206, 184)),
+    # tight regular sisal weave, pale, low slub, crisp strands
+    'sisal':         dict(warp=80,  weft=64, slub=0.3,  contrast=0.24, aniso=0.5,  tint=(216, 206, 178)),
+    # rolled-paper weave: wide flat horizontal bands, soft, low contrast
+    'paperweave':    dict(warp=24,  weft=46, slub=0.35, contrast=0.20, aniso=0.7,  tint=(212, 202, 178)),
+}
+
+
+def _seamless_noise(size, scale, seed, octaves=4):
+    """Tileable value-noise via WRAP-padded gaussian-blurred white noise summed
+    over octaves. Wrapping is exact because gaussian_filter(mode='wrap') keeps
+    opposite edges continuous."""
+    rng = np.random.default_rng(seed)
+    H = W = size
+    acc = np.zeros((H, W), np.float64)
+    amp = 1.0
+    total = 0.0
+    s = scale
+    for o in range(octaves):
+        white = rng.standard_normal((H, W))
+        sig = max(0.6, s)
+        n = gaussian_filter(white, sigma=sig, mode='wrap')
+        n /= (n.std() or 1.0)
+        acc += amp * n
+        total += amp
+        amp *= 0.5
+        s *= 0.5
+    return acc / total
+
+
+def _strand_field(size, freq, phase_jitter, seed):
+    """A periodic strand pattern across `freq` strands that wraps exactly:
+    freq must be an integer number of cycles across the tile so sin() closes.
+    Adds wrap-safe per-strand phase jitter so strands aren't a perfect ruler."""
+    H = W = size
+    x = np.linspace(0, 2 * np.pi * freq, W, endpoint=False)
+    # wrap-safe row-wise phase wobble (tileable noise, 1px tall band reused)
+    wobble = _seamless_noise(size, size / max(freq, 1) * 0.5, seed) * phase_jitter
+    line = np.sin(x[None, :] + wobble)
+    # strand "ridges": |sin| gives rounded strand cross-sections
+    return np.abs(line)
+
+
+def _wrap_feather(arr, band):
+    """Force opposite edges of a 2-D float array EQUAL with the heal localized
+    in a `band`-px edge strip (mirror cross-blend, 0.5 at the very edge fading to
+    0 by `band` inward). Centre untouched. arr[:,0]==arr[:,-1] and arr[0]==arr[-1]
+    after, so the tile wraps."""
+    H, W = arr.shape
+    out = arr.copy()
+    b = int(max(1, min(band, W // 2 - 1, H // 2 - 1)))
+    for i in range(b):
+        w = 0.5 * (1.0 - i / b)
+        out[:, i] = (1 - w) * arr[:, i] + w * arr[:, W - 1 - i]
+        out[:, W - 1 - i] = (1 - w) * arr[:, W - 1 - i] + w * arr[:, i]
+    g2 = out.copy()
+    for j in range(b):
+        w = 0.5 * (1.0 - j / b)
+        out[j, :] = (1 - w) * g2[j, :] + w * g2[H - 1 - j, :]
+        out[H - 1 - j, :] = (1 - w) * g2[H - 1 - j, :] + w * g2[j, :]
+    return out
+
+
+def make_fibre(fibre_type, size=1024, seed=11):
+    p = FIBRE.get(fibre_type)
+    if p is None:
+        raise ValueError(f'unknown fibre type {fibre_type!r}; choices={list(FIBRE)}')
+    H = W = size
+
+    # warp = horizontal strands (vary down the rows) -> use vertical frequency
+    # weft = vertical strands (vary across the cols)
+    warp = _strand_field(size, p['weft'], 0.35, seed + 1).T   # transpose: strands run horizontally
+    weft = _strand_field(size, p['warp'], 0.35, seed + 2)
+    aniso = p['aniso']
+    weave = aniso * warp + (1 - aniso) * weft                  # over/under blend
+
+    # slubs: low-frequency irregular thickening of strands
+    slub = _seamless_noise(size, size / 14.0, seed + 3, octaves=3)
+    slub = (slub - slub.min()) / (float(np.ptp(slub)) or 1.0)
+    weave = weave * (1.0 + p['slub'] * 0.6 * (slub - 0.5))
+
+    # fine fibre grain on top (broken-strand fuzz), wrap-safe
+    grain = _seamless_noise(size, 1.6, seed + 4, octaves=2)
+
+    field = weave + 0.25 * grain
+    field = (field - field.mean()) / (field.std() or 1.0)     # zero-mean unit-var
+
+    # GUARANTEE edge wrap: the integer-cycle sin strands are theoretically
+    # tileable, but the additive wobble + grain leave a small residual at the
+    # seam. A thin mirror-wrap band (same technique as seamless-bg-swap's
+    # make_seamless_wrap) forces opposite edges EQUAL while leaving the centre
+    # untouched, so the swatch tiles by construction. Band is ~3% of the tile so
+    # the woven look is preserved everywhere a viewer reads it.
+    field = _wrap_feather(field, max(8, size // 32))
+
+    # map to luminance modulation around the tint
+    tint = np.array(p['tint'], np.float64)
+    mod = p['contrast'] * field                               # +/- around 0
+    rgb = tint[None, None, :] * (1.0 + mod[..., None])
+    rgb = np.clip(rgb, 0, 255).astype(np.uint8)
+    return Image.fromarray(rgb, 'RGB')
+
+
+def _verify_seamless(img):
+    """Sanity: opposite edges of the fibre swatch must match (so the downstream
+    composite stays seamless). Reports max channel-mean edge ΔE."""
+    a = np.asarray(img, np.float64)
+    lr = float(np.mean(np.abs(a[:, 0, :] - a[:, -1, :])))
+    tb = float(np.mean(np.abs(a[0, :, :] - a[-1, :, :])))
+    return lr, tb
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--type', required=True, help=str(list(FIBRE)))
+    ap.add_argument('--out', required=True)
+    ap.add_argument('--size', type=int, default=1024)
+    ap.add_argument('--seed', type=int, default=11)
+    args = ap.parse_args()
+    img = make_fibre(args.type, args.size, args.seed)
+    os.makedirs(os.path.dirname(os.path.abspath(args.out)), exist_ok=True)
+    img.save(args.out, 'PNG', optimize=True)
+    lr, tb = _verify_seamless(img)
+    print(f'wrote {args.out}  {img.size}  type={args.type}  edge ΔE L/R={lr:.2f} T/B={tb:.2f}',
+          file=sys.stderr)
+    # the procedural wrap should be near-zero; warn loudly if not
+    if max(lr, tb) > 6.0:
+        print(f'WARN: fibre swatch edge ΔE {max(lr,tb):.2f} > 6 — not cleanly seamless', file=sys.stderr)
+        sys.exit(1)
+    print(args.out)
+
+
+if __name__ == '__main__':
+    main()
diff --git a/scripts/native-seamless-redo.py b/scripts/native-seamless-redo.py
new file mode 100644
index 0000000..0d6df9e
--- /dev/null
+++ b/scripts/native-seamless-redo.py
@@ -0,0 +1,321 @@
+#!/usr/bin/env python3
+"""native-seamless-redo.py — REDO of the BOUNDED sanity sample (2026-06-11).
+
+Steve rejected the first native-seamless sample (#58050-58054): the open+flat
+text prompt overwhelmed SDXL subject adherence (giraffe→waves, frog→leaf-blades)
+and "grasscloth" in the prompt gave a flat dead fill. Brief: "redo them."
+
+THE THREE FIXES (vs native-seamless-sample.py):
+
+1. ZERO smear — UNCHANGED mechanism: native-seamless generation only (ComfyUI
+   SeamlessTile + MakeCircularVAE circular pad). The tile wraps by construction.
+   We NEVER touch make_seamless.py / force-edge-seamless.py (the smear sources).
+
+2. SUBJECT ANCHORING — the first sample was txt2img (denoise 1.0) which let the
+   open/flat prompt drift the subject to abstract. This redo is IMG2IMG: we
+   VAEEncode the ROOT image and run KSampler at denoise≈0.58, so the diffusion
+   STARTS from the actual root composition. The giraffe stays a giraffe; the
+   prompt only nudges it toward open/sparse/two-tone, it can't invent waves.
+   (ControlNet + IPAdapter are NOT installed on this ComfyUI; img2img from the
+   root is the available, correct anchor — it preserves the root pixels' motif
+   identity directly.)
+
+3. REAL natural-fibre GROUND — we do NOT ask SDXL for "grasscloth" (flat dead
+   fill). Instead the clean two-tone img2img tile is run through the existing
+   deterministic composite path (seamless-bg-swap.py) using a REAL woven-fibre
+   swatch built by make-fibre-ground.py. The composite modulates ONLY the flat
+   ground's luminance by genuine warp/weft fibre grain; motif pixels are kept
+   byte-for-byte; both layers wrap, so the composite stays seamless.
+
+4. OPEN / less busy — img2img at moderate denoise + an "open, sparse, fewer
+   elements, generous negative space" prompt opens the dense root toile without
+   abandoning its motif.
+
+SACRED ROOTS: never overwrites a root. Each candidate → NEW file + NEW id with
+is_published=FALSE, user_removed=FALSE, parent_design_id=<root>. The final
+composite is verified with verify-edge-seamless.py on the WRITTEN file; the real
+verdict is recorded (a WARN/FAIL is recorded as such, never a fake PASS).
+
+NOT a bulk run. Caps at --ids. DATABASE_URL self-resolves (never echoed).
+
+Usage:
+  python3 scripts/native-seamless-redo.py --ids 2662,54266,54076,54610,53896
+  python3 scripts/native-seamless-redo.py --ids 2662 --denoise 0.6
+"""
+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__)))
+GENDIR = os.path.join(ROOT, 'data', 'generated')
+FIBREDIR = os.path.join(ROOT, 'data', 'fibre-grounds')
+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')            # psql / root_meta
+edgeverify = import_module('verify-edge-seamless')  # true-toroidal edge gate
+mkfibre = import_module('make-fibre-ground')        # procedural fibre swatch
+
+COMFY = os.environ.get('COMFY_URL', 'http://192.168.1.133:8188')
+MODEL = os.environ.get('COMFY_MODEL', 'sd_xl_base_1.0.safetensors')
+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']
+
+
+def extract_subject(prompt: str) -> str:
+    """First two comma clauses = the animal + drink subject; drop the busy
+    'sunflowers / foliage / reference' bloat that follows."""
+    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]
+    subject = re.split(r'\.\s', subject)[0]
+    return subject.strip().rstrip('.')
+
+
+def build_prompt(subject: str):
+    """Open/sparse/two-tone prompt. NOTE — NO fibre-ground prompting here: the
+    ground texture is added deterministically downstream by the composite, so we
+    keep this purely about the MOTIF (flat two-tone silhouette) on a plain flat
+    ground. That plain flat ground is exactly what the composite needs to detect
+    and replace with real fibre."""
+    positive = (
+        f"A single {subject}, rendered as ONE clean flat two-tone silhouette "
+        "motif on a plain solid flat-colour ground. Open airy wallcovering "
+        "repeat — the motif is large, isolated and widely spaced with generous "
+        "empty negative space of bare flat ground around it; sparse minimal "
+        "pattern, calm and uncluttered, NOT busy, NOT edge-to-edge, NOT a dense "
+        "all-over print. The figure is a flat solid-colour silhouette in two "
+        "muted archival ink tones, low saturation tone-on-tone, hard clean "
+        "edges, no shading, no depth, no relief. Elegant heritage toile, "
+        "designer wallcovering."
+    )
+    negative = (
+        "busy, cluttered, dense, edge-to-edge, all-over print, tessellated "
+        "densely, packed motifs, many animals, crowded figures, sunflowers, "
+        "foliage filling the background, leaves filling the background, vines "
+        "everywhere, jungle scene, abstract shapes, abstract waves, abstract "
+        "blobs, leaf blades, unrecognizable subject, embossed, relief, raised "
+        "texture, 3D, dimensional, depth, drop shadow, ambient occlusion, ghost "
+        "layer, ghosted, faded background copies, gradient fill, halftone, "
+        "ben-day dots, outline only, hollow shape, 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, 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 comfy_upload_image(src_path):
+    """Upload the root PNG into ComfyUI's input dir; return the server filename."""
+    r = subprocess.run(
+        ['curl', '-sf', '-m', '60', '-X', 'POST', f'{COMFY}/upload/image',
+         '-F', f'image=@{src_path}', '-F', 'overwrite=true'],
+        capture_output=True, text=True)
+    if r.returncode != 0:
+        raise RuntimeError(f'comfy upload failed: {r.stderr[:200]}')
+    name = json.loads(r.stdout).get('name')
+    if not name:
+        raise RuntimeError(f'comfy upload returned no name: {r.stdout[:200]}')
+    return name
+
+
+def gen_img2img_seamless(positive, negative, seed, root_png, denoise, out_path):
+    """img2img native-seamless: VAEEncode(root) -> KSampler(denoise<1) with the
+    SeamlessTile model + MakeCircularVAE circular-pad VAE -> SaveImage. Subject
+    anchored by starting from the root latent; edges wrap by construction (zero
+    post-hoc smear)."""
+    server_name = comfy_upload_image(root_png)
+    workflow = {
+        '4':  {'class_type': 'CheckpointLoaderSimple', 'inputs': {'ckpt_name': MODEL}},
+        '10': {'class_type': 'LoadImage', 'inputs': {'image': server_name}},
+        # encode the root through the circular VAE so the start latent already wraps
+        '11': {'class_type': 'VAEEncode', 'inputs': {'pixels': ['10', 0], 'vae': ['2000', 0]}},
+        '6':  {'class_type': 'CLIPTextEncode', 'inputs': {'text': positive, 'clip': ['4', 1]}},
+        '7':  {'class_type': 'CLIPTextEncode', 'inputs': {'text': negative, 'clip': ['4', 1]}},
+        '3':  {'class_type': 'KSampler', 'inputs': {
+            'seed': seed, 'steps': 30, 'cfg': 7.5, 'sampler_name': SAMPLER,
+            'scheduler': SCHEDULER, 'denoise': denoise,
+            'model': ['1000', 0], 'positive': ['6', 0], 'negative': ['7', 0],
+            'latent_image': ['11', 0]}},
+        '8':  {'class_type': 'VAEDecode', 'inputs': {'samples': ['3', 0], 'vae': ['2000', 0]}},
+        '9':  {'class_type': 'SaveImage', 'inputs': {'filename_prefix': f'wallco_nsr_{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-nsr'})
+    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 dominant_hex(png_path):
+    """Most-common quantized colour = the flat ground, for the bg-swap mask."""
+    im = Image.open(png_path).convert('RGB').resize((128, 128))
+    arr = np.asarray(im).reshape(-1, 3)
+    q = (arr // 16 * 16).astype(np.uint8)
+    vals, counts = np.unique(q, axis=0, return_counts=True)
+    r, g, b = vals[counts.argmax()]
+    return f'#{r:02x}{g:02x}{b:02x}'
+
+
+def composite_real_fibre(motif_png, ground, base_hex, out_path):
+    """Run the clean img2img motif tile through seamless-bg-swap.py with a REAL
+    procedural fibre swatch so the flat ground reads as woven natural fibre.
+    Returns the bg-swap stdout (contains its own edges-scan)."""
+    slug = ground.replace(' ', '-')
+    fibre_png = os.path.join(FIBREDIR, f'{slug}.png')
+    if not os.path.exists(fibre_png):
+        mkfibre.make_fibre(ground, 1024, 11).save(fibre_png, 'PNG')
+    cmd = ['python3', os.path.join(ROOT, 'scripts', 'seamless-bg-swap-file.py'),
+           '--src', motif_png,
+           '--texture-image', fibre_png, '--texture-name', f'real-{slug}',
+           '--base-hex', base_hex,
+           '--strength', '0.6', '--feather', '40',
+           '--mask-tolerance', '20', '--mask-feather', '1.2',
+           '--no-sanitize', '--out', out_path]
+    r = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
+    if r.returncode != 0 or not os.path.exists(out_path):
+        raise RuntimeError(f'bg-swap composite failed rc={r.returncode}: {r.stderr[-300:]}')
+    return r.stdout
+
+
+def insert_child_with_prompt(root, out_path, positive, negative, ground):
+    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-redo','img2img-anchored','real-fibre-{ground.replace(' ','-')}','open-composition','redo-sample-from-{root}']::text[], "
+        f"  FALSE, FALSE, 'native-seamless-redo', {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, denoise):
+    os.makedirs(GENDIR, 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)
+
+    seed = int.from_bytes(os.urandom(4), 'big') % (2**31 - 1)
+    ts = int(time.time() * 1000)
+    motif_png = os.path.join(GENDIR, f'{ts}_{seed}_nsr_motif.png')
+    final_png = os.path.join(GENDIR, f'{ts}_{seed}_nsr.png')
+
+    # 1. img2img native-seamless (subject-anchored, zero smear)
+    gen_img2img_seamless(positive, negative, seed, src, denoise, motif_png)
+
+    # 2. real-fibre composite (deterministic, both layers wrap)
+    base_hex = dominant_hex(motif_png)
+    composite_real_fibre(motif_png, ground, base_hex, final_png)
+
+    # 3. true-toroidal edge gate on the FINAL composite
+    ev = edgeverify.verify_path(Path(final_png))
+
+    new_id = insert_child_with_prompt(root, final_png, positive, negative, ground)
+
+    return {
+        'ts': datetime.datetime.now(datetime.timezone.utc).isoformat().replace('+00:00', 'Z'),
+        'root_id': root, 'variant': 'native-seamless-redo',
+        'generator': 'native-seamless-redo',
+        'category': meta.get('category'), 'subject': subject, 'ground': ground,
+        'seed': seed, 'denoise': denoise, 'base_hex': base_hex,
+        'technique': 'img2img native-seamless (SeamlessTile+MakeCircularVAE, denoise<1, subject-anchored) + deterministic real-fibre composite (seamless-bg-swap) — NO 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, 'motif': motif_png, 'out': final_png, 'ok': True,
+    }
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--ids', required=True)
+    ap.add_argument('--ground', help='force one ground for all (else rotates)')
+    ap.add_argument('--denoise', type=float, default=0.58)
+    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, args.denoise)
+        except Exception as e:
+            r = {'root_id': rid, 'ok': False, 'error': str(e), 'ground': 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', '?'))
+        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')}", 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()
diff --git a/scripts/seamless-bg-swap-file.py b/scripts/seamless-bg-swap-file.py
new file mode 100644
index 0000000..329f1f6
--- /dev/null
+++ b/scripts/seamless-bg-swap-file.py
@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""seamless-bg-swap-file.py — file-input wrapper around seamless-bg-swap.py.
+
+seamless-bg-swap.py composites a REAL fibre texture's grain into a design's flat
+ground (deterministic, both layers wrap, motif kept byte-for-byte). But it only
+accepts --id (a row in spoon_all_designs). native-seamless-redo.py needs to run
+the SAME composite on a FRESH motif PNG that is not yet in the DB.
+
+This wrapper reuses seamless-bg-swap.py's composite functions (build_texture_layer,
+build_bg_mask, hex_to_rgb) verbatim on a source FILE + an explicit base-hex. No
+DB, no AI, no smear — identical math, file in / file out.
+
+Usage:
+  python3 scripts/seamless-bg-swap-file.py --src motif.png \
+      --texture-image data/fibre-grounds/grasscloth.png --base-hex '#dccdac' \
+      --out out.png [--strength .6 --feather 40 --mask-tolerance 20 --no-sanitize]
+"""
+import argparse
+import os
+import sys
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(HERE))
+from importlib import import_module
+bgs = import_module('seamless-bg-swap')   # reuse the proven composite functions
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--src', required=True, help='source motif PNG (flat ground + motif)')
+    ap.add_argument('--texture-image', required=True)
+    ap.add_argument('--texture-name', default='real-fibre')
+    ap.add_argument('--base-hex', required=True, help='flat ground colour to replace')
+    ap.add_argument('--out', required=True)
+    ap.add_argument('--strength', type=float, default=0.6)
+    ap.add_argument('--feather', type=int, default=40)
+    ap.add_argument('--mask-tolerance', type=float, default=20.0)
+    ap.add_argument('--mask-feather', type=float, default=1.2)
+    ap.add_argument('--no-sanitize', action='store_true')
+    args = ap.parse_args()
+
+    if not os.path.exists(args.src):
+        print(f'ERROR: src missing {args.src}', file=sys.stderr); sys.exit(2)
+
+    src = Image.open(args.src).convert('RGB')
+    src_arr = np.asarray(src, dtype=np.float64)
+    H, W = src_arr.shape[:2]
+    base_rgb = bgs.hex_to_rgb(args.base_hex)
+
+    tex = bgs.load_texture(args.texture_image, sanitize=not args.no_sanitize)
+    mult = bgs.build_texture_layer(tex, (H, W), args.feather, args.strength)
+    mask, de = bgs.build_bg_mask(src_arr, base_rgb, args.mask_tolerance, args.mask_feather)
+
+    bg_pct = 100.0 * float(mask.mean())
+    print(f'· src {W}x{H} ground={args.base_hex} bg-cover≈{bg_pct:.1f}% '
+          f'texture={args.texture_name} strength={args.strength}', file=sys.stderr)
+
+    bg_layer = np.clip(base_rgb.reshape(1, 1, 3) * mult[..., None], 0, 255)
+    m3 = mask[..., None]
+    out_arr = np.clip(m3 * bg_layer + (1.0 - m3) * src_arr, 0, 255).astype(np.uint8)
+
+    out_path = Path(args.out)
+    out_path.parent.mkdir(parents=True, exist_ok=True)
+    Image.fromarray(out_arr).save(out_path, 'PNG', optimize=True)
+    print(f'· wrote {out_path}', file=sys.stderr)
+    print(str(out_path))
+
+
+if __name__ == '__main__':
+    main()

← 3555103 TODO: native-seamless sanity sample results (smear killed, 2  ·  back to Wallco Ai  ·  Wire seam-fix wave cadence (DTD verdict C): gated one-wave-p fc72875 →