← back to Wallco Ai
scripts/classify-fuzzy.js
206 lines
#!/usr/bin/env node
// classify-fuzzy.js — vision-QA pass over wallco design images, labelling each
// CRISP vs FUZZY with a confidence score and short reason. Writes JSONL.
//
// Driven by Steve's standing rule (2026-05-19): every design must be crisp
// like a screenprint — fuzzy / layer-over-layer / washed-out designs are
// quality rejects. This script SURFACES the flagged list; it never deletes.
//
// Usage:
// node classify-fuzzy.js # full pass (every .png in data/generated-web)
// node classify-fuzzy.js --sample 30 # sample N random + 4 known cases (9317/18/19/23)
// node classify-fuzzy.js --files a.png,b.png,c.png
//
// Env:
// GEMINI_API_KEY — required (read from env or ~/Projects/secrets-manager/.env)
// CLASSIFIER_CONCURRENCY=10 — parallel Gemini calls (default 10)
// CLASSIFIER_MODEL=gemini-2.5-flash — multimodal text-out model
//
// Output: data/fuzzy-classifier.jsonl (append-only, dedup by file)
"use strict";
const fs = require("fs");
const path = require("path");
const { execSync, spawnSync } = require("child_process");
const ROOT = path.join(__dirname, "..");
const WEB_DIR = path.join(ROOT, "data", "generated-web");
const OUT = path.join(ROOT, "data", "fuzzy-classifier.jsonl");
function loadGeminiKey() {
if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
try {
const txt = fs.readFileSync(
path.join(process.env.HOME, "Projects/secrets-manager/.env"),
"utf8",
);
const m = txt.match(/^GEMINI_API_KEY=(.+)$/m);
if (m) return m[1].replace(/^["']|["']$/g, "").trim();
} catch {}
return null;
}
const KEY = loadGeminiKey();
if (!KEY) {
console.error("GEMINI_API_KEY not found in env or ~/Projects/secrets-manager/.env");
process.exit(2);
}
const MODEL = process.env.CLASSIFIER_MODEL || "gemini-2.5-flash";
const CONCURRENCY = parseInt(process.env.CLASSIFIER_CONCURRENCY || "10", 10);
const PROMPT = `You are a wallpaper-quality auditor. Look at this generated wallpaper design and judge whether it meets the "crisp screenprint" standard.
CRISP = sharp clean edges, opaque richly-saturated ink, distinct motifs you can name, flat or properly-rendered color, no smudgy blur, no washed-out gradients, no see-through layering.
FUZZY = soft layered look, washed-out / low-contrast pastel haze, indistinct or smudgy motifs, gradient bleed, layer-over-layer ghosting, halftone/dot-screen breakdown, blurred edges.
Return ONLY a JSON object on one line (no prose, no code fence):
{"verdict":"CRISP"|"FUZZY","confidence":0.0-1.0,"reason":"<<=80 chars>"}
If borderline, lean CRISP only when motifs are clearly legible AND color is opaque.`;
async function classify(file) {
const buf = fs.readFileSync(file);
const b64 = buf.toString("base64");
const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
const body = {
contents: [{
role: "user",
parts: [
{ text: PROMPT },
{ inline_data: { mime_type: "image/png", data: b64 } },
],
}],
generationConfig: {
temperature: 0.1, maxOutputTokens: 400,
responseMimeType: "application/json",
thinkingConfig: { thinkingBudget: 0 }, // disable 2.5 thinking — otherwise eats all tokens
},
};
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 30000);
try {
const r = await fetch(url, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), signal: ctrl.signal,
});
clearTimeout(t);
const j = await r.json();
const text = j.candidates?.[0]?.content?.parts?.[0]?.text || "";
let parsed = null;
try { parsed = JSON.parse(text); } catch {
const m = text.match(/\{[\s\S]*?\}/);
if (m) try { parsed = JSON.parse(m[0]); } catch {}
}
if (!parsed || !parsed.verdict) return { error: "parse_fail", raw: text.slice(0, 200) };
return parsed;
} catch (e) {
clearTimeout(t);
return { error: String(e.message || e).slice(0, 200) };
}
}
// Map filename → design_id via spoon_all_designs.local_path basename
function buildFilenameMap() {
try {
const out = execSync(
`psql dw_unified -At -q -c "SELECT regexp_replace(local_path,'^.*/',''), id FROM spoon_all_designs WHERE local_path LIKE '%generated/%';"`,
{ encoding: "utf8" },
);
const map = new Map();
for (const line of out.trim().split("\n")) {
const [fn, id] = line.split("|");
if (fn && id) map.set(fn, parseInt(id, 10));
}
return map;
} catch (e) {
console.error("filename→id map failed:", e.message);
return new Map();
}
}
function loadAlreadyDone() {
if (!fs.existsSync(OUT)) return new Set();
const seen = new Set();
for (const line of fs.readFileSync(OUT, "utf8").trim().split("\n")) {
try {
const j = JSON.parse(line);
if (j.file) seen.add(j.file);
} catch {}
}
return seen;
}
async function main() {
const args = process.argv.slice(2);
let files = [];
const sampleIdx = args.indexOf("--sample");
const filesIdx = args.indexOf("--files");
if (sampleIdx >= 0) {
const n = parseInt(args[sampleIdx + 1], 10) || 30;
const all = fs.readdirSync(WEB_DIR).filter(f => f.endsWith(".png"));
// Always include known cases
const known = ["1779224403913_1106085464.png", "1779224412957_1415526741.png",
"1779224440803_787371989.png", "1779224536695_1788929927.png"]
.filter(f => all.includes(f));
const rest = all.filter(f => !known.includes(f));
// shuffle
for (let i = rest.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[rest[i], rest[j]] = [rest[j], rest[i]];
}
files = known.concat(rest.slice(0, Math.max(0, n - known.length)));
} else if (filesIdx >= 0) {
files = args[filesIdx + 1].split(",").map(f => f.trim()).filter(Boolean);
} else {
files = fs.readdirSync(WEB_DIR).filter(f => f.endsWith(".png"));
}
const done = loadAlreadyDone();
files = files.filter(f => !done.has(f));
console.log(`Classifying ${files.length} files (model=${MODEL}, parallel=${CONCURRENCY})`);
if (done.size) console.log(` (skipping ${done.size} already classified)`);
const fmap = buildFilenameMap();
const out = fs.createWriteStream(OUT, { flags: "a" });
let done_n = 0, crisp = 0, fuzzy = 0, errs = 0;
const t0 = Date.now();
async function worker(slice) {
for (const fn of slice) {
const full = path.join(WEB_DIR, fn);
if (!fs.existsSync(full)) { errs++; continue; }
const result = await classify(full);
const rec = {
ts: new Date().toISOString(),
file: fn,
design_id: fmap.get(fn) || null,
...result,
};
out.write(JSON.stringify(rec) + "\n");
done_n++;
if (result.error) errs++;
else if (result.verdict === "CRISP") crisp++;
else if (result.verdict === "FUZZY") fuzzy++;
if (done_n % 25 === 0) {
const rate = done_n / ((Date.now() - t0) / 1000);
const eta = (files.length - done_n) / rate;
console.log(` ${done_n}/${files.length} · crisp=${crisp} fuzzy=${fuzzy} err=${errs} · ${rate.toFixed(1)}/s · eta ${Math.round(eta)}s`);
}
}
}
// shard files into CONCURRENCY slices
const slices = Array.from({ length: CONCURRENCY }, () => []);
files.forEach((f, i) => slices[i % CONCURRENCY].push(f));
await Promise.all(slices.map(worker));
out.end();
const dt = ((Date.now() - t0) / 1000).toFixed(1);
console.log(`\nDone in ${dt}s — crisp=${crisp} fuzzy=${fuzzy} errs=${errs}`);
console.log(`Output: ${OUT}`);
}
main().catch(e => { console.error(e); process.exit(1); });