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