← 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); });