← 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
A scripts/scan-frame-overlay.js
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 →