← back to Trending Dw
gapgen: add file-based heal-tile.py (near-miss seam rescue, reuses wallco heal algo, no DB)
52f5a8b8a968cbab2b42fe4b7ed60692fcd27c0b · 2026-07-07 09:49:41 -0700 · Steve
Files touched
Diff
commit 52f5a8b8a968cbab2b42fe4b7ed60692fcd27c0b
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Jul 7 09:49:41 2026 -0700
gapgen: add file-based heal-tile.py (near-miss seam rescue, reuses wallco heal algo, no DB)
---
scripts/heal-tile.py | 137 +++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 137 insertions(+)
diff --git a/scripts/heal-tile.py b/scripts/heal-tile.py
new file mode 100644
index 0000000..29d4de6
--- /dev/null
+++ b/scripts/heal-tile.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""
+heal-tile.py — file-based seam healer for trending-dw gap candidates.
+
+The gapgen pipeline is self-host + file-based (candidates are PNGs in /tmp/gap-cand,
+no DB). wallco-ai's heal-seam-region.py does the same joint-healing but is coupled
+to the all_designs DB table, so it can't be used here. This lifts its pure-numpy
+heal algorithm (heal_band_mid / heal_edge_wrap) and drives it from a PNG PATH:
+
+ 1. scan the tile via wallco-ai/scripts/seam-defect-boxes.py --path (get FAIL/WARN boxes)
+ 2. heal each flagged joint in-place (v_mid/h_mid band-average, *_edge mirror-wrap)
+ 3. save <name>_healed.png (NEVER overwrites the source — round-1 output is sacred)
+ 4. re-scan the healed file and print before/after JSON
+
+This is the motif-lane rescue the DTD panel required before motif generation resumes:
+figural motifs (leopard/paisley/deco) tile badly (seam 15-46); healing their joints
+can pull a near-miss under the <=5 gate without re-rolling (which discards the design).
+
+Usage:
+ python3 scripts/heal-tile.py --path /tmp/gap-cand/TR-020__s123.png
+ python3 scripts/heal-tile.py --path <png> --kinds v_mid,h_mid # heal only these joints
+Exit 0 if the healed tile PASSES seam (overall_max <= 5), else 1.
+"""
+import argparse, json, subprocess, sys
+from pathlib import Path
+
+WALLCO_SEAM = Path('/Users/macstudio3/Projects/wallco-ai/scripts/seam-defect-boxes.py')
+PY = sys.executable # run under the same interpreter (the .gapvenv python that has numpy+PIL)
+
+BLEND_PX = 6 # px each side of the seam to average
+BAND_PX = 32 # full heal-band width (centered on the seam line)
+
+
+def scan(png: Path) -> dict:
+ r = subprocess.run([PY, str(WALLCO_SEAM), '--path', str(png)], capture_output=True, text=True, timeout=60)
+ if r.returncode != 0:
+ raise RuntimeError(f'seam scan failed: {r.stderr.strip()[:200]}')
+ return json.loads(r.stdout)
+
+
+# ---- heal primitives (ported verbatim from wallco-ai/scripts/heal-seam-region.py) ----
+def heal_band_mid(arr, axis, idx, start, end):
+ """axis='v': vertical seam at x=idx, healing rows [start:end];
+ axis='h': horizontal seam at y=idx, healing cols [start:end]."""
+ import numpy as np
+ 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, :].mean(axis=1)
+ right = arr[start:end, idx:min(W, idx + BLEND_PX), :].mean(axis=1)
+ avg = ((left + right) / 2).round().astype('uint8')
+ 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, :] * (1 - w)).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, :].mean(axis=0)
+ bot = arr[idx:min(H, idx + BLEND_PX), start:end, :].mean(axis=0)
+ avg = ((top + bot) / 2).round().astype('uint8')
+ 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, :] * (1 - w)).astype('uint8')
+
+
+def heal_edge_wrap(arr, kind, start, end):
+ """Average each edge strip with its opposite-edge mirror so the tile-repeat
+ boundary is invisible. kind in {top,bottom,left,right}_edge."""
+ 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')
+ blend = ((top + bottom[::-1, :, :]) / 2).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')
+ blend = ((left + right[:, ::-1, :]) / 2).astype('uint8')
+ arr[y0:y1, 0:S, :] = blend
+ arr[y0:y1, W - S:W, :] = blend[:, ::-1, :]
+
+
+def main():
+ ap = argparse.ArgumentParser()
+ ap.add_argument('--path', required=True, help='PNG candidate to heal')
+ ap.add_argument('--kinds', help='comma-separated joint kinds to heal (default: all flagged)')
+ args = ap.parse_args()
+
+ src = Path(args.path)
+ if not src.exists():
+ print(json.dumps({'ok': False, 'error': f'not found: {src}'})); sys.exit(2)
+
+ before = scan(src)
+ boxes = before.get('boxes', [])
+ if args.kinds:
+ wanted = set(args.kinds.split(','))
+ boxes = [b for b in boxes if b['kind'] in wanted]
+ if not boxes:
+ print(json.dumps({'ok': True, 'note': 'no joints to heal', 'verdict': before['verdict'],
+ 'scores': before['scores'], 'healed_boxes': 0}))
+ sys.exit(0 if before['verdict'] == 'PASS' else 1)
+
+ from PIL import Image
+ import numpy as np
+ arr = np.asarray(Image.open(src).convert('RGB')).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 == 'v_mid':
+ heal_band_mid(arr, 'v', W // 2, y, y + h)
+ elif k == 'h_mid':
+ heal_band_mid(arr, 'h', H // 2, x, x + w)
+ elif 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)
+
+ out = src.with_name(src.stem + '_healed.png')
+ Image.fromarray(arr).save(out, optimize=True)
+ after = scan(out)
+
+ passed = after['scores'].get('overall_max', 99) <= 5.0
+ print(json.dumps({
+ 'ok': True, 'src': str(src), 'healed': str(out), 'healed_boxes': len(boxes),
+ 'before': {'verdict': before['verdict'], 'overall_max': round(before['scores'].get('overall_max', 0), 2)},
+ 'after': {'verdict': after['verdict'], 'overall_max': round(after['scores'].get('overall_max', 0), 2)},
+ 'passes_seam_gate': passed,
+ }))
+ sys.exit(0 if passed else 1)
+
+
+if __name__ == '__main__':
+ main()
← bedb55d gapgen: data-driven style-keyed briefs from live gap items (
·
back to Trending Dw
·
auto-save: 2026-07-07T09:59:53 (1 files) — scripts/__pycache 7991ccc →