← back to Wallco Ai
scripts/cactus-vision-score.js
136 lines
#!/usr/bin/env node
'use strict';
/*
* cactus-vision-score.js — FREE local-vision quality scorer for the Cactus
* Curator. For every cactus design lacking a vision_score, asks a local
* Ollama vision model (qwen2.5vl:7b on Mac1 by default — unmetered) to rate
* 0..100 how much the tile looks like a REAL, sellable, high-end-catalog
* wallcovering ("most like our dw_unified catalog patterns"). Writes the
* score into wallco_cactus_rank.vision_score; the curator's rank_score
* (0.6*vision + 0.4*seam) is computed live in the list query, so the grid
* re-ranks as scores land.
*
* Resumable (skips already-scored), throttled by the model itself, keeps the
* model warm via keep_alive. Reads images from local_path when present, else
* from the running server's /designs/img/by-id/<id>.
*
* node scripts/cactus-vision-score.js [--limit N] [--host H:PORT] [--model M]
*
* Env: OLLAMA_VISION_HOST (default 192.168.1.133:11434), OLLAMA_VISION_MODEL
* (default qwen2.5vl:7b), WALLCO_PORT (local server for by-id, default 9905).
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execSync } = require('child_process');
const { psqlQuery, psqlExecLocal, pgEsc } = require('../lib/db');
const args = process.argv.slice(2);
const flag = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
const LIMIT = parseInt(flag('--limit', '0'), 10) || 0;
const HOST = flag('--host', process.env.OLLAMA_VISION_HOST || '192.168.1.133:11434');
const MODEL = flag('--model', process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b');
const SRV_PORT = process.env.WALLCO_PORT || '9905';
const TMP = path.join(os.tmpdir(), 'cactus-vision');
fs.mkdirSync(TMP, { recursive: true });
// Quality benchmark = Steve's top-20 unified-db lines + DW Bespoke (the set
// surveyed in build_professional_palette_reference.js). The score literally
// means "most like our unified-db catalog lines."
const PROMPT =
'You are a luxury wallcovering buyer. Score 0-100 how much this wallpaper tile looks like a REAL design from ' +
'our top catalog lines — Thibaut, Schumacher, Cole & Son, Scalamandré, Osborne & Little, Designers Guild, ' +
'Maya Romanoff, Arte International, Brunschwig & Fils, William Morris, Lee Jofa, Romo, Zuber-grade hand-blocked ' +
'scenic, and our own DW Bespoke studio: clean even all-over repeat, cohesive aged/archival low-saturation palette, ' +
'NO AI smears or garbled/melted motifs, no harsh visible seams, no obvious 2x2 grid repeat, balanced and tasteful ' +
'(not cartoonish, not fluorescent). 100 = indistinguishable from one of those premium catalog products; ' +
'0 = obviously broken AI junk. Reply with ONLY the integer, nothing else.';
// ensure a rank row exists for every cactus design, then pick the unscored set
psqlExecLocal(`
INSERT INTO wallco_cactus_rank (design_id)
SELECT id FROM all_designs WHERE category ILIKE '%cactus%'
ON CONFLICT (design_id) DO NOTHING;
`);
const idsRaw = psqlQuery(
`SELECT d.id, COALESCE(d.local_path,'') FROM all_designs d
JOIN wallco_cactus_rank r ON r.design_id = d.id
WHERE d.category ILIKE '%cactus%' AND r.vision_score IS NULL
AND NOT COALESCE(d.user_removed,false)
ORDER BY d.id ${LIMIT ? 'LIMIT ' + LIMIT : ''};`
).trim();
const rows = idsRaw ? idsRaw.split('\n').map(l => { const [id, lp] = l.split('|'); return { id: id.trim(), lp: (lp || '').trim() }; }) : [];
console.log(`[vision] ${rows.length} cactus designs to score · model=${MODEL} @ ${HOST}`);
if (!rows.length) process.exit(0);
async function getJpegB64(row) {
const out = path.join(TMP, row.id + '.jpg');
let src = row.lp && fs.existsSync(row.lp) ? row.lp : null;
if (!src) {
// fetch from the running server's by-id route
const r = await fetch(`http://127.0.0.1:${SRV_PORT}/designs/img/by-id/${row.id}`);
if (!r.ok) throw new Error('img fetch ' + r.status);
const buf = Buffer.from(await r.arrayBuffer());
src = path.join(TMP, row.id + '.src');
fs.writeFileSync(src, buf);
}
// downscale to 512 jpeg (fast, small base64) — sips is on every mac
execSync(`sips -Z 512 -s format jpeg ${JSON.stringify(src)} --out ${JSON.stringify(out)}`, { stdio: 'ignore' });
const b64 = fs.readFileSync(out).toString('base64');
try { fs.unlinkSync(out); if (src.endsWith('.src')) fs.unlinkSync(src); } catch {}
return b64;
}
async function score(b64) {
const body = JSON.stringify({
model: MODEL, prompt: PROMPT, images: [b64], stream: false,
keep_alive: '30m', options: { temperature: 0.1, num_predict: 12 },
});
const ctl = AbortSignal.timeout(180000);
const r = await fetch(`http://${HOST}/api/generate`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body, signal: ctl,
});
if (!r.ok) throw new Error('ollama ' + r.status);
const j = await r.json();
return (j.response || '').trim();
}
function parseScore(reply) {
const m = String(reply).match(/\d{1,3}/);
if (!m) return null;
return Math.max(0, Math.min(100, parseInt(m[0], 10)));
}
(async () => {
let ok = 0, fail = 0;
const t0 = Date.now();
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
try {
const b64 = await getJpegB64(row);
const reply = await score(b64);
const s = parseScore(reply);
psqlExecLocal(`UPDATE wallco_cactus_rank
SET vision_score=${s == null ? 'NULL' : s},
vision_raw=${pgEsc(reply.slice(0, 120))},
vision_model=${pgEsc(MODEL)},
vision_scored_at=now()
WHERE design_id=${row.id};`);
if (s == null) { fail++; }
else { ok++; }
} catch (e) {
fail++;
// leave vision_score NULL so a re-run retries this id
if (fail <= 5 || fail % 25 === 0) console.warn(`\n[vision] id ${row.id} failed: ${e.message}`);
}
if ((i + 1) % 10 === 0 || i === rows.length - 1) {
const rate = (i + 1) / ((Date.now() - t0) / 1000);
const eta = ((rows.length - i - 1) / rate / 60).toFixed(1);
process.stdout.write(`\r[vision] ${i + 1}/${rows.length} ok=${ok} fail=${fail} ${rate.toFixed(2)}/s ETA ${eta}m `);
}
}
console.log(`\n[vision] done — scored ${ok}, failed ${fail}`);
})();