[object Object]

← 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 →