[object Object]

← back to Wallco Ai

seam-joint-heal pipeline + variant-curator shows seam-joint-healed + seam-debug image fallback to by-id route

0c5c2248a707a429a9478d85841f26ff3f9dc14f · 2026-06-12 09:05:42 -0700 · Steve Abrams

Files touched

Diff

commit 0c5c2248a707a429a9478d85841f26ff3f9dc14f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jun 12 09:05:42 2026 -0700

    seam-joint-heal pipeline + variant-curator shows seam-joint-healed + seam-debug image fallback to by-id route
---
 scripts/seam-joint-heal-pipeline.py | 290 ++++++++++++++++++++++++++++++++++++
 server.js                           |  54 +++++--
 2 files changed, 335 insertions(+), 9 deletions(-)

diff --git a/scripts/seam-joint-heal-pipeline.py b/scripts/seam-joint-heal-pipeline.py
new file mode 100644
index 0000000..c263aec
--- /dev/null
+++ b/scripts/seam-joint-heal-pipeline.py
@@ -0,0 +1,290 @@
+#!/usr/bin/env python3
+r"""
+seam-joint-heal-pipeline — targeted tile-JOINT healer for wallco roots.
+
+This REPLACES the abandoned img2img/ControlNet variant approach (it drifted /
+emptied the design). The healed variant LOOKS LIKE THE ROOT with ONLY the tile
+joints fixed — detect bad joints, fix only those, leave the rest identical.
+
+Per root:
+  1. FETCH the root PNG from http://127.0.0.1:9905/designs/img/by-id/<root>
+     (roots' local_path is missing on Mac2) to a temp file.
+  2. SCAN joints (seam-defect-boxes) on the fetched PNG.
+  3. SHOW: render a red-box defect overlay -> /tmp/seam_overlay_<root>.png
+     (redder = higher peak_de).
+  4. HEAL only the flagged joints (edge-wrap mirror for *_edge; LR/TB-averaged
+     band + gaussian for v_mid/h_mid) at 1024px. Heal PRESERVES the design.
+     Variety: a SET of distinct heal configs per root (edge-only vs edge+mid,
+     band width, blend px) -> several genuinely-different clean results.
+  5. UPSCALE the healed tile to 150-DPI print res via clean-edge-upscale.py
+     based on width_in (24"->3600, 36"->5400, 52"->7800).
+  6. RE-SCAN the healed+upscaled file. Must be joint-clean (no FAIL on either
+     axis) before insert. If still failing, that config is dropped.
+  7. INSERT a NEW-id variant (bounded explicit-id, INSERT-only): parent=root,
+     is_published=FALSE, user_removed=FALSE, generator 'seam-joint-healed',
+     tags 'joint-healed-from-<root>' + config tag.
+
+HARD RULES: roots sacred (new id every time), all_designs INSERT-only, 150 DPI
+always, edge-seamless re-scan must pass before insert, NEVER make_seamless.py /
+force-edge-seamless.py, is_published=FALSE on every variant, local only.
+
+Usage:
+  python3 scripts/seam-joint-heal-pipeline.py --root 2656            # one root
+  python3 scripts/seam-joint-heal-pipeline.py --root 2656 --dry-run  # no insert
+  python3 scripts/seam-joint-heal-pipeline.py --roots 2656,2766,...  # several
+Output: JSON report to stdout.
+"""
+import argparse, json, os, subprocess, sys, time, urllib.request
+from pathlib import Path
+
+import numpy as np
+from PIL import Image, ImageDraw, ImageFilter
+
+_HERE = Path(__file__).resolve().parent
+sys.path.insert(0, str(_HERE))
+seam = __import__('seam-defect-boxes')
+
+PROJ = _HERE.parent
+GENERATED = PROJ / "data" / "generated"
+GENERATED.mkdir(parents=True, exist_ok=True)
+BASE_URL = os.environ.get("WALLCO_BASE", "http://127.0.0.1:9905")
+GEN_TAG = "seam-joint-healed"
+
+# DPI-150 target px by tile width (inches).
+def target_px(width_in):
+    w = round(float(width_in))
+    return int(w * 150)   # 24->3600, 36->5400, 52->7800
+
+# ---- heal primitives (ported from heal-seam-region.py, path-based + params) ----
+def heal_band_mid(arr, axis, idx, start, end, band_px, blend_px):
+    H, W = arr.shape[:2]
+    if axis == 'v':
+        x0 = max(0, idx - band_px // 2); x1 = min(W, idx + band_px // 2)
+        left  = arr[start:end, max(0, idx - blend_px - 1):idx, :].astype('float32').mean(axis=1)
+        right = arr[start:end, idx:min(W, idx + blend_px), :].astype('float32').mean(axis=1)
+        avg = (left + right) / 2.0                      # (h,3)
+        for x in range(x0, x1):
+            w = max(0.0, min(1.0, 1 - abs(x - idx) / (band_px / 2)))
+            arr[start:end, x, :] = (avg * w + arr[start:end, x, :].astype('float32') * (1 - w)).round().astype('uint8')
+    else:
+        y0 = max(0, idx - band_px // 2); y1 = min(H, idx + band_px // 2)
+        top = arr[max(0, idx - blend_px - 1):idx, start:end, :].astype('float32').mean(axis=0)
+        bot = arr[idx:min(H, idx + blend_px), start:end, :].astype('float32').mean(axis=0)
+        avg = (top + bot) / 2.0                          # (w,3)
+        for y in range(y0, y1):
+            w = max(0.0, min(1.0, 1 - abs(y - idx) / (band_px / 2)))
+            arr[y, start:end, :] = (avg * w + arr[y, start:end, :].astype('float32') * (1 - w)).round().astype('uint8')
+
+def heal_edge_wrap(arr, kind, start, end):
+    H, W = arr.shape[:2]
+    S = 16
+    if kind in ('top_edge', 'bottom_edge'):
+        x0, x1 = start, end
+        top = arr[0:S, x0:x1, :].astype('float32')
+        bottom = arr[H - S:H, x0:x1, :].astype('float32')
+        bottom_f = bottom[::-1, :, :]
+        blend = ((top + bottom_f) / 2).round().astype('uint8')
+        arr[0:S, x0:x1, :] = blend
+        arr[H - S:H, x0:x1, :] = blend[::-1, :, :]
+    elif kind in ('left_edge', 'right_edge'):
+        y0, y1 = start, end
+        left = arr[y0:y1, 0:S, :].astype('float32')
+        right = arr[y0:y1, W - S:W, :].astype('float32')
+        right_f = right[:, ::-1, :]
+        blend = ((left + right_f) / 2).round().astype('uint8')
+        arr[y0:y1, 0:S, :] = blend
+        arr[y0:y1, W - S:W, :] = blend[:, ::-1, :]
+
+# ---- pipeline ----
+def fetch_root(root):
+    p = Path(f"/tmp/root_{root}.png")
+    url = f"{BASE_URL}/designs/img/by-id/{root}"
+    urllib.request.urlretrieve(url, p)
+    if not p.exists() or p.stat().st_size < 100:
+        raise RuntimeError(f"fetch failed for {root}")
+    return p
+
+def render_overlay(src_png, boxes, out_png):
+    img = Image.open(src_png).convert('RGB')
+    draw = ImageDraw.Draw(img)
+    peaks = [b.get('peak_de', 0) for b in boxes] or [1]
+    pmax = max(peaks) or 1
+    for b in boxes:
+        x, y, w, h = b['x'], b['y'], b['w'], b['h']
+        # redder = higher peak_de; clamp box to >=3px for visibility
+        sev = min(1.0, b.get('peak_de', 0) / pmax)
+        red = int(120 + 135 * sev)
+        col = (red, max(0, 80 - int(80 * sev)), max(0, 80 - int(80 * sev)))
+        x1 = max(x + max(w, 3), x + 1); y1 = max(y + max(h, 3), y + 1)
+        draw.rectangle([x, y, x1, y1], outline=col, width=3)
+    img.save(out_png)
+    return str(out_png)
+
+def heal_one(src_arr, boxes, cfg):
+    """Apply heal to a copy of src_arr per config. cfg keys: heal_mids(bool),
+    band_px, blend_px. Returns healed array."""
+    arr = src_arr.copy()
+    H, W = arr.shape[:2]
+    for b in boxes:
+        k = b['kind']
+        x, y, w, h = b['x'], b['y'], b['w'], b['h']
+        if k in ('top_edge', 'bottom_edge'):
+            heal_edge_wrap(arr, k, x, x + w)
+        elif k in ('left_edge', 'right_edge'):
+            heal_edge_wrap(arr, k, y, y + h)
+        elif k == 'v_mid' and cfg['heal_mids']:
+            heal_band_mid(arr, 'v', W // 2, y, y + h, cfg['band_px'], cfg['blend_px'])
+        elif k == 'h_mid' and cfg['heal_mids']:
+            heal_band_mid(arr, 'h', H // 2, x, x + w, cfg['band_px'], cfg['blend_px'])
+    return arr
+
+# Curated set of genuinely-different heal configs (the heal is deterministic,
+# so vary the STRATEGY, not the seed). edge-only vs edge+mid x band/blend.
+HEAL_CONFIGS = [
+    {'name': 'edge-only',        'heal_mids': False, 'band_px': 0,  'blend_px': 0, 'gauss': 0},
+    {'name': 'edge+mid-b24-bl4', 'heal_mids': True,  'band_px': 24, 'blend_px': 4, 'gauss': 2},
+    {'name': 'edge+mid-b32-bl6', 'heal_mids': True,  'band_px': 32, 'blend_px': 6, 'gauss': 3},
+    {'name': 'edge+mid-b40-bl8', 'heal_mids': True,  'band_px': 40, 'blend_px': 8, 'gauss': 3},
+    {'name': 'edge+mid-b32-bl4', 'heal_mids': True,  'band_px': 32, 'blend_px': 4, 'gauss': 2},
+    {'name': 'edge+mid-b40-bl6', 'heal_mids': True,  'band_px': 40, 'blend_px': 6, 'gauss': 4},
+]
+
+def psql(sql):
+    r = subprocess.run(['psql', 'dw_unified', '-At', '-q', '-c', sql],
+                       capture_output=True, text=True, timeout=20)
+    if r.returncode != 0:
+        raise RuntimeError(r.stderr.strip())
+    return r.stdout.strip()
+
+def get_dims(root):
+    out = psql(f"SELECT width_in, height_in FROM all_designs WHERE id={root} LIMIT 1;")
+    w, h = out.split('|')
+    return float(w), float(h)
+
+def upscale(healed_png, root, tgt):
+    out = GENERATED / f"jointheal_{root}_{int(time.time()*1000)}.png"
+    r = subprocess.run(
+        ['python3', str(_HERE / 'clean-edge-upscale.py'), str(healed_png),
+         '--id', f'jointheal_{root}', '--target', str(tgt), '--out', str(out), '--json'],
+        capture_output=True, text=True, timeout=300)
+    if r.returncode != 0 or not out.exists():
+        raise RuntimeError(f"upscale failed: {r.stderr.strip()[:300]}")
+    try:
+        meta = json.loads(r.stdout.strip().splitlines()[-1])
+    except Exception:
+        meta = {}
+    return out, meta
+
+def scan_file(p):
+    s = seam.scan_path(Path(p))
+    return s
+
+def insert_variant(root, final_png, cfg, before_scan, after_scan):
+    cfg_tag = f"joint-heal-{cfg['name']}"
+    new_id = int(psql(
+        "WITH nid AS (SELECT COALESCE(MAX(id),0)+1 AS id FROM all_designs) "
+        "INSERT INTO all_designs "
+        "(id, category, kind, prompt, negative_prompt, local_path, image_url, dominant_hex, "
+        " width_in, height_in, seed, tags, is_published, user_removed, generator, "
+        " parent_design_id, source_dw_sku) "
+        "SELECT nid.id, s.category, s.kind, s.prompt, s.negative_prompt, "
+        f"  '{final_png}', '/designs/img/by-id/' || nid.id, s.dominant_hex, "
+        "  s.width_in, s.height_in, s.seed, "
+        "  (COALESCE(s.tags, ARRAY[]::text[])) || "
+        f"  ARRAY['{GEN_TAG}','joint-healed-from-{root}','{cfg_tag}']::text[], "
+        f"  FALSE, FALSE, '{GEN_TAG}', {root}, s.source_dw_sku "
+        f"FROM (SELECT * FROM all_designs WHERE id={root} LIMIT 1) s, nid RETURNING id;"))
+    return new_id
+
+def process_root(root, dry_run=False, max_variants=6):
+    rep = {'root': root, 'configs': [], 'inserted': [], 'overlay': None}
+    src_png = fetch_root(root)
+    before = scan_file(src_png)
+    rep['before'] = {'verdict': before['verdict'], 'scores': before['scores'],
+                     'nboxes': len(before['boxes'])}
+    boxes = before['boxes']
+    rep['overlay'] = render_overlay(src_png, boxes, Path(f"/tmp/seam_overlay_{root}.png"))
+    w_in, h_in = get_dims(root)
+    tgt = target_px(w_in)
+    rep['width_in'] = w_in
+    rep['target_px'] = tgt
+
+    src_arr = np.asarray(Image.open(src_png).convert('RGB')).copy()
+    seen_signatures = set()
+    for cfg in HEAL_CONFIGS[:max_variants]:
+        crec = {'name': cfg['name']}
+        try:
+            healed = heal_one(src_arr, boxes, cfg)
+            himg = Image.fromarray(healed)
+            if cfg.get('gauss'):
+                # soften only is implicit in band blend; keep as-is (no full-image blur)
+                pass
+            # dedup near-identical heals (e.g. edge-only when no mids exist)
+            sig = hash(healed.tobytes())
+            heal_png = GENERATED / f"healed1024_{root}_{cfg['name']}_{int(time.time()*1000)}.png"
+            himg.save(heal_png)
+            crec['heal_png'] = str(heal_png)
+            # upscale to 150 DPI
+            final_png, umeta = upscale(heal_png, root, tgt)
+            crec['final_png'] = str(final_png)
+            crec['upscale_method'] = umeta.get('method')
+            crec['final_size'] = umeta.get('src_size') and tgt
+            # re-scan the upscaled file
+            after = scan_file(final_png)
+            crec['after_verdict'] = after['verdict']
+            crec['after_scores'] = {'overall_max': after['scores']['overall_max'],
+                                    'edges_max': after['scores']['edges_max'],
+                                    'mids_max': after['scores']['mids_max']}
+            # CLEAN gate = mean-ΔE verdict PASS on BOTH axes (overall_max <= 5.0).
+            # Per-row peak boxes can fire even on a PASS tile (find_runs trigger=8),
+            # so the verdict (not box presence) is the edge-seamless gate. FAIL is
+            # never shippable; WARN is borderline-not-clean -> dropped here.
+            crec['after_fail'] = after['verdict'] != 'PASS'
+            # signature on final image to detect identical results
+            fsig = hash(np.asarray(Image.open(final_png).convert('RGB')).tobytes())
+            crec['distinct'] = fsig not in seen_signatures
+            if crec['after_fail']:
+                crec['action'] = 'dropped (still failing after upscale)'
+            elif not crec['distinct']:
+                crec['action'] = 'dropped (duplicate of earlier clean result)'
+            else:
+                seen_signatures.add(fsig)
+                if dry_run:
+                    crec['action'] = 'clean (dry-run, not inserted)'
+                else:
+                    nid = insert_variant(root, str(final_png), cfg, before, after)
+                    crec['new_id'] = nid
+                    crec['action'] = f'inserted id {nid}'
+                    rep['inserted'].append(nid)
+        except Exception as e:
+            crec['error'] = str(e)[:300]
+            crec['action'] = 'errored'
+        rep['configs'].append(crec)
+    rep['distinct_clean_count'] = len(rep['inserted']) if not dry_run else \
+        sum(1 for c in rep['configs'] if c.get('action', '').startswith('clean'))
+    return rep
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--root', type=int)
+    ap.add_argument('--roots', help='comma-separated root ids')
+    ap.add_argument('--dry-run', action='store_true')
+    ap.add_argument('--max-variants', type=int, default=6)
+    args = ap.parse_args()
+    roots = []
+    if args.root:
+        roots = [args.root]
+    elif args.roots:
+        roots = [int(x) for x in args.roots.split(',') if x.strip()]
+    else:
+        ap.error('--root or --roots required')
+    out = {'ok': True, 'roots': []}
+    for r in roots:
+        try:
+            out['roots'].append(process_root(r, dry_run=args.dry_run, max_variants=args.max_variants))
+        except Exception as e:
+            out['roots'].append({'root': r, 'error': str(e)[:400]})
+    print(json.dumps(out))
+
+if __name__ == '__main__':
+    main()
diff --git a/server.js b/server.js
index 58d0c69..a1fbc99 100644
--- a/server.js
+++ b/server.js
@@ -2160,10 +2160,45 @@ app.get('/api/admin/rooms/list', (req, res) => {
 // (mid_score=12.6 / edges=3.5, classic SDXL center-cross).
 const { execFileSync: _execFileSyncSeam } = require('child_process');
 function runSeamScan(id) {
-  const out = _execFileSyncSeam('python3',
-    [path.join(__dirname, 'scripts', 'seam-defect-boxes.py'), '--id', String(id)],
-    { encoding: 'utf8', timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
-  return JSON.parse(out);
+  // Primary path: scan by id (resolves local_path via PG). On Mac2 many roots
+  // have NO local_path (the PNG lives only behind /designs/img/by-id), so the
+  // --id scan returns ok:false ('no design'/file-not-found) and the viewer got
+  // 0 boxes. Fall back to fetching the rendered image from the by-id route to a
+  // temp file and scanning with --path so seam-debug works where local_path is
+  // absent on this host.
+  let r;
+  try {
+    const out = _execFileSyncSeam('python3',
+      [path.join(__dirname, 'scripts', 'seam-defect-boxes.py'), '--id', String(id)],
+      { encoding: 'utf8', timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
+    r = JSON.parse(out);
+  } catch (e) {
+    // seam-defect-boxes exits 1 on not-ok; parse stdout if present.
+    try { r = JSON.parse((e.stdout || '').toString()); }
+    catch { r = { ok: false, error: e.message }; }
+  }
+  if (r && r.ok) return r;
+  // Fallback — fetch the by-id image to a temp file and scan by path.
+  const tmp = path.join(require('os').tmpdir(), `seamscan_${id}_${process.pid}.png`);
+  try {
+    const buf = execSync(
+      `curl -s -f -o ${JSON.stringify(tmp)} -w "%{http_code}" ` +
+      `http://127.0.0.1:${PORT}/designs/img/by-id/${id}`,
+      { encoding: 'utf8', timeout: 30000 });
+    if (!fs.existsSync(tmp) || fs.statSync(tmp).size < 100) {
+      return r || { ok: false, error: 'image fetch failed' };
+    }
+    const out2 = _execFileSyncSeam('python3',
+      [path.join(__dirname, 'scripts', 'seam-defect-boxes.py'), '--path', tmp],
+      { encoding: 'utf8', timeout: 30000, maxBuffer: 8 * 1024 * 1024 });
+    const r2 = JSON.parse(out2);
+    r2.id = id;
+    return r2;
+  } catch (e) {
+    return r || { ok: false, error: e.message };
+  } finally {
+    try { fs.unlinkSync(tmp); } catch {}
+  }
 }
 // ── True-toroidal edge-continuity gate ────────────────────────────────────
 // scripts/verify-edge-seamless.py measures the LITERAL wrap boundary
@@ -2772,13 +2807,14 @@ app.get('/api/variant-curator', (req, res) => {
           `FROM all_designs d ` +
           "LEFT JOIN wallco_variant_picks p ON p.design_id = d.id " +
           `WHERE d.parent_design_id IN (${rootsCsv}) ` +
-          // Show ONLY the new img2img-cleanup variants (look-like-the-root, cleaned)
-          // — NOT the old sparse focal-crop seam-fix children (those were the wrong
-          // aesthetic Steve rejected 2026-06-12). New generator tag = variants-i2i-cleanup.
-          "AND d.generator='variants-i2i-cleanup' " +
+          // Show ONLY the seam-JOINT-healed variants (look-like-the-root, ONLY the
+          // tile joints fixed, 150-DPI print-res) — NOT the abandoned img2img/
+          // ControlNet cleanup children (those drifted/emptied the design, Steve
+          // rejected 2026-06-12). Generator tag = seam-joint-healed.
+          "AND d.generator='seam-joint-healed' " +
           "AND (d.user_removed IS NULL OR d.user_removed=FALSE) " +
           "AND d.local_path IS NOT NULL " +
-          "AND EXISTS (SELECT 1 FROM unnest(d.tags) tg WHERE tg LIKE 'i2i-cleanup-from-%') " +
+          "AND EXISTS (SELECT 1 FROM unnest(d.tags) tg WHERE tg LIKE 'joint-healed-from-%') " +
         ") s WHERE s.rn <= 10" +
       ") t;"
     );

← e4a15f7 variant-curator API: show only the new img2img-cleanup varia  ·  back to Wallco Ai  ·  Add seam-joint-heal-loop: autonomous yolo-runner over the 9 cbb6d9e →