[object Object]

← back to Wallco Ai

seamless-tile physics gate: detector lib + sweeper + pre-publish wire-up

afee5cd4230bfeeb6b77cced9e35523bd951ad9d · 2026-05-24 08:04:16 -0700 · Steve Abrams

Three pieces:

1. lib/seamless-detector.js — Node wrapper around scripts/seamless-check.py
   (the existing edge-pixel diff check). Exports checkSeamless() for
   informational use and verifySeamless() as a throw-on-defect gate.
   Returns code SEAMLESS_OK / SEAMLESS_FAIL_TB / SEAMLESS_FAIL_LR /
   SEAMLESS_FAIL_BOTH plus the raw tb/lr diffs.

2. scripts/scan-seamless.js — sweeper that runs the physics check across
   the published catalog. Mirrors scan-ghost-layer.js shape: resumable,
   appends to data/seamless-scan-results.jsonl + flagged subset to
   data/seamless-scan-flagged.jsonl. ~16/s vs ghost-detector ~1.5/s — 11×
   faster since no API call ($0 cost).

3. scripts/generate_designs.js — pre-publish gate alongside the existing
   ghost-layer gate. Only runs on kind=seamless_tile (panels are scenic
   murals where edge-wrap is not required). On defect, regenerates once
   with a stronger seamless-anchor prompt (same retry pattern as ghost
   gate). Skip if still defective. Opt-out: WALLCO_SEAMLESS_GATE=0.
   Tolerance: WALLCO_SEAMLESS_TOLERANCE (default 12).

Why this lens: the Gemini ghost-detector catches motif-opacity defects
but missed tile-seam discontinuities — designs that look fine in isolation
but show a visible seam line when actually tiled on a wall. Steve sent a
screenshot of a sloth design where the head was cut in half at the y=50%
boundary; that pattern unanimously confirms CONFIRMED-ghost in the
Gemini scan but the true defect is the seam, not opacity.

Smoke-tested on 30 catalog designs (16/s, 14 flagged, 5 file-missing).
Diff distribution clean: SEAMLESS_OK designs land tb<8 lr<10; flagged
designs hit tb=12+ lr=12+ with several at 30+ (egregious cutoff).

Files touched

Diff

commit afee5cd4230bfeeb6b77cced9e35523bd951ad9d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 08:04:16 2026 -0700

    seamless-tile physics gate: detector lib + sweeper + pre-publish wire-up
    
    Three pieces:
    
    1. lib/seamless-detector.js — Node wrapper around scripts/seamless-check.py
       (the existing edge-pixel diff check). Exports checkSeamless() for
       informational use and verifySeamless() as a throw-on-defect gate.
       Returns code SEAMLESS_OK / SEAMLESS_FAIL_TB / SEAMLESS_FAIL_LR /
       SEAMLESS_FAIL_BOTH plus the raw tb/lr diffs.
    
    2. scripts/scan-seamless.js — sweeper that runs the physics check across
       the published catalog. Mirrors scan-ghost-layer.js shape: resumable,
       appends to data/seamless-scan-results.jsonl + flagged subset to
       data/seamless-scan-flagged.jsonl. ~16/s vs ghost-detector ~1.5/s — 11×
       faster since no API call ($0 cost).
    
    3. scripts/generate_designs.js — pre-publish gate alongside the existing
       ghost-layer gate. Only runs on kind=seamless_tile (panels are scenic
       murals where edge-wrap is not required). On defect, regenerates once
       with a stronger seamless-anchor prompt (same retry pattern as ghost
       gate). Skip if still defective. Opt-out: WALLCO_SEAMLESS_GATE=0.
       Tolerance: WALLCO_SEAMLESS_TOLERANCE (default 12).
    
    Why this lens: the Gemini ghost-detector catches motif-opacity defects
    but missed tile-seam discontinuities — designs that look fine in isolation
    but show a visible seam line when actually tiled on a wall. Steve sent a
    screenshot of a sloth design where the head was cut in half at the y=50%
    boundary; that pattern unanimously confirms CONFIRMED-ghost in the
    Gemini scan but the true defect is the seam, not opacity.
    
    Smoke-tested on 30 catalog designs (16/s, 14 flagged, 5 file-missing).
    Diff distribution clean: SEAMLESS_OK designs land tb<8 lr<10; flagged
    designs hit tb=12+ lr=12+ with several at 30+ (egregious cutoff).
---
 lib/seamless-detector.js    |  73 ++++++++++++++++++
 scripts/generate_designs.js |  40 ++++++++++
 scripts/scan-seamless.js    | 180 ++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 293 insertions(+)

diff --git a/lib/seamless-detector.js b/lib/seamless-detector.js
new file mode 100644
index 0000000..6446b18
--- /dev/null
+++ b/lib/seamless-detector.js
@@ -0,0 +1,73 @@
+// Seamless-tile defect detector (pure-physics).
+//
+// Wraps scripts/seamless-check.py — the existing edge-pixel diff check that
+// compares top↔bottom and left↔right rows after downscaling to 512px. A
+// defective tile has high pixel diff at one or both edge pairs because motifs
+// cut at the boundary don't wrap to the opposite edge.
+//
+// Why this lens (added 2026-05-24): the Gemini ghost-detector catches motif-
+// opacity defects (intentional design rule violations) but misses tile-seam
+// discontinuities (the seam line itself is visible when tiled). Both are
+// defects but the physics check is cheaper ($0), faster (~50ms), and
+// deterministic — runs before the Gemini call as a first-line filter.
+//
+// Returns:
+//   { ok: bool, top_bottom_diff: number, left_right_diff: number,
+//     tolerance: number, code: 'SEAMLESS_OK' | 'SEAMLESS_FAIL_TB' |
+//     'SEAMLESS_FAIL_LR' | 'SEAMLESS_FAIL_BOTH' }
+//
+// Tolerance bands (from scripts/seamless-check.py header):
+//   < 4   — perceptually clean tile
+//   < 8   — soft honest repeat
+//   12    — default tolerance (script default)
+//   15+   — visible seam, defect
+//
+// verifySeamless(imagePath) — gate variant: throws SEAMLESS_DEFECT if NOT ok.
+
+const { spawnSync } = require('child_process');
+const path = require('path');
+
+const SCRIPT = path.join(__dirname, '..', 'scripts', 'seamless-check.py');
+
+function checkSeamless(imagePath, opts = {}) {
+  const tolerance = opts.tolerance ?? 12;
+  const r = spawnSync('python3', [SCRIPT, imagePath], {
+    env: { ...process.env, SEAMLESS_TOLERANCE: String(tolerance) },
+    encoding: 'utf8',
+    timeout: 15_000,
+  });
+  // The script exits 0 if ok, 1 if not — both write valid JSON to stdout.
+  // Exit 2 = usage error; anything else = unexpected.
+  if (r.status !== 0 && r.status !== 1) {
+    throw new Error(`seamless-check: status=${r.status} ${r.stderr || r.stdout}`.slice(0, 300));
+  }
+  let parsed;
+  try { parsed = JSON.parse(r.stdout.trim()); }
+  catch (e) { throw new Error(`seamless-check non-JSON: ${r.stdout.slice(0, 200)}`); }
+  const tbFail = parsed.top_bottom_diff > parsed.tolerance;
+  const lrFail = parsed.left_right_diff > parsed.tolerance;
+  let code = 'SEAMLESS_OK';
+  if (tbFail && lrFail) code = 'SEAMLESS_FAIL_BOTH';
+  else if (tbFail)      code = 'SEAMLESS_FAIL_TB';
+  else if (lrFail)      code = 'SEAMLESS_FAIL_LR';
+  return {
+    ok: parsed.ok,
+    top_bottom_diff: parsed.top_bottom_diff,
+    left_right_diff: parsed.left_right_diff,
+    tolerance: parsed.tolerance,
+    code,
+  };
+}
+
+async function verifySeamless(imagePath, opts = {}) {
+  const r = checkSeamless(imagePath, opts);
+  if (!r.ok) {
+    const e = new Error(`tile not seamless: ${r.code} tb=${r.top_bottom_diff} lr=${r.left_right_diff} tol=${r.tolerance}`);
+    e.code = 'SEAMLESS_DEFECT';
+    e.detector_result = r;
+    throw e;
+  }
+  return r;
+}
+
+module.exports = { checkSeamless, verifySeamless };
diff --git a/scripts/generate_designs.js b/scripts/generate_designs.js
index dd6cc9b..db43231 100644
--- a/scripts/generate_designs.js
+++ b/scripts/generate_designs.js
@@ -563,6 +563,46 @@ async function main() {
         console.warn('  ⚠ ghost-gate non-fatal:', e.message);
       }
 
+      // ── Pre-publish seamless-tile gate (2026-05-24) ──────────────────────
+      // Physics check — top↔bottom and left↔right edge-pixel diff via
+      // scripts/seamless-check.py (downscale to 512px then compare row 0 to
+      // row h-1, col 0 to col w-1). Catches tile-seam discontinuities where
+      // SDXL motifs get cut at the boundary and don't wrap to the opposite
+      // edge. Unlike the ghost gate, this is deterministic, fast (~50ms), free.
+      //
+      // Repair strategy: make_seamless.py is currently a no-op pass-through
+      // (the wrap-shift+blend approach produced ghost-layer artifacts), so
+      // we DON'T try to repair in place. Instead, retry once with a stronger
+      // seamless-anchor prompt and a new seed — same pattern as the ghost
+      // gate. If still defective, skip the design.
+      //
+      // Opt-out via WALLCO_SEAMLESS_GATE=0. Strict tolerance via
+      // WALLCO_SEAMLESS_TOLERANCE (default 12; perceptually clean is <4).
+      try {
+        const { verifySeamless } = require('../lib/seamless-detector');
+        if (opt.kind === 'seamless_tile' && process.env.WALLCO_SEAMLESS_GATE !== '0') {
+          const tolerance = parseFloat(process.env.WALLCO_SEAMLESS_TOLERANCE || '12');
+          try {
+            await verifySeamless(outPath, { tolerance });
+          } catch (serr) {
+            if (serr.code === 'SEAMLESS_DEFECT') {
+              const dr = serr.detector_result;
+              console.warn(`  ⚠ not seamless (${dr.code} tb=${dr.top_bottom_diff} lr=${dr.left_right_diff}) — regenerating once`);
+              const retryPrompt = prompt + '\n\nABSOLUTE RULE — SEAMLESSLY TILEABLE. Every motif that touches the right edge MUST continue on the left edge at the same height. Every motif that touches the top edge MUST continue on the bottom edge at the same x. No half-elements at the boundary that float without a wrap-around match. The image will be rejected if the seam is visible when tiled.';
+              generate(retryPrompt, seed + 1, outPath, { kind: opt.kind, category: opt.category });
+              const r2 = await verifySeamless(outPath, { tolerance }).catch(e => e);
+              if (r2 && r2.code === 'SEAMLESS_DEFECT') {
+                console.warn(`  ✗ still not seamless after retry — skipping #${seed}`);
+                continue;
+              }
+              console.log(`  ✓ seamless after retry`);
+            } else { throw serr; }
+          }
+        }
+      } catch (e) {
+        console.warn('  ⚠ seamless-gate non-fatal:', e.message);
+      }
+
       const id = persistDesign({
         kind: opt.kind, prompt, seed, localPath: outPath,
         width: thisWidth, height: thisHeight, panels: opt.panels,
diff --git a/scripts/scan-seamless.js b/scripts/scan-seamless.js
new file mode 100755
index 0000000..6818aec
--- /dev/null
+++ b/scripts/scan-seamless.js
@@ -0,0 +1,180 @@
+#!/usr/bin/env node
+// scan-seamless — sweep the catalog for tile-seam-discontinuity defects
+// using the pure-physics seamless-detector (no Gemini, no $ cost).
+//
+// Mirrors scripts/scan-ghost-layer.js but uses lib/seamless-detector.js
+// instead of the Gemini ghost lens. Catches a DIFFERENT defect class:
+// motifs that get cut at the tile boundary and don't wrap to the opposite
+// edge — visible as a hard seam line when the design tiles.
+//
+// Output:
+//   data/seamless-scan-results.jsonl — one line per design
+//   data/seamless-scan-flagged.jsonl — subset that failed the gate (not seamless)
+//
+// Resumable — skips IDs already in results.
+//
+// Usage:
+//   node scripts/scan-seamless.js                    # all is_published designs
+//   node scripts/scan-seamless.js --limit 200        # smoke test
+//   node scripts/scan-seamless.js --tolerance 8      # strict band
+//   node scripts/scan-seamless.js --concurrency 4    # parallel workers (default 6)
+//   node scripts/scan-seamless.js --category damask  # restrict
+//   node scripts/scan-seamless.js --source pg|json|both  (default both)
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { checkSeamless } = require('../lib/seamless-detector');
+
+const ARGS = process.argv.slice(2);
+function arg(name, def) { const i = ARGS.indexOf('--' + name); return i >= 0 ? ARGS[i + 1] : def; }
+const LIMIT       = parseInt(arg('limit', '0'), 10);
+const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '6'), 10));
+const CATEGORY    = arg('category', null);
+const TOLERANCE   = parseFloat(arg('tolerance', '12'));
+const SOURCE      = (arg('source', 'both') || 'both').toLowerCase();
+
+const ROOT = path.join(__dirname, '..');
+const RESULTS = path.join(ROOT, 'data', 'seamless-scan-results.jsonl');
+const FLAGGED = path.join(ROOT, 'data', 'seamless-scan-flagged.jsonl');
+const GENERATED_DIR = path.join(ROOT, 'data', 'generated');
+
+function psql(sql) {
+  const onLinux = process.platform === 'linux';
+  const cmd = onLinux ? 'sudo' : 'psql';
+  const args = onLinux
+    ? ['-n', '-u', 'postgres', 'psql', 'dw_unified', '-At', '-q']
+    : ['dw_unified', '-At', '-q'];
+  const r = spawnSync(cmd, args, { input: sql, encoding: 'utf8', maxBuffer: 50_000_000 });
+  if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+  return r.stdout;
+}
+
+function loadScanned() {
+  if (!fs.existsSync(RESULTS)) return new Set();
+  const ids = new Set();
+  for (const l of fs.readFileSync(RESULTS, 'utf8').split('\n')) {
+    if (!l.trim()) continue;
+    try { ids.add(JSON.parse(l).id); } catch {}
+  }
+  return ids;
+}
+
+// Same vendor-skip list as scan-ghost-layer — only scan wallco-AI generations.
+const VENDOR_CATS = new Set([
+  'thibaut','dolce-gabbana','koroseal','graduate-collection','traditional-whimsy',
+  'phillipe-romano','coordonn-','ralph-lauren','mind-the-gap','designer-wallcoverings',
+  'malibu-wallpaper','maya-romanoff','roberto-cavalli-wallpaper','china-seas',
+  'daisy-bennett','glitter-walls','dw-shopify',
+]);
+
+function fetchPgTargets() {
+  const where = [
+    'is_published = TRUE',
+    'user_removed IS NOT TRUE',
+    'local_path IS NOT NULL',
+  ];
+  if (CATEGORY) where.push(`category = '${CATEGORY.replace(/'/g, "''")}'`);
+  const sql = `SELECT id, category, local_path
+               FROM spoon_all_designs
+               WHERE ${where.join(' AND ')}
+               ORDER BY id DESC`;
+  const rows = [];
+  for (const line of psql(sql).split('\n')) {
+    if (!line.trim()) continue;
+    const [id, category, local_path] = line.split('|');
+    rows.push({ id: parseInt(id, 10), category, local_path, source: 'pg' });
+  }
+  return rows;
+}
+
+function fetchJsonTargets(excludeIds) {
+  const f = path.join(ROOT, 'data', 'designs.json');
+  if (!fs.existsSync(f)) return [];
+  const arr = JSON.parse(fs.readFileSync(f, 'utf8'));
+  const rows = [];
+  for (const d of arr) {
+    if (excludeIds.has(d.id)) continue;
+    if (!d.id) continue;
+    if (CATEGORY && d.category !== CATEGORY) continue;
+    if (VENDOR_CATS.has((d.category || '').toLowerCase())) continue;
+    if (d.image_url && d.image_url.startsWith('/designs/img/')) {
+      const fn = d.image_url.replace(/^\/designs\/img\//, '');
+      const candidate = path.join(GENERATED_DIR, fn);
+      if (fs.existsSync(candidate)) {
+        rows.push({ id: d.id, category: d.category, local_path: candidate, source: 'json' });
+      }
+    }
+  }
+  return rows;
+}
+
+function fetchTargets() {
+  let rows = [];
+  if (SOURCE === 'pg' || SOURCE === 'both') rows = rows.concat(fetchPgTargets());
+  if (SOURCE === 'json' || SOURCE === 'both') {
+    const have = new Set(rows.map(r => r.id));
+    rows = rows.concat(fetchJsonTargets(have));
+  }
+  if (LIMIT > 0) rows = rows.slice(0, LIMIT);
+  return rows;
+}
+
+async function processOne(row) {
+  if (!row.local_path || !fs.existsSync(row.local_path)) {
+    return { id: row.id, ts: new Date().toISOString(), error: 'file_missing', category: row.category, source: row.source };
+  }
+  try {
+    const r = checkSeamless(row.local_path, { tolerance: TOLERANCE });
+    return {
+      id: row.id, ts: new Date().toISOString(),
+      ok: r.ok,
+      top_bottom_diff: r.top_bottom_diff,
+      left_right_diff: r.left_right_diff,
+      code: r.code,
+      tolerance: r.tolerance,
+      category: row.category,
+      source: row.source,
+    };
+  } catch (e) {
+    return { id: row.id, ts: new Date().toISOString(), error: e.message.slice(0, 200), category: row.category, source: row.source };
+  }
+}
+
+async function runPool(rows, workers, onResult) {
+  let cursor = 0;
+  const next = () => (cursor < rows.length ? rows[cursor++] : null);
+  async function worker() {
+    while (true) {
+      const r = next();
+      if (!r) return;
+      onResult(await processOne(r));
+    }
+  }
+  await Promise.all(Array.from({ length: workers }, worker));
+}
+
+(async () => {
+  console.log(`[scan-seamless] tolerance=${TOLERANCE} concurrency=${CONCURRENCY} category=${CATEGORY || 'all'}`);
+  const scanned = loadScanned();
+  console.log(`[scan-seamless] already-scanned: ${scanned.size}`);
+  const all = fetchTargets();
+  const todo = all.filter(r => !scanned.has(r.id));
+  console.log(`[scan-seamless] eligible: ${all.length}, todo: ${todo.length}`);
+
+  let n = 0, flagged = 0, errored = 0;
+  const t0 = Date.now();
+  const onResult = (out) => {
+    n++;
+    fs.appendFileSync(RESULTS, JSON.stringify(out) + '\n');
+    if (out.error) errored++;
+    else if (!out.ok) { flagged++; fs.appendFileSync(FLAGGED, JSON.stringify(out) + '\n'); }
+    if (n % 50 === 0 || n === todo.length) {
+      const rate = n / ((Date.now() - t0) / 1000);
+      const eta = ((todo.length - n) / Math.max(rate, 0.01)).toFixed(0);
+      console.log(`[scan-seamless] ${n}/${todo.length} · ${flagged} flagged · ${errored} errored · ${rate.toFixed(1)}/s · ETA ${eta}s`);
+    }
+  };
+  await runPool(todo, CONCURRENCY, onResult);
+  console.log(`[scan-seamless] done · scanned=${n} flagged=${flagged} errored=${errored}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });

← 3fc81bf triage: colorway-sibling fallback in resolveImagePath  ·  back to Wallco Ai  ·  make_seamless.py: disable to no-op pass-through d301665 →