[object Object]

← back to Wallco Ai

ghost-detector: seam-frame lens — catches plates at the tile boundary

c85af21f32e4d9e93e5f742d15072d21cb42681c · 2026-05-24 22:50:49 -0700 · Steve Abrams

Sub-defect of frame-overlay. When SDXL positions a circular/oval plate at
the EDGE of a single tile (not centered), each tile shows only HALF the
plate at its boundary. The single-tile detector (analyzeFrameOverlay)
correctly returns false — there's no centered plate. But when the tile
hangs as wallpaper, neighboring strips meet at that edge and the half-
plates reassemble into a WHOLE plate running along the seam.

First flagged on design 32171 (chinoiserie · antique-tan): single-tile
detector said no defect; locator found a circle at (24, 23, 29x29). The
plate was sitting at the TOP edge of the single tile — invisible as a
defect in isolation but obvious when tiled.

New lens (parallel to frame-overlay, separate concern):
- PROMPT_SEAM_FRAME — Gemini prompt explicitly asks about shapes at the
  x=50%/y=50% midlines of a 2x2 tiled view (= the seam in real wallpaper)
- analyzeSeamFrame(imagePath, opts) — builds 2x2 PIL preview, runs prompt
- analyzeSeamFrameStable(imagePath, opts) — 3-vote majority
- verifyNoSeamFrame(imagePath, opts) — gate, throws SEAM_FRAME_DETECTED
- Internal build2x2TilePreview() via PIL spawnSync, cleans up tmp file

Smoke test:
  32171 (chinoiserie, known defect)  → TRUE unanimous, horizontal_seam circle
  39288 (drunk-zoo-36, clean)        → FALSE unanimous, continuous flow

Scenic categories skipped same as frame-overlay (single tile).

Not yet wired into scripts/generate_designs.js or scan-frame-overlay.js
— that's the next step once Steve approves the lens behavior.

Files touched

Diff

commit c85af21f32e4d9e93e5f742d15072d21cb42681c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 22:50:49 2026 -0700

    ghost-detector: seam-frame lens — catches plates at the tile boundary
    
    Sub-defect of frame-overlay. When SDXL positions a circular/oval plate at
    the EDGE of a single tile (not centered), each tile shows only HALF the
    plate at its boundary. The single-tile detector (analyzeFrameOverlay)
    correctly returns false — there's no centered plate. But when the tile
    hangs as wallpaper, neighboring strips meet at that edge and the half-
    plates reassemble into a WHOLE plate running along the seam.
    
    First flagged on design 32171 (chinoiserie · antique-tan): single-tile
    detector said no defect; locator found a circle at (24, 23, 29x29). The
    plate was sitting at the TOP edge of the single tile — invisible as a
    defect in isolation but obvious when tiled.
    
    New lens (parallel to frame-overlay, separate concern):
    - PROMPT_SEAM_FRAME — Gemini prompt explicitly asks about shapes at the
      x=50%/y=50% midlines of a 2x2 tiled view (= the seam in real wallpaper)
    - analyzeSeamFrame(imagePath, opts) — builds 2x2 PIL preview, runs prompt
    - analyzeSeamFrameStable(imagePath, opts) — 3-vote majority
    - verifyNoSeamFrame(imagePath, opts) — gate, throws SEAM_FRAME_DETECTED
    - Internal build2x2TilePreview() via PIL spawnSync, cleans up tmp file
    
    Smoke test:
      32171 (chinoiserie, known defect)  → TRUE unanimous, horizontal_seam circle
      39288 (drunk-zoo-36, clean)        → FALSE unanimous, continuous flow
    
    Scenic categories skipped same as frame-overlay (single tile).
    
    Not yet wired into scripts/generate_designs.js or scan-frame-overlay.js
    — that's the next step once Steve approves the lens behavior.
---
 lib/ghost-detector.js | 122 +++++++++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 121 insertions(+), 1 deletion(-)

diff --git a/lib/ghost-detector.js b/lib/ghost-detector.js
index 09be9b4..9502cf1 100644
--- a/lib/ghost-detector.js
+++ b/lib/ghost-detector.js
@@ -376,6 +376,124 @@ async function locateFrameOverlay(imagePath) {
   };
 }
 
+// ─────────────────────────────────────────────────────────────────────────
+// Seam-frame detector (2026-05-24) — sub-defect of frame-overlay
+// ─────────────────────────────────────────────────────────────────────────
+// When SDXL positions a circular/oval plate AT the edge of the single tile
+// (not centered), each tile shows only HALF the plate at its boundary.
+// The defect is invisible in single-tile inspection (analyzeFrameOverlay
+// correctly returns false — there's no centered plate). But when the tile
+// is hung as wallpaper, neighboring strips meet at that edge and the half-
+// plates reassemble into WHOLE plates running along the seam — a visible
+// "row of circles at the y=50% line of the tiled view."
+//
+// First flagged on design 32171 (chinoiserie · antique-tan). Single-tile
+// detector said false; the locator found a circle at (24, 23, 29x29).
+// Seam-band lens on the 2x2 tiled view catches it unanimously.
+//
+// Approach: build a 2x2 tile preview via PIL, run a seam-focused Gemini
+// prompt that asks specifically about shapes appearing at the x=50% /
+// y=50% midlines of the composed view.
+
+const { spawnSync: spawnSyncForTile } = require('child_process');
+
+const PROMPT_SEAM_FRAME = `This is a 2x2 TILED PREVIEW of a wallpaper tile. The crosshair where the 4 tiles meet shows what the WALLPAPER LOOKS LIKE WHEN HUNG ON A WALL — the seams between tile copies. Look at the VERTICAL midpoint line (x=50%) and the HORIZONTAL midpoint line (y=50%) — these are where adjacent wallpaper strips would meet.
+
+Defect to detect: a STRUCTURAL ELEMENT (oval / circle / rounded shape) that appears EXACTLY ON or NEAR the seam line. When SDXL generates a tile where a circular plate is positioned at the edge of the original single tile, the plate becomes visible IN HALVES at each tile's edge, then reassembles into a WHOLE plate AT THE SEAM when tiled — creating a visible recurring shape running along the seam.
+
+hasSeamFrame: TRUE if you see a circular / oval / rounded shape (or a row/column of them) appearing AT or near the y=50% line and/or x=50% line of this 2x2 view that wouldn't be there in a clean tile. This often manifests as a row of repeating oval/circular plates along the horizontal seam, or a column along the vertical seam.
+
+hasSeamFrame: FALSE if there's no such shape at the seam — just continuous wallpaper pattern flowing across the boundary, OR a centered repeat unit that happens to sit at the seam by virtue of being a half-drop offset (those are intentional design choices).
+
+Reply with JSON only:
+{"hasSeamFrame": true|false, "where": "horizontal_seam"|"vertical_seam"|"both"|"none", "shape": "circle"|"oval"|"rectangle"|"other"|"none", "confidence": <0-1>, "reason": "<one short sentence>"}`;
+
+function build2x2TilePreview(srcPath) {
+  const dest = '/tmp/seam-detect-' + Date.now() + '-' + Math.floor(Math.random() * 1e6) + '.png';
+  const py = `
+from PIL import Image
+im = Image.open('${srcPath}').convert('RGB')
+w, h = im.size
+out = Image.new('RGB', (w*2, h*2))
+for x in range(2):
+    for y in range(2):
+        out.paste(im, (x*w, y*h))
+out.thumbnail((1024, 1024))
+out.save('${dest}')
+`;
+  const r = spawnSyncForTile('python3', ['-c', py], { encoding: 'utf8', timeout: 15_000 });
+  if (r.status !== 0) throw new Error('2x2 tile build failed: ' + (r.stderr || r.stdout || '?').slice(0, 200));
+  return dest;
+}
+
+async function analyzeSeamFrame(imagePath, opts = {}) {
+  // Scenic murals don't tile-repeat — skip.
+  if (isScenicCat(opts.category)) {
+    return { hasSeamFrame: false, confidence: 1.0, reason: 'skipped: scenic category', skipped: true, cost_estimate: 0 };
+  }
+  const tiledPath = build2x2TilePreview(imagePath);
+  try {
+    const parsed = await callGeminiJson(tiledPath, PROMPT_SEAM_FRAME);
+    if (typeof parsed.hasSeamFrame !== 'boolean') {
+      throw new Error(`gemini missing hasSeamFrame field: ${JSON.stringify(parsed)}`);
+    }
+    return {
+      hasSeamFrame: parsed.hasSeamFrame,
+      where:        String(parsed.where || 'none'),
+      shape:        String(parsed.shape || 'none'),
+      confidence:   Number(parsed.confidence) || 0,
+      reason:       String(parsed.reason || '').slice(0, 250),
+      cost_estimate: 0.0005,
+    };
+  } finally {
+    try { fs.unlinkSync(tiledPath); } catch {}
+  }
+}
+
+async function analyzeSeamFrameStable(imagePath, opts = {}) {
+  const N = 3;
+  const settled = await Promise.allSettled(
+    Array.from({ length: N }, () => analyzeSeamFrame(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} seam-frame votes errored: ${e?.reason?.message || 'unknown'}`);
+  }
+  if (votes.every(v => v.skipped)) {
+    return { hasSeamFrame: false, confidence: 1, reason: votes[0].reason, votes: [], unanimous: true, errored: 0, skipped: true, cost_estimate: 0 };
+  }
+  const trueVotes  = votes.filter(v => v.hasSeamFrame).length;
+  const falseVotes = votes.length - trueVotes;
+  const hasSeamFrame = trueVotes > falseVotes;
+  const unanimous = trueVotes === votes.length || falseVotes === votes.length;
+  const consensus = Math.max(trueVotes, falseVotes) / votes.length;
+  const majorityVotes = votes.filter(v => v.hasSeamFrame === hasSeamFrame);
+  const winner = majorityVotes.sort((a, b) => b.confidence - a.confidence)[0];
+  return {
+    hasSeamFrame,
+    where:      winner.where,
+    shape:      winner.shape,
+    confidence: consensus,
+    reason:     winner.reason,
+    votes:      votes.map(v => ({ hasSeam: v.hasSeamFrame, where: v.where, shape: v.shape, conf: v.confidence })),
+    unanimous, errored,
+    cost_estimate: 0.0005 * votes.length,
+  };
+}
+
+async function verifyNoSeamFrame(imagePath, opts = {}) {
+  const r = await analyzeSeamFrameStable(imagePath, opts);
+  if (r.hasSeamFrame && r.unanimous) {
+    const err = new Error(`seam-frame detected (3/3 votes) at ${r.where}: ${r.reason}`);
+    err.code = 'SEAM_FRAME_DETECTED';
+    err.detector_result = r;
+    throw err;
+  }
+  return r;
+}
+
 // Gate variant — for the pre-publish pipeline. Throws if defective.
 // Same block-on-unanimous-TRUE policy as verifyNoGhostLayer.
 async function verifyNoFrameOverlay(imagePath, opts = {}) {
@@ -392,9 +510,11 @@ async function verifyNoFrameOverlay(imagePath, opts = {}) {
 module.exports = {
   // ghost-layer detector
   analyzeGhostLayer, analyzeGhostLayerStable, verifyNoGhostLayer,
-  // frame-overlay detector
+  // frame-overlay detector (centered plate on single-tile)
   analyzeFrameOverlay, analyzeFrameOverlayStable, verifyNoFrameOverlay,
   // frame-overlay locator (for the auto-fix pipeline)
   locateFrameOverlay,
+  // seam-frame detector (plate appears AT the tile boundary when tiled)
+  analyzeSeamFrame, analyzeSeamFrameStable, verifyNoSeamFrame,
   GEMINI_MODEL,
 };

← 913798d feat(generators): wire bad-aesthetic-patterns.jsonl into the  ·  back to Wallco Ai  ·  fix(deploy): drop single-quotes from RSYNC_EXTRA_EXCLUDES pa b4c58f1 →