← 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` : ''}`);
})();