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