← back to Wallco Ai
scripts/auto-label-ghost.js
223 lines
#!/usr/bin/env node
// auto-label-ghost — second-opinion vision recheck of ghost-detector
// flags using a DIFFERENT model (Mac1 Ollama qwen2.5vl) than the
// Gemini detector that produced the flags. The hypothesis: the Gemini
// detector over-flags translucent/layered design intent (Muybridge
// plates, atmospheric perspective scenics, multi-opacity stylization)
// as "ghost layer" defects. A second-opinion pass with category-aware
// prompting should agree on the real defects and disagree on the
// false-positives, giving us auto-labels we can sanity-check in the
// viewer instead of clicking through 6,305 by hand.
//
// Auto-labels are written to data/ghost-labels.jsonl with:
// source: 'auto'
// model: 'qwen2.5vl:7b@mac1'
// so they don't collide with Steve's manual labels (read-side uses
// last-wins per id — manual labels added later will overwrite).
//
// Usage:
// node scripts/auto-label-ghost.js # all flagged, not yet labeled
// node scripts/auto-label-ghost.js --limit 50 # smoke test
// node scripts/auto-label-ghost.js --concurrency 4 # default 4 (Mac1 capacity)
// node scripts/auto-label-ghost.js --category cactus
const fs = require('fs');
const path = require('path');
const OLLAMA_URL = process.env.OLLAMA_BASE_URL || 'http://192.168.1.133:11434';
const MODEL = process.env.AUTO_LABEL_MODEL || 'qwen2.5vl:7b';
const ARGS = process.argv.slice(2);
const arg = (n, d) => { const i = ARGS.indexOf('--' + n); return i >= 0 ? ARGS[i + 1] : d; };
const LIMIT = parseInt(arg('limit', '0'), 10);
const CONCURRENCY = Math.max(1, parseInt(arg('concurrency', '4'), 10));
const CATEGORY = arg('category', null);
const FORCE = ARGS.includes('--force'); // re-label even if already labeled
const ROOT = path.join(__dirname, '..');
const FLAGGED_F = path.join(ROOT, 'data', 'ghost-scan-flagged.jsonl');
const LABELS_F = path.join(ROOT, 'data', 'ghost-labels.jsonl');
const IMG_DIR = path.join(ROOT, 'data', 'generated');
// ─ Category-aware framing: tell qwen what the intended design is so it
// doesn't over-flag intentional translucency as "ghost layer." Falls back
// to a generic frame if category unknown.
const CATEGORY_CONTEXT = {
'muybridge-plate': "Muybridge designs INTENTIONALLY show the same animal in sequential frozen positions (motion study). Multiple instances of the same figure is the design, not a defect. Only flag if there's a TRANSLUCENT GHOST OVERLAY separate from the intentional motion-study repetition.",
'cactus-pine-scenic': "Scenic landscapes have legitimate atmospheric perspective — distant elements naturally appear fainter. That is NOT a ghost layer. Only flag if you see a duplicated stacked translucent copy of the entire scene.",
'designer-scenic': "Scenic murals have atmospheric perspective. Faded distant elements = depth, not defect. Flag only true ghost overlays (whole-scene stacked translucent copy).",
'damask': "Damasks often use tone-on-tone (darker motif over lighter background of the same hue). That is the design, not a ghost. Only flag if there are DUPLICATE motifs at different opacities — same shape appearing twice.",
'drunk-animals': "Stylized animals may have soft outlines or overlapping silhouettes by design. Only flag actual translucent duplicates of the SAME motif.",
'drunk-monkeys-v2': "Stylized animals may have soft outlines or overlapping silhouettes by design. Only flag actual translucent duplicates of the SAME motif.",
'designer-zoo-calm': "Stylized animals may have soft outlines. Only flag actual translucent duplicates of the same motif.",
'stoned-animals': "Stylized animals may have soft outlines or overlapping silhouettes by design. Only flag actual translucent duplicates of the SAME motif.",
'face-skull-damask': "Damask tone-on-tone is normal. Only flag duplicate motifs at different opacities.",
'throttled-plaster-roses': "Plaster/relief effects use shadow + lighting layers by design — that is the medium, not a defect. Flag only when the SAME bloom appears twice at different opacities.",
'chinoiserie': "Chinoiserie scenics use atmospheric perspective and layered fabric/inkwash effects by design. Only flag a duplicate stacked translucent copy.",
'stripe': "Stripes are flat geometric repeats — any duplicate offset stripe at lower opacity IS a ghost layer (very rare for stripes to need atmospheric depth).",
};
const DEFAULT_CONTEXT = "A 'ghost layer' = the SAME motif appearing TWICE at different opacities — one sharp, one faded behind. Atmospheric perspective in scenics (distant elements naturally fainter) is NOT a ghost layer. Intentional design layering (Muybridge motion studies, damask tone-on-tone, plaster relief shading) is NOT a ghost layer. Only flag a true unintended duplicate-at-different-opacity defect.";
function buildPrompt(category) {
const ctx = CATEGORY_CONTEXT[category] || DEFAULT_CONTEXT;
return `You are giving a SECOND OPINION on whether this wallpaper image has a "ghost layer" defect. A previous detector flagged it; we suspect the previous detector confuses intentional design layering with defects.
CATEGORY-SPECIFIC CONTEXT for this image (category="${category || 'unknown'}"):
${ctx}
A "ghost layer" defect = the SAME motif appearing TWICE in the SAME tile/scene at different opacities — one sharp, one translucent behind it, NOT explained by the design intent above.
Reply with ONLY this JSON (no markdown, no prose):
{"hasGhostLayer": true|false, "confidence": <0..1>, "reason": "<short ≤20 word explanation>", "looks_like_seam_break": true|false}
"looks_like_seam_break" = TRUE if you see hard edges/discontinuities suggesting the tile pattern is broken at the image edges (not the same as a ghost layer — a separate defect).`;
}
// ─ Already-labeled set (so we skip them unless --force)
function loadLabeled() {
if (!fs.existsSync(LABELS_F)) return new Set();
const ids = new Set();
for (const line of fs.readFileSync(LABELS_F, 'utf8').split('\n')) {
if (!line.trim()) continue;
try { ids.add(JSON.parse(line).id); } catch {}
}
return ids;
}
// ─ Load flagged set
function loadFlagged() {
const rows = [];
if (!fs.existsSync(FLAGGED_F)) return rows;
for (const line of fs.readFileSync(FLAGGED_F, 'utf8').split('\n')) {
if (!line.trim()) continue;
try { rows.push(JSON.parse(line)); } catch {}
}
return rows;
}
// ─ Image bytes: resolve PG local_path OR fall back to /by-id route
async function loadImageB64(item) {
// The flagged-set rows don't carry local_path. Look up via designs.json
// index OR fall back to fetching from the local server's by-id route.
const url = `http://127.0.0.1:9877/designs/img/by-id/${item.id}`;
const r = await fetch(url);
if (!r.ok) throw new Error(`fetch ${r.status}`);
const buf = Buffer.from(await r.arrayBuffer());
if (buf.length < 1024) throw new Error(`tiny body ${buf.length}b`);
return buf.toString('base64');
}
// ─ Single qwen2.5vl call
async function classifyOne(item) {
const b64 = await loadImageB64(item);
const body = {
model: MODEL,
prompt: buildPrompt(item.category),
images: [b64],
stream: false,
format: 'json',
options: { temperature: 0.1, num_ctx: 2048 },
};
const r = await fetch(`${OLLAMA_URL}/api/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) throw new Error(`ollama ${r.status}: ${(await r.text()).slice(0, 200)}`);
const j = await r.json();
// qwen with format:json returns the JSON in `response` as a string
let parsed;
try { parsed = JSON.parse(j.response); }
catch { throw new Error(`bad json from model: ${j.response.slice(0, 200)}`); }
return parsed;
}
// ─ Combine classifier output into a verdict + crops
function toLabel(item, qwen) {
// Decide:
// qwen agrees ghost → tp_ghost (no crops — auto labels don't have boxes)
// qwen says seam-break → tp_seam (no crops)
// qwen says clean → fp
if (qwen.looks_like_seam_break && !qwen.hasGhostLayer) {
return { verdict: 'tp_seam', conf: qwen.confidence || 0.5, reason: qwen.reason };
}
if (qwen.hasGhostLayer) {
return { verdict: 'tp_ghost', conf: qwen.confidence || 0.5, reason: qwen.reason };
}
return { verdict: 'fp', conf: qwen.confidence || 0.5, reason: qwen.reason };
}
// ─ Append a label row
function writeLabel(item, lbl) {
const row = {
id: item.id,
verdict: lbl.verdict,
crops: [],
crop: null,
notes: `auto: ${lbl.reason || ''}`.slice(0, 480),
source: 'auto',
model: `${MODEL}@${OLLAMA_URL.includes('192.168.1.133') ? 'mac1' : 'mac2'}`,
qwen_confidence: lbl.conf,
detector_confidence: item.confidence,
detector_reason: item.reason,
ts: new Date().toISOString(),
};
fs.appendFileSync(LABELS_F, JSON.stringify(row) + '\n');
}
// ─ Concurrency pool
async function pool(items, n, fn) {
let i = 0;
const next = () => (i < items.length ? items[i++] : null);
async function worker() {
while (true) {
const it = next();
if (!it) return;
try { await fn(it); }
catch (e) { console.error(`[auto-label] id=${it.id} ERR ${e.message.slice(0, 120)}`); }
}
}
await Promise.all(Array.from({ length: n }, worker));
}
// ─ Main
(async () => {
console.log(`[auto-label] model=${MODEL} ollama=${OLLAMA_URL} concurrency=${CONCURRENCY}`);
// Endpoint check
const probe = await fetch(`${OLLAMA_URL}/api/tags`).catch(() => null);
if (!probe || !probe.ok) {
console.error('Ollama endpoint unreachable:', OLLAMA_URL);
process.exit(1);
}
const labeled = FORCE ? new Set() : loadLabeled();
let flagged = loadFlagged();
if (CATEGORY) flagged = flagged.filter(r => r.category === CATEGORY);
const todo = flagged.filter(r => !labeled.has(r.id));
const work = LIMIT > 0 ? todo.slice(0, LIMIT) : todo;
console.log(`[auto-label] flagged=${flagged.length} already-labeled=${labeled.size} todo=${work.length}`);
let done = 0, byVerdict = { tp_ghost: 0, tp_seam: 0, fp: 0 }, errs = 0;
const t0 = Date.now();
await pool(work, CONCURRENCY, async (item) => {
let qwen;
try { qwen = await classifyOne(item); }
catch (e) { errs++; throw e; }
const lbl = toLabel(item, qwen);
writeLabel(item, lbl);
byVerdict[lbl.verdict]++;
done++;
if (done % 10 === 0 || done === work.length) {
const rate = done / ((Date.now() - t0) / 1000);
const eta = ((work.length - done) / rate).toFixed(0);
const fpPct = ((byVerdict.fp / done) * 100).toFixed(1);
console.log(
`[auto-label] ${done}/${work.length} · ` +
`fp=${byVerdict.fp}(${fpPct}%) ghost=${byVerdict.tp_ghost} seam=${byVerdict.tp_seam} err=${errs} · ` +
`${rate.toFixed(2)}/s ETA ${eta}s`
);
}
});
console.log(`[auto-label] done · ${done} labels written · ${errs} errors`);
console.log(`[auto-label] verdicts: fp=${byVerdict.fp} ghost=${byVerdict.tp_ghost} seam=${byVerdict.tp_seam}`);
})().catch(e => { console.error('FATAL', e); process.exit(1); });