← back to Wallco Ai
lib/ghost-detector.js
526 lines
// Ghost-layer detector (Gemini 2.0 Flash vision).
//
// A "ghost layer" = multiple opacity tiers in a single design — solid
// foreground motifs PLUS faded/translucent copies of the same motifs sitting
// behind. SDXL produces these reliably despite our anti-prompts (see memory
// note feedback_sdxl_antiprompts_unreliable.md). Anti-prompts alone don't
// catch it; we have to verify visually after generation.
//
// Exports:
// analyzeGhostLayer(imagePath) → SINGLE Gemini call (non-deterministic at temp=0 for vision)
// analyzeGhostLayerStable(imagePath) → 3 parallel calls, majority vote (deterministic in aggregate)
// verifyNoGhostLayer(imagePath) → throws if stable-vote says hasGhostLayer === true
//
// Why the stable variant (2026-05-23): gemini-2.0-flash does not honor temperature=0 for
// vision inputs reliably — same image, same prompt, same temp=0 returns different verdicts
// across runs (~20-30% flip rate on borderline cases). Ground-truth pass with the
// graphic-designer subagent on 5 wallco test images confirmed 1/5 single-call false negatives.
// Three parallel votes + majority rule pushes that to a vanishing rate without changing the
// underlying model. Cost: 3× per check (~$0.0015 vs $0.0005) — still trivial.
const fs = require('fs');
const path = require('path');
// 2026-06-01: gemini-2.0-flash was retired by Google and now 404s ("model no
// longer available"), making every ghost-vote error out (non-fatal in the batch
// path but pure latency + log noise, and fail-CLOSED anywhere this gate is
// mandatory). Same migration commit b25dd485 already moved settlement-post-gen-
// vision to 2.5-flash. Override via GHOST_DETECTOR_MODEL if needed.
const GEMINI_MODEL = process.env.GHOST_DETECTOR_MODEL || 'gemini-2.5-flash';
const GEMINI_BASE = 'https://generativelanguage.googleapis.com/v1beta/models';
const PROMPT_TILE = `You are inspecting a TILED WALLPAPER REPEAT-PATTERN image for a manufacturing defect called a "ghost layer."
A ghost layer = the SAME MOTIF appearing TWICE in the same pattern at different opacity levels (one sharp solid, one faded/translucent behind). Intended aesthetic = ONE flat ink on a background. Any duplicate copies at lower opacity = defect.
hasGhostLayer: TRUE if you see ANY of these in the SAME pattern:
- Identical motifs (same cactus, same flower, same beetle, etc.) rendered TWICE — one sharp + one faded behind
- Semi-transparent secondary copies of the foreground motifs
- "Halo" pale silhouettes echoing the sharp ones
- Multi-opacity copies of repeat elements
hasGhostLayer: FALSE if:
- The design is uniformly flat single-ink-on-background
- All motifs are at the SAME opacity (even if multiple colors)
- It's a scenic landscape with intentional atmospheric perspective (distant elements fainter than near ones — that is NOT a ghost layer, that's depth)
DO NOT flag atmospheric perspective in landscapes. DO NOT flag intentional shading inside a motif. ONLY flag when the SAME motif clearly appears at two opacity levels in a repeating tile context.
confidence: 0.0-1.0
reason: ONE short sentence (≤25 words) describing what you saw
Reply with ONLY a JSON object in this exact shape (no markdown, no prose):
{"hasGhostLayer": true|false, "confidence": <number>, "reason": "<short sentence>"}`;
const PROMPT_SCENIC = `You are inspecting a SCENIC / PANORAMIC MURAL image (a single landscape scene, not a repeat tile).
For scenic murals, atmospheric perspective (distant mountains lighter, distant foliage fainter, depth-haze) is the INTENDED aesthetic — not a defect.
A "ghost layer" in a scenic mural is something different: a duplicated foreground motif appearing twice at different opacities, OR a watermark-like translucent overlay of the WHOLE scene repeated.
hasGhostLayer: TRUE only if you see:
- The same foreground tree / cactus / animal duplicated at sharp + faded versions in the same view
- A full-image translucent ghost overlay (the scene appearing twice stacked)
hasGhostLayer: FALSE if:
- Distant elements are simply lighter than near elements (= atmospheric perspective, intended)
- The scene is a single coherent landscape with depth
- There are multiple distinct planes (foreground, midground, background) at different brightness — that's correct scenic composition
DO NOT confuse atmospheric depth with ghost layers.
confidence: 0.0-1.0
reason: ONE short sentence (≤25 words)
Reply with ONLY a JSON object (no markdown, no prose):
{"hasGhostLayer": true|false, "confidence": <number>, "reason": "<short sentence>"}`;
const PROMPT_TOILE = `You are inspecting a CLASSICAL DAMASK / TOILE / CHINOISERIE pattern for ghost layers.
These patterns INTENTIONALLY use engraved internal shading — fine linework, hatching, cross-hatching, or dark detail lines drawn INSIDE a single motif shape. That is the historical engraved-print aesthetic — NOT a ghost layer.
hasGhostLayer: TRUE only if the SAME MOTIF (flower, leaf, urn, bird) appears as TWO SEPARATE COPIES at different opacities — one fully opaque + one faded sitting beside or behind it as a distinct second silhouette.
hasGhostLayer: FALSE if:
- The pattern is a single-color motif (or two-tone tone-on-tone) with internal linework / engraving / hatching detail on a contrasting ground
- The detail lines are INSIDE the motif boundary, not a second copy of the whole motif
- It looks like a classically-engraved damask/toile/chinoiserie print (expected aesthetic — fine darker linework within a solid motif is correct)
- Motifs share the same outline but no faded duplicate silhouette is visible behind/beside them
DO NOT confuse engraved internal shading with ghost layers. Engraving = detail INSIDE one motif. Ghost layer = TWO copies of the WHOLE motif at different opacities.
confidence: 0.0-1.0
reason: ONE short sentence (≤25 words)
Reply with ONLY a JSON object (no markdown, no prose):
{"hasGhostLayer": true|false, "confidence": <number>, "reason": "<short sentence>"}`;
// scenic categories — atmospheric perspective is intended, use the lenient prompt
const SCENIC_CATEGORIES = new Set([
'cactus-pine-scenic', 'cactus-11ft-mural', 'tree-mural',
'mural', 'scenic', 'panoramic', 'desert-flora', 'mural-animal',
'mural-scenic', 'designer-scenic',
]);
// toile categories — classical engraved aesthetic, internal linework is NOT a ghost layer.
// Fix added 2026-05-23 after PROMPT_TILE single-call mis-flagged ~1,700 clean damask designs.
const TOILE_CATEGORIES = new Set([
'damask', 'chinoiserie', 'toile', 'face-skull-damask',
'throttled-plaster-roses',
]);
function pickPrompt(category) {
if (!category) return PROMPT_TILE;
const c = category.toLowerCase();
if (SCENIC_CATEGORIES.has(c)) return PROMPT_SCENIC;
if (TOILE_CATEGORIES.has(c)) return PROMPT_TOILE;
return PROMPT_TILE;
}
function readKey() {
if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
// .env fallback when script run outside pm2
try {
const env = fs.readFileSync(path.join(__dirname, '..', '.env'), 'utf8');
const m = env.match(/^GEMINI_API_KEY=(.+)$/m);
if (m) return m[1].trim();
} catch {}
throw new Error('GEMINI_API_KEY missing');
}
// 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}`;
const body = {
contents: [{
parts: [
{ text: prompt },
{ inline_data: { mime_type: mime, data: bytes.toString('base64') } },
],
}],
generationConfig: {
temperature: 0.0,
topP: 1.0,
topK: 1,
candidateCount: 1,
response_mime_type: 'application/json',
},
};
let r, lastTxt = '';
const MAX_ATTEMPTS = 6;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (r.ok) break;
lastTxt = await r.text().catch(() => '');
if (r.status !== 429 && r.status < 500) break; // non-retryable
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)}`);
const j = await r.json();
const text = j?.candidates?.[0]?.content?.parts?.[0]?.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)}`);
}
return {
hasGhostLayer: parsed.hasGhostLayer,
confidence: Number(parsed.confidence) || 0,
reason: String(parsed.reason || '').slice(0, 250),
cost_estimate: 0.0005, // approx — gemini-2.0-flash vision
};
}
// Stable variant — 3 parallel votes, majority rules. Returns:
// { hasGhostLayer, confidence, reason, votes: [v1,v2,v3], unanimous: bool, errored: n }
// `confidence` here is REASSIGNED to encode consensus strength:
// unanimous 3/3 → 1.00
// majority 2/1 → 0.67 (borderline — the dissent is the most-likely diagnostic info)
// if 1+ vote errors, returns based on surviving votes; 0 surviving = re-throw
async function analyzeGhostLayerStable(imagePath, opts = {}) {
const N = 3;
const settled = await Promise.allSettled(
Array.from({ length: N }, () => analyzeGhostLayer(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} ghost-detector votes errored: ${e?.reason?.message || 'unknown'}`);
}
const trueVotes = votes.filter(v => v.hasGhostLayer).length;
const falseVotes = votes.length - trueVotes;
const hasGhostLayer = trueVotes > falseVotes;
const unanimous = trueVotes === votes.length || falseVotes === votes.length;
const consensus = Math.max(trueVotes, falseVotes) / votes.length; // 1.0 unanimous, 0.67 split
// Reason — pick the one matching the majority verdict, prefer highest single-call confidence
const majorityVotes = votes.filter(v => v.hasGhostLayer === hasGhostLayer);
const winner = majorityVotes.sort((a, b) => b.confidence - a.confidence)[0];
return {
hasGhostLayer,
confidence: consensus,
reason: winner.reason,
votes: votes.map(v => ({ hasGhost: v.hasGhostLayer, conf: v.confidence })),
unanimous,
errored,
cost_estimate: 0.0005 * votes.length,
};
}
// Gate variant — for the pre-publish pipeline. Throws if defective.
// Uses the STABLE (3x voting) variant so a single bad Gemini call can't false-reject.
async function verifyNoGhostLayer(imagePath, opts = {}) {
const r = await analyzeGhostLayerStable(imagePath, opts);
// hasGhostLayer is now a majority verdict; consensus (r.confidence) is 1.0 unanimous or 0.67 split.
// Block only on unanimous TRUE — a 2/1 split means borderline, let it through with a warning.
// (Old gate fired on confidence>=0.5 from a single call, which was the bug source.)
if (r.hasGhostLayer && r.unanimous) {
const err = new Error(`ghost-layer detected (3/3 votes): ${r.reason}`);
err.code = 'GHOST_LAYER_DETECTED';
err.detector_result = r;
throw err;
}
return r;
}
// ─────────────────────────────────────────────────────────────────────────
// 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,
};
}
// Locate the frame-overlay region. Returns { region: {x,y,w,h} as % of image, subject: {x,y,w,h} } so the caller can pass them to /api/design/:id/crop-fix with fix_kind='frame-overlay' (region=red, subject=green keep).
// Why a separate prompt: detection returns true/false; this returns geometry.
const PROMPT_LOCATE_FRAME = `You are inspecting a WALLPAPER TILE design. The design has a "pasted frame overlay" defect — a centered shape (rectangle / rounded-square / oval / circle) sitting ON TOP of the background pattern with hard geometric edges, with a subject motif inside it.
Return TWO bounding boxes as percentages of the image dimensions (0-100):
- "region": the FULL outer footprint of the pasted plate/frame INCLUDING its outer edge. This is the area to erase.
- "subject": the inner subject motif (animal silhouette, portrait, etc) that sits INSIDE the frame and must be preserved. Tight bbox around just the subject, not the frame ring.
Add 2-3% padding on the region (outside the visible plate edge) so the fix has room to blend. The subject bbox should be tight to the silhouette.
If the design has MULTIPLE pasted frames repeating across the tile (e.g., 4 frames in a 2x2 layout), return the bbox of the CENTERMOST one — the caller will re-run on offset versions to catch the rest.
If you can't identify a clear pasted frame, return all-zeros bboxes and set "located": false.
Reply with ONLY a JSON object (no markdown, no prose):
{"located": true|false, "region": {"x": <0-100>, "y": <0-100>, "w": <0-100>, "h": <0-100>}, "subject": {"x": <0-100>, "y": <0-100>, "w": <0-100>, "h": <0-100>}, "shape": "rectangle"|"rounded-square"|"oval"|"circle"|"other"}`;
async function locateFrameOverlay(imagePath) {
const parsed = await callGeminiJson(imagePath, PROMPT_LOCATE_FRAME);
if (typeof parsed.located !== 'boolean') {
throw new Error(`gemini missing located field: ${JSON.stringify(parsed)}`);
}
return {
located: parsed.located,
region: parsed.region || { x:0, y:0, w:0, h:0 },
subject: parsed.subject || { x:0, y:0, w:0, h:0 },
shape: String(parsed.shape || 'unknown'),
cost_estimate: 0.0005,
};
}
// ─────────────────────────────────────────────────────────────────────────
// 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 = {}) {
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 (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,
};