← back to Wallco Ai
ghost-detector: new frame-overlay lens — detects "pasted-on" geometric plates
44322de77aa95fb44a3e153e71f71c6c04d38dce · 2026-05-24 22:23:08 -0700 · Steve Abrams
A discrete RECTANGULAR / OVAL / ROUNDED-SQUARE / circular 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
(pitlab puppy in a tan rounded-rectangle floating on dark ground).
Adds (parallel to the existing ghost-layer lens):
- PROMPT_FRAME_OVERLAY — Gemini prompt with explicit positive/negative
examples, scenic-skip carve-out, false-positive guardrails for
legitimate damask cartouches + Wedgwood-style cameos that blend
smoothly into a same-color ground
- analyzeFrameOverlay(imagePath, opts) — single call, returns
{ hasFrameOverlay, confidence, reason }
- analyzeFrameOverlayStable(imagePath, opts) — 3-vote majority wrapper
mirroring analyzeGhostLayerStable's shape
- verifyNoFrameOverlay(imagePath, opts) — gate that throws
FRAME_OVERLAY_DETECTED on unanimous true (same policy as ghost gate)
Refactor: extracted callGeminiJson() helper shared by both lenses.
analyzeGhostLayer keeps its exact external signature — no risk to
existing callers (scan-ghost-layer.js, triage-ghost-flagged.js, the
pre-publish gate at generate_designs.js:544).
Smoke test (4 designs):
39288 frog-on-lattice → false ✓ (no cameo)
39303 stripe → false ✓ (integrated stripes, not pasted)
39322 Wedgwood cameo → true ✓ (pitlab batch, pasted oval)
39330 pitlab king → true ✓ (the originating defect case)
All unanimous, reasons match the expected defect pattern.
Validation panel via graphic-designer subagent + Prong B/C wire-up to
generate_designs.js are the next steps.
Files touched
Diff
commit 44322de77aa95fb44a3e153e71f71c6c04d38dce
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 24 22:23:08 2026 -0700
ghost-detector: new frame-overlay lens — detects "pasted-on" geometric plates
A discrete RECTANGULAR / OVAL / ROUNDED-SQUARE / circular 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
(pitlab puppy in a tan rounded-rectangle floating on dark ground).
Adds (parallel to the existing ghost-layer lens):
- PROMPT_FRAME_OVERLAY — Gemini prompt with explicit positive/negative
examples, scenic-skip carve-out, false-positive guardrails for
legitimate damask cartouches + Wedgwood-style cameos that blend
smoothly into a same-color ground
- analyzeFrameOverlay(imagePath, opts) — single call, returns
{ hasFrameOverlay, confidence, reason }
- analyzeFrameOverlayStable(imagePath, opts) — 3-vote majority wrapper
mirroring analyzeGhostLayerStable's shape
- verifyNoFrameOverlay(imagePath, opts) — gate that throws
FRAME_OVERLAY_DETECTED on unanimous true (same policy as ghost gate)
Refactor: extracted callGeminiJson() helper shared by both lenses.
analyzeGhostLayer keeps its exact external signature — no risk to
existing callers (scan-ghost-layer.js, triage-ghost-flagged.js, the
pre-publish gate at generate_designs.js:544).
Smoke test (4 designs):
39288 frog-on-lattice → false ✓ (no cameo)
39303 stripe → false ✓ (integrated stripes, not pasted)
39322 Wedgwood cameo → true ✓ (pitlab batch, pasted oval)
39330 pitlab king → true ✓ (the originating defect case)
All unanimous, reasons match the expected defect pattern.
Validation panel via graphic-designer subagent + Prong B/C wire-up to
generate_designs.js are the next steps.
---
lib/ghost-detector.js | 159 +++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 143 insertions(+), 16 deletions(-)
diff --git a/lib/ghost-detector.js b/lib/ghost-detector.js
index aeaf7e8..ab67b8b 100644
--- a/lib/ghost-detector.js
+++ b/lib/ghost-detector.js
@@ -123,15 +123,18 @@ function readKey() {
throw new Error('GEMINI_API_KEY missing');
}
-async function analyzeGhostLayer(imagePath, opts = {}) {
+// Internal helper — POST a (prompt, image) pair to Gemini-2.0-flash vision
+// and return the parsed JSON response. Shared by analyzeGhostLayer and
+// analyzeFrameOverlay so both lenses ride the same transport + retry logic.
+//
+// Retry-on-429 (and 5xx) with exponential backoff: 6 attempts cover up
+// to ~31s of backoff (1s/2s/4s/8s/16s) — enough to ride out the quota
+// refresh window when multiple processes saturate the endpoint.
+async function callGeminiJson(imagePath, prompt) {
const KEY = readKey();
const bytes = fs.readFileSync(imagePath);
const mime = imagePath.toLowerCase().endsWith('.png') ? 'image/png' : 'image/jpeg';
const url = `${GEMINI_BASE}/${GEMINI_MODEL}:generateContent?key=${KEY}`;
- // Pick the prompt based on design category — scenic murals get the
- // "atmospheric perspective is OK" prompt; tile patterns get the strict one.
- const prompt = pickPrompt(opts.category);
-
const body = {
contents: [{
parts: [
@@ -147,11 +150,6 @@ async function analyzeGhostLayer(imagePath, opts = {}) {
response_mime_type: 'application/json',
},
};
-
- // Retry-on-429 (and 5xx) with exponential backoff. Gemini Flash returns
- // 429 "Resource exhausted" under high parallel load. 5 retries cover up
- // to ~31s of backoff (1s/2s/4s/8s/16s) — enough to ride out the quota
- // refresh window when multiple processes saturate the endpoint.
let r, lastTxt = '';
const MAX_ATTEMPTS = 6;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
@@ -166,14 +164,19 @@ async function analyzeGhostLayer(imagePath, opts = {}) {
if (attempt === MAX_ATTEMPTS - 1) break;
await new Promise(res => setTimeout(res, 1000 * Math.pow(2, attempt) + Math.random() * 500));
}
- if (!r.ok) {
- throw new Error(`gemini ${r.status}: ${lastTxt.slice(0, 200)}`);
- }
+ if (!r.ok) throw new Error(`gemini ${r.status}: ${lastTxt.slice(0, 200)}`);
const j = await r.json();
const text = j?.candidates?.[0]?.content?.parts?.[0]?.text || '';
- let parsed;
- try { parsed = JSON.parse(text); }
+ try { return JSON.parse(text); }
catch (e) { throw new Error(`gemini returned non-JSON: ${text.slice(0, 200)}`); }
+}
+
+async function analyzeGhostLayer(imagePath, opts = {}) {
+ // Pick the prompt based on design category — scenic murals get the
+ // "atmospheric perspective is OK" prompt; tile patterns get the strict one;
+ // toile categories get the engraved-linework-is-OK prompt.
+ const prompt = pickPrompt(opts.category);
+ const parsed = await callGeminiJson(imagePath, prompt);
if (typeof parsed.hasGhostLayer !== 'boolean') {
throw new Error(`gemini missing hasGhostLayer field: ${JSON.stringify(parsed)}`);
}
@@ -237,4 +240,128 @@ async function verifyNoGhostLayer(imagePath, opts = {}) {
return r;
}
-module.exports = { analyzeGhostLayer, analyzeGhostLayerStable, verifyNoGhostLayer, GEMINI_MODEL };
+// ─────────────────────────────────────────────────────────────────────────
+// Frame-overlay detector (2026-05-24)
+// ─────────────────────────────────────────────────────────────────────────
+// A separate defect class from ghost-layer — a centered or repeating
+// RECTANGULAR / OVAL / ROUNDED-SQUARE shape that sits ON TOP of the
+// background pattern like a pasted sticker, with hard edges that don't
+// flow into the surrounding repeat.
+//
+// First flagged on design 39330 (a "pitlab king" cameo where the round
+// medallion was wrapped in a tan rounded-rectangle floating on the dark
+// background). seamless-check.py passes it (outer edges fine; defect is
+// interior); analyzeGhostLayer's TILE/SCENIC/TOILE prompts miss it
+// (different defect class).
+//
+// Wired as a SEPARATE detector — not another branch in pickPrompt() —
+// so existing callers of analyzeGhostLayer are unaffected.
+
+const PROMPT_FRAME_OVERLAY = `You are inspecting a WALLPAPER TILE design for a "pasted frame overlay" defect.
+
+A pasted frame overlay = a discrete RECTANGULAR / OVAL / ROUNDED-SQUARE / circular shape sitting ON TOP of the background pattern like a sticker, with hard geometric edges that break the flow of the surrounding design. The motif sits INSIDE this shape; the shape itself is a separate visual object, not integrated into the seamless repeat.
+
+hasFrameOverlay: TRUE if you see:
+ - A clearly defined rectangle / rounded-rectangle / oval / circle / cartouche sitting as a SOLID-COLORED PLATE on top of a different background pattern
+ - The shape's edge is a HARD VISUAL BOUNDARY that cuts through what would otherwise be a flowing wallpaper repeat
+ - Interior of the shape (where the motif sits) has different content/color from the exterior, and the boundary is geometric (not organic motif-shaped)
+ - Examples: dog/animal inside a brown rounded-rectangle floating on a dark ground; cameo medallion sitting on a contrasting square panel; rectangular bordered "card" enclosing the design subject; rounded-square plate framing a portrait
+
+hasFrameOverlay: FALSE if:
+ - The motif sits DIRECTLY on the background with no enclosing geometric shape (typical wallpaper repeat — flat motif on flat ground)
+ - A damask cartouche or ornamental scroll where the cartouche IS the repeat unit, blending with the background via integrated texture/color in the SAME line-style
+ - A Wedgwood-style cameo where the cameo edges blend smoothly into a same-color ground (no hard plate boundary, just an embossed-style oval drawn in the same ink color as everything else)
+ - Toile vignettes drawn at the same opacity/color as the surrounding pattern
+ - Concentric circular/decorative elements that are part of an integrated medallion design (the medallion IS the entire repeat unit, no separate background pattern visible behind it)
+
+DO NOT flag ornamental scrollwork integrated with the design. DO NOT flag a frame that's drawn in the same line style as the surrounding pattern (it's part of the design language, not a pasted plate). ONLY flag a shape that visibly sits ON TOP of a DIFFERENT background pattern with a hard geometric edge.
+
+confidence: 0.0-1.0
+reason: ONE short sentence (≤25 words) describing the shape you saw and why it reads as pasted
+
+Reply with ONLY a JSON object (no markdown, no prose):
+{"hasFrameOverlay": true|false, "confidence": <number>, "reason": "<short sentence>"}`;
+
+function isScenicCat(category) {
+ if (!category) return false;
+ const base = category.split(' · ')[0].toLowerCase();
+ return SCENIC_CATEGORIES.has(base);
+}
+
+async function analyzeFrameOverlay(imagePath, opts = {}) {
+ // Scenic murals legitimately have non-repeating centered subjects framed
+ // by atmospheric perspective — not a defect class for them.
+ if (isScenicCat(opts.category)) {
+ return { hasFrameOverlay: false, confidence: 1.0, reason: 'skipped: scenic category', skipped: true, cost_estimate: 0 };
+ }
+ const parsed = await callGeminiJson(imagePath, PROMPT_FRAME_OVERLAY);
+ if (typeof parsed.hasFrameOverlay !== 'boolean') {
+ throw new Error(`gemini missing hasFrameOverlay field: ${JSON.stringify(parsed)}`);
+ }
+ return {
+ hasFrameOverlay: parsed.hasFrameOverlay,
+ confidence: Number(parsed.confidence) || 0,
+ reason: String(parsed.reason || '').slice(0, 250),
+ cost_estimate: 0.0005,
+ };
+}
+
+// Stable variant — 3 parallel votes, majority rules. Same shape as
+// analyzeGhostLayerStable. Confidence is consensus strength (1.0 unanimous,
+// 0.67 split). If all 3 are scenic-skipped, surface that fact.
+async function analyzeFrameOverlayStable(imagePath, opts = {}) {
+ const N = 3;
+ const settled = await Promise.allSettled(
+ Array.from({ length: N }, () => analyzeFrameOverlay(imagePath, opts))
+ );
+ const votes = settled.filter(s => s.status === 'fulfilled').map(s => s.value);
+ const errored = N - votes.length;
+ if (votes.length === 0) {
+ const e = settled.find(s => s.status === 'rejected');
+ throw new Error(`all ${N} frame-overlay votes errored: ${e?.reason?.message || 'unknown'}`);
+ }
+ // Scenic-skip short-circuit: every vote returned skipped=true (no API call made)
+ if (votes.every(v => v.skipped)) {
+ return {
+ hasFrameOverlay: false, confidence: 1, reason: votes[0].reason,
+ votes: [], unanimous: true, errored: 0, skipped: true, cost_estimate: 0,
+ };
+ }
+ const trueVotes = votes.filter(v => v.hasFrameOverlay).length;
+ const falseVotes = votes.length - trueVotes;
+ const hasFrameOverlay = trueVotes > falseVotes;
+ const unanimous = trueVotes === votes.length || falseVotes === votes.length;
+ const consensus = Math.max(trueVotes, falseVotes) / votes.length;
+ const majorityVotes = votes.filter(v => v.hasFrameOverlay === hasFrameOverlay);
+ const winner = majorityVotes.sort((a, b) => b.confidence - a.confidence)[0];
+ return {
+ hasFrameOverlay,
+ confidence: consensus,
+ reason: winner.reason,
+ votes: votes.map(v => ({ hasFrame: v.hasFrameOverlay, conf: v.confidence })),
+ unanimous,
+ errored,
+ cost_estimate: 0.0005 * votes.length,
+ };
+}
+
+// Gate variant — for the pre-publish pipeline. Throws if defective.
+// Same block-on-unanimous-TRUE policy as verifyNoGhostLayer.
+async function verifyNoFrameOverlay(imagePath, opts = {}) {
+ const r = await analyzeFrameOverlayStable(imagePath, opts);
+ if (r.hasFrameOverlay && r.unanimous) {
+ const err = new Error(`frame-overlay detected (3/3 votes): ${r.reason}`);
+ err.code = 'FRAME_OVERLAY_DETECTED';
+ err.detector_result = r;
+ throw err;
+ }
+ return r;
+}
+
+module.exports = {
+ // ghost-layer detector
+ analyzeGhostLayer, analyzeGhostLayerStable, verifyNoGhostLayer,
+ // frame-overlay detector
+ analyzeFrameOverlay, analyzeFrameOverlayStable, verifyNoFrameOverlay,
+ GEMINI_MODEL,
+};
← 352e9ce auto-fix: decouple ?legacy routing from prompt-version; fix
·
back to Wallco Ai
·
generate: wire frame-overlay gate + strengthen anti-frame pr a2e6fe6 →