← back to Whatsmystyle

scripts/closet-vision.js

111 lines

/**
 * Closet vision worker — scans data/closet for un-parsed photos and pipes
 * each to Ollama llava to extract garment bounding boxes + category +
 * color guesses. Updates the closet row in-place.
 *
 * Run on demand:  node scripts/closet-vision.js
 * Scheduled via ~/Library/LaunchAgents/com.steve.wms-closet-vision.plist
 * (every 120s).
 */
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const fetch = require('node-fetch');

const OLLAMA = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const MODEL  = process.env.OLLAMA_VISION_MODEL || 'llava:latest';

// Two-pass extraction:
//   Pass 1 — classify the photo as "single garment" or "closet/outfit"
//   Pass 2 — extract items, capped by classification
// On a single-garment Shopify product shot llava previously hallucinated up
// to 12 alternating top/bottom rows. The classifier collapses that to 1.
async function classifyScene(imagePath) {
  const b64 = fs.readFileSync(imagePath).toString('base64');
  const prompt = `Look at this photo. Answer with ONE word only.
Reply "single" if the photo shows exactly one garment, accessory, shoe, or bag (e.g. a product shot on a model or flat-lay).
Reply "multi" only if the photo shows a wardrobe / closet / outfit with several distinct garments at once.
Reply "none" if you see no clothing.
Word only:`;
  try {
    const r = await fetch(`${OLLAMA}/api/generate`, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ model: MODEL, prompt, images: [b64], stream: false, options: { temperature: 0 } }),
    });
    const j = await r.json();
    const w = (j.response || '').toLowerCase().match(/single|multi|none/);
    return w ? w[0] : 'single';
  } catch { return 'single'; }
}

async function visionDescribe(imagePath, maxItems) {
  const buf = fs.readFileSync(imagePath);
  const b64 = buf.toString('base64');
  const prompt = maxItems === 1
    ? `You see a photograph of ONE garment, shoe, bag, or accessory. Return STRICT JSON only:
{"items":[{"category":"top|bottom|dress|outerwear|shoes|bag|accessory","color":"...","pattern":"solid|stripe|floral|plaid|...","material_guess":"cotton|silk|wool|denim|leather|other","vendor_guess":"unknown|<brand>","bbox":{"x":0,"y":0,"w":1,"h":1}}]}
Return EXACTLY ONE item. No prose. JSON only.`
    : `You see a photograph of a closet or outfit with multiple garments. Return STRICT JSON only:
{"items":[{"category":"top|bottom|dress|outerwear|shoes|bag|accessory","color":"...","pattern":"solid|stripe|floral|plaid|...","material_guess":"cotton|silk|wool|denim|leather|other","vendor_guess":"unknown|<brand>","bbox":{"x":0,"y":0,"w":1,"h":1}}]}
Return up to ${maxItems} distinct items. No prose. JSON only.`;
  const r = await fetch(`${OLLAMA}/api/generate`, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ model: MODEL, prompt, images: [b64], stream: false, options: { temperature: 0.2 } }),
  });
  const j = await r.json();
  const raw = j.response || '';
  const m = raw.match(/\{[\s\S]*\}/);
  if (!m) return { items: [] };
  try { return JSON.parse(m[0]); } catch { return { items: [], _raw: raw }; }
}

async function processOne(db, row) {
  if (!row.photo_path || !fs.existsSync(row.photo_path)) return;
  // /api/closet/photo is a single-file endpoint — user uploads one garment
  // at a time. Llava's "multi" classification on Shopify product shots is
  // unreliable (returns 12 alternating top/bottom rows). For now hard-cap
  // at 1. A future /api/closet/photo/bulk scan-my-closet flow can opt in
  // via source_meta.allow_multi.
  let meta = {}; try { meta = JSON.parse(row.source_meta || '{}'); } catch {}
  const allowMulti = !!meta.allow_multi;
  const scene = allowMulti ? await classifyScene(row.photo_path) : 'single';
  const cap = (allowMulti && scene === 'multi') ? 12 : 1;
  console.log(`[vision] ${row.id} ${row.photo_path}  scene=${scene} cap=${cap}`);
  if (scene === 'none') {
    db.prepare(`UPDATE closet SET source_meta=? WHERE id=?`)
      .run(JSON.stringify({ scene: 'none', note: 'no clothing detected' }), row.id);
    return;
  }
  const out = await visionDescribe(row.photo_path, cap);
  const items = (out.items || []).slice(0, cap);
  if (!items.length) return;
  const first = items[0];
  db.prepare(`UPDATE closet SET category=?, color=?, pattern=?, material_guess=?, vendor_guess=?, bbox=?, source_meta=? WHERE id=?`)
    .run(first.category, first.color, first.pattern, first.material_guess, first.vendor_guess,
         JSON.stringify(first.bbox || {}), JSON.stringify({ count: items.length, scene }), row.id);
  // Only fan out additional rows when the photo truly is a multi-garment shot.
  if (cap > 1 && items.length > 1) {
    const ins = db.prepare(`INSERT INTO closet (user_id, photo_path, category, color, pattern, material_guess, vendor_guess, bbox, acquired_via, source_meta)
      VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'photo', ?)`);
    for (let i = 1; i < items.length; i++) {
      const it = items[i];
      ins.run(row.user_id, row.photo_path, it.category, it.color, it.pattern, it.material_guess, it.vendor_guess,
              JSON.stringify(it.bbox || {}), JSON.stringify({ from_photo: row.id, idx: i, scene }));
    }
  }
}

async function main() {
  const dbPath = path.join(__dirname, '..', 'data', 'whatsmystyle.db');
  const db = new Database(dbPath);
  const rows = db.prepare(`SELECT * FROM closet WHERE category IS NULL AND photo_path IS NOT NULL LIMIT 25`).all();
  console.log(`[vision] ${rows.length} pending`);
  for (const r of rows) {
    try { await processOne(db, r); } catch (e) { console.error('[vision] err', r.id, e.message); }
  }
}

if (require.main === module) main().catch(e => { console.error(e); process.exit(1); });