← back to Trending Dw

scripts/qc-real-images.js

73 lines

#!/usr/bin/env node
// qc-real-images.js — $0 LOCAL vision gate over downloaded real-product images.
// Every downloaded file is judged by a local ollama vision model (qwen2.5vl:7b, fallback
// gemma3:12b): it must look like a wallpaper/textile pattern swatch or a product/room shot
// showing wallpaper — logos, text banners, site chrome, screenshots, blank tiles are rejected.
// Style/color match is advisory (lenient); the hard job is keeping garbage off the board.
// Reject → promote the next altCandidate (status back to 'candidate' for the download script),
// else 'unresolved'. Re-run download → qc until no candidates remain.
const fs = require('fs');
const path = require('path');
const { ASSETS, loadLedger, saveLedger, loadBest, fingerprint } = require('./real-lib');

const DRY = process.argv.includes('--dry-run');
const ONLY = (() => { const i = process.argv.indexOf('--only'); return i > -1 ? process.argv[i + 1] : null; })();
const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const MODELS = ['qwen2.5vl:7b', 'gemma3:12b'];

// style/color hints per fingerprint, for the advisory match part of the rubric
const hints = {};
for (const it of loadBest().items) hints[fingerprint(it)] = { style: it.style, color: it.color, title: it.title };

async function judge(model, file, hint){
  const b64 = fs.readFileSync(path.join(ASSETS, file)).toString('base64');
  const prompt = `You are quality-gating an image for an internal wallpaper market-intelligence board.
The image is supposed to show a REAL trending wallpaper product: "${hint.title}" (style: ${hint.style}, color family: ${hint.color}).
ACCEPT if the image is a wallpaper/textile pattern swatch, a wallpaper product photo, or a room scene where wallpaper is clearly visible.
REJECT if it is: a logo, a text-heavy banner or ad, website chrome or a page screenshot, a mostly-blank placeholder, a photo of people/objects with no wallpaper, or a watermark-dominated image.
A loose style/color mismatch alone is NOT a reason to reject — only reject on the categories above, or if the image is obviously a completely different product category.
Answer with ONLY a JSON object: {"ok": true|false, "reason": "<max 12 words>"}`;
  const res = await fetch(OLLAMA + '/api/generate', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ model, prompt, images: [b64], stream: false, options: { temperature: 0 } }),
    signal: AbortSignal.timeout(120000)
  });
  if (!res.ok) throw new Error(`ollama HTTP ${res.status}`);
  const out = (await res.json()).response || '';
  const m = out.match(/\{[\s\S]*?\}/);
  if (!m) throw new Error('no JSON in model output: ' + out.slice(0, 80));
  return JSON.parse(m[0]);
}

(async () => {
  const ledger = loadLedger();
  const todo = Object.values(ledger).filter(l => l.status === 'downloaded' && l.file && (!ONLY || l.id === ONLY));
  console.log(`to QC: ${todo.length}${DRY ? ' (dry-run)' : ''} · model ${MODELS[0]} @ ${OLLAMA} ($0 local)`);
  if (DRY){ todo.forEach(l => console.log(`  ${l.id}  ${l.file}`)); return; }

  let pass = 0, reject = 0, err = 0;
  for (const l of todo){
    const hint = hints[l.fingerprint] || { style: '?', color: '?', title: l.title || '?' };
    let verdict = null, used = null;
    for (const model of MODELS){
      try { verdict = await judge(model, l.file, hint); used = model; break; }
      catch(e){ console.log(`  ! ${l.id} ${model}: ${e.message}`); }
    }
    if (!verdict){ err++; continue; }                            // leave as downloaded — retriable
    l.qc = { model: used, verdict: verdict.ok ? 'pass' : 'reject', reason: verdict.reason || '' };
    l.updatedAt = new Date().toISOString();
    if (verdict.ok){ l.status = 'qc-pass'; pass++; console.log(`  ✓ ${l.id} — ${verdict.reason || 'ok'}`); }
    else {
      reject++;
      fs.rmSync(path.join(ASSETS, l.file), { force: true });     // never leave rejected bytes deployable
      l.file = null; l.sha256 = null;
      const alts = l.altCandidates || [];
      if (alts.length){ l.candidateUrl = alts.shift(); l.altCandidates = alts; l.status = 'candidate'; console.log(`  ↻ ${l.id} — REJECT (${verdict.reason}); alt queued`); }
      else { l.status = 'qc-reject'; console.log(`  ✗ ${l.id} — REJECT (${verdict.reason})`); }
    }
    saveLedger(ledger);                                          // per-item save — a crash loses nothing
  }
  const retriable = Object.values(ledger).filter(l => l.status === 'candidate').length;
  console.log(`qc-pass ${pass}, reject ${reject}, model-errors ${err}${retriable ? ` · ${retriable} alts queued — re-run download-real-images.js then this` : ''}`);
})();