[object Object]

← back to Wallco Ai

scripts: scan-frame-overlay.js — catalog sweep for the new defect lens

641da47d04ae09addb91a1ef0c3c807b2566cad8 · 2026-05-24 22:30:19 -0700 · Steve Abrams

Mirror of scripts/scan-seamless.js shape but pointed at
analyzeFrameOverlay / analyzeFrameOverlayStable. Resumable, appends to
data/frame-overlay-scan-results.jsonl + flagged subset to
data/frame-overlay-scan-flagged.jsonl. Vendor-imported categories
excluded (only scan wallco-AI generations).

Modes:
  default — 3-vote stable, ~$0.0015/design, more deterministic
  --single — 1-vote, ~$0.0005/design, faster + cheaper for initial sweep
             where the goal is a prevalence estimate, not final verdicts

Scenic categories are skipped inside the lens itself (no API call).

Flagged subset is gated on unanimous-true in stable3 mode (matches the
verifyNoFrameOverlay gate policy), or simply hasFrameOverlay=true in
single mode (no consensus signal available).

Files touched

Diff

commit 641da47d04ae09addb91a1ef0c3c807b2566cad8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 22:30:19 2026 -0700

    scripts: scan-frame-overlay.js — catalog sweep for the new defect lens
    
    Mirror of scripts/scan-seamless.js shape but pointed at
    analyzeFrameOverlay / analyzeFrameOverlayStable. Resumable, appends to
    data/frame-overlay-scan-results.jsonl + flagged subset to
    data/frame-overlay-scan-flagged.jsonl. Vendor-imported categories
    excluded (only scan wallco-AI generations).
    
    Modes:
      default — 3-vote stable, ~$0.0015/design, more deterministic
      --single — 1-vote, ~$0.0005/design, faster + cheaper for initial sweep
                 where the goal is a prevalence estimate, not final verdicts
    
    Scenic categories are skipped inside the lens itself (no API call).
    
    Flagged subset is gated on unanimous-true in stable3 mode (matches the
    verifyNoFrameOverlay gate policy), or simply hasFrameOverlay=true in
    single mode (no consensus signal available).
---
 scripts/scan-frame-overlay.js | 193 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 193 insertions(+)

diff --git a/scripts/scan-frame-overlay.js b/scripts/scan-frame-overlay.js
new file mode 100755
index 0000000..1cae569
--- /dev/null
+++ b/scripts/scan-frame-overlay.js
@@ -0,0 +1,193 @@
+#!/usr/bin/env node
+// scan-frame-overlay — sweep the published catalog for the "pasted frame
+// overlay" defect via lib/ghost-detector.js analyzeFrameOverlayStable.
+//
+// A pasted frame overlay = a centered or repeating rectangle/oval/rounded-
+// square shape sitting ON TOP of the background pattern like a sticker,
+// with hard geometric edges that break the seamless repeat flow. First
+// flagged on design 39330. Distinct defect class from ghost-layer and
+// outer-edge tileability.
+//
+// Mirror of scripts/scan-seamless.js shape — resumable, appends to
+// data/frame-overlay-scan-results.jsonl + flagged subset to
+// data/frame-overlay-scan-flagged.jsonl. Scenic categories are skipped
+// inside the lens itself (returns skipped=true).
+//
+// Cost: ~$0.0015 per design at 3-vote stable, ~$0.0005 single-call.
+// Speed: bound by Gemini Flash rate-limits. Run at concurrency 4 to start;
+// ramp up if no 429s.
+//
+// Usage:
+//   node scripts/scan-frame-overlay.js                    # all is_published
+//   node scripts/scan-frame-overlay.js --limit 500        # smoke / sample
+//   node scripts/scan-frame-overlay.js --concurrency 4    # default 4
+//   node scripts/scan-frame-overlay.js --category designer-zoo-calm
+//   node scripts/scan-frame-overlay.js --source pg|json|both  (default both)
+//   node scripts/scan-frame-overlay.js --single           # 1-vote not 3-vote (3× cheaper, noisier)
+
+const fs = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { analyzeFrameOverlay, analyzeFrameOverlayStable } = require('../lib/ghost-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', '4'), 10));
+const CATEGORY    = arg('category', null);
+const SOURCE      = (arg('source', 'both') || 'both').toLowerCase();
+const SINGLE      = ARGS.includes('--single');
+
+const ROOT = path.join(__dirname, '..');
+const RESULTS = path.join(ROOT, 'data', 'frame-overlay-scan-results.jsonl');
+const FLAGGED = path.join(ROOT, 'data', 'frame-overlay-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 — 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 = SINGLE
+      ? await analyzeFrameOverlay(row.local_path, { category: row.category })
+      : await analyzeFrameOverlayStable(row.local_path, { category: row.category });
+    return {
+      id: row.id, ts: new Date().toISOString(),
+      hasFrameOverlay: r.hasFrameOverlay,
+      confidence:      r.confidence,
+      reason:          r.reason,
+      unanimous:       r.unanimous ?? null,
+      skipped:         r.skipped ?? false,
+      mode:            SINGLE ? 'single' : 'stable3',
+      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-frame-overlay] mode=${SINGLE ? 'single' : 'stable3'} concurrency=${CONCURRENCY} category=${CATEGORY || 'all'} source=${SOURCE} limit=${LIMIT || 'all'}`);
+  const scanned = loadScanned();
+  console.log(`[scan-frame-overlay] already-scanned: ${scanned.size}`);
+  const all = fetchTargets();
+  const todo = all.filter(r => !scanned.has(r.id));
+  console.log(`[scan-frame-overlay] eligible: ${all.length}, todo: ${todo.length}`);
+
+  let n = 0, flagged = 0, errored = 0, skipped = 0;
+  const t0 = Date.now();
+  const onResult = (out) => {
+    n++;
+    fs.appendFileSync(RESULTS, JSON.stringify(out) + '\n');
+    if (out.error) errored++;
+    else if (out.skipped) skipped++;
+    else if (out.hasFrameOverlay && (out.mode === 'single' || out.unanimous)) {
+      flagged++;
+      fs.appendFileSync(FLAGGED, JSON.stringify(out) + '\n');
+    }
+    if (n % 25 === 0 || n === todo.length) {
+      const rate = n / ((Date.now() - t0) / 1000);
+      const eta = ((todo.length - n) / Math.max(rate, 0.01)).toFixed(0);
+      const apiCalls = SINGLE ? n : (n - skipped) * 3;
+      const cost = (apiCalls * 0.0005).toFixed(3);
+      console.log(`[scan-frame-overlay] ${n}/${todo.length} · ${flagged} flagged · ${skipped} scenic-skip · ${errored} errored · ${rate.toFixed(1)}/s · ETA ${eta}s · est-$ ${cost}`);
+    }
+  };
+  await runPool(todo, CONCURRENCY, onResult);
+  console.log(`[scan-frame-overlay] done · scanned=${n} flagged=${flagged} scenic-skip=${skipped} errored=${errored}`);
+})().catch(e => { console.error('FATAL', e); process.exit(1); });

← 78cd6df fix(learn-and-purge): PG-side vendor-cat safety net  ·  back to Wallco Ai  ·  fix-frame-overlay skill: detector + locator + crop-fix kind f31431e →